From ad75cc43beacf52808633b8a281d61a9d527bbbe Mon Sep 17 00:00:00 2001 From: Bhargav Date: Tue, 3 Nov 2026 11:44:10 +0000 Subject: [PATCH 01/35] fix(infra): make the Kubernetes explorer work at any ingest flatten level (#16300) --- .../__fixtures__/flattenSchemas.ts | 84 ++++++++++++++++ .../kubernetes2/kubernetesEvents.spec.ts | 60 +++++++++++- .../kubernetes2/kubernetesEvents.ts | 74 ++++++++------ .../kubernetes2/kubernetesFlatten.spec.ts | 97 +++++++++++++++++++ .../kubernetes2/kubernetesFlatten.ts | 67 +++++++++++++ .../kubernetes2/kubernetesObjects.spec.ts | 49 +++++++++- .../kubernetes2/kubernetesObjects.ts | 58 ++++++----- .../useKubernetesInventory.spec.ts | 32 +++++- .../kubernetes2/useKubernetesInventory.ts | 10 +- 9 files changed, 473 insertions(+), 58 deletions(-) create mode 100644 web/src/views/Infrastructure/kubernetes2/__fixtures__/flattenSchemas.ts create mode 100644 web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.spec.ts create mode 100644 web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.ts diff --git a/web/src/views/Infrastructure/kubernetes2/__fixtures__/flattenSchemas.ts b/web/src/views/Infrastructure/kubernetes2/__fixtures__/flattenSchemas.ts new file mode 100644 index 0000000000..889665370e --- /dev/null +++ b/web/src/views/Infrastructure/kubernetes2/__fixtures__/flattenSchemas.ts @@ -0,0 +1,84 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const BASE = ["_timestamp", "event_name", "k8s_cluster", "k8s_namespace_name", "k8s_resource_name"]; + +export const LEVEL3_COLUMNS = [ + ...BASE, + "body_type", + "body_object_kind", + "body_object_type", + "body_object_reason", + "body_object_note", + "body_object_regarding", + "body_object_reportingcontroller", + "body_object_reportinginstance", + "body_object_series", + "body_object_deprecatedcount", + "body_object_eventtime", + "body_object_metadata", + "body_object_spec", + "body_object_status", +]; + +export const LEVEL0_COLUMNS = [ + ...BASE, + "body_type", + "body_object_kind", + "body_object_type", + "body_object_reason", + "body_object_note", + "body_object_regarding_kind", + "body_object_regarding_name", + "body_object_regarding_namespace", + "body_object_regarding_uid", + "body_object_reportingcontroller", + "body_object_reportinginstance", + "body_object_series_count", + "body_object_series_lastobservedtime", + "body_object_deprecatedcount", + "body_object_eventtime", + "body_object_metadata_uid", + "body_object_metadata_name", + "body_object_metadata_creationtimestamp", + "body_object_status_conditions", + "body_object_status_containerstatuses", + "body_object_status_qosclass", + "body_object_spec_unschedulable", +]; + +export const LEVEL2_COLUMNS = [...BASE, "body_type", "body_object"]; + +export const LEVEL1_COLUMNS = [...BASE, "body"]; + +export const MIXED_3_2_COLUMNS = [...LEVEL3_COLUMNS, "body_object"]; + +export const MIXED_3_0_COLUMNS = [...new Set([...LEVEL3_COLUMNS, ...LEVEL0_COLUMNS])]; + +export const SCHEMAS: Record = { + "level 3": LEVEL3_COLUMNS, + "level 0": LEVEL0_COLUMNS, + "level 2": LEVEL2_COLUMNS, + "level 1": LEVEL1_COLUMNS, + "mixed 3+2": MIXED_3_2_COLUMNS, + "mixed 3+0": MIXED_3_0_COLUMNS, +}; + +// Stream columns a query reads that the schema lacks; aliases it defines itself are not stream columns. +export function missingColumns(sql: string, schema: string[]): string[] { + const aliases = new Set([...sql.matchAll(/ AS (\w+)/g)].map((m) => m[1])); + const refs = new Set(sql.match(/\bbody(?:_object\w*|_type)?\b(?!')/g) ?? []); + return [...refs].filter((r) => !schema.includes(r) && !aliases.has(r)); +} diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.spec.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.spec.ts index dc18292777..35a0870794 100644 --- a/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.spec.ts +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.spec.ts @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { clusterFallbackSql, detailEventsSql, @@ -27,6 +27,8 @@ import { } from "./kubernetesEvents"; import { buildInventory, type Series, type WarningEvent } from "./kubernetesModel"; import type { QueryId } from "./kubernetesQueries"; +import { setStreamColumns } from "./kubernetesFlatten"; +import { SCHEMAS, missingColumns } from "./__fixtures__/flattenSchemas"; // Catches a quadratic blow-up (seconds), not machine speed: CI runs this slower, under coverage. const GROUPING_BUDGET_MS = 250; @@ -435,3 +437,59 @@ describe("event counts", () => { expect(parseEventCounts(undefined)).toBeNull(); }); }); + +describe("flatten-level aware SQL", () => { + afterEach(() => setStreamColumns(null)); + const build = () => ({ + W: warningSql({ cluster: "prod", namespaces: ["data"] }, "Pod", true), + EC: eventCountsSql("prod"), + E: eventsSql({ cluster: "prod", namespaces: ["data"] }, 10), + DE: detailEventsSql("prod", { kind: "pod", namespace: "data", name: "x", uid: "u1" }), + }); + + for (const [level, schema] of Object.entries(SCHEMAS)) { + it(`reads only columns that exist at ${level}`, () => { + setStreamColumns(schema); + for (const [id, sql] of Object.entries(build())) { + expect(missingColumns(sql, schema), id).toEqual([]); + } + }); + } + + it("leaves the level 3 SQL identical to the schema-less SQL", () => { + const before = build(); + setStreamColumns(SCHEMAS["level 3"]); + expect(build()).toEqual(before); + }); + + it("filters Events with COALESCE over existing columns on a mixed 3+2 schema", () => { + setStreamColumns(SCHEMAS["mixed 3+2"]); + const sql = eventsSql({ cluster: "prod", namespaces: [] }, 10); + expect(sql).toContain("COALESCE(body_object_kind,json_get_str(body_object,'kind'))"); + expect(missingColumns(sql, SCHEMAS["mixed 3+2"])).toEqual([]); + }); + + it("selects the nested event fields from body_object at level 2", () => { + setStreamColumns(SCHEMAS["level 2"]); + const sql = eventsSql({ cluster: "prod", namespaces: [] }, 10); + expect(sql).toContain("json_get_str(body_object,'type') AS body_object_type"); + expect(sql).toContain("json_get_json(body_object,'regarding') AS body_object_regarding"); + }); + + it("parses flattened regarding and series columns at level 0", () => { + const [row] = parseEvents([ + { + event_name: "x.1", + body_object_regarding_kind: "Pod", + body_object_regarding_name: "x", + body_object_regarding_namespace: "data", + body_object_regarding_uid: "u", + body_object_series_count: 4, + body_object_series_lastobservedtime: "2026-10-06T10:00:00Z", + }, + ]); + expect(row.object).toEqual({ kind: "Pod", name: "x", namespace: "data", uid: "u" }); + expect(row.count).toBe(4); + expect(row.lastSeen).toBe(Date.parse("2026-10-06T10:00:00Z") * 1000); + }); +}); diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.ts index 463ead647b..ea3e9e7c85 100644 --- a/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.ts +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesEvents.ts @@ -16,6 +16,7 @@ import { escapeSingleQuotes } from "@/utils/zincutils"; import type { I18nKey } from "@/types/i18n"; import { EVENTS_STREAM, KIND_INFO, type DetailKind } from "./kubernetesQueries"; +import { col, selectAs, selectObject } from "./kubernetesFlatten"; import { inScope, type Inventory, type PodRow, type WarningEvent } from "./kubernetesModel"; export interface InvolvedObject { @@ -54,10 +55,15 @@ export interface EventScope { export const W_ROW_LIMIT = 20000; -const EK = - "COALESCE(json_get_str(body_object_metadata,'uid'), k8s_namespace_name || '/' || event_name)"; +const EK = () => `COALESCE(${col(["metadata", "uid"])}, k8s_namespace_name || '/' || event_name)`; -const REGARDING = (field: string) => `json_get_str(body_object_regarding,'${field}')`; +const REGARDING_FIELDS = ["kind", "name", "namespace", "uid"]; + +const SERIES_FIELDS = ["count", "lastObservedTime"]; + +const REGARDING = (field: string) => col(["regarding", field]); + +const KIND_IS_EVENT = () => `${col(["kind"])} = 'Event'`; const q = (value: string) => `'${escapeSingleQuotes(value)}'`; @@ -66,20 +72,19 @@ const clusterTerm = (cluster: string | null) => (cluster ? ` AND k8s_cluster = $ const eventNsFilter = (namespaces: string[]) => namespaces.length ? ` AND ${REGARDING("namespace")} IN (${namespaces.map(q).join(", ")})` : ""; -const EVENT_COLUMNS = [ - "event_name", - "body_object_type", - "body_object_reason", - "body_object_note", - "body_object_regarding", - "body_object_reportingcontroller", - "body_object_reportinginstance", - "body_object_series", - "body_object_deprecatedcount", - "body_object_eventtime", - "k8s_namespace_name", - "_timestamp", -].join(", "); +const eventColumns = () => + [ + "event_name", + ...["type", "reason", "note"].map((f) => selectAs(col([f]), `body_object_${f}`)), + ...selectObject(["regarding"], "body_object_regarding", REGARDING_FIELDS), + ...["reportingcontroller", "reportinginstance"].map((f) => + selectAs(col([f]), `body_object_${f}`), + ), + ...selectObject(["series"], "body_object_series", SERIES_FIELDS), + ...["deprecatedcount", "eventtime"].map((f) => selectAs(col([f]), `body_object_${f}`)), + "k8s_namespace_name", + "_timestamp", + ].join(", "); const KIND_BY_NAME = new Map( Object.entries(KIND_INFO).map(([kind, info]) => [info.kind, kind as DetailKind]), @@ -104,9 +109,9 @@ export function warningSql(scope: EventScope, kind: string | null, namespaced: b "first_value(note ORDER BY last_seen DESC) AS note FROM (" + `SELECT ${REGARDING("kind")} AS kind, ${REGARDING("name")} AS name, ` + `${REGARDING("namespace")} AS namespace, ${REGARDING("uid")} AS uid, ` + - "body_object_reason AS reason, body_object_note AS note, _timestamp AS last_seen, " + - `ROW_NUMBER() OVER (PARTITION BY ${EK} ORDER BY _timestamp DESC) AS rn ` + - `FROM "${EVENTS_STREAM}" WHERE body_object_kind = 'Event' AND body_object_type = 'Warning'` + + `${selectAs(col(["reason"]), "reason")}, ${selectAs(col(["note"]), "note")}, _timestamp AS last_seen, ` + + `ROW_NUMBER() OVER (PARTITION BY ${EK()} ORDER BY _timestamp DESC) AS rn ` + + `FROM "${EVENTS_STREAM}" WHERE ${KIND_IS_EVENT()} AND ${col(["type"])} = 'Warning'` + `${clusterTerm(scope.cluster)}${nsFilter}${kindFilter}` + ") WHERE rn = 1 GROUP BY kind, name, namespace, uid ORDER BY last_seen DESC" ); @@ -115,8 +120,8 @@ export function warningSql(scope: EventScope, kind: string | null, namespaced: b export function eventCountsSql(cluster: string) { return ( "SELECT count(*) AS total, count(CASE WHEN type = 'Warning' THEN 1 END) AS warnings FROM (" + - `SELECT body_object_type AS type, ROW_NUMBER() OVER (PARTITION BY ${EK} ORDER BY _timestamp DESC) AS rn ` + - `FROM "${EVENTS_STREAM}" WHERE body_object_kind = 'Event'${clusterTerm(cluster)}` + + `SELECT ${selectAs(col(["type"]), "type")}, ROW_NUMBER() OVER (PARTITION BY ${EK()} ORDER BY _timestamp DESC) AS rn ` + + `FROM "${EVENTS_STREAM}" WHERE ${KIND_IS_EVENT()}${clusterTerm(cluster)}` + ") WHERE rn = 1" ); } @@ -170,8 +175,14 @@ export function parseWarnings( export function parseEvents(hits: any[]): EventRow[] { return hits.map((h) => { - const regarding = parseJson(h.body_object_regarding); - const series = parseJson(h.body_object_series); + const regarding = { + ...flatChildren(h, "regarding", REGARDING_FIELDS), + ...parseJson(h.body_object_regarding), + }; + const series = { + ...flatChildren(h, "series", SERIES_FIELDS), + ...parseJson(h.body_object_series), + }; const seriesCount = Number(series.count); const deprecated = Number(h.body_object_deprecatedcount); const source = [h.body_object_reportingcontroller, h.body_object_reportinginstance] @@ -297,13 +308,22 @@ function keepPodEvent(event: WarningEvent, podsByName: Map) { function eventsQuery(terms: string, limit: number) { return ( - `SELECT * FROM (SELECT ${EVENT_COLUMNS}, ` + - `ROW_NUMBER() OVER (PARTITION BY ${EK} ORDER BY _timestamp DESC) AS rn ` + - `FROM "${EVENTS_STREAM}" WHERE body_object_kind = 'Event'${terms}` + + `SELECT * FROM (SELECT ${eventColumns()}, ` + + `ROW_NUMBER() OVER (PARTITION BY ${EK()} ORDER BY _timestamp DESC) AS rn ` + + `FROM "${EVENTS_STREAM}" WHERE ${KIND_IS_EVENT()}${terms}` + `) WHERE rn = 1 ORDER BY _timestamp DESC LIMIT ${limit}` ); } +function flatChildren(hit: any, parent: string, fields: string[]) { + const out: Record = {}; + for (const f of fields) { + const v = hit[`body_object_${parent}_${f.toLowerCase()}`]; + if (v != null) out[f] = v; + } + return out; +} + function parseJson(value: unknown): Record { if (value && typeof value === "object") return value as Record; if (typeof value !== "string" || !value) return {}; diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.spec.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.spec.ts new file mode 100644 index 0000000000..81d2b272ab --- /dev/null +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.spec.ts @@ -0,0 +1,97 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it } from "vitest"; +import { col, selectAs, selectObject, setStreamColumns } from "./kubernetesFlatten"; +import { SCHEMAS } from "./__fixtures__/flattenSchemas"; + +describe("kubernetesFlatten col", () => { + afterEach(() => setStreamColumns(null)); + + it("emits the level 3 form while the schema is unknown", () => { + expect(col(["type"])).toBe("body_object_type"); + expect(col(["metadata", "uid"])).toBe("json_get_str(body_object_metadata,'uid')"); + expect(col(["spec", "template", "spec", "nodeSelector"], "json")).toBe( + "json_get_json(body_object_spec,'template','spec','nodeSelector')", + ); + }); + + it("emits the level 3 form on a level 3 schema", () => { + setStreamColumns(SCHEMAS["level 3"]); + expect(col(["status", "qosClass"])).toBe("json_get_str(body_object_status,'qosClass')"); + expect(col(["metadata"], "json")).toBe("body_object_metadata"); + }); + + it("is NULL on a level 3 stream that lacks an optional column", () => { + setStreamColumns(SCHEMAS["level 3"].filter((c) => c !== "body_object_series")); + expect(col(["series"], "json")).toBe("NULL"); + expect(selectObject(["series"], "body_object_series", ["count"])).toEqual([ + "NULL AS body_object_series", + ]); + }); + + it("prefers a flattened column at level 0 and lowercases its name", () => { + setStreamColumns(SCHEMAS["level 0"]); + expect(col(["status", "qosClass"])).toBe("body_object_status_qosclass"); + expect(col(["spec", "unschedulable"], "bool")).toBe("body_object_spec_unschedulable"); + }); + + it("is NULL at level 0 for a nested value with no flattened column", () => { + setStreamColumns(SCHEMAS["level 0"]); + expect(col(["metadata", "labels"], "json")).toBe("NULL"); + }); + + it("walks to body_object at level 2 and body at level 1", () => { + setStreamColumns(SCHEMAS["level 2"]); + expect(col(["metadata", "uid"])).toBe("json_get_str(body_object,'metadata','uid')"); + expect(col(["kind"])).toBe("json_get_str(body_object,'kind')"); + setStreamColumns(SCHEMAS["level 1"]); + expect(col(["metadata", "uid"])).toBe("json_get_str(body,'object','metadata','uid')"); + }); + + it("escapes quotes in path segments", () => { + setStreamColumns(SCHEMAS["level 2"]); + expect(col(["a'b"])).toBe("json_get_str(body_object,'a''b')"); + }); + + it("aliases only when the expression differs from the alias", () => { + expect(selectAs("body_object_type", "body_object_type")).toBe("body_object_type"); + expect(selectAs("x", "y")).toBe("x AS y"); + }); + + it("coalesces the level 3 and level 2 layouts on a mixed 3+2 schema", () => { + setStreamColumns(SCHEMAS["mixed 3+2"]); + expect(col(["kind"])).toBe("COALESCE(body_object_kind,json_get_str(body_object,'kind'))"); + expect(col(["metadata", "uid"])).toBe( + "COALESCE(json_get_str(body_object_metadata,'uid'),json_get_str(body_object,'metadata','uid'))", + ); + }); + + it("coalesces the level 3 and level 0 layouts on a mixed 3+0 schema", () => { + setStreamColumns(SCHEMAS["mixed 3+0"]); + expect(col(["kind"])).toBe("body_object_kind"); + expect(col(["metadata", "uid"])).toBe( + "COALESCE(body_object_metadata_uid,json_get_str(body_object_metadata,'uid'))", + ); + }); + + it("selects the flattened children of an object column at level 0", () => { + setStreamColumns(SCHEMAS["level 0"]); + expect(selectObject(["regarding"], "body_object_regarding", ["kind", "uid"])).toEqual([ + "body_object_regarding_kind", + "body_object_regarding_uid", + ]); + }); +}); diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.ts new file mode 100644 index 0000000000..7453a3390e --- /dev/null +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesFlatten.ts @@ -0,0 +1,67 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { escapeSingleQuotes } from "@/utils/zincutils"; + +export type JsonKind = "str" | "json" | "bool"; + +let known: ReadonlySet | null = null; + +export const setStreamColumns = (names: Iterable | null) => { + known = names ? new Set(names) : null; +}; + +const seg = (s: string) => s.toLowerCase().replace(/[^a-z0-9_]/g, "_"); + +const flatName = (path: string[]) => `body_object_${path.map(seg).join("_")}`; + +const jsonGet = (kind: JsonKind, column: string, path: string[]) => + `json_get_${kind}(${column},${path.map((s) => `'${escapeSingleQuotes(s)}'`).join(",")})`; + +/** SQL for the logical field body.object., resolved against the stream's actual columns. */ +export function col(path: string[], kind: JsonKind = "str"): string { + const level3 = () => + path.length === 1 ? flatName(path) : jsonGet(kind, flatName(path.slice(0, 1)), path.slice(1)); + if (!known) return level3(); + const found: string[] = []; + for (let n = path.length; n >= 1; n--) { + const name = flatName(path.slice(0, n)); + if (known.has(name)) found.push(n === path.length ? name : jsonGet(kind, name, path.slice(n))); + } + if (known.has("body_object")) found.push(jsonGet(kind, "body_object", path)); + if (known.has("body")) found.push(jsonGet(kind, "body", ["object", ...path])); + // A re-levelled stream keeps old and new columns side by side, so rows of either layout must resolve. + if (found.length === 1) return found[0]; + if (found.length > 1) return `COALESCE(${found.join(",")})`; + return known.has("body_object_kind") ? "NULL" : level3(); +} + +/** SQL for body_type, which sits beside body.object. */ +export function bodyTypeCol(): string { + return known && !known.has("body_type") && known.has("body") + ? jsonGet("str", "body", ["type"]) + : "body_type"; +} + +export const selectAs = (expr: string, alias: string) => + expr === alias ? expr : `${expr} AS ${alias}`; + +/** Select items for a JSON-object field, falling back to its flattened children when the parent column is gone. */ +export function selectObject(path: string[], alias: string, children: string[]): string[] { + const parent = col(path, "json"); + if (parent !== "NULL") return [selectAs(parent, alias)]; + const flat = children.map((c) => flatName([...path, c])).filter((n) => known?.has(n)); + return flat.length ? flat : [selectAs("NULL", alias)]; +} diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.spec.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.spec.ts index fa5deb595c..0721c5e131 100644 --- a/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.spec.ts +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.spec.ts @@ -13,10 +13,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { buildInventory, type Series } from "./kubernetesModel"; import type { QueryId } from "./kubernetesQueries"; +import { setStreamColumns } from "./kubernetesFlatten"; +import { SCHEMAS, missingColumns } from "./__fixtures__/flattenSchemas"; import { + LIST_OBJECT_KINDS, + MAP_COLUMNS, OVERVIEW_COLUMNS, joinObjects, objectDetailSql, @@ -271,3 +275,46 @@ describe("object join", () => { expect(inventory.cronjobs[0].lastSchedule).toBe(Date.parse("2026-10-06T02:00:00Z") * 1000); }); }); + +describe("flatten-level aware object SQL", () => { + afterEach(() => setStreamColumns(null)); + const build = () => ({ + ...Object.fromEntries( + LIST_OBJECT_KINDS.map((k) => [`list ${k}`, objectListSql(k, "prod", ["data"])]), + ), + mapPod: objectListSql("pod", "prod", [], MAP_COLUMNS.pod), + mapNode: objectListSql("node", "prod", [], MAP_COLUMNS.node), + overviewPod: objectListSql("pod", "prod", [], OVERVIEW_COLUMNS.pod), + detail: objectDetailSql("pod", "prod", { namespace: "data", name: "x", uid: "u1" }), + }); + + for (const [level, schema] of Object.entries(SCHEMAS)) { + it(`reads only columns that exist at ${level}`, () => { + setStreamColumns(schema); + for (const [id, sql] of Object.entries(build())) { + expect(missingColumns(sql, schema), id).toEqual([]); + } + }); + } + + it("leaves the level 3 SQL identical to the schema-less SQL", () => { + const before = build(); + setStreamColumns(SCHEMAS["level 3"]); + expect(build()).toEqual(before); + }); + + it("reads json parents from body_object at level 2 and keeps the aliases", () => { + setStreamColumns(SCHEMAS["level 2"]); + const sql = objectDetailSql("pod", "prod", { namespace: "data", name: "x", uid: null }); + expect(sql).toContain("json_get_json(body_object,'metadata') AS body_object_metadata"); + expect(sql).toContain("json_get_str(body_object,'metadata','uid') AS uid"); + }); + + it("uses the flattened column at level 0 and a null for what cannot be rebuilt", () => { + setStreamColumns(SCHEMAS["level 0"]); + const sql = objectListSql("pod", "prod", []); + expect(sql).toContain("body_object_status_qosclass AS qos_class"); + expect(sql).toContain("body_object_metadata_uid AS uid"); + expect(sql).toContain("body_object_metadata_creationtimestamp AS created"); + }); +}); diff --git a/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.ts b/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.ts index b4ff11648b..4f4a2ae9ec 100644 --- a/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.ts +++ b/web/src/views/Infrastructure/kubernetes2/kubernetesObjects.ts @@ -14,6 +14,7 @@ // along with this program. If not, see . import { escapeSingleQuotes } from "@/utils/zincutils"; +import { bodyTypeCol, col, selectAs, type JsonKind } from "./kubernetesFlatten"; import { EVENTS_STREAM, KIND_INFO, type DetailKind } from "./kubernetesQueries"; import { INVENTORY_KEY, @@ -32,35 +33,39 @@ export interface ObjectRecord { object: K8sObject; } +type ColumnRef = { kind: JsonKind; path: string[] }; + +const ref = (kind: JsonKind, ...path: string[]): ColumnRef => ({ kind, path }); + +const LABELS = ref("json", "metadata", "labels"); + +const CONDITIONS = ref("json", "status", "conditions"); + // Each list variant reads only what its list renders (§7.3), plus the creation time for Age. -const LIST_COLUMNS: Partial>> = { - pod: { qos_class: "json_get_str(body_object_status,'qosClass')" }, - node: { - labels: "json_get_json(body_object_metadata,'labels')", - unschedulable: "json_get_bool(body_object_spec,'unschedulable')", - }, - deployment: { conditions: "json_get_json(body_object_status,'conditions')" }, - daemonset: { node_selector: "json_get_json(body_object_spec,'template','spec','nodeSelector')" }, - cronjob: { last_schedule: "json_get_str(body_object_status,'lastScheduleTime')" }, - namespace: { labels: "json_get_json(body_object_metadata,'labels')" }, +const LIST_COLUMNS: Partial>> = { + pod: { qos_class: ref("str", "status", "qosClass") }, + node: { labels: LABELS, unschedulable: ref("bool", "spec", "unschedulable") }, + deployment: { conditions: CONDITIONS }, + daemonset: { node_selector: ref("json", "spec", "template", "spec", "nodeSelector") }, + cronjob: { last_schedule: ref("str", "status", "lastScheduleTime") }, + namespace: { labels: LABELS }, }; // The map variant reads only what the map filters or groups on. -export const MAP_COLUMNS: Record<"pod" | "node", Record> = { - pod: { ...LIST_COLUMNS.pod, labels: "json_get_json(body_object_metadata,'labels')" }, +export const MAP_COLUMNS: Record<"pod" | "node", Record> = { + pod: { ...LIST_COLUMNS.pod, labels: LABELS }, node: { ...LIST_COLUMNS.node }, }; // The overview variant reads only what "for 2h" on Needs attention is derived from. -export const OVERVIEW_COLUMNS: Record<"pod" | "node", Record> = { - pod: { - conditions: "json_get_json(body_object_status,'conditions')", - container_statuses: "json_get_json(body_object_status,'containerStatuses')", - }, - node: { conditions: "json_get_json(body_object_status,'conditions')" }, +export const OVERVIEW_COLUMNS: Record<"pod" | "node", Record> = { + pod: { conditions: CONDITIONS, container_statuses: ref("json", "status", "containerStatuses") }, + node: { conditions: CONDITIONS }, }; -const CREATED = "json_get_str(body_object_metadata,'creationTimestamp')"; +const CREATED = ref("str", "metadata", "creationTimestamp"); + +const BODY_PARTS = ["metadata", "spec", "status"]; const OBJECT_LIST_LIMIT = 5000; @@ -74,7 +79,9 @@ export function objectListSql( namespaces: string[], variant = LIST_COLUMNS[kind], ) { - const columns = Object.entries({ ...variant, created: CREATED }); + const columns = Object.entries({ ...variant, created: CREATED }).map( + ([alias, c]) => [alias, col(c.path, c.kind)] as const, + ); const nsTerm = KIND_INFO[kind].namespaced && namespaces.length ? ` AND k8s_namespace_name IN (${namespaces.map(q).join(", ")})` @@ -97,10 +104,11 @@ export function objectDetailSql( let terms = ` AND event_name = ${q(target.name)}`; if (KIND_INFO[kind].namespaced) terms += ` AND k8s_namespace_name = ${q(target.namespace)}`; if (kind === "pod" && target.uid) { - terms += ` AND json_get_str(body_object_metadata,'uid') = ${q(target.uid)}`; + terms += ` AND ${col(["metadata", "uid"])} = ${q(target.uid)}`; } - const bodies = "body_object_metadata, body_object_spec, body_object_status"; - return objectSql(kind, cluster, bodies, bodies, terms, 1); + const inner = BODY_PARTS.map((p) => selectAs(col([p], "json"), `body_object_${p}`)).join(", "); + const outer = BODY_PARTS.map((p) => `body_object_${p}`).join(", "); + return objectSql(kind, cluster, inner, outer, terms, 1); } export function parseObjects(hits: any[]): ObjectRecord[] { @@ -248,8 +256,8 @@ function objectSql( ) { return ( `SELECT uid, event_name, k8s_namespace_name, body_type, ${outerColumns}, _timestamp FROM (` + - "SELECT json_get_str(body_object_metadata,'uid') AS uid, event_name, k8s_namespace_name, " + - `body_type, ${innerColumns}, _timestamp, ` + + `SELECT ${selectAs(col(["metadata", "uid"]), "uid")}, event_name, k8s_namespace_name, ` + + `${selectAs(bodyTypeCol(), "body_type")}, ${innerColumns}, _timestamp, ` + "ROW_NUMBER() OVER (PARTITION BY k8s_cluster, k8s_resource_name, " + "COALESCE(k8s_namespace_name, ''), event_name ORDER BY _timestamp DESC) AS rn " + `FROM "${EVENTS_STREAM}" WHERE k8s_resource_name = ${q(KIND_INFO[kind].resource)} ` + diff --git a/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.spec.ts b/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.spec.ts index a4eb304c94..044e1c867b 100644 --- a/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.spec.ts +++ b/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.spec.ts @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ref } from "vue"; import * as objects from "./kubernetesObjects"; import searchService from "@/services/search"; @@ -21,6 +21,7 @@ import { queryClient } from "@/composables/query/queryClient"; import * as queries from "@/services/kubernetes.queries"; import { useKubernetesInventory, type K8sTime } from "./useKubernetesInventory"; import { CLUSTER_QUERIES, QUERY_STREAM, queryText, type QueryId } from "./kubernetesQueries"; +import { col, setStreamColumns } from "./kubernetesFlatten"; import { parseUrlState, type K8sUrlState } from "./kubernetesUrlState"; const getStreams = vi.fn(); @@ -117,6 +118,8 @@ const setup = async ( }; describe("useKubernetesInventory", () => { + afterEach(() => setStreamColumns(null)); + beforeEach(() => { vi.clearAllMocks(); queryClient.clear(); @@ -378,6 +381,19 @@ describe("useKubernetesInventory", () => { await inv.load(); expect(search).not.toHaveBeenCalled(); }); + + it("clears the previous column set when a later stream load fails", async () => { + await setup({ view: "events" }, { eventFields: ["k8s_cluster", "body"] }); + expect(col(["kind"])).toBe("json_get_str(body,'object','kind')"); + getStreams.mockRejectedValue(new Error("down")); + const inv = useKubernetesInventory( + () => parseUrlState({ view: "events" }), + () => ({ start: START, end: END, relative: true }), + () => "org2", + ); + await inv.loadStreams(); + expect(col(["kind"])).toBe("body_object_kind"); + }); }); describe("map object queries (AC 81)", () => { @@ -525,6 +541,20 @@ describe("useKubernetesInventory", () => { expect(sentSql()[0]).toMatchObject({ start_time: START, end_time: END }); }); + it("builds the events SQL from the stream's actual columns at flatten level 2", async () => { + const { inv } = await setup( + { view: "events" }, + { eventFields: ["k8s_cluster", "body_object", "body_type"] }, + ); + await inv.load(); + const events = sentSql().filter((q: any) => q.sql.includes("k8s_events")); + expect(events.length).toBeGreaterThan(0); + for (const q of events) { + expect(q.sql).toContain("json_get_str(body_object,'kind')"); + expect(q.sql).not.toContain("body_object_kind"); + } + }); + it("marks all of 1,200 affected objects, and banners a truncated W", async () => { const pods = Array.from({ length: 1200 }, (_, i) => `p${i}`); fixture = { diff --git a/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.ts b/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.ts index 36d99c3f96..47dfd0a2ca 100644 --- a/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.ts +++ b/web/src/views/Infrastructure/kubernetes2/useKubernetesInventory.ts @@ -74,6 +74,7 @@ import { parseObjects, type ObjectRecord, } from "./kubernetesObjects"; +import { setStreamColumns } from "./kubernetesFlatten"; import { encodeDetails, type DetailsRef, type K8sUrlState } from "./kubernetesUrlState"; export type Detection = "unknown" | "error" | "undetected" | "detected"; @@ -232,14 +233,15 @@ export function useKubernetesInventory( ]); const logNames = new Set(((logs?.list ?? []) as Array<{ name: string }>).map((s) => s.name)); let scoped = false; + let columns: string[] | null = null; if (logNames.has(EVENTS_STREAM)) { // A failed lookup must not read as "no k8s_cluster field", which would run events unscoped. const schema: any = await getStream(EVENTS_STREAM, "logs", true); - scoped = ((schema?.schema ?? []) as Array<{ name: string }>).some( - (f) => f.name === "k8s_cluster", - ); + columns = ((schema?.schema ?? []) as Array<{ name: string }>).map((f) => f.name); + scoped = columns.includes("k8s_cluster"); } if (gen !== streamsGeneration) return; + setStreamColumns(columns); metricStreams.value = new Set( ((metrics?.list ?? []) as Array<{ name: string }>).map((s) => s.name), ); @@ -248,6 +250,7 @@ export function useKubernetesInventory( streamsError.value = false; } catch { if (gen !== streamsGeneration) return; + setStreamColumns(null); metricStreams.value = null; logStreams.value = null; streamsError.value = true; @@ -260,6 +263,7 @@ export function useKubernetesInventory( countsGeneration++; detailsTarget = undefined; streamsGeneration++; + setStreamColumns(null); metricStreams.value = null; logStreams.value = null; eventsScoped.value = false; From 43cdd4fe618963244457cbea25c5858dd9f61d0d Mon Sep 17 00:00:00 2001 From: uddhavdave Date: Tue, 3 Nov 2026 11:52:08 +0000 Subject: [PATCH 02/35] feat(ai-obs): fix the AI observability UX gaps (#15984) --- skills/openobserve-instrument/SKILL.md | 313 + .../openobserve-instrument/scripts/verify.py | 338 + src/api/http/src/handler/http/router/mod.rs | 11 +- .../http/src/handler/http/router/openapi.rs | 43 + .../http/router/openapi_coverage/baseline.txt | 4 - src/api/http/src/router/http/mod.rs | 5 + src/api/management/src/models/datasets/mod.rs | 16 +- .../src/models/experiment_comparison.rs | 3 +- src/api/management/src/models/experiments.rs | 120 +- src/api/management/src/models/scorers/mod.rs | 73 +- .../management/src/request/datasets/mod.rs | 2 +- src/api/management/src/request/experiments.rs | 83 +- .../management/src/request/gen_ai_health.rs | 1716 +++++ src/api/management/src/request/mod.rs | 1 + .../management/src/request/model_pricing.rs | 279 +- src/api/search/src/traces/list/query.rs | 4 +- src/api/search/src/traces/list/response.rs | 14 +- src/api/search/src/traces/schema_compat.rs | 76 + src/api/search/src/traces/session.rs | 12 +- src/builtins/src/model_pricing.rs | 178 +- src/builtins/src/model_pricing_seed.json | 5622 +++++++++++++++++ src/config/src/config.rs | 3 +- src/config/src/meta/eval_job_health.rs | 5 +- src/config/src/meta/model_pricing.rs | 29 + .../meta/self_reporting/llm_experiments.rs | 8 + src/config/src/router.rs | 23 +- src/core/src/traces/mod.rs | 65 +- src/core/src/traces/otel/attributes.rs | 3 + .../traces/otel/extractors/input_output.rs | 141 +- src/core/src/traces/otel/mod.rs | 4 +- src/core/src/traces/otel/processor.rs | 471 +- src/db/src/schema.rs | 6 + .../src/table/entity/llm_dataset_items.rs | 35 + ...0001_add_llm_dataset_item_write_version.rs | 222 + src/infra/src/table/migration/mod.rs | 3 + src/infra/src/table/model_pricing.rs | 105 +- src/infra/src/table/score_configs.rs | 17 +- src/infra/src/table/scorers.rs | 33 +- src/jobs/src/job/mod.rs | 6 +- src/super_cluster_queue/src/eval_datasets.rs | 154 +- web/src/components/common/KpiCard.vue | 9 +- .../ingestion/ai/AIIntegrationDetail.spec.ts | 53 +- .../ingestion/ai/AIIntegrationDetail.vue | 54 +- .../ingestion/ai/CodingAgentSetup.spec.ts | 97 + .../ingestion/ai/CodingAgentSetup.vue | 164 + .../ingestion/ai/codingAgentPrompt.spec.ts | 22 + .../ingestion/ai/codingAgentPrompt.ts | 42 + .../ingestion/setupCard/SetupCardRenderer.vue | 14 +- .../components/ingestion/setupCard/envFile.ts | 41 + .../settings/ModelPricingEditor.spec.ts | 40 + .../settings/ModelPricingEditor.vue | 29 +- .../settings/ModelPricingGaps.spec.ts | 189 + .../components/settings/ModelPricingGaps.vue | 179 + .../settings/ModelPricingList.spec.ts | 72 + .../components/settings/ModelPricingList.vue | 73 +- .../settings/UnpricedModelsTable.spec.ts | 315 + .../settings/UnpricedModelsTable.vue | 467 ++ web/src/components/settings/index.vue | 1 + .../settings/unpricedModelPricing.spec.ts | 113 + .../settings/unpricedModelPricing.ts | 57 + .../shared/useManagementRoutes.spec.ts | 30 +- .../composables/shared/useManagementRoutes.ts | 10 + web/src/composables/useTraces.ts | 3 + .../AIObservability/AgentScopeCascade.vue | 18 +- .../AIObservability/AiAgentsLoadError.vue | 56 + .../AiNoAgentIdentityBanner.vue | 70 + .../components/AIObservability/AiScopeBar.vue | 5 + .../AIObservability/ExperimentBrowser.vue | 5 +- .../ExperimentComparisonRowDrawer.spec.ts | 2 +- .../ExperimentForm.drafts.spec.ts | 6 +- .../AIObservability/ExperimentForm.spec.ts | 22 +- .../AIObservability/ExperimentForm.vue | 78 +- .../ExperimentRowDetailDrawer.spec.ts | 2 +- .../ExperimentRowDetailDrawer.vue | 2 +- .../RemoteTaskFormPage.spec.ts | 1 + .../AIObservability/RemoteTaskFormPage.vue | 1 + .../components/onlineEvals/EvalJobList.vue | 9 +- .../onlineEvals/ImportScoreConfig.vue | 10 +- .../components/onlineEvals/ImportScorer.vue | 7 +- .../onlineEvals/ScoreConfigLibrary.vue | 7 +- .../components/onlineEvals/ScorerLibrary.vue | 25 +- .../composables/useTraceScoreChips.spec.ts | 14 +- .../composables/useTraceScoreChips.ts | 6 +- .../onlineEvals/forms/JobFormPage.vue | 18 +- .../onlineEvals/forms/ScoreConfigDialog.vue | 21 +- .../forms/ScorerFormPage.schema.ts | 15 + .../onlineEvals/forms/ScorerFormPage.spec.ts | 26 + .../onlineEvals/forms/ScorerFormPage.vue | 42 +- .../composables/useAgentScope.spec.ts | 207 +- .../enterprise/composables/useAgentScope.ts | 303 +- .../AIObservability/AgentBehaviorPage.spec.ts | 181 + .../AIObservability/AgentBehaviorPage.vue | 151 +- .../AIObservability/AgentGraphPage.spec.ts | 142 +- .../views/AIObservability/AgentGraphPage.vue | 230 +- .../AIObservability/DatasetDetailPage.vue | 4 +- .../views/AIObservability/DatasetsPage.vue | 5 +- .../ExperimentComparePage.spec.ts | 2 +- .../ExperimentDetailPage.spec.ts | 5 +- .../AIObservability/ExperimentDetailPage.vue | 147 +- .../views/AIObservability/LLMInsightsPage.vue | 13 +- .../views/AIObservability/PlaygroundPage.vue | 13 +- .../views/AIObservability/SessionsPage.vue | 9 +- .../experimentDiscovery.spec.ts | 22 +- .../AIObservability/experimentDiscovery.ts | 45 + .../experimentProviderCheck.spec.ts | 102 + .../experimentProviderCheck.ts | 119 + .../AIObservability/experimentTestFixtures.ts | 1 + .../lib/core/EmptyState/OEmptyState.spec.ts | 77 +- web/src/lib/core/EmptyState/OEmptyState.vue | 22 +- web/src/lib/core/EmptyState/presets.ts | 14 +- web/src/locales/languages/en-US.json | 186 +- web/src/plugins/traces/LLMCostCell.vue | 119 + .../plugins/traces/LLMDataHealthStrip.spec.ts | 152 + web/src/plugins/traces/LLMDataHealthStrip.vue | 378 ++ web/src/plugins/traces/LLMErrorTable.spec.ts | 132 + web/src/plugins/traces/LLMErrorTable.vue | 30 +- .../traces/LLMInsightsDashboard.spec.ts | 382 +- .../plugins/traces/LLMInsightsDashboard.vue | 617 +- web/src/plugins/traces/SessionDetails.spec.ts | 76 +- web/src/plugins/traces/SessionDetails.vue | 107 +- web/src/plugins/traces/SessionsList.spec.ts | 206 +- web/src/plugins/traces/SessionsList.vue | 268 +- web/src/plugins/traces/ThreadToolCalls.vue | 15 +- web/src/plugins/traces/ThreadView.spec.ts | 54 +- web/src/plugins/traces/ThreadView.vue | 17 +- web/src/plugins/traces/agentOptionFormat.ts | 16 +- .../components/TracesSearchResultList.vue | 22 +- .../composables/useLLMDataHealth.spec.ts | 115 + .../traces/composables/useLLMDataHealth.ts | 142 + .../plugins/traces/composables/useSessions.ts | 23 + .../plugins/traces/llmDataTrust.utils.spec.ts | 167 + web/src/plugins/traces/llmDataTrust.utils.ts | 154 + .../traces/llmInsightsEmptyState.spec.ts | 65 + .../plugins/traces/llmInsightsEmptyState.ts | 75 + .../services/gen-ai-data-health.service.ts | 85 + web/src/services/llm-datasets.service.ts | 13 +- web/src/services/llm-experiments.service.ts | 34 +- web/src/services/model_pricing.queries.ts | 31 +- web/src/services/model_pricing.querykeys.ts | 4 + web/src/services/model_pricing.ts | 83 +- .../online-evals.service.queries.spec.ts | 159 + .../services/online-evals.service.queries.ts | 29 + web/src/services/online-evals.service.ts | 19 - web/src/services/sessions.ts | 4 + 144 files changed, 17513 insertions(+), 1068 deletions(-) create mode 100644 skills/openobserve-instrument/SKILL.md create mode 100755 skills/openobserve-instrument/scripts/verify.py create mode 100644 src/api/management/src/request/gen_ai_health.rs create mode 100644 src/builtins/src/model_pricing_seed.json create mode 100644 src/infra/src/table/migration/m20261028_000001_add_llm_dataset_item_write_version.rs create mode 100644 web/src/components/ingestion/ai/CodingAgentSetup.spec.ts create mode 100644 web/src/components/ingestion/ai/CodingAgentSetup.vue create mode 100644 web/src/components/ingestion/ai/codingAgentPrompt.spec.ts create mode 100644 web/src/components/ingestion/ai/codingAgentPrompt.ts create mode 100644 web/src/components/ingestion/setupCard/envFile.ts create mode 100644 web/src/components/settings/ModelPricingGaps.spec.ts create mode 100644 web/src/components/settings/ModelPricingGaps.vue create mode 100644 web/src/components/settings/UnpricedModelsTable.spec.ts create mode 100644 web/src/components/settings/UnpricedModelsTable.vue create mode 100644 web/src/components/settings/unpricedModelPricing.spec.ts create mode 100644 web/src/components/settings/unpricedModelPricing.ts create mode 100644 web/src/enterprise/components/AIObservability/AiAgentsLoadError.vue create mode 100644 web/src/enterprise/components/AIObservability/AiNoAgentIdentityBanner.vue create mode 100644 web/src/enterprise/views/AIObservability/AgentBehaviorPage.spec.ts create mode 100644 web/src/enterprise/views/AIObservability/experimentProviderCheck.spec.ts create mode 100644 web/src/enterprise/views/AIObservability/experimentProviderCheck.ts create mode 100644 web/src/plugins/traces/LLMCostCell.vue create mode 100644 web/src/plugins/traces/LLMDataHealthStrip.spec.ts create mode 100644 web/src/plugins/traces/LLMDataHealthStrip.vue create mode 100644 web/src/plugins/traces/LLMErrorTable.spec.ts create mode 100644 web/src/plugins/traces/composables/useLLMDataHealth.spec.ts create mode 100644 web/src/plugins/traces/composables/useLLMDataHealth.ts create mode 100644 web/src/plugins/traces/llmDataTrust.utils.spec.ts create mode 100644 web/src/plugins/traces/llmDataTrust.utils.ts create mode 100644 web/src/plugins/traces/llmInsightsEmptyState.spec.ts create mode 100644 web/src/plugins/traces/llmInsightsEmptyState.ts create mode 100644 web/src/services/gen-ai-data-health.service.ts create mode 100644 web/src/services/online-evals.service.queries.spec.ts diff --git a/skills/openobserve-instrument/SKILL.md b/skills/openobserve-instrument/SKILL.md new file mode 100644 index 0000000000..dcdd745f7f --- /dev/null +++ b/skills/openobserve-instrument/SKILL.md @@ -0,0 +1,313 @@ +--- +name: openobserve-instrument +description: Instrument an LLM or AI-agent app to send OpenTelemetry traces to OpenObserve, run one sample request, print the link to the first trace, and run the OpenObserve AI data health checks. Use when the user asks to instrument, trace, or connect an AI app to OpenObserve, or to check that its AI telemetry is complete. +--- + +# Instrument an AI app for OpenObserve and verify the data + +You edit the user's project so every LLM call is traced to OpenObserve, prove it with one real +request, and report what is still missing. Work in the project root. Print what you change at each +step; leave the diff for the user to review and commit. + +Helper script: `scripts/verify.py` next to this file (Python 3 standard library only). If you +fetched this file by URL, fetch the script from the same directory: +`https://raw.githubusercontent.com/openobserve/openobserve/main/skills/openobserve-instrument/scripts/verify.py` +and save it outside the project (for example `/tmp/openobserve-verify.py`). + +## Rules + +- Never print, echo, log or paste a secret value. Mention variable **names** only. Do not `cat` the + `.env`; read it with code that prints names, or let `verify.py` read it. +- Never put the token in source code, the chat, or a command line you show. The app reads it from + the environment (`load_dotenv()`). +- Do not commit. Do not change unrelated code. +- The org and stream named in the user's prompt win over values in `.env`. + +## 1. Read the configuration + +The user downloads a `.env` from the OpenObserve AI integration card into the project root. It holds: + +| Variable | Required | Meaning | +|---|---|---| +| `OPENOBSERVE_URL` | yes | OpenObserve API base URL, e.g. `https://api.openobserve.ai` | +| `OPENOBSERVE_ORG` | yes | Organization identifier | +| `OPENOBSERVE_AUTH_TOKEN` | yes | `Basic ` (or `Bearer `) | +| `OPENOBSERVE_TRACES_STREAM_NAME` | no | Traces stream; `OPENOBSERVE_STREAM` is accepted too. Default `default` | +| `OPENOBSERVE_UI_URL` | no | Web UI root when it differs from `OPENOBSERVE_URL`, used for links only (links are `/web/…`) | + +Check which names exist, for example: + +```bash +python3 -c "import re;print(', '.join(m.group(1) for l in open('.env') if (m:=re.match(r'\s*(?:export\s+)?(OPENOBSERVE_\w+)\s*=',l))))" +``` + +Print `✓ .env found: `. If a required name is missing, stop and tell the user to download +the `.env` from the integration card (Data Sources → AI Integrations → *their SDK* → *With a coding +agent*). If the user named an org or a stream, set `OPENOBSERVE_ORG=` or +`OPENOBSERVE_TRACES_STREAM_NAME=` in `.env` (the SDK reads these names). Make sure `.env` +is listed in `.gitignore`; add it if not. + +## 2. Read the project + +Find and print one line, e.g. `Python 3.12 · openai 1.54 · entry point app/main.py · agent name "research-agent"`: + +- **Language and runtime**: `pyproject.toml`, `requirements*.txt`, `setup.py`, `package.json`, `go.mod`, … +- **LLM SDK / framework** in use: imports and dependencies (`openai`, `anthropic`, `google-genai`, + `langchain`, `crewai`, `google-adk`, `openai-agents`, `litellm`, `claude-agent-sdk`, OpenRouter + via the `openai` client, …). +- **Entry point**: the module that starts the app (`__main__`, `main.py`, CLI script in + `pyproject.toml`, server startup file). +- **Agent name**: the project or service name (`[project].name`, package name, directory name). +- **Version source**: `[project].version`, `__version__`, `package.json` `version`, or the git + short SHA (`git rev-parse --short HEAD`) when nothing else exists. +- **Session id**: whatever identifies one conversation or request in the app (chat/thread/session + id, request id). If the app has none, generate one per run with `uuid.uuid4()`. + +## 3. Install + +### Python, supported SDK — use the OpenObserve installer + +Run the installer the integration card uses, pinned to a reviewed commit: download it to a temp +directory, check its SHA-256 against the one below, and run it from there only if they match; +never pipe a URL into a shell. A mismatch means the file is not the reviewed one: stop and tell +the user instead of running it or retrying another way. It installs the packages into the +interpreter it finds and verifies the imports. It takes the token only as a command-line argument, +which other local processes can read, so it never gets the real one: the project's `.env` already +holds the token. Give it a placeholder and point its env file into the temp directory, so it +cannot overwrite the project's `.env`. The URL is not a secret; read it from `.env` inside the +command. Do not `source` the `.env`: the token value `Basic ` contains an unquoted space, +which a shell reads as a command. + +```bash +env_get() { python3 -c 'import sys +for l in open(".env"): + k, _, v = l.strip().removeprefix("export ").partition("=") + if k.strip() == sys.argv[1]: print(v.strip().strip("\"'\''")); break' "$1"; } +O2_INSTALLER_RAW=https://raw.githubusercontent.com/openobserve/o2-datasource/9fdf2ca60b03a43273677a875140cfd19e29262b/ai +O2_INSTALLER_SHA256=307c0d275c8b91f270d557d41524d438f799934fe8502b513e3a22e6b2fcb0af +O2_INSTALLER_DIR="$(mktemp -d)" +mkdir "$O2_INSTALLER_DIR/frameworks" +curl -fsSL "$O2_INSTALLER_RAW/frameworks/setup.sh" -o "$O2_INSTALLER_DIR/frameworks/setup.sh" +if command -v sha256sum >/dev/null; then o2_sha256() { sha256sum "$@"; } +else o2_sha256() { shasum -a 256 "$@"; }; fi +if echo "$O2_INSTALLER_SHA256 $O2_INSTALLER_DIR/frameworks/setup.sh" | o2_sha256 -c -; then + REPO_RAW="$O2_INSTALLER_RAW" bash "$O2_INSTALLER_DIR/frameworks/setup.sh" \ + --integration= \ + --url="$(env_get OPENOBSERVE_URL)" \ + --org= \ + --token="Bearer placeholder" \ + --env-file="$O2_INSTALLER_DIR/.env" \ + --quiet +else + echo "setup.sh does not match the reviewed SHA-256; not running it" >&2 +fi +``` + +The SHA-256 is that of `ai/frameworks/setup.sh` at the pinned commit. Moving the pin to a newer +reviewed commit means updating both lines: `O2_INSTALLER_RAW`, and `O2_INSTALLER_SHA256` from +`gh api -H "Accept: application/vnd.github.raw" +"repos/openobserve/o2-datasource/contents/ai/frameworks/setup.sh?ref=" | shasum -a 256`. + +`REPO_RAW` makes the installer fetch its helper library and integration list from the same +commit. `` is one of `openai`, `anthropic`, `gemini`, `langchain`, `crewai`, +`google-adk`, `claude-agent-sdk`, `openai-agents`, `openrouter`, `litellm`. If the project uses a +virtualenv, uv, poetry or pipenv, install into that environment instead and add the packages to +the project's dependency file. Packages and init lines per integration: + +| Integration | Packages (plus `openobserve-telemetry-sdk python-dotenv`) | Instrumentor | +|---|---|---| +| openai | `opentelemetry-instrumentation-openai` | `from opentelemetry.instrumentation.openai import OpenAIInstrumentor` | +| anthropic | `opentelemetry-instrumentation-anthropic` | `from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor` | +| gemini | `opentelemetry-instrumentation-google-generativeai` | `from opentelemetry.instrumentation.google_generativeai import GoogleGenerativeAiInstrumentor` | +| langchain | `opentelemetry-instrumentation-langchain` | `from opentelemetry.instrumentation.langchain import LangchainInstrumentor` | +| crewai | `openinference-instrumentation-crewai` | `from openinference.instrumentation.crewai import CrewAIInstrumentor` (set `CREWAI_TELEMETRY_OPT_OUT=true`; call `openobserve_init()` first) | +| google-adk | `openinference-instrumentation-google-adk` | `from openinference.instrumentation.google_adk import GoogleADKInstrumentor` | +| openai-agents | `openinference-instrumentation-openai-agents` | `from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor` | +| openrouter | `openinference-instrumentation-openai` | `from openinference.instrumentation.openai import OpenAIInstrumentor` | +| litellm | `openinference-instrumentation-litellm` | `from openinference.instrumentation.litellm import LiteLLMInstrumentor` | +| claude-agent-sdk | — | wrap each `query()` in a span (see the snippet the installer saves, below) | + +The installer's env file holds the placeholder token; never copy its `OPENOBSERVE_*` lines. +Merge only the integration's extra variables (for example `CREWAI_TELEMETRY_OPT_OUT`) into the +project's `.env`, keep the snippet it saved (`paste_me_.py`) if you need it, then +remove the temp directory: + +```bash +python3 - "$O2_INSTALLER_DIR/.env" <<'PY' +import sys +def name(line): + return line.strip().removeprefix("export ").partition("=")[0].strip() +text = open(".env").read() +have = {name(l) for l in text.splitlines() if "=" in l} +extra = [l.strip() for l in open(sys.argv[1]) + if "=" in l and not l.lstrip().startswith("#") + and not name(l).startswith("OPENOBSERVE_") and name(l) not in have] +if extra: + with open(".env", "a") as f: + f.write(("" if not text or text.endswith("\n") else "\n") + "\n".join(extra) + "\n") +print("extra env:", ", ".join(name(x) for x in extra) or "none") +PY +cat "$O2_INSTALLER_DIR/paste_me_.py" +rm -rf "$O2_INSTALLER_DIR" +``` + +Print `✓ setup.sh@9fdf2ca --integration=` and the extra env names (no values). + +### Anything else — plain OTLP/HTTP + +For other languages or SDKs without an instrumentor, use the standard OpenTelemetry SDK with the +OTLP/HTTP exporter and an OpenInference or OpenTelemetry GenAI instrumentor where one exists: + +- Endpoint: `/api//v1/traces` (protobuf or JSON over HTTP). +- Headers: `Authorization: ` and `stream-name: `. +- Environment form, read by every OpenTelemetry SDK: + `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=/api//v1/traces`, + `OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Basic%20,stream-name=` + (write these into `.env` from the existing values; do not print them). +- If no instrumentor exists, wrap each LLM call in a span named `chat ` with + `gen_ai.operation.name=chat`, `gen_ai.provider.name`, `gen_ai.request.model`, + `gen_ai.response.model`, `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens`. + +## 4. Edit the entry point + +Put the init lines at the very top of the entry point, **before** any import of the LLM client +(instrumentors patch the client at import time). Python with the OpenAI SDK: + +```python +import os +import uuid +from contextlib import contextmanager +from typing import Optional # Python 3.9: no `str | None` + +from dotenv import load_dotenv +load_dotenv() # openobserve_init() reads OPENOBSERVE_* from the environment + +from opentelemetry import baggage, context +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.instrumentation.openai import OpenAIInstrumentor +from openobserve import get_tracer_provider, openobserve_init + +AGENT_NAME = "research-agent" # from step 2 +AGENT_VERSION = "1.4.0" # from step 2 (read it at runtime when you can) + + +class ConversationIdProcessor(SpanProcessor): + """Copy the request's conversation id onto every span started inside it.""" + + def on_start(self, span, parent_context=None): + conversation_id = baggage.get_baggage("gen_ai.conversation.id", parent_context) + if conversation_id: + span.set_attribute("gen_ai.conversation.id", str(conversation_id)) + + +OpenAIInstrumentor().instrument() +openobserve_init( + agent_name=AGENT_NAME, + resource_attributes={ + "service.name": AGENT_NAME, + "gen_ai.agent.name": AGENT_NAME, + "gen_ai.agent.version": AGENT_VERSION, + }, +) +get_tracer_provider().add_span_processor(ConversationIdProcessor()) + + +@contextmanager +def conversation(conversation_id: Optional[str] = None): + """Wrap one request / chat turn: `with conversation(session_id): ...`.""" + token = context.attach( + baggage.set_baggage("gen_ai.conversation.id", conversation_id or str(uuid.uuid4())) + ) + try: + yield + finally: + context.detach(token) + + +from openai import OpenAI # client imports come after the init lines +``` + +Then wrap the code that handles one request or conversation turn in `with conversation():`. These three attributes are what two of the data health checks look for: +`gen_ai.agent.name` (missing agent identity), `gen_ai.agent.version`, and +`gen_ai.conversation.id` (spans without session id). Short-lived scripts must exit normally (the +SDK flushes at exit) or call `openobserve_flush()` before exiting. + +Print each edit, e.g.: + +``` +✓ OpenAIInstrumentor().instrument() and openobserve_init() above "from openai import" +✓ gen_ai.agent.name = research-agent, version from pyproject.toml +✓ gen_ai.conversation.id = the request's session id +``` + +## 5. Run one sample request + +Record the start time, then run the smallest real request the app supports (a CLI flag, a test +script, or one HTTP call to the running server). Use the app's own credentials (`OPENAI_API_KEY` +etc.); never ask for them in chat. + +```bash +START=$(date +%s) +python -m app.main --once "Summarize the EU AI Act disclosure rules" +``` + +If the app cannot make a real call (no API key), tell the user and stop; do not fake spans. +`load_dotenv()` never overrides a variable that is already set, even to an empty string; if the +app reports a missing key that `.env` has, `unset` the empty variable in the shell first. + +## 6. Verify: first trace and data health + +```bash +python3 /scripts/verify.py --env-file .env --stream --since "$START" --service --agent +``` + +`--agent` scopes the data-health checks to this app; without it they cover every app on the stream. + +The script waits up to 120 s for GenAI spans (those with `gen_ai.operation.name`; HTTP or database +spans alone do not count), prints the span count and the link to the first trace, then calls +`GET /api//gen_ai/data_health?stream_name=…&start_time=…&end_time=…&refresh=true&include_samples=true` +and prints each check with the page that fixes it. Show its output to the user as is: + +``` + ✓ 14 spans arrived in 6.2 s + + First trace + https:///web/traces/trace-details?stream=default&trace_id=4e0c91d7…&from=…&to=…&org_identifier=default + + Data health + ✓ Missing agent identity 0 of 14 spans + ✓ Spans without session id 0 of 14 spans + ✓ Zero-duration spans 0 of 14 spans + ✗ Unpriced models acme-internal-llm · 3,420 tokens counted as $0 + Fix in LLM Model Pricing: https:///web/settings/model_pricing/gaps?stream=default&org_identifier=default + + 3 of 4 checks pass. Fix the items above, then run the checks again. + Exit code: 1 (data-health checks found an error). +``` + +Exit codes: `0` GenAI spans arrived and no check is in error, `1` a check is in error, `2` +configuration problem, a server without the data health API (upgrade it), or no GenAI span before +the timeout (every check covering 0 spans counts as +none). On `2` with no GenAI spans: confirm the OpenAI/Anthropic instrumentor is installed, the init +lines run before the client import, the process exits cleanly, and the stream name matches. + +What each failed check means and where to fix it: + +| Check | Fix | +|---|---| +| Missing agent identity | Set `gen_ai.agent.name` (step 4) or map an existing attribute in Settings → GenAI Agent Mapping | +| Spans without session id | Wrap each request in `conversation(...)` (step 4) | +| Zero-duration spans | Spans ended before the work did — usually a span closed before a streamed response was consumed. See the AI integration docs | +| Unpriced models | Add the model's price in Settings → LLM Model Pricing (the link opens the unpriced list) | + +When the user fixes something and asks to check again, rerun only the checks: + +```bash +python3 /scripts/verify.py --env-file .env --stream --since "$START" --agent --health-only +``` + +## 7. Report + +End with: the files you changed, the first trace link, the health summary line, and the fixes left +for the user. diff --git a/skills/openobserve-instrument/scripts/verify.py b/skills/openobserve-instrument/scripts/verify.py new file mode 100755 index 0000000000..a1fbdee8c6 --- /dev/null +++ b/skills/openobserve-instrument/scripts/verify.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +# Copyright 2026 OpenObserve Inc. +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Wait for an instrumented app's first GenAI spans in OpenObserve, print the +first trace link, then run the OpenObserve AI data health checks. + +Only spans with gen_ai.operation.name count: HTTP or database spans alone mean +the LLM client is not instrumented. + +Python 3 standard library only. Credentials come from the project's .env (or +the process environment); the script prints variable names, never values. + +Exit codes: + 0 GenAI spans arrived and no health check is in error + 1 GenAI spans arrived but at least one health check is in error + 2 configuration problem, no GenAI span arrived before the timeout, or the server + has no data health API (upgrade OpenObserve) +""" + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +REQUIRED_VARS = ("OPENOBSERVE_URL", "OPENOBSERVE_ORG", "OPENOBSERVE_AUTH_TOKEN") +STREAM_VARS = ("OPENOBSERVE_TRACES_STREAM_NAME", "OPENOBSERVE_STREAM") +DOCS_URL = "https://openobserve.ai/docs/integration/ai/" + +CHECK_ORDER = ( + "missing_agent_identity", + "missing_session_id", + "zero_duration_spans", + "unpriced_models", +) +CHECK_LABELS = { + "missing_agent_identity": "Missing agent identity", + "missing_session_id": "Spans without session id", + "zero_duration_spans": "Zero-duration spans", + "unpriced_models": "Unpriced models", +} +STATUS_MARKS = {"pass": "✓", "warning": "!", "error": "✗"} + + +def parse_env_file(path): + """Parse KEY=VALUE lines (optional `export`, quotes and comments).""" + values = {} + with open(path, encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.startswith("export "): + line = line[len("export ") :].lstrip() + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + elif " #" in value: + value = value.split(" #", 1)[0].rstrip() + values[key] = value + return values + + +def load_config(env_file): + file_values = {} + if os.path.isfile(env_file): + file_values = parse_env_file(env_file) + found = [k for k in REQUIRED_VARS + STREAM_VARS + ("OPENOBSERVE_UI_URL",) if k in file_values] + print(f" .env found ({env_file}): {', '.join(found) if found else 'no OPENOBSERVE_* variables'}") + else: + print(f" No .env at {env_file}; using the process environment") + + def get(name): + return os.environ.get(name) or file_values.get(name) or "" + + missing = [name for name in REQUIRED_VARS if not get(name)] + if missing: + print(f"✗ Missing {', '.join(missing)}. Download the .env from the AI integration card.") + sys.exit(2) + return get + + +def auth_header(token): + token = token.strip() + if token.split(" ", 1)[0] in ("Basic", "Bearer"): + return token + return f"Basic {token}" + + +class Client: + def __init__(self, base_url, org, token): + self.base_url = base_url.rstrip("/") + self.org = org + self.auth = auth_header(token) + + def request(self, method, path, query=None, body=None): + url = f"{self.base_url}/api/{urllib.parse.quote(self.org, safe='')}/{path}" + if query: + url += "?" + urllib.parse.urlencode(query) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", self.auth) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8") or "{}") + + def search_traces(self, sql, start_us, end_us, size): + body = { + "query": { + "sql": sql, + "start_time": start_us, + "end_time": end_us, + "from": 0, + "size": size, + } + } + return self.request("POST", "_search", {"type": "traces"}, body).get("hits", []) + + +def sql_string(value): + return "'" + value.replace("'", "''") + "'" + + +def first_trace(client, stream, service, start_us, end_us): + """Return (span_count, first_trace_id) for GenAI spans in the window, or (0, None).""" + where = " WHERE gen_ai_operation_name IS NOT NULL" + if service: + where += f" AND service_name = {sql_string(service)}" + table = '"' + stream.replace('"', '""') + '"' + count_hits = client.search_traces( + f"SELECT count(*) AS spans FROM {table}{where}", start_us, end_us, 1 + ) + spans = int(count_hits[0].get("spans", 0)) if count_hits else 0 + if spans == 0: + return 0, None + trace_hits = client.search_traces( + f"SELECT trace_id, min(_timestamp) AS first_ts FROM {table}{where} " + "GROUP BY trace_id ORDER BY first_ts ASC LIMIT 1", + start_us, + end_us, + 1, + ) + return spans, (trace_hits[0].get("trace_id") if trace_hits else None) + + +def wait_for_spans(client, stream, service, start_us, timeout_s): + started = time.monotonic() + last_error = None + while True: + end_us = int(time.time() * 1_000_000) + try: + spans, trace_id = first_trace(client, stream, service, start_us, end_us) + if spans and trace_id: + return spans, trace_id, time.monotonic() - started + except urllib.error.HTTPError as err: + # 401/403 never heal; anything else (e.g. the stream does not exist yet) may. + if err.code in (401, 403): + print(f"✗ OpenObserve rejected the credentials (HTTP {err.code}). Check OPENOBSERVE_AUTH_TOKEN.") + sys.exit(2) + last_error = f"HTTP {err.code}" + except urllib.error.URLError as err: + last_error = str(err.reason) + if time.monotonic() - started >= timeout_s: + reason = f" (last error: {last_error})" if last_error else "" + print(f"✗ No GenAI spans on stream '{stream}' after {timeout_s:.0f} s{reason}.") + print(" Only spans with gen_ai.operation.name count. Check that the OpenAI/Anthropic") + print(" instrumentor is installed, that the init lines run before the LLM client is") + print(" imported, and that the process exits cleanly or calls openobserve_flush().") + sys.exit(2) + time.sleep(3) + + +def trace_url(ui_url, org, stream, trace_id, start_us, end_us): + query = urllib.parse.urlencode( + { + "stream": stream, + "trace_id": trace_id, + "from": start_us, + "to": end_us, + "org_identifier": org, + } + ) + return f"{ui_url}/web/traces/trace-details?{query}" + + +def fix_link(check_id, ui_url, org, stream): + org_q = urllib.parse.quote(org, safe="") + if check_id == "unpriced_models": + stream_q = urllib.parse.quote(stream, safe="") + return ( + "Fix in LLM Model Pricing", + f"{ui_url}/web/settings/model_pricing/gaps?stream={stream_q}&org_identifier={org_q}", + ) + if check_id == "missing_agent_identity": + return ( + "Fix in GenAI Agent Mapping", + f"{ui_url}/web/settings/gen_ai_agent_mapping?org_identifier={org_q}", + ) + return ("See", DOCS_URL) + + +def describe(check): + check_id = check.get("id", "") + affected = int(check.get("affected", 0)) + total = int(check.get("total", 0)) + if check_id == "unpriced_models" and check.get("models"): + parts = [ + f"{m.get('model') or '(no model name)'} · {int(m.get('tokens', 0)):,} tokens counted as $0" + for m in check["models"] + ] + if check.get("models_truncated"): + # The server lists the models with the most tokens; the totals cover all of them. + parts.append( + f"top {len(check['models'])} of {int(check.get('model_count', 0)):,} models, " + f"{int(check.get('unpriced_tokens', 0)):,} tokens in all" + ) + return ", ".join(parts) + return f"{affected:,} of {plural(total, 'span')}" + + +def plural(count, noun): + return f"{count:,} {noun}" + ("" if count == 1 else "s") + + +def run_health(client, ui_url, org, stream, start_us, end_us, agent): + query = { + "stream_name": stream, + "start_time": start_us, + "end_time": end_us, + "refresh": "true", + # The script prints a sample trace per failing check. + "include_samples": "true", + } + if agent: + query["agent"] = agent + try: + result = client.request("GET", "gen_ai/data_health", query) + except urllib.error.HTTPError as err: + if err.code == 404: + print("✗ This OpenObserve server has no AI data health API yet; upgrade it to run the checks.") + return 2 + print(f"✗ Data health request failed (HTTP {err.code}).") + return 2 + except urllib.error.URLError as err: + print(f"✗ Data health request failed ({err.reason}).") + return 2 + + checks = {c.get("id"): c for c in result.get("checks", [])} + if not any(int(c.get("total", 0)) for c in checks.values()): + scope = f" for agent '{agent}'" if agent else "" + print(f"✗ No GenAI spans on stream '{stream}'{scope} in this window: every check covered 0 spans.") + if agent: + print(" Check the agent name and time window, or omit --agent to inspect the whole stream.") + else: + print(" No span carries gen_ai.operation.name, so the LLM client is not instrumented.") + print(f" Install the OpenAI/Anthropic instrumentor: {DOCS_URL}") + return 2 + ordered = [checks[i] for i in CHECK_ORDER if i in checks] + ordered += [c for i, c in checks.items() if i not in CHECK_ORDER] + width = max(len(CHECK_LABELS.get(c.get("id"), c.get("id", ""))) for c in ordered) if ordered else 0 + passed = 0 + has_error = False + for check in ordered: + check_id = check.get("id", "") + status = check.get("status", "pass") + label = CHECK_LABELS.get(check_id, check_id) + print(f" {STATUS_MARKS.get(status, '?')} {label.ljust(width)} {describe(check)}") + if status == "pass": + passed += 1 + continue + has_error = has_error or status == "error" + text, link = fix_link(check_id, ui_url, org, stream) + print(f" {' ' * width} {text}: {link}") + samples = check.get("samples") or [] + if samples: + print(f" {' ' * width} Sample trace: {trace_url(ui_url, org, stream, samples[0], start_us, end_us)}") + + print() + if passed == len(ordered): + print(f" All {len(ordered)} checks pass.") + else: + print(f" {passed} of {len(ordered)} checks pass. Fix the items above, then run the checks again.") + return 1 if has_error else 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--env-file", default=".env", help="path to the project .env (default: ./.env)") + parser.add_argument("--stream", help="traces stream (default: OPENOBSERVE_TRACES_STREAM_NAME, OPENOBSERVE_STREAM, then 'default')") + parser.add_argument("--since", type=float, help="Unix seconds the sample run started (default: 15 minutes ago)") + parser.add_argument("--timeout", type=float, default=120, help="seconds to wait for spans (default: 120)") + parser.add_argument("--service", help="only count spans whose service.name is this value") + parser.add_argument("--agent", help="scope the health checks to this agent name") + parser.add_argument("--ui-url", help="OpenObserve web URL when it differs from OPENOBSERVE_URL (or set OPENOBSERVE_UI_URL)") + parser.add_argument("--health-only", action="store_true", help="skip waiting for spans and rerun the health checks") + args = parser.parse_args() + + get = load_config(args.env_file) + base_url = get("OPENOBSERVE_URL").rstrip("/") + org = get("OPENOBSERVE_ORG") + stream = args.stream or next((get(v) for v in STREAM_VARS if get(v)), "") or "default" + ui_url = (args.ui_url or get("OPENOBSERVE_UI_URL") or base_url).rstrip("/") + client = Client(base_url, org, get("OPENOBSERVE_AUTH_TOKEN")) + + since = args.since if args.since is not None else time.time() - 15 * 60 + # One minute of slack covers clock skew between this machine and the server. + start_us = int((since - 60) * 1_000_000) + print(f" Org {org} · stream {stream}") + + if not args.health_only: + print(f" Waiting for GenAI spans on '{stream}' (up to {args.timeout:.0f} s)…") + spans, trace_id, elapsed = wait_for_spans(client, stream, args.service, start_us, args.timeout) + # Give the exporter's trailing batch a moment, then recount so the + # printed total and the checks cover the whole run. + time.sleep(3) + end_us = int((time.time() + 60) * 1_000_000) + spans = first_trace(client, stream, args.service, start_us, end_us)[0] or spans + print(f" ✓ {plural(spans, 'span')} arrived in {elapsed:.1f} s") + print() + print(" First trace") + print(f" {trace_url(ui_url, org, stream, trace_id, start_us, end_us)}") + print() + else: + end_us = int((time.time() + 60) * 1_000_000) + + print(" Data health") + return run_health(client, ui_url, org, stream, start_us, end_us, args.agent) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index b2aa1fc96e..0cba32d8e9 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -31,10 +31,10 @@ use openobserve_api_management::request::org_domains; #[cfg(feature = "cpu-profiling")] use openobserve_api_management::request::profiling; use openobserve_api_management::request::{ - alerts, announcements, authz, dashboards, db_monitoring, downtimes, folders, ingest_rejections, - kv, metrics_usage, model_pricing, organization, public_dashboards, query_history, - rum_analytics, service_accounts, short_url, slack, slos, sourcemaps, status, status_pages, - stream, synthetics, users, + alerts, announcements, authz, dashboards, db_monitoring, downtimes, folders, gen_ai_health, + ingest_rejections, kv, metrics_usage, model_pricing, organization, public_dashboards, + query_history, rum_analytics, service_accounts, short_url, slack, slos, sourcemaps, status, + status_pages, stream, synthetics, users, }; #[cfg(feature = "cloud")] use openobserve_api_management::request::{cloud, organization::billing_group}; @@ -1346,6 +1346,9 @@ pub fn service_routes() -> Router { // NOTE: named routes MUST be registered before {model_id} or they match as a model ID .route("/{org_id}/llm/models/built-in", get(model_pricing::get_built_in)) .route("/{org_id}/llm/models/refresh-built-in", post(model_pricing::refresh_built_in)) + .route("/{org_id}/llm/models/unpriced", get(model_pricing::list_unpriced)) + // AI data health checks (LLM Insights strip, instrument skill) + .route("/{org_id}/gen_ai/data_health", get(gen_ai_health::get_data_health)) .route("/{org_id}/llm/models/test", post(model_pricing::test_model_match)) .route("/{org_id}/llm/models/{model_id}", get(model_pricing::get).put(model_pricing::update).delete(model_pricing::delete)) diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index 001f2e4dc4..315af19a64 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -315,6 +315,8 @@ use crate::{ gen_ai::get_agent_mapping, gen_ai::list_scored_agents, gen_ai::save_agent_mapping, + openobserve_api_management::request::gen_ai_health::get_data_health, + openobserve_api_management::request::model_pricing::list_unpriced, openobserve_api_management::request::alerts::deduplication::get_semantic_groups, openobserve_api_management::request::alerts::deduplication::preview_semantic_groups_diff, openobserve_api_management::request::alerts::deduplication::save_semantic_groups, @@ -607,6 +609,18 @@ use crate::{ config::meta::gen_ai::GenAiAgentMappingConfig, gen_ai::GenAiAgentListItem, gen_ai::GenAiAgentListResponse, + openobserve_api_management::request::gen_ai_health::DataHealthResponse, + openobserve_api_management::request::gen_ai_health::HealthCheck, + openobserve_api_management::request::gen_ai_health::ModelUsage, + openobserve_api_management::request::gen_ai_health::CheckId, + openobserve_api_management::request::gen_ai_health::CheckStatus, + openobserve_api_management::request::model_pricing::UnpricedModelsResponse, + openobserve_api_management::request::model_pricing::UnpricedModel, + openobserve_api_management::request::model_pricing::UnpricedModelMatch, + openobserve_api_management::request::gen_ai_health::UnpricedModelRow, + config::meta::model_pricing::CostStatus, + config::meta::model_pricing::PricingSource, + config::meta::model_pricing::PricingTierDefinition, config::meta::dashboards::Dashboard, config::meta::dashboards::v1::AxisItem, config::meta::dashboards::v1::Dashboard, @@ -1050,6 +1064,7 @@ use crate::{ (name = "Domain Management", description = "Allowed email domains for sign-in (enterprise)"), (name = "EvalJobs", description = "Online evaluation jobs (enterprise)"), (name = "Experiments", description = "LLM evaluation experiments (enterprise)"), + (name = "GenAI Settings", description = "GenAI agent mapping, the agent list and AI data health"), (name = "Groups", description = "User group management"), (name = "Keys", description = "Cipher key management (enterprise)"), (name = "LLM", description = "LLM model pricing definitions"), @@ -2156,6 +2171,34 @@ mod tests { } } + #[test] + fn unpriced_models_path_and_schemas_are_registered() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let op = &spec["paths"]["/api/{org_id}/llm/models/unpriced"]["get"]; + assert_eq!(op["operationId"], "ListUnpricedModels", "{op}"); + assert_eq!( + op["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/UnpricedModelsResponse" + ); + for code in ["403", "429"] { + assert!(op["responses"][code].is_object(), "{code}"); + assert!( + spec["paths"]["/api/{org_id}/gen_ai/data_health"]["get"]["responses"][code] + .is_object(), + "data_health {code}" + ); + } + let schemas = spec["components"]["schemas"].as_object().unwrap(); + for name in [ + "UnpricedModelsResponse", + "UnpricedModel", + "UnpricedModelMatch", + "UnpricedModelRow", + ] { + assert!(schemas.contains_key(name), "schema {name} missing"); + } + } + #[test] fn fleet_reads_are_documented_with_their_403() { let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); diff --git a/src/api/http/src/handler/http/router/openapi_coverage/baseline.txt b/src/api/http/src/handler/http/router/openapi_coverage/baseline.txt index 268163b5b5..db900afea0 100644 --- a/src/api/http/src/handler/http/router/openapi_coverage/baseline.txt +++ b/src/api/http/src/handler/http/router/openapi_coverage/baseline.txt @@ -203,7 +203,6 @@ all ratelimit POST /api/{org_id}/functions/test all mcp GET /api/{org_id}/gen_ai/agents all ratelimit GET /api/{org_id}/gen_ai/agents all summary GET /api/{org_id}/gen_ai/agents -all tag GET /api/{org_id}/gen_ai/agents all mcp GET /api/{org_id}/groups all ratelimit GET /api/{org_id}/groups all mcp POST /api/{org_id}/groups @@ -280,15 +279,12 @@ all summary PUT /api/{org_id}/service_streams/config/identity all mcp GET /api/{org_id}/settings/gen_ai/agent_mapping all ratelimit GET /api/{org_id}/settings/gen_ai/agent_mapping all summary GET /api/{org_id}/settings/gen_ai/agent_mapping -all tag GET /api/{org_id}/settings/gen_ai/agent_mapping all mcp PUT /api/{org_id}/settings/gen_ai/agent_mapping all ratelimit PUT /api/{org_id}/settings/gen_ai/agent_mapping all summary PUT /api/{org_id}/settings/gen_ai/agent_mapping -all tag PUT /api/{org_id}/settings/gen_ai/agent_mapping all mcp DELETE /api/{org_id}/settings/gen_ai/agent_registry all ratelimit DELETE /api/{org_id}/settings/gen_ai/agent_registry all summary DELETE /api/{org_id}/settings/gen_ai/agent_registry -all tag DELETE /api/{org_id}/settings/gen_ai/agent_registry all security POST /api/{org_id}/short all security GET /api/{org_id}/short/{short_id} all response POST /api/{org_id}/slack/link-requests/{request_id}/resend diff --git a/src/api/http/src/router/http/mod.rs b/src/api/http/src/router/http/mod.rs index 008cb1c137..bb12eb6d95 100644 --- a/src/api/http/src/router/http/mod.rs +++ b/src/api/http/src/router/http/mod.rs @@ -897,6 +897,11 @@ mod tests { assert!(is_querier_route("/api/default/config")); assert!(!is_querier_route("/api/default/config/_json")); assert!(is_querier_route("/api/default/ai/chat_stream")); + assert!(is_querier_route( + "/api/default/gen_ai/data_health?stream_name=default&start_time=1&end_time=2" + )); + assert!(is_querier_route("/api/default/llm/models/unpriced")); + assert!(!is_querier_route("/api/default/llm/models")); } #[test] diff --git a/src/api/management/src/models/datasets/mod.rs b/src/api/management/src/models/datasets/mod.rs index 49ce9c6861..50438befe0 100644 --- a/src/api/management/src/models/datasets/mod.rs +++ b/src/api/management/src/models/datasets/mod.rs @@ -69,7 +69,10 @@ pub struct DatasetResponseBody { pub name: String, pub description: Option, pub tags: Vec, + /// Per-row MVCC counter: the value an Experiment pins and a Snapshot reads. pub global_version: i64, + /// The Dataset version to display: one per write request. + pub write_version: i64, /// Live (non-deleted) Items, counted once per logical Item rather than once /// per stored revision. pub item_count: i64, @@ -115,8 +118,14 @@ impl From for ListDatasetItems { #[into_params(parameter_in = Query)] #[serde(deny_unknown_fields)] pub struct DatasetSnapshotRowsQuery { - /// Snapshot version to resolve. Defaults to the Dataset's current version. + /// Row-level Snapshot/pin key. When both this and `write_version` are + /// omitted, reads the Dataset's current row version. pub version: Option, + /// Displayed write version W to pin. Resolves to the highest row version + /// among rows whose write version is at or below W (labels and row versions + /// are separate counters); cannot be combined with `version`. Use the + /// returned `snapshotVersion` as the Experiment's `datasetVersion`. + pub write_version: Option, /// Zero-based result offset. Defaults to 0. pub from: Option, /// Page size from 1 through 100. Defaults to 20. @@ -128,6 +137,7 @@ impl From for ReadSnapshotRows { let defaults = ReadSnapshotRows::default(); Self { snapshot_version: value.version, + write_version: value.write_version, from: value.from.unwrap_or(defaults.from), size: value.size.unwrap_or(defaults.size), } @@ -251,6 +261,8 @@ pub struct DatasetItemResponseBody { pub input: Value, pub expected_output: Option, pub global_version: i64, + /// The Dataset version of the write request that stored this row. + pub write_version: i64, pub is_deleted: bool, pub source: DatasetItemSourceResponseBody, pub source_ref: Option, @@ -442,6 +454,7 @@ impl From for DatasetResponseBody { description: value.description, tags: value.tags, global_version: value.global_version, + write_version: value.write_version, item_count: value.item_count, sources: value.sources.into(), created_by: value.created_by, @@ -558,6 +571,7 @@ impl From for DatasetItemResponseBody { input: value.input, expected_output: value.expected_output, global_version: value.global_version, + write_version: value.write_version, is_deleted: value.is_deleted, source: value.source.into(), source_ref: value.source_ref, diff --git a/src/api/management/src/models/experiment_comparison.rs b/src/api/management/src/models/experiment_comparison.rs index f7fda97322..705e8313c5 100644 --- a/src/api/management/src/models/experiment_comparison.rs +++ b/src/api/management/src/models/experiment_comparison.rs @@ -32,7 +32,8 @@ pub struct ExperimentComparisonQuery { pub baseline_id: String, pub candidate_id: String, pub threshold: Option, - /// Comma-separated dimension IDs; omitted selects all eligible columns, empty selects none. + /// Comma-separated dimension IDs; omitted selects every eligible Score column (cost and + /// latency gate only when listed), empty selects none. pub outcome_dimensions: Option, } diff --git a/src/api/management/src/models/experiments.rs b/src/api/management/src/models/experiments.rs index 8b24de7dd2..177e4e7114 100644 --- a/src/api/management/src/models/experiments.rs +++ b/src/api/management/src/models/experiments.rs @@ -30,11 +30,11 @@ use openobserve_core::llm_evaluations::{ RecordBatch, RecordBatchResult, }, results::{ - ExperimentAggregateSummary, ExperimentClientScoreSummary, ExperimentProgress, - ExperimentResultScore, ExperimentResultScoreStatus, ExperimentResultSlot, - ExperimentResultTaskStatus, ExperimentScoreOutcomes, ExperimentScoreSummary, - ExperimentSkipSummary, ExperimentSlotStatus, ExperimentSummaryStatus, - ExperimentTaskOutcomes, ScoringStatus, + ExperimentAggregateSummary, ExperimentClientScoreSummary, ExperimentFailureCause, + ExperimentProgress, ExperimentResultScore, ExperimentResultScoreStatus, + ExperimentResultSlot, ExperimentResultTaskStatus, ExperimentScoreOutcomes, + ExperimentScoreSummary, ExperimentSkipSummary, ExperimentSlotStatus, + ExperimentSummaryStatus, ExperimentTaskOutcomes, ScoringStatus, }, }, }; @@ -78,6 +78,8 @@ pub struct ExperimentResultRowBody { pub output: Option, pub score_summaries: Vec, pub p50_latency_ms: Option, + /// Sample message of the row's largest failure cause (grouped by kind, else by first line). + pub error_message: Option, pub dispersion: Option, } @@ -1022,7 +1024,10 @@ pub struct ExperimentResponseBody { /// Resolved from the Dataset the experiment pinned, so callers do not have /// to fetch the Dataset list just to label a run. None when it was deleted. pub dataset_name: Option, + /// The pinned `global_version`: the row cut every Slot reads. pub dataset_version: i64, + /// The Dataset version to display for that pin: one per write request. + pub dataset_write_version: i64, pub dataset_filter: Option, pub task: ExperimentTaskBody, pub scorers: Vec, @@ -1053,10 +1058,10 @@ impl ExperimentResponseBody { self.dataset_name = dataset_name; self } -} -impl From for ExperimentResponseBody { - fn from(value: Experiment) -> Self { + /// `dataset_write_version` is what the pin shows as, resolved by the + /// handler from the Dataset's rows. + pub fn new(value: Experiment, dataset_write_version: i64) -> Self { Self { id: value.id, org_id: value.org_id, @@ -1065,6 +1070,7 @@ impl From for ExperimentResponseBody { dataset_id: value.dataset_id, dataset_name: None, dataset_version: value.dataset_version, + dataset_write_version, dataset_filter: value.dataset_filter.map(Into::into), task: value.task.into(), scorers: value.scorers.into_iter().map(Into::into).collect(), @@ -1149,6 +1155,7 @@ pub struct ExperimentRowDetailResponseBody { pub struct ExperimentRowSnapshotBody { pub dataset_id: String, pub dataset_version: i64, + pub dataset_write_version: i64, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -1404,6 +1411,31 @@ pub struct ExperimentAggregateSummaryBody { pub incomplete_task_slots: u64, pub incomplete_score_dimensions: u64, pub error_task_slots: u64, + /// Top 3 failure causes, largest first: errored slots grouped by kind, by first line if none. + pub failure_causes: Vec, + /// Distinct failure causes across every errored slot. + pub failure_cause_count: u64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExperimentFailureCauseBody { + /// Shared failure taxonomy (`provider_auth`, `timeout`, `execution_error`, …); absent + /// when the producer did not classify the failure, including current SDK/client paths. + pub kind: Option, + /// The group's most common first error line. + pub message: String, + pub slot_count: u64, +} + +impl From for ExperimentFailureCauseBody { + fn from(value: ExperimentFailureCause) -> Self { + Self { + kind: value.kind, + message: value.message, + slot_count: value.slot_count, + } + } } impl From for ExperimentSlotStatusBody { @@ -1573,6 +1605,8 @@ impl From for ExperimentAggregateSummaryBody { incomplete_task_slots: value.incomplete_task_slots, incomplete_score_dimensions: value.incomplete_score_dimensions, error_task_slots: value.error_task_slots, + failure_causes: value.failure_causes.into_iter().map(Into::into).collect(), + failure_cause_count: value.failure_cause_count, } } } @@ -1585,10 +1619,10 @@ pub struct CreateExperimentResponseBody { pub created: bool, } -impl From for CreateExperimentResponseBody { - fn from(value: CreateExperimentResult) -> Self { +impl CreateExperimentResponseBody { + pub fn new(value: CreateExperimentResult, dataset_write_version: i64) -> Self { Self { - experiment: value.experiment.into(), + experiment: ExperimentResponseBody::new(value.experiment, dataset_write_version), preview: value.preview.into(), created: value.created, } @@ -1769,37 +1803,40 @@ mod tests { #[test] fn execution_only_response_uses_explicit_execution_field_names() { - let body = ExperimentResponseBody::from(Experiment { - id: "experiment-1".to_string(), - org_id: "org-1".to_string(), - name: "Contract".to_string(), - description: None, - dataset_id: "dataset-1".to_string(), - dataset_version: 1, - dataset_filter: None, - task: ExperimentTaskConfig::Sdk { - task_fingerprint: "sha256:test".to_string(), - config: serde_json::json!({}), + let body = ExperimentResponseBody::new( + Experiment { + id: "experiment-1".to_string(), + org_id: "org-1".to_string(), + name: "Contract".to_string(), + description: None, + dataset_id: "dataset-1".to_string(), + dataset_version: 1, + dataset_filter: None, + task: ExperimentTaskConfig::Sdk { + task_fingerprint: "sha256:test".to_string(), + config: serde_json::json!({}), + }, + scorers: Vec::new(), + trial_count: 1, + metadata: None, + status: ExperimentStatus::Completed, + status_reason: Some("done".to_string()), + deadline_at: 1, + completed_at: Some(2), + lifecycle_version: 1, + retry_count: 0, + scores_settled_at: Some(2), + idempotency_key: None, + is_baseline: false, + prompt_id: Some("prompt-1".to_string()), + prompt_name: Some("Support answer".to_string()), + prompt_version: Some(3), + prompt_content_hash: Some("sha256:content".to_string()), + created_by: "owner@example.com".to_string(), + created_at: 1, }, - scorers: Vec::new(), - trial_count: 1, - metadata: None, - status: ExperimentStatus::Completed, - status_reason: Some("done".to_string()), - deadline_at: 1, - completed_at: Some(2), - lifecycle_version: 1, - retry_count: 0, - scores_settled_at: Some(2), - idempotency_key: None, - is_baseline: false, - prompt_id: Some("prompt-1".to_string()), - prompt_name: Some("Support answer".to_string()), - prompt_version: Some(3), - prompt_content_hash: Some("sha256:content".to_string()), - created_by: "owner@example.com".to_string(), - created_at: 1, - }); + 1, + ); let value = serde_json::to_value(body).unwrap(); @@ -1831,6 +1868,7 @@ mod tests { dataset_id: "dataset-1".to_string(), dataset_name: Some("Dataset".to_string()), dataset_version: 1, + dataset_write_version: 1, dataset_filter: None, task: ExperimentTaskBody::Sdk { task_fingerprint: "sha256:test".to_string(), diff --git a/src/api/management/src/models/scorers/mod.rs b/src/api/management/src/models/scorers/mod.rs index b40a8a1ac4..4de049e000 100644 --- a/src/api/management/src/models/scorers/mod.rs +++ b/src/api/management/src/models/scorers/mod.rs @@ -32,7 +32,11 @@ pub struct LlmJudgeScorerParams { pub provider_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_f64" + )] pub temperature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, @@ -46,6 +50,25 @@ pub struct LlmJudgeScorerParams { pub extra_metadata_fields: Option>, } +/// Deserializes an optional float through `serde_json::Number`. +/// +/// The workspace enables `serde_json/arbitrary_precision`, under which a +/// non-integer number is handed to visitors as a private map. Inside an +/// internally tagged enum (`#[serde(tag = "type")]`) that map is buffered and +/// a plain `f64` field rejects it with `invalid type: map, expected f64`. +/// `Number` understands both the buffered map and a plain number. +fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|n| { + n.as_f64() + .ok_or_else(|| serde::de::Error::custom(format!("{n} is not a valid f64"))) + }) + .transpose() +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub struct RemoteScorerParams { @@ -748,6 +771,54 @@ mod tests { ); } + /// Issue #3143: with `serde_json/arbitrary_precision`, a float inside the + /// internally tagged scorer config used to fail with + /// `invalid type: map, expected f64`. Parse from raw JSON, as the HTTP + /// extractor does. + #[test] + fn test_llm_judge_scorer_accepts_float_temperature() { + let scorer_json = r#"{ + "type": "llm_judge", + "producesScoreConfigVersion": 2, + "template": "Judge {{input}}", + "params": { + "provider_id": "p1", + "temperature": 0.1, + "max_tokens": 256, + "timeout_ms": 30000 + } + }"#; + + let create: ScorerRequestBody = + serde_json::from_str(&format!(r#"{{"name": "judge", "scorer": {scorer_json}}}"#)) + .unwrap(); + let scorer = infra::table::scorers::Scorer::from(create); + assert_eq!(scorer.params["temperature"].as_f64(), Some(0.1)); + assert_eq!(scorer.params["max_tokens"].as_u64(), Some(256)); + let stored: LlmJudgeScorerParams = serde_json::from_value(scorer.params).unwrap(); + assert_eq!(stored.temperature, Some(0.1)); + + let update: ScorerUpdateRequestBody = + serde_json::from_str(&format!(r#"{{"scorer": {scorer_json}}}"#)).unwrap(); + let ScorerUpdateConfig::LlmJudge(update) = update.scorer else { + panic!("expected llm_judge"); + }; + assert_eq!(update.params.temperature, Some(0.1)); + + let test: ScorerTestRequestBody = serde_json::from_str(&format!( + r#"{{"name": "judge", "scorer": {scorer_json}, "inputVariables": {{}}}}"# + )) + .unwrap(); + let ScorerCreateConfig::LlmJudge(test) = test.scorer else { + panic!("expected llm_judge"); + }; + assert_eq!(test.params.temperature, Some(0.1)); + + let omitted: LlmJudgeScorerParams = + serde_json::from_str(r#"{"provider_id": "p1"}"#).unwrap(); + assert_eq!(omitted.temperature, None); + } + #[test] fn test_remote_scorer_request_deserialize_rejects_response_paths() { let result: Result = serde_json::from_value(serde_json::json!({ diff --git a/src/api/management/src/request/datasets/mod.rs b/src/api/management/src/request/datasets/mod.rs index 5c2fa997f0..96ee57482a 100644 --- a/src/api/management/src/request/datasets/mod.rs +++ b/src/api/management/src/request/datasets/mod.rs @@ -134,7 +134,7 @@ pub async fn list_dataset_items( tag = "Datasets", operation_id = "GetDatasetSnapshotRows", summary = "Read the row set of a pinned Dataset Snapshot", - description = "Resolves the Dataset's MVCC history at `version` and returns one page of the live rows that Snapshot contains. Omitting `version` reads the current Snapshot and answers with the version it resolved. This is the read an Experiment pins at creation, so it is also how a client inspects the exact rows a historical Experiment ran against.", + description = "Resolves the Dataset's MVCC history at row-level `version` and returns one page of live rows. Alternatively, `write_version` W resolves to the highest row version among rows whose write version is at or below W; write versions and row versions are separate counters. Omitting both reads the current Snapshot. The response's `snapshotVersion` is always a row-level pin key: use it as the Experiment's `datasetVersion`. Existing pins and `version` reads retain their original semantics.", security(("Authorization" = [])), params( ("org_id" = String, Path, description = "Organization name"), diff --git a/src/api/management/src/request/experiments.rs b/src/api/management/src/request/experiments.rs index c8f63d3cbb..9692fffe63 100644 --- a/src/api/management/src/request/experiments.rs +++ b/src/api/management/src/request/experiments.rs @@ -90,6 +90,29 @@ impl ScorerSelection<'_> { } } +/// What an Experiment's pinned `global_version` shows as. When the Dataset's +/// rows cannot be read the pin shows as itself, as it does for a deleted +/// Dataset; the response describes an action that already happened. +async fn pinned_write_version(org_id: &str, dataset_id: &str, pin: i64) -> i64 { + datasets::pinned_write_version(org_id, dataset_id, pin) + .await + .unwrap_or_else(|error| { + log::error!( + "[Experiment] failed to resolve the write version of Dataset {dataset_id} at {pin}: {error}" + ); + pin + }) +} + +async fn experiment_body( + org_id: &str, + experiment: experiments::Experiment, +) -> ExperimentResponseBody { + let shown = + pinned_write_version(org_id, &experiment.dataset_id, experiment.dataset_version).await; + ExperimentResponseBody::new(experiment, shown) +} + fn experiment_error_response(error: ExperimentError) -> Response { match error { ExperimentError::Database(error) => { @@ -333,6 +356,11 @@ fn experiment_result_rows( score_configs, ), p50_latency_ms: aggregate.p50_latency_ms, + error_message: aggregate + .failure_causes + .into_iter() + .next() + .map(|cause| cause.message), dispersion: dispersions.remove(&first.row_id).map(Into::into), }) }) @@ -602,7 +630,13 @@ pub async fn create_experiment( match experiments::create(&org_id, &user.user_id, input, &canonical_request).await { Ok(result) => { set_ownership(&org_id, "experiments", Authz::new(&result.experiment.id)).await; - MetaHttpResponse::json(CreateExperimentResponseBody::from(result)) + let shown = pinned_write_version( + &org_id, + &result.experiment.dataset_id, + result.experiment.dataset_version, + ) + .await; + MetaHttpResponse::json(CreateExperimentResponseBody::new(result, shown)) } Err(error) => experiment_error_response(error), } @@ -636,15 +670,8 @@ pub async fn list_experiments( Ok(list) => list, Err(error) => return MetaHttpResponse::forbidden(error.to_string()), }; - // One Dataset query for the whole list — a lookup per experiment would be N+1. - let dataset_names: std::collections::HashMap = datasets::list(&org_id) - .await - .map(|list| { - list.into_iter() - .map(|dataset| (dataset.id, dataset.name)) - .collect() - }) - .unwrap_or_default(); + // One names-only Dataset query for the whole list — no per-experiment lookups or aggregates. + let dataset_names = datasets::list_names(&org_id).await.unwrap_or_default(); let dataset_id = query .dataset_id .as_deref() @@ -700,14 +727,27 @@ pub async fn list_experiments( } else { Vec::new() }; + // One query for every pin in the list, not one per experiment. + let pins: Vec<(String, i64)> = experiments + .iter() + .map(|experiment| (experiment.dataset_id.clone(), experiment.dataset_version)) + .collect(); + let shown_versions = datasets::pinned_write_versions(&org_id, &pins) + .await + .unwrap_or_else(|error| { + log::error!("[Experiment] failed to resolve pinned Dataset write versions: {error}"); + HashMap::new() + }); MetaHttpResponse::json(ListExperimentsResponseBody { list: experiments .into_iter() .map(|experiment| { let id = experiment.id.clone(); let dataset_name = dataset_names.get(&experiment.dataset_id).cloned(); + let pin = (experiment.dataset_id.clone(), experiment.dataset_version); + let shown = shown_versions.get(&pin).copied().unwrap_or(pin.1); ExperimentViewResponseBody { - experiment: ExperimentResponseBody::from(experiment) + experiment: ExperimentResponseBody::new(experiment, shown) .with_dataset_name(dataset_name), summary: include_summary.then(|| { experiment_summary_body( @@ -877,9 +917,12 @@ pub async fn get_experiment( summaries.remove(&experiment.id).unwrap_or_default(), &score_configs, ); + let shown = + pinned_write_version(&org_id, &experiment.dataset_id, experiment.dataset_version).await; MetaHttpResponse::json(ExperimentDetailResponseBody { experiment: ExperimentViewResponseBody { - experiment: ExperimentResponseBody::from(experiment).with_dataset_name(dataset_name), + experiment: ExperimentResponseBody::new(experiment, shown) + .with_dataset_name(dataset_name), summary: Some(summary), }, preview: preview.into(), @@ -1231,11 +1274,14 @@ pub async fn get_experiment_row( .slots .first() .expect("a resolved Experiment row always contains at least one trial"); + let dataset_write_version = + pinned_write_version(&org_id, &experiment.dataset_id, experiment.dataset_version).await; let response = ExperimentRowDetailResponseBody { experiment_id: experiment.id, snapshot: ExperimentRowSnapshotBody { dataset_id: experiment.dataset_id, dataset_version: experiment.dataset_version, + dataset_write_version, }, navigation: ExperimentRowNavigationBody { row_index: row.row_index, @@ -1279,7 +1325,7 @@ pub async fn get_experiment_row( )] pub async fn cancel_experiment(Path((org_id, experiment_id)): Path<(String, String)>) -> Response { match experiments::cancel(&org_id, &experiment_id).await { - Ok(experiment) => MetaHttpResponse::json(ExperimentResponseBody::from(experiment)), + Ok(experiment) => MetaHttpResponse::json(experiment_body(&org_id, experiment).await), Err(error) => experiment_error_response(error), } } @@ -1314,7 +1360,7 @@ pub async fn set_experiment_baseline( } match baseline::set_baseline(&org_id, &experiment_id, &user.user_id).await { Ok(change) => MetaHttpResponse::json(ExperimentBaselineResponseBody { - experiment: ExperimentResponseBody::from(change.experiment), + experiment: experiment_body(&org_id, change.experiment).await, previous_baseline_id: change.previous_baseline_id, }), Err(error) => experiment_error_response(error), @@ -1349,7 +1395,7 @@ pub async fn clear_experiment_baseline( return response; } match baseline::clear_baseline(&org_id, &experiment_id, &user.user_id).await { - Ok(experiment) => MetaHttpResponse::json(ExperimentResponseBody::from(experiment)), + Ok(experiment) => MetaHttpResponse::json(experiment_body(&org_id, experiment).await), Err(error) => experiment_error_response(error), } } @@ -1433,7 +1479,7 @@ pub async fn retry_experiment( ) .await { - Ok(experiment) => MetaHttpResponse::json(ExperimentResponseBody::from(experiment)), + Ok(experiment) => MetaHttpResponse::json(experiment_body(&org_id, experiment).await), Err(error) => experiment_error_response(error), } } @@ -1563,7 +1609,7 @@ pub async fn clone_experiment( match experiments::clone_sealed(&org_id, &experiment_id, &user.user_id, overrides).await { Ok(experiment) => { set_ownership(&org_id, "experiments", Authz::new(&experiment.id)).await; - MetaHttpResponse::json(ExperimentResponseBody::from(experiment)) + MetaHttpResponse::json(experiment_body(&org_id, experiment).await) } Err(error) => experiment_error_response(error), } @@ -1705,7 +1751,7 @@ pub async fn finalize_experiment( return response; } match ingest::finalize(&org_id, &experiment_id).await { - Ok(experiment) => MetaHttpResponse::json(ExperimentResponseBody::from(experiment)), + Ok(experiment) => MetaHttpResponse::json(experiment_body(&org_id, experiment).await), Err(error) => ingest_error_response(error), } } @@ -1749,6 +1795,7 @@ mod tests { output: None, score_summaries: Vec::new(), p50_latency_ms: None, + error_message: None, dispersion: Some(crate::models::experiments::ExperimentRowDispersionBody { row_id, logical_id: format!("case-{row_index}"), diff --git a/src/api/management/src/request/gen_ai_health.rs b/src/api/management/src/request/gen_ai_health.rs new file mode 100644 index 0000000000..21c3f4542e --- /dev/null +++ b/src/api/management/src/request/gen_ai_health.rs @@ -0,0 +1,1716 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! AI data health: checks that make LLM pages look empty or numbers look wrong (unpriced +//! models, spans without agent identity, zero-duration spans, spans without a session id), +//! plus the per-model unpriced / approximate listing behind Model Pricing's Unpriced tab. +//! +//! The checks run on request against the trace stream and are cached in memory for +//! [`CACHE_TTL_MICROS`] per (org, stream, scope, window after the caller's max query range, +//! rounded to the minute): Insights loads them on every visit, and a short cache keeps +//! refreshes cheap without a scheduled job or a results stream. A stream whose schema lacks a +//! column is handled per check, never an error. + +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use axum::{extract::Path, response::Response}; +use config::{ + meta::{ + model_pricing::{COST_STATUS_COLUMN, CostStatus, MatchKind}, + search::{Query as SearchQuery, Request as SearchRequest, RequestEncoding}, + stream::StreamType, + }, + utils::{json, time::now_micros}, +}; +use db::model_pricing::DefinitionMatch; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::common::meta::http::HttpResponse as MetaHttpResponse; + +const CACHE_TTL_MICROS: i64 = 60 * 1_000_000; +const CACHE_MAX_ENTRIES: usize = 512; +const WINDOW_QUANTUM_MICROS: i64 = 60 * 1_000_000; +const MAX_SAMPLES: usize = 3; +/// Recent matching spans scanned for sample trace ids; deduped in Rust down to `MAX_SAMPLES`. +const SAMPLE_SCAN_ROWS: usize = 100; +const MAX_MODELS: usize = 20; +const MAX_UNPRICED_ROWS: usize = 100; +/// Agent names listed per unpriced model row; `agent_count` keeps the full distinct count. +pub const MAX_AGENT_NAMES: usize = 10; + +const LLM_SPAN_COLUMN: &str = "gen_ai_operation_name"; +const AGENT_NAME_COLUMN: &str = "gen_ai_agent_name"; +const AGENT_ID_COLUMN: &str = "gen_ai_agent_id"; +const AGENT_ENV_COLUMN: &str = "gen_ai_agent_env"; +const AGENT_VERSION_COLUMN: &str = "gen_ai_agent_version"; +const TOKENS_COLUMN: &str = "gen_ai_usage_total_tokens"; +const COST_COLUMN: &str = "gen_ai_usage_cost"; + +#[derive(Debug, Clone, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct DataHealthQuery { + /// Trace stream to check. + pub stream_name: String, + /// Window start, microseconds. + pub start_time: i64, + /// Window end, microseconds. + pub end_time: i64, + /// Only spans of this agent name (`gen_ai_agent_name`); ignored when `agent_id` is set. + #[serde(default)] + pub agent: Option, + /// Only spans of this agent id (`gen_ai_agent_id`). Agents that emit only an id are + /// listed with the id as their name, so the id is the filter that matches their spans. + #[serde(default)] + pub agent_id: Option, + /// Only spans of this environment (`gen_ai_agent_env`). + #[serde(default)] + pub env: Option, + /// Only spans of this agent version (`gen_ai_agent_version`). + #[serde(default)] + pub version: Option, + /// Skip the short server-side cache. + #[serde(default)] + pub refresh: bool, + /// Also return up to three recent trace ids per failing check; off by default, one query each. + #[serde(default)] + pub include_samples: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CheckStatus { + Pass, + Warning, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CheckId { + UnpricedModels, + MissingAgentIdentity, + ZeroDurationSpans, + MissingSessionId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +pub struct ModelUsage { + /// Null for spans that carry no model name. + pub model: Option, + pub spans: i64, + pub tokens: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)] +pub struct HealthCheck { + pub id: CheckId, + pub status: CheckStatus, + /// LLM spans the problem touches. + pub affected: i64, + /// LLM spans checked. + pub total: i64, + /// Unpriced check only: the 20 models with the most unpriced tokens. + #[serde(skip_serializing_if = "Option::is_none")] + pub models: Option>, + /// Unpriced check only: every distinct model behind the unpriced spans (spans without a + /// model name count as one), however many `models` lists. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_count: Option, + /// Unpriced check only: tokens on every unpriced span. + #[serde(skip_serializing_if = "Option::is_none")] + pub unpriced_tokens: Option, + /// Unpriced check only: `models` leaves some of the `model_count` models out. + #[serde(skip_serializing_if = "Option::is_none")] + pub models_truncated: Option, + /// Up to three recent trace ids with the problem; present only with `include_samples`. + #[serde(skip_serializing_if = "Option::is_none")] + pub samples: Option>, +} + +impl HealthCheck { + /// A check with no breakdown yet: the unpriced one starts with an empty, complete one. + fn new(id: CheckId, affected: i64, total: i64, include_samples: bool) -> Self { + let unpriced = id == CheckId::UnpricedModels; + Self { + id, + status: status_for(id, affected), + affected, + total, + models: unpriced.then(Vec::new), + model_count: unpriced.then_some(0), + unpriced_tokens: unpriced.then_some(0), + models_truncated: unpriced.then_some(false), + samples: include_samples.then(Vec::new), + } + } +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct DataHealthResponse { + /// When the checks ran, microseconds. + pub checked_at: i64, + pub checks: Vec, +} + +/// How a check selects affected spans on a given stream schema. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Pred { + /// The column the check reads is absent, so no span can have the problem. + Never, + /// The column that would prove the span healthy is absent, so every span has it. + Always, + Sql(String), +} + +/// Columns of the stream schema the checks read. +struct Columns(std::collections::HashSet); + +impl Columns { + fn from_schema(schema: &arrow_schema::Schema) -> Self { + Self(schema.fields().iter().map(|f| f.name().clone()).collect()) + } + + fn has(&self, name: &str) -> bool { + self.0.contains(name) + } + + fn model_column(&self) -> Option<&'static str> { + ["gen_ai_response_model", "gen_ai_request_model"] + .into_iter() + .find(|c| self.has(c)) + } + + fn tokens_expr(&self) -> &'static str { + if self.has(TOKENS_COLUMN) { + "COALESCE(SUM(gen_ai_usage_total_tokens), 0)" + } else { + "0" + } + } +} + +const CHECK_IDS: [CheckId; 4] = [ + CheckId::UnpricedModels, + CheckId::MissingAgentIdentity, + CheckId::ZeroDurationSpans, + CheckId::MissingSessionId, +]; + +fn quote_str(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn quote_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn blank(column: &str) -> String { + format!("({column} IS NULL OR {column} = '')") +} + +/// The predicate of check `id`. With model pricing disabled nothing is priced, so no span is +/// unpriced: the check passes and does not point at the hidden Model Pricing page. +fn check_pred(id: CheckId, cols: &Columns, pricing_enabled: bool) -> Pred { + match id { + CheckId::UnpricedModels if pricing_enabled && cols.has(COST_STATUS_COLUMN) => { + Pred::Sql(format!( + "{COST_STATUS_COLUMN} = {}", + quote_str(CostStatus::Unpriced.as_str()) + )) + } + CheckId::UnpricedModels => Pred::Never, + CheckId::MissingAgentIdentity => { + let present: Vec = [AGENT_NAME_COLUMN, AGENT_ID_COLUMN] + .into_iter() + .filter(|c| cols.has(c)) + .map(blank) + .collect(); + if present.is_empty() { + Pred::Always + } else { + Pred::Sql(present.join(" AND ")) + } + } + CheckId::ZeroDurationSpans if cols.has("duration") => Pred::Sql("duration = 0".into()), + CheckId::ZeroDurationSpans => Pred::Never, + CheckId::MissingSessionId => { + // A span has a session when any of the stream's session columns is set. + let present: Vec = config::meta::traces::session::SESSION_ID_COLUMNS + .iter() + .filter(|c| cols.has(c)) + .map(|c| blank("e_ident(c))) + .collect(); + if present.is_empty() { + Pred::Always + } else { + Pred::Sql(present.join(" AND ")) + } + } + } +} + +fn non_empty(value: &Option) -> Option<&str> { + value.as_deref().filter(|v| !v.is_empty()) +} + +/// The LLM-span and scope predicates every check query shares; `None` when the stream has no +/// LLM spans in scope at all (a scope column or the operation column is absent). +/// +/// The agent is matched on `gen_ai_agent_id` when an id is given, else on its name, as the +/// Insights panels do. +fn base_where(cols: &Columns, query: &DataHealthQuery) -> Option { + if !cols.has(LLM_SPAN_COLUMN) { + return None; + } + let agent = match non_empty(&query.agent_id) { + Some(id) => (AGENT_ID_COLUMN, Some(id)), + None => (AGENT_NAME_COLUMN, non_empty(&query.agent)), + }; + let mut preds = vec![format!("{LLM_SPAN_COLUMN} IS NOT NULL")]; + for (column, value) in [ + agent, + (AGENT_ENV_COLUMN, non_empty(&query.env)), + (AGENT_VERSION_COLUMN, non_empty(&query.version)), + ] { + let Some(value) = value else { + continue; + }; + if !cols.has(column) { + return None; + } + preds.push(format!("{column} = {}", quote_str(value))); + } + Some(preds.join(" AND ")) +} + +fn counts_sql(stream: &str, base: &str, preds: &[Pred], cols: &Columns) -> String { + let mut selects = vec!["COUNT(*) AS total".to_string()]; + for (i, pred) in preds.iter().enumerate() { + if let Pred::Sql(p) = pred { + selects.push(format!("SUM(CASE WHEN {p} THEN 1 ELSE 0 END) AS c{i}")); + } + } + if let Some(Pred::Sql(unpriced)) = preds.first() { + let model = cols.model_column(); + let distinct = model.map_or_else( + || "0".to_string(), + |column| format!("COUNT(DISTINCT CASE WHEN {unpriced} THEN {column} END)"), + ); + let no_model = model.map_or_else( + || unpriced.clone(), + |column| format!("({unpriced}) AND {column} IS NULL"), + ); + let tokens = if cols.has(TOKENS_COLUMN) { + TOKENS_COLUMN + } else { + "0" + }; + selects.push(format!("{distinct} AS distinct_models")); + selects.push(format!( + "SUM(CASE WHEN {no_model} THEN 1 ELSE 0 END) AS no_model_spans" + )); + selects.push(format!( + "SUM(CASE WHEN {unpriced} THEN {tokens} ELSE 0 END) AS tokens" + )); + } + format!( + "SELECT {} FROM {} WHERE {base}", + selects.join(", "), + quote_ident(stream) + ) +} + +fn samples_sql(stream: &str, base: &str, pred: &Pred) -> String { + let filter = match pred { + Pred::Sql(p) => format!(" AND {p}"), + _ => String::new(), + }; + format!( + "SELECT trace_id FROM {} WHERE {base}{filter} \ + ORDER BY _timestamp DESC LIMIT {SAMPLE_SCAN_ROWS}", + quote_ident(stream) + ) +} + +/// The first `MAX_SAMPLES` distinct trace ids of rows ordered newest first. +fn distinct_trace_ids(hits: &[json::Value]) -> Vec { + let mut ids: Vec = Vec::with_capacity(MAX_SAMPLES); + for id in hits + .iter() + .filter_map(|h| h.get("trace_id").and_then(|v| v.as_str())) + { + if ids.len() == MAX_SAMPLES { + break; + } + if !ids.iter().any(|seen| seen == id) { + ids.push(id.to_string()); + } + } + ids +} + +/// The `model` select item and grouping key. A stream with no model column holds only spans +/// without a model name: they form one group, read back with no `model` value. +fn model_grouping(cols: &Columns) -> (String, Option<&'static str>) { + match cols.model_column() { + Some(model) => (format!("{model} AS model, "), Some(model)), + None => (String::new(), None), + } +} + +/// A model name from a result row; `None` when the spans carry none. +fn model_from(hit: &json::Value) -> Option { + hit.get("model") + .and_then(|v| v.as_str()) + .filter(|m| !m.is_empty()) + .map(str::to_string) +} + +fn unpriced_models_sql( + stream: &str, + base: &str, + pred: &str, + cols: &Columns, + limit: usize, +) -> String { + let (select, model) = model_grouping(cols); + let group = model.map(|m| format!(" GROUP BY {m}")).unwrap_or_default(); + format!( + "SELECT {select}COUNT(*) AS spans, {} AS tokens FROM {} \ + WHERE {base} AND {pred}{group} ORDER BY tokens DESC LIMIT {limit}", + cols.tokens_expr(), + quote_ident(stream) + ) +} + +/// A model's pricing gap is closed when today's definitions match its name exactly; a +/// family-pattern match only approximates it. Pricing gaps marks such rows "Priced for new +/// spans" by the same rule, so the check and the page agree. +pub(crate) fn priced_now(resolved: Option<&DefinitionMatch>) -> bool { + resolved.is_some_and(|r| r.match_kind == MatchKind::Exact) +} + +/// Whether every model behind the unpriced spans has a price now: the check then passes, while +/// its counts keep the spans recorded at $0, which the cost tile still reads as a lower bound. +/// Spans without a model name, or models past the listed rows, keep it failing. +fn every_model_priced_now( + model_hits: &[json::Value], + model_count: i64, + priced: impl Fn(&str) -> bool, +) -> bool { + !model_hits.is_empty() + && model_count <= model_hits.len() as i64 + && model_hits + .iter() + .all(|h| model_from(h).is_some_and(|m| priced(&m))) +} + +/// The uncapped totals behind a capped per-model breakdown of the spans matching `filter`: +/// distinct model names, spans without one (the breakdown's null-model row), and tokens. +fn breakdown_totals_sql(stream: &str, filter: &str, cols: &Columns) -> String { + let models = match cols.model_column() { + Some(m) => format!( + "COUNT(DISTINCT {m}) AS distinct_models, \ + SUM(CASE WHEN {m} IS NULL THEN 1 ELSE 0 END) AS no_model_spans" + ), + None => "0 AS distinct_models, COUNT(*) AS no_model_spans".to_string(), + }; + format!( + "SELECT {models}, {} AS tokens FROM {} WHERE {filter}", + cols.tokens_expr(), + quote_ident(stream) + ) +} + +/// Rows the uncapped breakdown has and their tokens, from a [`breakdown_totals_sql`] row. +fn breakdown_totals(row: Option<&json::Value>) -> (i64, i64) { + let int = |key: &str| row.and_then(|r| r.get(key)).map_or(0, json::get_int_value); + let models = int("distinct_models") + i64::from(int("no_model_spans") > 0); + (models, int("tokens")) +} + +/// Fills the unpriced check from the capped per-model rows and the uncapped totals row, so +/// the counts stay right when the list is cut at [`MAX_MODELS`]. +fn set_unpriced_breakdown( + check: &mut HealthCheck, + model_hits: &[json::Value], + totals: Option<&json::Value>, +) { + let models: Vec = model_hits + .iter() + .map(|h| ModelUsage { + model: model_from(h), + spans: h.get("spans").map_or(0, json::get_int_value), + tokens: h.get("tokens").map_or(0, json::get_int_value), + }) + .collect(); + let (model_count, tokens) = breakdown_totals(totals); + // Spans ingested between the two queries can only add models, never hide listed ones. + let model_count = model_count.max(models.len() as i64); + check.models_truncated = Some(model_count > models.len() as i64); + check.model_count = Some(model_count); + check.unpriced_tokens = Some(tokens); + check.models = Some(models); +} + +fn status_for(id: CheckId, affected: i64) -> CheckStatus { + match (id, affected) { + (_, 0) => CheckStatus::Pass, + (CheckId::UnpricedModels, _) => CheckStatus::Error, + _ => CheckStatus::Warning, + } +} + +/// Affected count of check `i` from the counts row. +fn affected_from(pred: &Pred, row: &json::Value, i: usize, total: i64) -> i64 { + match pred { + Pred::Never => 0, + Pred::Always => total, + Pred::Sql(_) => row.get(format!("c{i}")).map_or(0, json::get_int_value), + } +} + +fn all_pass(total: i64, include_samples: bool) -> Vec { + CHECK_IDS + .iter() + .map(|&id| HealthCheck::new(id, 0, total, include_samples)) + .collect() +} + +/// The search window after the caller's max query range for the stream is applied. +#[derive(Debug)] +struct Window { + start: i64, + end: i64, + /// The note the search attaches when the start was moved; empty when it was not. + range_error: String, +} + +impl Window { + async fn for_stream(org_id: &str, user_id: &str, stream: &str, start: i64, end: i64) -> Self { + let max_hours = search_service::query_range::get_max_query_range( + &[stream.to_string()], + org_id, + user_id, + StreamType::Traces, + ) + .await; + Self::clamped(start, end, max_hours) + } + + fn clamped(start: i64, end: i64, max_hours: i64) -> Self { + let max_micros = max_hours * 3600 * 1_000_000; + if max_hours > 0 && end - start > max_micros { + Self { + start: end - max_micros, + end, + range_error: format!( + "Query duration is modified due to query range restriction of {max_hours} hours" + ), + } + } else { + Self { + start, + end, + range_error: String::new(), + } + } + } + + /// Whether a response is missing data. The search also flags a window clamped to the max + /// query range as partial, with only the range note in `function_error`; its hits are + /// complete for the clamped window, so that alone is not a failure. + fn is_real_partial(&self, resp: &config::meta::search::Response) -> bool { + resp.is_partial + && (self.range_error.is_empty() + || resp.function_error.iter().any(|e| *e != self.range_error)) + } +} + +async fn run_sql( + org_id: &str, + user_id: &str, + sql: String, + window: &Window, + size: usize, +) -> Result, anyhow::Error> { + let req = SearchRequest { + query: SearchQuery { + sql, + from: 0, + size: size as i64, + start_time: window.start, + end_time: window.end, + ..Default::default() + }, + encoding: RequestEncoding::Empty, + ..Default::default() + }; + let trace_id = config::ider::generate_trace_id(); + let resp = search_service::cache::search( + &trace_id, + org_id, + StreamType::Traces, + Some(user_id.to_string()), + &req, + window.range_error.clone(), + false, + None, + false, + ) + .await?; + if window.is_real_partial(&resp) { + anyhow::bail!("partial search result: {}", resp.function_error.join("; ")); + } + Ok(resp.hits) +} + +async fn stream_columns(org_id: &str, stream: &str) -> Option { + infra::schema::get_stream_schema_from_cache(org_id, stream, StreamType::Traces) + .await + .map(|s| Columns::from_schema(&s)) +} + +/// The queries that follow the counts: sample trace ids per failing check, only when the +/// caller asked for samples, and the unpriced model breakdown. +fn follow_up_sql( + stream: &str, + base: &str, + cols: &Columns, + preds: &[Pred], + checks: &[HealthCheck], + include_samples: bool, +) -> (Vec<(usize, String)>, Option) { + let samples = if include_samples { + checks + .iter() + .zip(preds) + .enumerate() + .filter(|(_, (c, _))| c.affected > 0) + .map(|(i, (_, pred))| (i, samples_sql(stream, base, pred))) + .collect() + } else { + vec![] + }; + let unpriced = match (&preds[0], checks[0].affected > 0) { + (Pred::Sql(p), true) => Some(unpriced_models_sql( + stream, + base, + p, + cols, + MAX_UNPRICED_ROWS, + )), + _ => None, + }; + (samples, unpriced) +} + +/// Enrich cached strip checks without repeating the window-wide aggregations. +async fn populate_samples( + org_id: &str, + user_id: &str, + query: &DataHealthQuery, + window: &Window, + checks: &mut [HealthCheck], +) -> Result<(), anyhow::Error> { + for check in checks.iter_mut() { + check.samples = Some(Vec::new()); + } + let Some(cols) = stream_columns(org_id, &query.stream_name).await else { + return Ok(()); + }; + let Some(base) = base_where(&cols, query) else { + return Ok(()); + }; + let pricing_enabled = config::get_config().common.model_pricing_enabled; + let jobs = checks + .iter() + .enumerate() + .filter(|(_, check)| check.status != CheckStatus::Pass) + .map(|(i, check)| { + let pred = check_pred(check.id, &cols, pricing_enabled); + let sql = samples_sql(&query.stream_name, &base, &pred); + async move { + ( + i, + run_sql(org_id, user_id, sql, window, SAMPLE_SCAN_ROWS).await, + ) + } + }) + .collect::>(); + for (i, hits) in futures::future::join_all(jobs).await { + checks[i].samples = Some(distinct_trace_ids(&hits?)); + } + Ok(()) +} + +async fn compute_checks( + org_id: &str, + user_id: &str, + query: &DataHealthQuery, + window: &Window, +) -> Result, anyhow::Error> { + let include_samples = query.include_samples; + let Some(cols) = stream_columns(org_id, &query.stream_name).await else { + return Ok(all_pass(0, include_samples)); + }; + let Some(base) = base_where(&cols, query) else { + return Ok(all_pass(0, include_samples)); + }; + let stream = &query.stream_name; + let pricing_enabled = config::get_config().common.model_pricing_enabled; + let preds: Vec = CHECK_IDS + .iter() + .map(|&id| check_pred(id, &cols, pricing_enabled)) + .collect(); + + let rows = run_sql( + org_id, + user_id, + counts_sql(stream, &base, &preds, &cols), + window, + 1, + ) + .await?; + let row = rows.into_iter().next().unwrap_or_default(); + let total = row.get("total").map_or(0, json::get_int_value); + + let mut checks: Vec = CHECK_IDS + .iter() + .zip(&preds) + .enumerate() + .map(|(i, (&id, pred))| { + HealthCheck::new( + id, + affected_from(pred, &row, i, total), + total, + include_samples, + ) + }) + .collect(); + + let (sample_sqls, unpriced_sql) = + follow_up_sql(stream, &base, &cols, &preds, &checks, include_samples); + let sample_jobs = sample_sqls + .into_iter() + .map(|(i, sql)| async move { + ( + i, + run_sql(org_id, user_id, sql, window, SAMPLE_SCAN_ROWS).await, + ) + }) + .collect::>(); + let unpriced_job = async { + let Some(sql) = unpriced_sql else { + return Ok(None); + }; + run_sql(org_id, user_id, sql, window, MAX_UNPRICED_ROWS) + .await + .map(Some) + }; + let (samples, unpriced) = tokio::join!(futures::future::join_all(sample_jobs), unpriced_job); + + for (i, hits) in samples { + checks[i].samples = Some(distinct_trace_ids(&hits?)); + } + if let Some(model_hits) = unpriced? { + let listed = &model_hits[..model_hits.len().min(MAX_MODELS)]; + set_unpriced_breakdown(&mut checks[0], listed, Some(&row)); + let entries = db::model_pricing::get_org_pricing_entries(org_id); + let now = now_micros(); + let model_count = checks[0].model_count.unwrap_or(0); + if every_model_priced_now(&model_hits, model_count, |m| { + priced_now(db::model_pricing::find_pricing_sync_at(&entries, m, Some(now)).as_ref()) + }) { + checks[0].status = CheckStatus::Pass; + // Samples are per failing check, as the cached path returns them. + checks[0].samples = include_samples.then(Vec::new); + } + } + + // Worst first, so the strip and the skill lead with what matters. + checks.sort_by_key(|c| match c.status { + CheckStatus::Error => 0, + CheckStatus::Warning => 1, + CheckStatus::Pass => 2, + }); + Ok(checks) +} + +/// A cached value and the time it was computed, for the [`CACHE_TTL_MICROS`] caches here. +trait CheckedAt { + fn checked_at(&self) -> i64; +} + +impl CheckedAt for DataHealthResponse { + fn checked_at(&self) -> i64 { + self.checked_at + } +} + +type TtlCache = LazyLock>>; + +static CACHE: TtlCache = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Keyed by the window after the caller's max query range, so callers with different range +/// limits never share an entry. +fn cache_key(org_id: &str, q: &DataHealthQuery, window: &Window) -> String { + format!( + "{org_id}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", + q.stream_name, + q.agent.as_deref().unwrap_or(""), + q.agent_id.as_deref().unwrap_or(""), + q.env.as_deref().unwrap_or(""), + q.version.as_deref().unwrap_or(""), + window.start / WINDOW_QUANTUM_MICROS, + window.end / WINDOW_QUANTUM_MICROS, + q.include_samples, + ) +} + +fn cache_get( + cache: &Mutex>, + key: &str, + now: i64, +) -> Option { + let cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + cache + .get(key) + .filter(|r| now - r.checked_at() < CACHE_TTL_MICROS) + .cloned() +} + +/// Inserts `value`, first dropping expired entries and then, if the map is still full, only +/// the oldest entry, so one new scope never evicts every warm one. +fn insert_bounded( + cache: &mut HashMap, + key: String, + value: V, + max_entries: usize, +) { + let now = value.checked_at(); + cache.retain(|_, r| now - r.checked_at() < CACHE_TTL_MICROS); + if cache.len() >= max_entries + && !cache.contains_key(&key) + && let Some(oldest) = cache + .iter() + .min_by_key(|(_, r)| r.checked_at()) + .map(|(k, _)| k.clone()) + { + cache.remove(&oldest); + } + cache.insert(key, value); +} + +fn cache_put(cache: &Mutex>, key: String, value: V) { + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + insert_bounded(&mut cache, key, value, CACHE_MAX_ENTRIES); +} + +fn validate_window(start_time: i64, end_time: i64) -> Result<(), &'static str> { + if start_time <= 0 || end_time <= 0 || start_time >= end_time { + return Err("start_time and end_time must be positive and start_time < end_time"); + } + Ok(()) +} + +/// The gates the trace endpoints apply on enterprise: the license search allowance (429), then +/// read access to the trace stream (403). +async fn authorize_read(org_id: &str, user_id: &str, stream: &str) -> Result<(), Response> { + #[cfg(feature = "enterprise")] + { + if let Err(e) = search_service::check_search_allowed(org_id, Some(stream)) { + return Err(MetaHttpResponse::too_many_requests(e.to_string())); + } + if let Some(denied) = openobserve_core::authz::check_stream_permissions( + stream, + org_id, + user_id, + &StreamType::Traces, + openobserve_core::authz::StreamPermissionResourceType::Search, + ) + .await + { + return Err(denied); + } + } + #[cfg(not(feature = "enterprise"))] + let _ = (org_id, user_id, stream); + Ok(()) +} + +/// GetGenAiDataHealth +/// +/// #{"ratelimit_module":"Traces", "ratelimit_module_operation":"list"}# +#[utoipa::path( + get, + path = "/{org_id}/gen_ai/data_health", + context_path = "/api", + tag = "GenAI Settings", + operation_id = "GetGenAiDataHealth", + summary = "AI data health checks", + description = "Counts LLM spans with no model price, no agent identity, zero duration, or no session id in a trace stream and window. Pass include_samples=true for sample trace ids per failing check. Results are cached for about a minute; pass refresh=true to recompute.", + params(("org_id" = String, Path, description = "Organization ID"), DataHealthQuery), + responses( + (status = 200, description = "Data health checks", body = DataHealthResponse), + (status = 400, description = "Invalid query"), + (status = 403, description = "Forbidden"), + (status = 429, description = "Search not allowed for the organization"), + (status = 500, description = "Internal server error") + ), + security(("Authorization" = [])), + extensions( + ("x-o2-ratelimit" = json!({"module": "Traces", "operation": "list"})), + ("x-o2-mcp" = json!({"enabled": false})) + ) +)] +pub async fn get_data_health( + Path(org_id): Path, + openobserve_api_common::extractors::Headers(user_email): openobserve_api_common::extractors::Headers< + openobserve_core::auth::UserEmail, + >, + axum::extract::Query(query): axum::extract::Query, +) -> Response { + if let Err(e) = validate_window(query.start_time, query.end_time) { + return MetaHttpResponse::bad_request(e); + } + let user_id = &user_email.user_id; + if let Err(resp) = authorize_read(&org_id, user_id, &query.stream_name).await { + return resp; + } + + let window = Window::for_stream( + &org_id, + user_id, + &query.stream_name, + query.start_time, + query.end_time, + ) + .await; + let key = cache_key(&org_id, &query, &window); + let now = now_micros(); + if !query.refresh + && let Some(cached) = cache_get(&CACHE, &key, now) + { + return MetaHttpResponse::json(cached); + } + let result = async { + if query.include_samples && !query.refresh { + let mut aggregate_query = query.clone(); + aggregate_query.include_samples = false; + let aggregate_key = cache_key(&org_id, &aggregate_query, &window); + if let Some(mut cached) = cache_get(&CACHE, &aggregate_key, now) { + populate_samples(&org_id, user_id, &query, &window, &mut cached.checks).await?; + return Ok::<_, anyhow::Error>(cached); + } + } + let checks = compute_checks(&org_id, user_id, &query, &window).await?; + Ok(DataHealthResponse { + checked_at: now, + checks, + }) + } + .await; + match result { + Ok(resp) => { + cache_put(&CACHE, key, resp.clone()); + MetaHttpResponse::json(resp) + } + Err(e) => { + log::error!( + "[gen_ai_health] checks failed for {org_id}/{}: {e}", + query.stream_name + ); + MetaHttpResponse::internal_error("Failed to run data health checks") + } + } +} + +#[derive(Debug, Clone, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct UnpricedModelsQuery { + pub stream_name: String, + pub start_time: i64, + pub end_time: i64, +} + +/// A model whose spans in the window were priced approximately or not at all. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct UnpricedModelRow { + /// Null for spans that carry no model name: they have tokens but nothing to price. + pub model: Option, + pub status: CostStatus, + pub spans: i64, + pub tokens: i64, + /// Cost recorded at ingest (0 for unpriced spans). + pub cost: f64, + /// Up to [`MAX_AGENT_NAMES`] agent names that sent the model. + pub agents: Vec, + /// Distinct agent names behind the row, including those past the cap. + pub agent_count: i64, +} + +/// The spans the Unpriced tab lists: marked unpriced or approximate. `free` spans have a price. +fn listed_status_pred() -> String { + format!( + "{COST_STATUS_COLUMN} IN ({}, {})", + quote_str(CostStatus::Unpriced.as_str()), + quote_str(CostStatus::Approximate.as_str()) + ) +} + +fn unpriced_list_sql(stream: &str, cols: &Columns) -> Option { + if !cols.has(COST_STATUS_COLUMN) { + return None; + } + let (select, model) = model_grouping(cols); + let group = match model { + Some(m) => format!("{m}, {COST_STATUS_COLUMN}"), + None => COST_STATUS_COLUMN.to_string(), + }; + let cost = if cols.has(COST_COLUMN) { + "COALESCE(SUM(gen_ai_usage_cost), 0)" + } else { + "0" + }; + let (agents, agent_count) = if cols.has(AGENT_NAME_COLUMN) { + ( + format!( + "array_slice(array_agg(DISTINCT gen_ai_agent_name) FILTER (WHERE \ + gen_ai_agent_name IS NOT NULL AND gen_ai_agent_name != ''), 1, {MAX_AGENT_NAMES})" + ), + "COUNT(DISTINCT NULLIF(gen_ai_agent_name, ''))", + ) + } else { + ("NULL".to_string(), "0") + }; + Some(format!( + "SELECT {select}{COST_STATUS_COLUMN} AS status, COUNT(*) AS spans, {} AS tokens, \ + {cost} AS cost, {agents} AS agents, {agent_count} AS agent_count FROM {} WHERE {} \ + GROUP BY {group} ORDER BY tokens DESC LIMIT {MAX_UNPRICED_ROWS}", + cols.tokens_expr(), + quote_ident(stream), + listed_status_pred(), + )) +} + +fn parse_unpriced_row(hit: &json::Value) -> Option { + let status = match hit.get("status")?.as_str()? { + "unpriced" => CostStatus::Unpriced, + "approximate" => CostStatus::Approximate, + _ => return None, + }; + let mut agents: Vec = match hit.get("agents") { + Some(json::Value::Array(items)) => items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + // Some engines return the aggregated array as a JSON string. + Some(json::Value::String(s)) => json::from_str::>(s).unwrap_or_default(), + _ => vec![], + }; + agents.truncate(MAX_AGENT_NAMES); + let agent_count = hit + .get("agent_count") + .map_or(0, json::get_int_value) + .max(agents.len() as i64); + Some(UnpricedModelRow { + model: model_from(hit), + status, + spans: hit.get("spans").map_or(0, json::get_int_value), + tokens: hit.get("tokens").map_or(0, json::get_int_value), + cost: hit.get("cost").map_or(0.0, json::get_float_value), + agents, + agent_count, + }) +} + +/// The models ingest marked unpriced or approximate in a window: the [`MAX_UNPRICED_ROWS`] +/// (model, status) rows with the most tokens, and totals over every marked span. +#[derive(Debug, Default, Clone)] +pub struct UnpricedModels { + pub rows: Vec, + /// Distinct models behind every marked span (spans without a model name count as one). + pub model_count: i64, + /// Tokens on every marked span. + pub tokens: i64, + /// `rows` leaves some of the `model_count` models out. + pub truncated: bool, +} + +fn unpriced_listing(hits: &[json::Value], totals: Option<&json::Value>) -> UnpricedModels { + // The raw value, so a '' model and a missing one stay apart as they do in the totals. + let listed = hits + .iter() + .map(|h| h.get("model").and_then(|v| v.as_str())) + .collect::>() + .len() as i64; + let (model_count, tokens) = breakdown_totals(totals); + // Spans ingested between the two queries can only add models, never hide listed ones. + let model_count = model_count.max(listed); + UnpricedModels { + rows: hits.iter().filter_map(parse_unpriced_row).collect(), + model_count, + tokens, + truncated: model_count > listed, + } +} + +/// A cached [`UnpricedModels`] listing; today's prices are applied after the cache, so a +/// price added or muted shows on the next request. +#[derive(Clone)] +struct CachedUnpriced { + checked_at: i64, + listing: UnpricedModels, +} + +impl CheckedAt for CachedUnpriced { + fn checked_at(&self) -> i64 { + self.checked_at + } +} + +static UNPRICED_CACHE: TtlCache = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Keyed by org, stream and the window after the caller's max query range, to the minute. +fn unpriced_cache_key(org_id: &str, stream: &str, window: &Window) -> String { + format!( + "{org_id}\u{1f}{stream}\u{1f}{}\u{1f}{}", + window.start / WINDOW_QUANTUM_MICROS, + window.end / WINDOW_QUANTUM_MICROS, + ) +} + +/// The cached listing under `key` when it is younger than [`CACHE_TTL_MICROS`], else the +/// result of `compute`, cached on success. +async fn cached_unpriced( + key: String, + now: i64, + compute: impl Future>, +) -> Result { + if let Some(cached) = cache_get(&UNPRICED_CACHE, &key, now) { + return Ok(cached.listing); + } + let listing = compute.await?; + cache_put( + &UNPRICED_CACHE, + key, + CachedUnpriced { + checked_at: now, + listing: listing.clone(), + }, + ); + Ok(listing) +} + +/// Models in `stream` that ingest marked unpriced or approximate during the window, cached +/// for [`CACHE_TTL_MICROS`] per org, stream and window. +pub async fn unpriced_models( + org_id: &str, + user_id: &str, + query: &UnpricedModelsQuery, +) -> Result { + let Some(cols) = stream_columns(org_id, &query.stream_name).await else { + return Ok(UnpricedModels::default()); + }; + let Some(sql) = unpriced_list_sql(&query.stream_name, &cols) else { + return Ok(UnpricedModels::default()); + }; + let totals_sql = breakdown_totals_sql(&query.stream_name, &listed_status_pred(), &cols); + let window = Window::for_stream( + org_id, + user_id, + &query.stream_name, + query.start_time, + query.end_time, + ) + .await; + let key = unpriced_cache_key(org_id, &query.stream_name, &window); + cached_unpriced(key, now_micros(), async { + let (hits, totals) = tokio::try_join!( + run_sql(org_id, user_id, sql, &window, MAX_UNPRICED_ROWS), + run_sql(org_id, user_id, totals_sql, &window, 1), + )?; + Ok(unpriced_listing(&hits, totals.first())) + }) + .await +} + +/// Validates the window and applies the trace endpoints' search allowance and stream access +/// gates. +pub async fn authorize_unpriced( + org_id: &str, + user_id: &str, + query: &UnpricedModelsQuery, +) -> Result<(), Response> { + validate_window(query.start_time, query.end_time).map_err(MetaHttpResponse::bad_request)?; + authorize_read(org_id, user_id, &query.stream_name).await +} + +#[cfg(test)] +mod tests { + use arrow_schema::{DataType, Field, Schema}; + + use super::*; + + fn cols(names: &[&str]) -> Columns { + Columns::from_schema(&Schema::new( + names + .iter() + .map(|n| Field::new(*n, DataType::Utf8, true)) + .collect::>(), + )) + } + + fn query() -> DataHealthQuery { + DataHealthQuery { + stream_name: "default".into(), + start_time: 1, + end_time: 2, + agent: None, + agent_id: None, + env: None, + version: None, + refresh: false, + include_samples: false, + } + } + + const FULL: &[&str] = &[ + "gen_ai_operation_name", + "gen_ai_agent_name", + "gen_ai_agent_id", + "gen_ai_agent_env", + "gen_ai_agent_version", + "gen_ai_conversation_id", + "gen_ai_response_model", + "gen_ai_usage_total_tokens", + "gen_ai_usage_cost", + "gen_ai_usage_cost_status", + "duration", + ]; + + #[test] + fn missing_columns_degrade_per_check() { + // A stream written before the cost marker, with no agent or session columns. + let c = cols(&["gen_ai_operation_name", "duration"]); + assert_eq!(check_pred(CheckId::UnpricedModels, &c, true), Pred::Never); + assert_eq!( + check_pred(CheckId::MissingAgentIdentity, &c, true), + Pred::Always + ); + assert_eq!( + check_pred(CheckId::MissingSessionId, &c, true), + Pred::Always + ); + assert_eq!( + check_pred(CheckId::ZeroDurationSpans, &c, true), + Pred::Sql("duration = 0".into()) + ); + // Only the present identity column is tested. + let c = cols(&["gen_ai_operation_name", "gen_ai_agent_name"]); + assert_eq!( + check_pred(CheckId::MissingAgentIdentity, &c, true), + Pred::Sql("(gen_ai_agent_name IS NULL OR gen_ai_agent_name = '')".into()) + ); + } + + #[test] + fn missing_session_check_reads_every_session_column() { + // A stream that keeps sessions in `session_id` alone must not flag every span. + let c = cols(&["gen_ai_operation_name", "session_id"]); + assert_eq!( + check_pred(CheckId::MissingSessionId, &c, true), + Pred::Sql("(\"session_id\" IS NULL OR \"session_id\" = '')".into()) + ); + // A span is missing a session only when every present session column is blank. + let c = cols(&["gen_ai_operation_name", "session_id", "llm_session_id"]); + assert_eq!( + check_pred(CheckId::MissingSessionId, &c, true), + Pred::Sql( + "(\"session_id\" IS NULL OR \"session_id\" = '') AND (\"llm_session_id\" IS NULL \ + OR \"llm_session_id\" = '')" + .into() + ) + ); + } + + #[test] + fn the_unpriced_check_passes_only_once_every_found_model_is_priced() { + let hits = |models: &[Option<&str>]| -> Vec { + models + .iter() + .map(|m| json::json!({"model": m, "spans": 1, "tokens": 1})) + .collect() + }; + let priced = |m: &str| m != "open"; + assert!(every_model_priced_now( + &hits(&[Some("a"), Some("b")]), + 2, + priced + )); + assert!(!every_model_priced_now( + &hits(&[Some("a"), Some("open")]), + 2, + priced + )); + // Spans without a model name can never be priced. + assert!(!every_model_priced_now( + &hits(&[Some("a"), None]), + 2, + priced + )); + // Models past the listed rows are unknown. + assert!(!every_model_priced_now(&hits(&[Some("a")]), 2, priced)); + assert!(!every_model_priced_now(&[], 0, priced)); + assert!(!priced_now(None)); + } + + #[test] + fn unpriced_check_passes_when_pricing_is_disabled() { + let c = cols(FULL); + assert!(matches!( + check_pred(CheckId::UnpricedModels, &c, true), + Pred::Sql(_) + )); + // Spans marked unpriced before pricing was turned off do not count. + let pred = check_pred(CheckId::UnpricedModels, &c, false); + assert_eq!(pred, Pred::Never); + let row = json::json!({ "total": 10, "c0": 7 }); + let check = HealthCheck::new( + CheckId::UnpricedModels, + affected_from(&pred, &row, 0, 10), + 10, + false, + ); + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!(check.affected, 0); + // No breakdown query runs, so there is no model to fix. + let base = base_where(&c, &query()).unwrap(); + let preds: Vec = CHECK_IDS + .iter() + .map(|&id| check_pred(id, &c, false)) + .collect(); + let (_, unpriced) = follow_up_sql("default", &base, &c, &preds, &[check], false); + assert!(unpriced.is_none()); + } + + #[test] + fn affected_counts_follow_predicate_semantics() { + let c = cols(&["gen_ai_operation_name", "duration"]); + let preds: Vec = CHECK_IDS + .iter() + .map(|&id| check_pred(id, &c, true)) + .collect(); + let row = json::json!({"total": 10, "c2": 4}); + assert_eq!(affected_from(&preds[0], &row, 0, 10), 0); + assert_eq!(affected_from(&preds[1], &row, 1, 10), 10); + assert_eq!(affected_from(&preds[2], &row, 2, 10), 4); + } + + #[test] + fn scope_filters_quote_values_and_require_columns() { + let mut q = query(); + q.agent = Some("o'brien".into()); + q.version = Some("1.2".into()); + let base = base_where(&cols(FULL), &q).unwrap(); + assert_eq!( + base, + "gen_ai_operation_name IS NOT NULL AND gen_ai_agent_name = 'o''brien' \ + AND gen_ai_agent_version = '1.2'" + ); + // Scoped to a version the stream has no column for: nothing is in scope. + assert!(base_where(&cols(&["gen_ai_operation_name", "gen_ai_agent_name"]), &q).is_none()); + // No LLM spans at all. + assert!(base_where(&cols(&["duration"]), &query()).is_none()); + } + + #[test] + fn agent_id_scopes_on_the_id_column() { + // An id-only agent is listed with its id as the name; the id is what its spans carry. + let mut q = query(); + q.agent = Some("a23ca1dd38a1ff350".into()); + q.agent_id = Some("a23ca1dd38a1ff350".into()); + q.env = Some("prod".into()); + assert_eq!( + base_where(&cols(FULL), &q).unwrap(), + "gen_ai_operation_name IS NOT NULL AND gen_ai_agent_id = 'a23ca1dd38a1ff350' \ + AND gen_ai_agent_env = 'prod'" + ); + // An empty id falls back to the name. + q.agent_id = Some(String::new()); + assert!( + base_where(&cols(FULL), &q) + .unwrap() + .contains("gen_ai_agent_name = 'a23ca1dd38a1ff350'") + ); + // A stream without the id column has none of that agent's spans. + q.agent_id = Some("a23ca1dd38a1ff350".into()); + assert!(base_where(&cols(&["gen_ai_operation_name", "gen_ai_agent_name"]), &q).is_none()); + } + + #[test] + fn a_range_clamp_alone_is_not_a_partial_result() { + const HOUR: i64 = 3600 * 1_000_000; + let end = 1_000 * HOUR; + // Seven days on a stream capped at 24 h: the start moves and the note is set. + let w = Window::clamped(end - 7 * 24 * HOUR, end, 24); + assert_eq!((w.start, w.end), (end - 24 * HOUR, end)); + let clamp_only = config::meta::search::Response { + is_partial: true, + function_error: vec![w.range_error.clone()], + ..Default::default() + }; + assert!(!w.is_real_partial(&clamp_only)); + // A real gap on top of the clamp still fails. + let mut gap = clamp_only.clone(); + gap.function_error.insert( + 0, + config::meta::search::PARTIAL_ERROR_RESPONSE_MESSAGE.into(), + ); + assert!(w.is_real_partial(&gap)); + // Without a clamp every partial result fails. + let w = Window::clamped(end - HOUR, end, 24); + assert!(w.range_error.is_empty()); + assert_eq!(w.start, end - HOUR); + assert!(w.is_real_partial(&clamp_only)); + // No cap configured. + assert_eq!(Window::clamped(1, end, 0).start, 1); + } + + #[test] + fn unpriced_is_an_error_others_warn() { + assert_eq!(status_for(CheckId::UnpricedModels, 1), CheckStatus::Error); + assert_eq!( + status_for(CheckId::MissingAgentIdentity, 5), + CheckStatus::Warning + ); + assert_eq!(status_for(CheckId::MissingSessionId, 0), CheckStatus::Pass); + } + + #[test] + fn unpriced_list_needs_the_marker_column() { + assert!(unpriced_list_sql("default", &cols(&["gen_ai_response_model"])).is_none()); + let sql = unpriced_list_sql("default", &cols(FULL)).unwrap(); + assert!(sql.contains("GROUP BY gen_ai_response_model, gen_ai_usage_cost_status")); + // Model-less spans are listed too, as one row per status. + assert!(!sql.contains("gen_ai_response_model IS NOT NULL")); + // Free (exact $0) spans have a price: they are not listed or counted. + assert!(sql.contains("WHERE gen_ai_usage_cost_status IN ('unpriced', 'approximate')")); + assert!( + parse_unpriced_row(&json::json!({ + "model": "acme-internal-llm", "status": "free", "spans": 1, "tokens": 10 + })) + .is_none() + ); + let row = parse_unpriced_row(&json::json!({ + "model": "acme-internal-llm", "status": "unpriced", "spans": 3, "tokens": 900, + "cost": null, "agents": ["research-agent"] + })) + .unwrap(); + assert_eq!(row.model.as_deref(), Some("acme-internal-llm")); + assert_eq!(row.status, CostStatus::Unpriced); + assert_eq!(row.agents, vec!["research-agent".to_string()]); + assert_eq!(row.cost, 0.0); + } + + #[test] + fn spans_without_a_model_name_form_a_null_model_row() { + let row = parse_unpriced_row(&json::json!({ + "model": null, "status": "unpriced", "spans": 2, "tokens": 300, "cost": null + })) + .unwrap(); + assert_eq!(row.model, None); + assert_eq!(row.tokens, 300); + // A stream that never saw a model column groups by status alone. + let c = cols(&["gen_ai_operation_name", "gen_ai_usage_cost_status"]); + let sql = unpriced_list_sql("default", &c).unwrap(); + assert!(sql.starts_with("SELECT gen_ai_usage_cost_status AS status,")); + assert!(sql.contains("GROUP BY gen_ai_usage_cost_status ORDER BY")); + let sql = unpriced_models_sql("default", "b", "p", &c, MAX_MODELS); + assert!(sql.starts_with("SELECT COUNT(*) AS spans,")); + assert!(!sql.contains("GROUP BY")); + assert_eq!(model_from(&json::json!({"spans": 2})), None); + assert_eq!(model_from(&json::json!({"model": ""})), None); + } + + fn failing_checks(cols: &Columns) -> (Vec, Vec) { + let preds: Vec = CHECK_IDS + .iter() + .map(|&id| check_pred(id, cols, true)) + .collect(); + let checks = CHECK_IDS + .iter() + .map(|&id| HealthCheck::new(id, 5, 10, false)) + .collect(); + (preds, checks) + } + + /// Per-model rows as the engine returns them: most tokens first, at most `limit`. + fn top_rows(models: &[(Option, i64)], limit: usize) -> Vec { + let mut sorted = models.to_vec(); + sorted.sort_by_key(|(_, tokens)| std::cmp::Reverse(*tokens)); + sorted + .into_iter() + .take(limit) + .map(|(model, tokens)| json::json!({ "model": model, "spans": 1, "tokens": tokens })) + .collect() + } + + #[test] + fn unpriced_totals_cover_models_past_the_list_cap() { + // 25 unpriced models, one of them spans without a model name. + let models: Vec<(Option, i64)> = (1..=25) + .map(|i| ((i != 7).then(|| format!("model-{i:02}")), i * 100)) + .collect(); + let all_tokens: i64 = models.iter().map(|(_, t)| t).sum(); + + let mut check = HealthCheck::new(CheckId::UnpricedModels, 25, 40, false); + let totals = + json::json!({ "distinct_models": 24, "no_model_spans": 1, "tokens": all_tokens }); + set_unpriced_breakdown(&mut check, &top_rows(&models, MAX_MODELS), Some(&totals)); + assert_eq!(check.models.as_ref().unwrap().len(), MAX_MODELS); + assert_eq!(check.model_count, Some(25)); + assert_eq!(check.unpriced_tokens, Some(all_tokens)); + assert_eq!(check.models_truncated, Some(true)); + // Every model fits: nothing is cut. + let few = &models[..3]; + let mut check = HealthCheck::new(CheckId::UnpricedModels, 3, 40, false); + let totals = json::json!({ "distinct_models": 3, "no_model_spans": 0, "tokens": 600 }); + set_unpriced_breakdown(&mut check, &top_rows(few, MAX_MODELS), Some(&totals)); + assert_eq!(check.model_count, Some(3)); + assert_eq!(check.models_truncated, Some(false)); + // The wire form carries the totals on the unpriced check only. + let wire = json::to_value(&check).unwrap(); + assert_eq!(wire["model_count"], 3); + assert_eq!(wire["unpriced_tokens"], 600); + assert_eq!(wire["models_truncated"], false); + let other = json::to_value(HealthCheck::new(CheckId::ZeroDurationSpans, 1, 4, false)); + assert!(other.unwrap().get("model_count").is_none()); + } + + #[test] + fn unpriced_list_totals_cover_rows_past_the_cap() { + let models: Vec<(Option, i64)> = (1..=120) + .map(|i| (Some(format!("model-{i:03}")), i)) + .collect(); + let mut rows = top_rows(&models, MAX_UNPRICED_ROWS); + for row in &mut rows { + row["status"] = json::json!("unpriced"); + } + let totals = json::json!({ "distinct_models": 120, "no_model_spans": 0, "tokens": 7260 }); + let listing = unpriced_listing(&rows, Some(&totals)); + assert_eq!(listing.rows.len(), MAX_UNPRICED_ROWS); + assert_eq!((listing.model_count, listing.tokens), (120, 7260)); + assert!(listing.truncated); + // One model seen both unpriced and approximate is two rows but one model. + let rows = vec![ + json::json!({ "model": "m", "status": "unpriced", "spans": 1, "tokens": 5 }), + json::json!({ "model": "m", "status": "approximate", "spans": 1, "tokens": 5 }), + ]; + let totals = json::json!({ "distinct_models": 1, "no_model_spans": 0, "tokens": 10 }); + assert!(!unpriced_listing(&rows, Some(&totals)).truncated); + // A stream without a model column: every marked span is the one null-model row. + let sql = breakdown_totals_sql("default", "x", &cols(&["gen_ai_usage_cost_status"])); + assert!(sql.starts_with("SELECT 0 AS distinct_models, COUNT(*) AS no_model_spans, 0 AS")); + } + + #[test] + fn samples_are_queried_only_when_asked_for() { + let c = cols(FULL); + let (preds, checks) = failing_checks(&c); + let (samples, unpriced) = follow_up_sql("default", "b", &c, &preds, &checks, false); + assert!(samples.is_empty()); + // The unpriced model breakdown is part of the strip, so it always runs. + assert!(unpriced.is_some()); + let (samples, _) = follow_up_sql("default", "b", &c, &preds, &checks, true); + assert_eq!(samples.len(), CHECK_IDS.len()); + // Bounded to the newest spans, not an aggregate over every span in the window. + assert!(samples.iter().all(|(_, sql)| { + sql.contains(&format!( + "ORDER BY _timestamp DESC LIMIT {SAMPLE_SCAN_ROWS}" + )) && !sql.contains("GROUP BY") + })); + // Without samples the field is left out of the response. + let check = &all_pass(0, false)[0]; + assert!(json::to_value(check).unwrap().get("samples").is_none()); + let check = &all_pass(0, true)[0]; + assert_eq!( + json::to_value(check).unwrap().get("samples"), + Some(&json::json!([])) + ); + } + + #[test] + fn sample_trace_ids_are_distinct_and_capped() { + let hits: Vec = ["t1", "t1", "t2", "t1", "t3", "t4"] + .iter() + .map(|id| json::json!({ "trace_id": id })) + .collect(); + assert_eq!(distinct_trace_ids(&hits), ["t1", "t2", "t3"]); + let hits = vec![ + json::json!({ "trace_id": "t1" }), + json::json!({}), + json::json!({ "trace_id": "t1" }), + ]; + assert_eq!(distinct_trace_ids(&hits), ["t1"]); + } + + #[test] + fn cache_key_rounds_the_window_to_the_minute() { + let mut a = query(); + a.start_time = 120_000_000; + a.end_time = 180_000_001; + let wa = Window::clamped(a.start_time, a.end_time, 0); + let mut b = a.clone(); + b.start_time += 1_000_000; + let wb = Window::clamped(b.start_time, b.end_time, 0); + assert_eq!(cache_key("o", &a, &wa), cache_key("o", &b, &wb)); + b.agent = Some("x".into()); + assert_ne!(cache_key("o", &a, &wa), cache_key("o", &b, &wb)); + let mut c = a.clone(); + c.agent_id = Some("x".into()); + assert_ne!(cache_key("o", &a, &wa), cache_key("o", &c, &wa)); + let mut d = a.clone(); + d.include_samples = true; + assert_ne!(cache_key("o", &a, &wa), cache_key("o", &d, &wa)); + } + + #[test] + fn callers_with_different_range_limits_get_different_keys() { + const HOUR: i64 = 3600 * 1_000_000; + let mut q = query(); + q.end_time = 1_000 * HOUR; + q.start_time = q.end_time - 7 * 24 * HOUR; + let capped = Window::clamped(q.start_time, q.end_time, 1); + let uncapped = Window::clamped(q.start_time, q.end_time, 0); + assert_ne!(capped.start, uncapped.start); + assert_ne!(cache_key("o", &q, &capped), cache_key("o", &q, &uncapped)); + // The same cap on the same request shares the entry. + let capped_again = Window::clamped(q.start_time, q.end_time, 1); + assert_eq!( + cache_key("o", &q, &capped), + cache_key("o", &q, &capped_again) + ); + } + + fn resp(checked_at: i64) -> DataHealthResponse { + DataHealthResponse { + checked_at, + checks: vec![], + } + } + + #[test] + fn a_full_cache_evicts_expired_then_only_the_oldest() { + let now = 10 * CACHE_TTL_MICROS; + let mut cache = HashMap::new(); + insert_bounded(&mut cache, "old".into(), resp(now - 3), 3); + insert_bounded(&mut cache, "mid".into(), resp(now - 2), 3); + insert_bounded(&mut cache, "new".into(), resp(now - 1), 3); + // Full: the oldest goes, the warm entries stay. + insert_bounded(&mut cache, "next".into(), resp(now), 3); + let mut keys: Vec<_> = cache.keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["mid", "new", "next"]); + // Replacing a present key evicts nothing. + insert_bounded(&mut cache, "mid".into(), resp(now), 3); + assert_eq!(cache.len(), 3); + // Expired entries are dropped before any live one. + insert_bounded( + &mut cache, + "late".into(), + resp(now - 1 + CACHE_TTL_MICROS), + 3, + ); + let mut keys: Vec<_> = cache.keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["late", "mid", "next"]); + } + + #[test] + fn an_insert_drops_every_expired_entry_and_keeps_every_live_one() { + let now = 10 * CACHE_TTL_MICROS; + let mut cache = HashMap::new(); + insert_bounded(&mut cache, "live".into(), resp(now - 1), 4); + insert_bounded( + &mut cache, + "staler".into(), + resp(now - CACHE_TTL_MICROS - 5), + 4, + ); + insert_bounded( + &mut cache, + "stale".into(), + resp(now - CACHE_TTL_MICROS - 3), + 4, + ); + assert_eq!(cache.len(), 3); + // Not full, so only the TTL sweep can remove anything. + insert_bounded(&mut cache, "next".into(), resp(now), 4); + let mut keys: Vec<_> = cache.keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["live", "next"]); + } + + #[tokio::test] + async fn unpriced_listing_is_cached_per_org_stream_and_window() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let runs = AtomicUsize::new(0); + let compute = |tokens: i64| { + let runs = &runs; + async move { + runs.fetch_add(1, Ordering::SeqCst); + Ok(UnpricedModels { + tokens, + ..Default::default() + }) + } + }; + let window = Window::clamped(120_000_000, 86_520_000_000, 0); + let key = unpriced_cache_key("cache_hit_org", "default", &window); + let now = 1_000 * CACHE_TTL_MICROS; + + let first = cached_unpriced(key.clone(), now, compute(7)).await.unwrap(); + assert_eq!(first.tokens, 7); + // Within the TTL: served from the cache, the search does not run again. + let hit = cached_unpriced(key.clone(), now + CACHE_TTL_MICROS - 1, compute(9)) + .await + .unwrap(); + assert_eq!(hit.tokens, 7); + assert_eq!(runs.load(Ordering::SeqCst), 1); + // Another stream misses. + let other = unpriced_cache_key("cache_hit_org", "other", &window); + assert_eq!( + cached_unpriced(other, now, compute(3)) + .await + .unwrap() + .tokens, + 3 + ); + assert_eq!(runs.load(Ordering::SeqCst), 2); + // Expired: recomputed. + let fresh = cached_unpriced(key, now + CACHE_TTL_MICROS, compute(9)) + .await + .unwrap(); + assert_eq!(fresh.tokens, 9); + assert_eq!(runs.load(Ordering::SeqCst), 3); + } +} diff --git a/src/api/management/src/request/mod.rs b/src/api/management/src/request/mod.rs index cd5e8d45be..e084768e0b 100644 --- a/src/api/management/src/request/mod.rs +++ b/src/api/management/src/request/mod.rs @@ -45,6 +45,7 @@ pub mod fleet; #[allow(deprecated)] pub mod folders; pub mod gen_ai; +pub mod gen_ai_health; pub mod ingest_rejections; pub mod keys; pub mod kv; diff --git a/src/api/management/src/request/model_pricing.rs b/src/api/management/src/request/model_pricing.rs index 0bfe3613d5..ddd4eb1a63 100644 --- a/src/api/management/src/request/model_pricing.rs +++ b/src/api/management/src/request/model_pricing.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; use axum::{ Json, @@ -33,7 +33,9 @@ use { openobserve_core::auth::{UserEmail, check_permissions}, }; -use crate::common::meta::http::HttpResponse as MetaHttpResponse; +use crate::{ + common::meta::http::HttpResponse as MetaHttpResponse, request::gen_ai_health::MAX_AGENT_NAMES, +}; fn source_priority(source: &PricingSource) -> u8 { match source { @@ -449,6 +451,8 @@ pub async fn delete( pub struct BuiltInQuery { #[serde(default)] pub search: String, + #[serde(default)] + pub metadata_only: bool, } /// A model pricing entry from the community/built-in source. @@ -468,7 +472,10 @@ pub struct BuiltInModelPricingEntry { pub struct BuiltInModelPricingResponse { pub models: Vec, pub source_url: String, + /// Unix seconds of this server's last successful online sync; 0 = none since startup. pub last_updated: i64, + /// Date of the seed price list compiled into this binary. + pub seed_date: &'static str, } /// GetBuiltInModelPricing @@ -486,6 +493,7 @@ pub struct BuiltInModelPricingResponse { params( ("org_id" = String, Path, description = "Organization name"), ("search" = Option, Query, description = "Filter by name"), + ("metadata_only" = Option, Query, description = "Return seed/sync metadata without loading the catalogue"), ), responses( (status = 200, description = "Success", content_type = "application/json", body = Object), @@ -519,6 +527,14 @@ pub async fn get_built_in( } let source_url = config::get_config().common.model_pricing_source_url.clone(); + if query.metadata_only { + return MetaHttpResponse::json(BuiltInModelPricingResponse { + models: Vec::new(), + source_url, + last_updated: openobserve_builtins::model_pricing::last_sync_timestamp(), + seed_date: openobserve_builtins::model_pricing::SEED_DATE, + }); + } // Read from DB (synced by background job) instead of making a live HTTP call. let all_built_in = match model_pricing::list(BUILT_IN_ORG).await { @@ -553,6 +569,7 @@ pub async fn get_built_in( models, source_url, last_updated, + seed_date: openobserve_builtins::model_pricing::SEED_DATE, }) } @@ -623,6 +640,180 @@ pub async fn refresh_built_in( } } +/// The pricing definition a listed model resolves to today. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UnpricedModelMatch { + pub name: String, + pub match_pattern: String, + pub source: PricingSource, + pub tiers: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UnpricedModel { + #[serde(flatten)] + pub row: crate::request::gen_ai_health::UnpricedModelRow, + /// Null when no definition matches the model today. + pub matched: Option, + /// An exact definition covers new spans; these older spans retain their recorded cost. + pub priced_now: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UnpricedModelsResponse { + /// Models with recorded pricing gaps, including those priced since ingestion. + pub models: Vec, + /// Distinct models ingest marked in the window, uncapped and before today's prices are + /// applied; spans without a model name count as one. + pub model_count: i64, + /// Tokens on every span ingest marked in the window. + pub tokens: i64, + /// The server listed only the models with the most tokens; `model_count` has them all. + pub models_truncated: bool, +} + +/// Joins recorded pricing gaps with today's definitions without hiding historical gaps. +fn resolve_unpriced( + rows: Vec, + entries: &[CachedModelPricing], + now_micros: i64, +) -> Vec { + let mut by_model: HashMap, UnpricedModel> = HashMap::new(); + let mut order = Vec::new(); + for row in rows { + let resolved = row + .model + .as_deref() + .and_then(|m| model_pricing::find_pricing_sync_at(entries, m, Some(now_micros))); + let priced_now = crate::request::gen_ai_health::priced_now(resolved.as_ref()); + // A model seen both unpriced and approximate (a price was added mid-window) is one row. + if let Some(existing) = by_model.get_mut(&row.model) { + existing.row.spans += row.spans; + existing.row.tokens += row.tokens; + existing.row.cost += row.cost; + // Counts per status overlap, so the larger is a floor for the merged distinct count. + existing.row.agent_count = existing.row.agent_count.max(row.agent_count); + let mut seen: HashSet = existing.row.agents.iter().cloned().collect(); + for agent in row.agents { + if existing.row.agents.len() >= MAX_AGENT_NAMES { + break; + } + if seen.insert(agent.clone()) { + existing.row.agents.push(agent); + } + } + existing.row.agent_count = existing + .row + .agent_count + .max(existing.row.agents.len() as i64); + continue; + } + let matched = resolved.map(|r| UnpricedModelMatch { + name: r.definition.name, + match_pattern: r.definition.match_pattern, + source: r.definition.source, + tiers: r.definition.tiers, + }); + let mut row = row; + if !priced_now { + row.status = if matched.is_some() { + config::meta::model_pricing::CostStatus::Approximate + } else { + config::meta::model_pricing::CostStatus::Unpriced + }; + } + order.push(row.model.clone()); + by_model.insert( + row.model.clone(), + UnpricedModel { + row, + matched, + priced_now, + }, + ); + } + order + .into_iter() + .filter_map(|m| by_model.remove(&m)) + .collect() +} + +/// ListUnpricedModels +/// +/// #{"ratelimit_module":"ModelPricing", "ratelimit_module_operation":"list"}# +#[utoipa::path( + get, + path = "/{org_id}/llm/models/unpriced", + context_path = "/api", + tag = "LLM", + operation_id = "ListUnpricedModels", + summary = "List models seen in traces with no price or an approximate one", + description = "Models in the stream's spans that ingest marked unpriced or approximately priced during the window, with spans, tokens and recorded cost. Historical gaps remain after adding an exact price and are marked priced_now; old costs are not backfilled. Spans with tokens but no model name are listed as one row with a null model.", + security(("Authorization" = [])), + params( + ("org_id" = String, Path, description = "Organization name"), + crate::request::gen_ai_health::UnpricedModelsQuery, + ), + responses( + (status = 200, description = "Success", body = UnpricedModelsResponse), + (status = 400, description = "Invalid query"), + (status = 403, description = "Forbidden"), + (status = 429, description = "Search not allowed for the organization"), + (status = 500, description = "Internal server error"), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "LLM", "operation": "list"})), + ("x-o2-mcp" = json!({"enabled": false})) + ), +)] +pub async fn list_unpriced( + Path(org_id): Path, + openobserve_api_common::extractors::Headers(user_email): openobserve_api_common::extractors::Headers< + openobserve_core::auth::UserEmail, + >, + Query(query): Query, +) -> Response { + let user_id = &user_email.user_id; + #[cfg(feature = "enterprise")] + if !check_permissions( + &org_id, + &org_id, + user_id, + "model_pricing", + "LIST", + None, + true, + false, + false, + ) + .await + { + return MetaHttpResponse::forbidden("Unauthorized Access"); + } + if let Err(resp) = + crate::request::gen_ai_health::authorize_unpriced(&org_id, user_id, &query).await + { + return resp; + } + match crate::request::gen_ai_health::unpriced_models(&org_id, user_id, &query).await { + Ok(listing) => { + let entries = model_pricing::get_org_pricing_entries(&org_id); + let models = + resolve_unpriced(listing.rows, &entries, config::utils::time::now_micros()); + MetaHttpResponse::json(UnpricedModelsResponse { + models, + model_count: listing.model_count, + tokens: listing.tokens, + models_truncated: listing.truncated, + }) + } + Err(e) => { + log::error!("[model_pricing] unpriced models for {org_id}: {e}"); + MetaHttpResponse::internal_error("Failed to list unpriced models") + } + } +} + /// Strip leading inline flag groups like `(?i)`, `(?ms)`, `(?-i)` from a regex pattern. fn strip_leading_flags(s: &str) -> &str { let mut rest = s; @@ -1489,6 +1680,90 @@ mod tests { ] } + fn unpriced_row( + model: &str, + status: config::meta::model_pricing::CostStatus, + tokens: i64, + ) -> crate::request::gen_ai_health::UnpricedModelRow { + crate::request::gen_ai_health::UnpricedModelRow { + model: Some(model.to_string()), + status, + spans: 1, + tokens, + cost: 0.0, + agents: vec!["a".to_string()], + agent_count: 1, + } + } + + #[test] + fn test_resolve_unpriced_keeps_historical_gaps_and_merges_statuses() { + use config::meta::model_pricing::CostStatus; + let mut entries = built_in_gpt_4o(); + // Approximate today: `gpt-5.4` covers only part of `gpt-5.4-mini`. + entries.push(entry("GPT-5.4", r"(?i)gpt-5\.4", PricingSource::BuiltIn)); + let rows = vec![ + unpriced_row("acme-internal-llm", CostStatus::Unpriced, 100), + // Now exactly priced, but the historical gap must remain visible. + unpriced_row("gpt-4o", CostStatus::Unpriced, 50), + unpriced_row("gpt-5.4-mini", CostStatus::Approximate, 30), + // Seen unpriced earlier in the window, approximate since: one row. + unpriced_row("gpt-5.4-mini", CostStatus::Unpriced, 20), + ]; + let out = resolve_unpriced(rows, &entries, 0); + let models: Vec<_> = out.iter().map(|m| m.row.model.as_deref()).collect(); + assert_eq!( + models, + [ + Some("acme-internal-llm"), + Some("gpt-4o"), + Some("gpt-5.4-mini") + ] + ); + assert!(out[0].matched.is_none()); + assert!(!out[0].priced_now); + assert!(out[1].priced_now); + assert_eq!(out[2].row.status, CostStatus::Approximate); + assert!(!out[2].priced_now); + assert_eq!(out[2].row.tokens, 50); + assert_eq!(out[2].matched.as_ref().unwrap().name, "GPT-5.4"); + } + + #[test] + fn test_resolve_unpriced_keeps_spans_without_a_model_name() { + use config::meta::model_pricing::CostStatus; + let mut no_model = unpriced_row("x", CostStatus::Unpriced, 70); + no_model.model = None; + let mut no_model_again = no_model.clone(); + no_model_again.tokens = 30; + let out = resolve_unpriced(vec![no_model, no_model_again], &built_in_gpt_4o(), 0); + assert_eq!(out.len(), 1); + assert_eq!(out[0].row.model, None); + assert_eq!(out[0].row.status, CostStatus::Unpriced); + assert_eq!(out[0].row.tokens, 100); + assert!(out[0].matched.is_none()); + // The model is null on the wire, not an empty name. + let wire = config::utils::json::to_value(&out[0]).unwrap(); + assert_eq!(wire.get("model"), Some(&config::utils::json::Value::Null)); + } + + #[test] + fn test_resolve_unpriced_caps_merged_agent_names_and_keeps_the_count() { + use config::meta::model_pricing::CostStatus; + let names = |range: std::ops::Range| range.map(|i| format!("agent-{i}")).collect(); + let mut unpriced = unpriced_row("m", CostStatus::Unpriced, 10); + unpriced.agents = names(0..8); + unpriced.agent_count = 25; + let mut approximate = unpriced_row("m", CostStatus::Approximate, 10); + approximate.agents = names(4..10); + approximate.agent_count = 6; + let out = resolve_unpriced(vec![unpriced, approximate], &[], 0); + let agents = &out[0].row.agents; + assert_eq!(agents.len(), MAX_AGENT_NAMES); + assert_eq!(agents.iter().collect::>().len(), MAX_AGENT_NAMES); + assert_eq!(out[0].row.agent_count, 25); + } + #[test] fn test_captured_models_prefix_rule_also_catches_other_models() { let captured = captured_models(&built_in_gpt_4o(), &org_candidate(r"(?i)^gpt-4o")); diff --git a/src/api/search/src/traces/list/query.rs b/src/api/search/src/traces/list/query.rs index fa1c60ee5b..b383189e0a 100644 --- a/src/api/search/src/traces/list/query.rs +++ b/src/api/search/src/traces/list/query.rs @@ -212,10 +212,12 @@ pub fn build_llm_trace_query( sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \ {total_tokens_expr}, \ sum(gen_ai_usage_cost) as gen_ai_usage_cost_details, \ + {}, \ array_agg(DISTINCT gen_ai_response_model) FILTER (WHERE gen_ai_response_model IS NOT NULL AND gen_ai_response_model != '') as gen_ai_response_models, \ {first_msg_clause} as gen_ai_input_messages, \ {extra_selects} \ - FROM {stream_ident}" + FROM {stream_ident}", + validated.unpriced_selects() ) } else { let total_tokens_expr = if validated.has_total_tokens { diff --git a/src/api/search/src/traces/list/response.rs b/src/api/search/src/traces/list/response.rs index 2e42f709fc..a558b5910e 100644 --- a/src/api/search/src/traces/list/response.rs +++ b/src/api/search/src/traces/list/response.rs @@ -34,6 +34,10 @@ pub struct TraceResponseItem { gen_ai_usage_cost: f64, gen_ai_input_messages: Option, models: Vec, + /// Tokens of this trace's spans that no price matched; their cost is missing. + gen_ai_unpriced_tokens: i64, + /// Models of those unpriced spans. + gen_ai_unpriced_models: Vec, } #[derive(Debug, Default, Serialize)] @@ -189,6 +193,8 @@ fn insert_summary( max_duration }; + let (gen_ai_unpriced_tokens, gen_ai_unpriced_models) = + crate::traces::schema_compat::parse_unpriced(item); batch.traces.insert( trace_id.clone(), TraceResponseItem { @@ -227,6 +233,8 @@ fn insert_summary( .collect() }) .unwrap_or_default(), + gen_ai_unpriced_tokens, + gen_ai_unpriced_models, }, ); } @@ -290,7 +298,8 @@ mod tests { "first_service_name": "api", "first_operation_name": "request", "gen_ai_usage_details_input": 10, "gen_ai_usage_details_output": 20, "gen_ai_usage_details_total": 30, "gen_ai_usage_cost_details": 0.5, - "gen_ai_input_messages": "hello", "gen_ai_response_models": ["model", null] + "gen_ai_input_messages": "hello", "gen_ai_response_models": ["model", null], + "gen_ai_unpriced_tokens": 12, "gen_ai_unpriced_models": ["acme-internal-llm"] }) } @@ -307,7 +316,8 @@ mod tests { "first_event": {"service_name": "gateway", "operation_name": "request"}, "gen_ai_usage_input_tokens": 10, "gen_ai_usage_output_tokens": 20, "gen_ai_usage_total_tokens": 30, "gen_ai_usage_cost": 0.5, - "gen_ai_input_messages": "hello", "models": ["model"] + "gen_ai_input_messages": "hello", "models": ["model"], + "gen_ai_unpriced_tokens": 12, "gen_ai_unpriced_models": ["acme-internal-llm"] }) ); } diff --git a/src/api/search/src/traces/schema_compat.rs b/src/api/search/src/traces/schema_compat.rs index 8e886abbce..95304d239e 100644 --- a/src/api/search/src/traces/schema_compat.rs +++ b/src/api/search/src/traces/schema_compat.rs @@ -133,6 +133,45 @@ pub(super) struct ValidatedLlmSchema { pub(super) has_cost_estimated_without_cache: bool, pub(super) has_cost_cache_read_savings: bool, pub(super) has_cost_net_cache_impact: bool, + /// `gen_ai_usage_cost_status` exists (ingest marks unpriced / approximate spans). + pub(super) has_cost_status: bool, +} + +impl ValidatedLlmSchema { + /// Per-row unpriced token sum and unpriced model list; zero / absent when the stream + /// predates the cost marker. + pub(super) fn unpriced_selects(&self) -> String { + if !self.has_gen_ai || !self.has_cost_status { + return "0 as gen_ai_unpriced_tokens".to_string(); + } + let tokens = if self.has_total_tokens { + "gen_ai_usage_total_tokens" + } else { + "COALESCE(gen_ai_usage_input_tokens, 0) + COALESCE(gen_ai_usage_output_tokens, 0)" + }; + format!( + "COALESCE(sum(CASE WHEN gen_ai_usage_cost_status = 'unpriced' THEN {tokens} ELSE 0 END), 0) as gen_ai_unpriced_tokens, \ + array_agg(DISTINCT gen_ai_response_model) FILTER (WHERE gen_ai_usage_cost_status = 'unpriced' \ + AND gen_ai_response_model IS NOT NULL AND gen_ai_response_model != '') as gen_ai_unpriced_models" + ) + } +} + +/// Reads the per-row unpriced fields [`ValidatedLlmSchema::unpriced_selects`] adds. +pub(super) fn parse_unpriced(item: &config::utils::json::Value) -> (i64, Vec) { + let tokens = item + .get("gen_ai_unpriced_tokens") + .map_or(0, config::utils::json::get_int_value); + let models = item + .get("gen_ai_unpriced_models") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|m| m.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + (tokens, models) } impl ValidatedLlmSchema { @@ -158,6 +197,7 @@ impl ValidatedLlmSchema { has_cost_estimated_without_cache: false, has_cost_cache_read_savings: false, has_cost_net_cache_impact: false, + has_cost_status: false, } } } @@ -239,6 +279,10 @@ pub(super) fn validate_llm_schema( has_cost_estimated_without_cache, has_cost_cache_read_savings, has_cost_net_cache_impact, + has_cost_status: has_gen_ai + && schema + .field_with_name(config::meta::model_pricing::COST_STATUS_COLUMN) + .is_ok(), }) } @@ -433,4 +477,36 @@ mod tests { assert!(!fallback.has_gen_ai); assert_eq!(fallback.columns, LlmColumns::legacy()); } + + #[test] + fn unpriced_selects_follow_the_cost_status_column() { + let base = [ + "gen_ai_usage_input_tokens", + "gen_ai_usage_output_tokens", + "gen_ai_usage_cost", + "gen_ai_response_model", + "gen_ai_conversation_id", + ]; + // A stream written before the marker: constant zero, no reference to the column. + let old = validate_llm_schema(&schema_with(&base), "s").unwrap(); + assert!(!old.has_cost_status); + assert_eq!(old.unpriced_selects(), "0 as gen_ai_unpriced_tokens"); + + let mut fields = base.to_vec(); + fields.push("gen_ai_usage_cost_status"); + let new = validate_llm_schema(&schema_with(&fields), "s").unwrap(); + assert!(new.has_cost_status); + let sql = new.unpriced_selects(); + assert!(sql.contains("gen_ai_usage_cost_status = 'unpriced'")); + assert!(sql.contains("as gen_ai_unpriced_models")); + + let (tokens, models) = parse_unpriced(&config::utils::json::json!({ + "gen_ai_unpriced_tokens": 7, "gen_ai_unpriced_models": ["a", null] + })); + assert_eq!((tokens, models), (7, vec!["a".to_string()])); + assert_eq!( + parse_unpriced(&config::utils::json::json!({})), + (0, Vec::new()) + ); + } } diff --git a/src/api/search/src/traces/session.rs b/src/api/search/src/traces/session.rs index 0ea3399874..fa21a491a8 100644 --- a/src/api/search/src/traces/session.rs +++ b/src/api/search/src/traces/session.rs @@ -1027,6 +1027,7 @@ fn build_session_trace_details_sql( "gen_ai_usage_cost_net_cache_impact", "gen_ai_usage_cost_net_cache_impact", ); + let unpriced_selects = validated.unpriced_selects(); format!( "SELECT trace_id, min({TIMESTAMP_COL_NAME}) as zo_sql_timestamp, \ min(start_time) as trace_start_time, max(end_time) as trace_end_time, \ @@ -1035,6 +1036,7 @@ fn build_session_trace_details_sql( sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \ {total_tokens_expr}, \ sum(gen_ai_usage_cost) as gen_ai_usage_cost_details, \ + {unpriced_selects}, \ {cache_read_tokens_expr}, \ {cache_creation_tokens_expr}, \ {cost_cache_read_expr}, \ @@ -1149,7 +1151,8 @@ fn build_session_trace_response_item( } else { max_duration }; - + let (gen_ai_unpriced_tokens, gen_ai_unpriced_models) = + super::schema_compat::parse_unpriced(item); let hit = SessionTraceResponseItem { trace_id: tid.clone(), user_ids, @@ -1215,6 +1218,8 @@ fn build_session_trace_response_item( .collect() }) .unwrap_or_default(), + gen_ai_unpriced_tokens, + gen_ai_unpriced_models, }; Some((tid, service_count, hit)) @@ -1626,6 +1631,7 @@ fn build_latest_sessions_sql( "gen_ai_usage_cost_net_cache_impact", "gen_ai_usage_cost_net_cache_impact", ); + let unpriced_selects = validated.unpriced_selects(); let stream_ident = traces::session::quote_identifier(stream_name); format!( @@ -1639,6 +1645,7 @@ fn build_latest_sessions_sql( sum({output_tokens_col}) as gen_ai_usage_output_tokens, \ {total_tokens_expr}, \ sum({cost_col}) as gen_ai_usage_cost, \ + {unpriced_selects}, \ {cache_read_tokens_expr}, \ {cache_creation_tokens_expr}, \ {cost_cache_read_expr}, \ @@ -1722,6 +1729,9 @@ struct SessionTraceResponseItem { gen_ai_usage_cost_net_cache_impact: f64, gen_ai_input_messages: Option, models: Vec, + /// Tokens of this trace's spans that no price matched; their cost is missing. + gen_ai_unpriced_tokens: i64, + gen_ai_unpriced_models: Vec, } #[derive(Debug, Default, Serialize)] diff --git a/src/builtins/src/model_pricing.rs b/src/builtins/src/model_pricing.rs index e6e44991fd..da0725e885 100644 --- a/src/builtins/src/model_pricing.rs +++ b/src/builtins/src/model_pricing.rs @@ -17,6 +17,8 @@ //! //! Syncs model pricing definitions from the configured GitHub source into the database //! under the `_openobserve` org. Runs on startup and periodically (every 6h by default). +//! Until a sync succeeds, or forever on an offline install, the seed list compiled into the +//! binary ([`SEED_PRICING_JSON`]) supplies the built-in prices. use std::sync::{ LazyLock, @@ -37,12 +39,49 @@ pub static GITHUB_SERVICE: LazyLock = LazyLock::new(GitHubDat /// Timestamp (Unix seconds) of the last successful sync from GitHub. 0 means never synced. static LAST_SYNC_TIMESTAMP: AtomicI64 = AtomicI64::new(0); +/// The built-in price list as of [`SEED_DATE`], shipped in the binary so prices work offline. +/// Same format as the GitHub source (`llm_pricing.json` in openobserve/sdr_patterns). +pub const SEED_PRICING_JSON: &str = include_str!("model_pricing_seed.json"); + +/// Date (UTC) of the upstream `llm_pricing.json` commit that [`SEED_PRICING_JSON`] copies. +pub const SEED_DATE: &str = "2026-10-28"; + /// Returns the Unix timestamp (seconds) of the last successful built-in pricing sync. /// Returns 0 if no sync has completed yet. pub fn last_sync_timestamp() -> i64 { LAST_SYNC_TIMESTAMP.load(Ordering::Relaxed) } +/// Parses the compiled-in seed list. +pub fn seed_entries() -> Result, anyhow::Error> { + let raw: Vec = serde_json::from_str(SEED_PRICING_JSON) + .map_err(|e| anyhow::anyhow!("Failed to parse seed pricing JSON: {e}"))?; + Ok(parse_entries(raw)) +} + +/// Fill missing compiled-in prices, retrying incomplete seeds without overwriting synced rows. +pub async fn load_seed() -> Result { + let names = table::model_pricing::list_built_in_names().await?; + let existing: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect(); + let mut missing = Vec::new(); + for entry in seed_entries()? { + if existing.contains(entry.name.as_str()) { + continue; + } + let definition = entry.into_definition(); + definition.validate_tiers(true).map_err(|error| { + anyhow::anyhow!("Invalid seed price '{}': {error}", definition.name) + })?; + missing.push(definition); + } + let created = usize::try_from(table::model_pricing::insert_missing_built_ins(missing).await?)?; + if created > 0 { + emit_sync_event().await; + } + log::info!("[model_pricing_sync] loaded {created} seed prices dated {SEED_DATE}"); + Ok(created) +} + /// A model pricing entry from the community/built-in GitHub JSON source. #[derive(Debug, Deserialize)] pub struct BuiltInModelPricingEntry { @@ -95,51 +134,7 @@ pub async fn sync_built_in_from_github(force_refresh: bool) -> Result { - if is_new { - created += 1; - } else { - updated += 1; - } - } - Err(e) => { - log::warn!("[model_pricing_sync] failed to upsert built-in model: {e}"); - } - } - } + let (created, updated) = upsert_entries(entries, &existing_names).await; // Disable models that were removed from the upstream source let mut disabled = 0usize; @@ -162,11 +157,7 @@ pub async fn sync_built_in_from_github(force_refresh: bool) -> Result Result, + existing_names: &std::collections::HashSet, +) -> (usize, usize) { + let mut created = 0usize; + let mut updated = 0usize; + for entry in entries { + let is_new = !existing_names.contains(&entry.name); + let definition = entry.into_definition(); + // An invalid entry could overprice every span; keep the previously stored row. + if let Err(e) = definition.validate_tiers(true) { + log::warn!( + "[model_pricing_sync] skipping invalid entry '{}': {e}", + definition.name + ); + continue; + } + + match table::model_pricing::put(definition).await { + Ok(_) => { + if is_new { + created += 1; + } else { + updated += 1; + } + } + Err(e) => { + log::warn!("[model_pricing_sync] failed to upsert built-in model: {e}"); + } + } + } + (created, updated) +} + +/// Tells every node to refresh its `_openobserve` pricing cache. +async fn emit_sync_event() { + let event_key = format!("/model_pricing/{BUILT_IN_ORG}/_sync"); + if let Err(e) = infra::coordinator::emit_put(&event_key).await { + log::error!("[model_pricing_sync] failed to emit sync event: {e}"); + } +} + +impl BuiltInModelPricingEntry { + fn into_definition(self) -> ModelPricingDefinition { + ModelPricingDefinition { + id: None, // will be looked up by (org, name) on upsert + org_id: BUILT_IN_ORG.to_string(), + name: self.name, + match_pattern: self.match_pattern, + enabled: true, + tiers: self.tiers, + valid_from: None, + sort_order: 0, + source: PricingSource::BuiltIn, + provider: self.provider, + description: self.description, + created_at: 0, // set by DB layer + updated_at: 0, + children: Vec::new(), + prepared_rule_keys: Default::default(), + } + } +} + /// Parses each catalogue entry on its own so one malformed entry does not stop the whole sync. fn parse_entries(raw: Vec) -> Vec { raw.into_iter() @@ -201,6 +258,27 @@ fn parse_entries(raw: Vec) -> Vec { mod tests { use super::*; + /// The seed list is all an offline install has, so every entry must parse and validate + /// without any network access. + #[test] + fn test_seed_list_loads_offline() { + let raw: Vec = serde_json::from_str(SEED_PRICING_JSON).unwrap(); + let entries = seed_entries().unwrap(); + assert!( + entries.len() > 50, + "seed list has {} entries", + entries.len() + ); + assert_eq!(entries.len(), raw.len(), "an entry failed to parse"); + for entry in entries { + let definition = entry.into_definition(); + definition + .validate_tiers(true) + .unwrap_or_else(|e| panic!("{}: {e}", definition.name)); + } + assert!(chrono::NaiveDate::parse_from_str(SEED_DATE, "%Y-%m-%d").is_ok()); + } + #[test] fn test_last_sync_timestamp_initial_value() { // Initial value is 0 (never synced), unless tests run after sync diff --git a/src/builtins/src/model_pricing_seed.json b/src/builtins/src/model_pricing_seed.json new file mode 100644 index 0000000000..1967ffe62d --- /dev/null +++ b/src/builtins/src/model_pricing_seed.json @@ -0,0 +1,5622 @@ +[ + { + "name": "GPT-6.1 Sol", + "provider": "OpenAI", + "description": "OpenAI GPT-6.1 Sol - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-6\\.1-sol", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 4e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Ultrafast \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "ultrafast" + ] + } + ], + "prices": { + "input": 2.4e-05, + "output": 9e-05, + "cache_read_input_tokens": 1.2e-06, + "cache_creation_input_tokens": 3e-05 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8e-06, + "output": 3e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 1e-05 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Ultrafast", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "ultrafast" + ] + } + ], + "prices": { + "input": 1.2e-05, + "output": 6e-05, + "cache_read_input_tokens": 6e-07, + "cache_creation_input_tokens": 1.5e-05 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-06, + "output": 2e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-06, + "output": 5e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "GPT-6.1 Terra", + "provider": "OpenAI", + "description": "OpenAI GPT-6.1 Terra - extended context pricing at 272k+ input tokens (place next to GPT-6.1 Sol; no overlap)", + "match_pattern": "(?i)gpt-6\\.1-terra", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 5e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 1.25e-06 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 2e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-06, + "output": 3.75e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 1.25e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 5e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 2.5e-08, + "cache_creation_input_tokens": 6.25e-07 + } + } + ] + }, + { + "name": "GPT-6 Astra", + "provider": "OpenAI", + "description": "OpenAI GPT-6 Astra - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-6-astra", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 2e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 2e-06, + "cache_creation_input_tokens": 2.5e-05 + } + }, + { + "name": "Ultrafast \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "ultrafast" + ] + } + ], + "prices": { + "input": 0.00012, + "output": 0.00045, + "cache_read_input_tokens": 1.2e-05, + "cache_creation_input_tokens": 0.00015 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-05, + "output": 0.00015, + "cache_read_input_tokens": 4e-06, + "cache_creation_input_tokens": 5e-05 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-05, + "output": 3.75e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + }, + { + "name": "Ultrafast", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "ultrafast" + ] + } + ], + "prices": { + "input": 6e-05, + "output": 0.0003, + "cache_read_input_tokens": 6e-06, + "cache_creation_input_tokens": 7.5e-05 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2e-05, + "output": 0.0001, + "cache_read_input_tokens": 2e-06, + "cache_creation_input_tokens": 2.5e-05 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + } + ] + }, + { + "name": "GPT-6 Sol", + "provider": "OpenAI", + "description": "OpenAI GPT-6 Sol - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-6-sol", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 4e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8e-06, + "output": 3e-05, + "cache_read_input_tokens": 8e-07, + "cache_creation_input_tokens": 1e-05 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-06, + "output": 2e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-06, + "output": 5e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "GPT-6 Luna", + "provider": "OpenAI", + "description": "OpenAI GPT-6 Luna - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-6-luna", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 5e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 1.25e-07 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 2e-07, + "output": 7.5e-07, + "cache_read_input_tokens": 2e-08, + "cache_creation_input_tokens": 2.5e-07 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-07, + "output": 1.5e-06, + "cache_read_input_tokens": 4e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-07, + "output": 3.75e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 1.25e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2e-07, + "output": 1e-06, + "cache_read_input_tokens": 2e-08, + "cache_creation_input_tokens": 2.5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 5e-08, + "output": 2.5e-07, + "cache_read_input_tokens": 5e-09, + "cache_creation_input_tokens": 6.25e-08 + } + } + ] + }, + { + "name": "GPT-5.6 Cyber", + "provider": "OpenAI", + "description": "OpenAI GPT-5.6 Cyber (trusted-access); also covers the deprecated gpt-daybreak-red-latest alias, which the docs say points to gpt-5.6-cyber. Long-context tier from the model page rule (2x input, 1.5x output for >272K); cache rates doubled by the same rule OpenAI applies to GPT-5.6 Sol - the pricing page leaves cyber long-context columns blank. Place next to the GPT-5.6 entries; no overlap.", + "match_pattern": "(?i)(?:gpt-5\\.6-cyber|gpt-daybreak-red)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.25e-06, + "cache_creation_input_tokens": 1.5625e-05 + } + } + ] + }, + { + "name": "GPT-5.6 Sol", + "provider": "OpenAI", + "description": "OpenAI GPT-5.6 Sol (also the plain gpt-5.6 alias and the deprecated gpt-daybreak-blue-latest alias) - extended context pricing at 272k+ input tokens; promotional pricing through at least 2026-11-21", + "match_pattern": "(?i)(?:gpt-5\\.6(?:-sol)?(?:$|-\\d)|gpt-daybreak-blue)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-06, + "output": 2e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 8e-06, + "output": 3e-05, + "cache_read_input_tokens": 8e-07, + "cache_creation_input_tokens": 1e-05 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 1.6e-05, + "output": 6e-05, + "cache_read_input_tokens": 1.6e-06, + "cache_creation_input_tokens": 2e-05 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 4e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8e-06, + "output": 4e-05, + "cache_read_input_tokens": 8e-07, + "cache_creation_input_tokens": 1e-05 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "GPT-5.6 Terra", + "provider": "OpenAI", + "description": "OpenAI GPT-5.6 Terra - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-5\\.6-terra", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1.2e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 4e-06, + "output": 1.8e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8e-06, + "output": 3.6e-05, + "cache_read_input_tokens": 8e-07, + "cache_creation_input_tokens": 1e-05 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 9e-06, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-06, + "output": 2.4e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-06, + "output": 6e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "GPT-5.6 Luna", + "provider": "OpenAI", + "description": "OpenAI GPT-5.6 Luna - extended context pricing at 272k+ input tokens", + "match_pattern": "(?i)gpt-5\\.6-luna", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07, + "output": 1.2e-06, + "cache_read_input_tokens": 2e-08, + "cache_creation_input_tokens": 2.5e-07 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 4e-07, + "output": 1.8e-06, + "cache_read_input_tokens": 4e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8e-07, + "output": 3.6e-06, + "cache_read_input_tokens": 8e-08, + "cache_creation_input_tokens": 1e-06 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2e-07, + "output": 9e-07, + "cache_read_input_tokens": 2e-08, + "cache_creation_input_tokens": 2.5e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4e-07, + "output": 2.4e-06, + "cache_read_input_tokens": 4e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-07, + "output": 6e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 1.25e-07 + } + } + ] + }, + { + "name": "GPT-5.5 Cyber", + "provider": "OpenAI", + "description": "OpenAI GPT-5.5 Cyber (trusted-access; also matches gpt-5.5-cyber-preview). Must stay out of the GPT-5.5 pattern, which it currently is (that pattern only allows $, -digit, -codex, -chat-latest).", + "match_pattern": "(?i)gpt-5\\.5-cyber", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "GPT-5.5 Pro", + "provider": "OpenAI", + "description": "OpenAI GPT-5.5 Pro", + "match_pattern": "(?i)gpt-5\\.5-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-05, + "output": 0.00018 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 6e-05, + "output": 0.00027 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 3e-05, + "output": 0.000135 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1.5e-05, + "output": 9e-05 + } + } + ] + }, + { + "name": "GPT-5.5", + "provider": "OpenAI", + "description": "OpenAI GPT-5.5", + "match_pattern": "(?i)gpt-5\\.5(?:$|-\\d|-codex|-chat-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 3e-05, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 1e-05, + "output": 4.5e-05, + "cache_read_input_tokens": 1e-06 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 5e-06, + "output": 2.25e-05, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2.5e-05, + "output": 0.0001125, + "cache_read_input_tokens": 2.5e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 1.25e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.25e-06 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2.5e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 2.5e-07 + } + } + ] + }, + { + "name": "GPT-5.4 Pro", + "provider": "OpenAI", + "description": "OpenAI GPT-5.4 Pro", + "match_pattern": "(?i)gpt-5\\.4-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-05, + "output": 0.00018 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 6e-05, + "output": 0.00027 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 3e-05, + "output": 0.000135 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1.5e-05, + "output": 9e-05 + } + } + ] + }, + { + "name": "GPT-5.4 Mini", + "provider": "OpenAI", + "description": "OpenAI GPT-5.4 Mini", + "match_pattern": "(?i)gpt-5\\.4-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-07, + "output": 4.5e-06, + "cache_read_input_tokens": 7.5e-08 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 1.5e-06, + "output": 9e-06, + "cache_read_input_tokens": 1.5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 3.75e-07, + "output": 2.25e-06, + "cache_read_input_tokens": 3.75e-08 + } + } + ] + }, + { + "name": "GPT-5.4 Nano", + "provider": "OpenAI", + "description": "OpenAI GPT-5.4 Nano", + "match_pattern": "(?i)gpt-5\\.4-nano", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07, + "output": 1.25e-06, + "cache_read_input_tokens": 2e-08 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-07, + "output": 6.25e-07, + "cache_read_input_tokens": 1e-08 + } + } + ] + }, + { + "name": "GPT-5.4", + "provider": "OpenAI", + "description": "OpenAI GPT-5.4", + "match_pattern": "(?i)gpt-5\\.4(?:$|-\\d|-codex|-chat-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 2.5e-07 + } + }, + { + "name": "Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "prices": { + "input": 5e-06, + "output": 2.25e-05, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Flex \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2.5e-06, + "output": 1.125e-05, + "cache_read_input_tokens": 2.5e-07 + } + }, + { + "name": "Fast mode \u00b7 Extended Context (272k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 272000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 1e-05, + "output": 4.5e-05, + "cache_read_input_tokens": 1e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 5e-06, + "output": 3e-05, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1.25e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 1.3e-07 + } + } + ] + }, + { + "name": "GPT-5.3", + "provider": "OpenAI", + "description": "OpenAI GPT-5.3 (chat-latest, codex, codex-spark)", + "match_pattern": "(?i)gpt-5\\.3", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.75e-06, + "output": 1.4e-05, + "cache_read_input_tokens": 1.75e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 3.5e-06, + "output": 2.8e-05, + "cache_read_input_tokens": 3.5e-07 + } + } + ] + }, + { + "name": "GPT-5.2 Pro", + "provider": "OpenAI", + "description": "OpenAI GPT-5.2 Pro", + "match_pattern": "(?i)gpt-5\\.2-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.1e-05, + "output": 0.000168 + } + } + ] + }, + { + "name": "GPT-5.2", + "provider": "OpenAI", + "description": "OpenAI GPT-5.2", + "match_pattern": "(?i)gpt-5\\.2(?:-codex|-chat-latest|-\\d{4}-\\d{2}-\\d{2})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.75e-06, + "output": 1.4e-05, + "cache_read_input_tokens": 1.75e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 3.5e-06, + "output": 2.8e-05, + "cache_read_input_tokens": 3.5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 8.75e-07, + "output": 7e-06, + "cache_read_input_tokens": 8.75e-08 + } + } + ] + }, + { + "name": "GPT-5.1 Codex Mini", + "provider": "OpenAI", + "description": "OpenAI GPT-5.1 Codex Mini (shut down; kept for historical spans). MUST sit before GPT-5.1, whose unanchored (?i)gpt-5\\.1 pattern otherwise bills it at $1.25/$10.", + "match_pattern": "(?i)gpt-5\\.1-codex-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-07, + "output": 2e-06, + "cache_read_input_tokens": 2.5e-08 + } + } + ] + }, + { + "name": "GPT-5.1", + "provider": "OpenAI", + "description": "OpenAI GPT-5.1", + "match_pattern": "(?i)gpt-5\\.1", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 1e-05, + "cache_read_input_tokens": 1.25e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2.5e-06, + "output": 2e-05, + "cache_read_input_tokens": 2.5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 6.25e-07, + "output": 5e-06, + "cache_read_input_tokens": 6.25e-08 + } + } + ] + }, + { + "name": "GPT-5 Search API", + "provider": "OpenAI", + "description": "OpenAI gpt-5-search-api (per-call web search fees not included). GPT-5's pattern is $-anchored so it does not catch this; place anywhere, e.g. right before GPT-5.", + "match_pattern": "(?i)gpt-5-search-api", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 1e-05, + "cache_read_input_tokens": 1.25e-07 + } + } + ] + }, + { + "name": "GPT-5 Pro", + "provider": "OpenAI", + "description": "OpenAI GPT-5 Pro", + "match_pattern": "(?i)gpt-5-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-05, + "output": 0.00012 + } + } + ] + }, + { + "name": "GPT-5 Mini", + "provider": "OpenAI", + "description": "OpenAI GPT-5 Mini", + "match_pattern": "(?i)gpt-5-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-07, + "output": 2e-06, + "cache_read_input_tokens": 2.5e-08 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4.5e-07, + "output": 3.6e-06, + "cache_read_input_tokens": 4.5e-08 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1.25e-07, + "output": 1e-06, + "cache_read_input_tokens": 1.25e-08 + } + } + ] + }, + { + "name": "GPT-5 Nano", + "provider": "OpenAI", + "description": "OpenAI GPT-5 Nano", + "match_pattern": "(?i)gpt-5-nano", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-08, + "output": 4e-07, + "cache_read_input_tokens": 5e-09 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 2.5e-08, + "output": 2e-07, + "cache_read_input_tokens": 2.5e-09 + } + } + ] + }, + { + "name": "GPT-5", + "provider": "OpenAI", + "description": "OpenAI GPT-5", + "match_pattern": "(?i)gpt-5(?:-codex|-chat-latest|-\\d{4}-\\d{2}-\\d{2})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 1e-05, + "cache_read_input_tokens": 1.25e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2.5e-06, + "output": 2e-05, + "cache_read_input_tokens": 2.5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 6.25e-07, + "output": 5e-06, + "cache_read_input_tokens": 6.25e-08 + } + } + ] + }, + { + "name": "GPT Rosalind", + "provider": "OpenAI", + "description": "OpenAI GPT-Rosalind life-sciences models (research, discovery); no cache-write pricing per OpenAI. No overlap with other entries.", + "match_pattern": "(?i)gpt-rosalind-(?:research|discovery)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07 + } + } + ] + }, + { + "name": "GPT-4.5 Preview", + "provider": "OpenAI", + "description": "OpenAI GPT-4.5 Preview", + "match_pattern": "(?i)gpt-4\\.5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-05, + "output": 0.00015, + "cache_read_input_tokens": 3.75e-05 + } + } + ] + }, + { + "name": "GPT-4.1 Nano", + "provider": "OpenAI", + "description": "OpenAI GPT-4.1 Nano", + "match_pattern": "(?i)gpt-4\\.1-nano", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 2.5e-08 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2e-07, + "output": 8e-07, + "cache_read_input_tokens": 5e-08 + } + } + ] + }, + { + "name": "GPT-4.1 Mini", + "provider": "OpenAI", + "description": "OpenAI GPT-4.1 Mini", + "match_pattern": "(?i)gpt-4\\.1-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-07, + "output": 1.6e-06, + "cache_read_input_tokens": 1e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 7e-07, + "output": 2.8e-06, + "cache_read_input_tokens": 1.75e-07 + } + } + ] + }, + { + "name": "GPT-4.1", + "provider": "OpenAI", + "description": "OpenAI GPT-4.1", + "match_pattern": "(?i)gpt-4\\.1(?:-\\d{4}-\\d{2}-\\d{2})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 8e-06, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 3.5e-06, + "output": 1.4e-05, + "cache_read_input_tokens": 8.75e-07 + } + } + ] + }, + { + "name": "GPT-4o Mini", + "provider": "OpenAI", + "description": "OpenAI GPT-4o Mini", + "match_pattern": "(?i)gpt-4o-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 6e-07, + "cache_read_input_tokens": 7.5e-08 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2.5e-07, + "output": 1e-06, + "cache_read_input_tokens": 1.25e-07 + } + } + ] + }, + { + "name": "ChatGPT-4o Latest", + "provider": "OpenAI", + "description": "OpenAI chatgpt-4o-latest - ChatGPT-tuned GPT-4o, priced above the API gpt-4o", + "match_pattern": "(?i)chatgpt-4o-latest", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 1.5e-05 + } + } + ] + }, + { + "name": "GPT-4o (2024-05-13)", + "provider": "OpenAI", + "description": "OpenAI gpt-4o-2024-05-13 snapshot (shut down), priced above GPT-4o; no cached-input price published. MUST sit before GPT-4o, whose pattern gpt-4o(?:-\\d{4}[-\\d]*)?$ also matches this snapshot.", + "match_pattern": "(?i)gpt-4o-2024-05-13", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 1.5e-05 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 8.75e-06, + "output": 2.625e-05 + } + } + ] + }, + { + "name": "GPT-4o", + "provider": "OpenAI", + "description": "OpenAI GPT-4o", + "match_pattern": "(?i)gpt-4o(?:-\\d{4}[-\\d]*)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-06, + "output": 1e-05, + "cache_read_input_tokens": 1.25e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 4.25e-06, + "output": 1.7e-05, + "cache_read_input_tokens": 2.125e-06 + } + } + ] + }, + { + "name": "o1 Pro", + "provider": "OpenAI", + "description": "OpenAI o1 Pro", + "match_pattern": "(?i)o1-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 0.00015, + "output": 0.0006, + "cache_read_input_tokens": 7.5e-05 + } + } + ] + }, + { + "name": "o1 Mini", + "provider": "OpenAI", + "description": "OpenAI o1 Mini", + "match_pattern": "(?i)o1-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.1e-06, + "output": 4.4e-06, + "cache_read_input_tokens": 5.5e-07 + } + } + ] + }, + { + "name": "o1", + "provider": "OpenAI", + "description": "OpenAI o1", + "match_pattern": "(?i)(?:^|[-/])o1(?:$|[-/]\\d|-preview)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-05, + "output": 6e-05, + "cache_read_input_tokens": 7.5e-06 + } + } + ] + }, + { + "name": "o3 Pro", + "provider": "OpenAI", + "description": "OpenAI o3 Pro", + "match_pattern": "(?i)o3-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-05, + "output": 8e-05, + "cache_read_input_tokens": 1e-05 + } + } + ] + }, + { + "name": "o3 Mini", + "provider": "OpenAI", + "description": "OpenAI o3 Mini", + "match_pattern": "(?i)o3-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.1e-06, + "output": 4.4e-06, + "cache_read_input_tokens": 5.5e-07 + } + } + ] + }, + { + "name": "o3 Deep Research", + "provider": "OpenAI", + "description": "OpenAI o3 Deep Research", + "match_pattern": "(?i)o3-deep-research", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 4e-05, + "cache_read_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "o3", + "provider": "OpenAI", + "description": "OpenAI o3", + "match_pattern": "(?i)(?:^|[-/])o3(?:$|[-/]\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 8e-06, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 3.5e-06, + "output": 1.4e-05, + "cache_read_input_tokens": 8.75e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 1e-06, + "output": 4e-06, + "cache_read_input_tokens": 2.5e-07 + } + } + ] + }, + { + "name": "o4 Mini Deep Research", + "provider": "OpenAI", + "description": "OpenAI o4-mini-deep-research (shut down; kept for historical spans). MUST sit before o4 Mini, whose unanchored (?i)o4-mini pattern otherwise bills it at $1.10/$4.40.", + "match_pattern": "(?i)o4-mini-deep-research", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 8e-06, + "cache_read_input_tokens": 5e-07 + } + } + ] + }, + { + "name": "o4 Mini", + "provider": "OpenAI", + "description": "OpenAI o4 Mini", + "match_pattern": "(?i)o4-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.1e-06, + "output": 4.4e-06, + "cache_read_input_tokens": 2.75e-07 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "fast", + "priority" + ] + } + ], + "prices": { + "input": 2e-06, + "output": 8e-06, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Flex", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "flex" + ] + } + ], + "prices": { + "input": 5.5e-07, + "output": 2.2e-06, + "cache_read_input_tokens": 1.38e-07 + } + } + ] + }, + { + "name": "GPT-4 Turbo", + "provider": "OpenAI", + "description": "OpenAI GPT-4 Turbo", + "match_pattern": "(?i)gpt-4-(?:turbo|\\d{4}-preview|preview|vision-preview)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 3e-05 + } + } + ] + }, + { + "name": "GPT-4 32k", + "provider": "OpenAI", + "description": "OpenAI GPT-4 32k", + "match_pattern": "(?i)gpt-4-32k", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 6e-05, + "output": 0.00012 + } + } + ] + }, + { + "name": "GPT-4", + "provider": "OpenAI", + "description": "OpenAI GPT-4", + "match_pattern": "(?i)gpt-4(?:-0314|-0613|-0314)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-05, + "output": 6e-05 + } + } + ] + }, + { + "name": "GPT-3.5 Turbo", + "provider": "OpenAI", + "description": "OpenAI GPT-3.5 Turbo", + "match_pattern": "(?i)gpt-3\\.5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 1.5e-06 + } + } + ] + }, + { + "name": "GPT Image 1.5", + "provider": "OpenAI", + "description": "OpenAI gpt-image-1.5 (deprecated). input/cache_read use TEXT-token rates ($5 / $1.25); output uses IMAGE output ($32). Image input ($8, cached $2) and text output ($10) are not represented. Order-independent: the GPT Image 1 pattern excludes 1.5.", + "match_pattern": "(?i)gpt-image-1\\.5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 3.2e-05, + "cache_read_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "GPT Image 1 Mini", + "provider": "OpenAI", + "description": "OpenAI gpt-image-1-mini (deprecated). input/cache_read use TEXT-token rates ($2 / $0.20); output uses IMAGE output ($8). Image input ($2.50, cached $0.25) is not represented; no text output price exists.", + "match_pattern": "(?i)gpt-image-1-mini", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 8e-06, + "cache_read_input_tokens": 2e-07 + } + } + ] + }, + { + "name": "GPT Image 1", + "provider": "OpenAI", + "description": "OpenAI gpt-image-1 (shut down 2026-10-23; kept for historical spans). input/cache_read use TEXT-token rates ($5 / $1.25); output uses IMAGE output ($40). Image input ($10, cached $2.50) is not represented. Pattern is anchored so it does not catch gpt-image-1.5 / gpt-image-1-mini.", + "match_pattern": "(?i)gpt-image-1(?:$|-\\d{4})", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 4e-05, + "cache_read_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "text-embedding-3-large", + "provider": "OpenAI", + "description": "OpenAI text embedding large", + "match_pattern": "(?i)text-embedding-3-large", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.3e-07 + } + } + ] + }, + { + "name": "text-embedding-3-small", + "provider": "OpenAI", + "description": "OpenAI text embedding small", + "match_pattern": "(?i)text-embedding-3-small", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-08 + } + } + ] + }, + { + "name": "text-embedding-ada-002", + "provider": "OpenAI", + "description": "OpenAI Ada embedding", + "match_pattern": "(?i)text-embedding-ada-002", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07 + } + } + ] + }, + { + "name": "Claude Opus 5.5", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 5.5 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-opus-5-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-06, + "output": 2e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 5e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "anthropic.response.speed", + "gen_ai.response.speed", + "anthropic.request.speed", + "gen_ai.request.speed", + "param.speed" + ], + "op": "in", + "values": [ + "fast" + ] + } + ], + "prices": { + "input": 8e-06, + "output": 4e-05, + "cache_read_input_tokens": 4e-07, + "cache_creation_input_tokens": 1e-05 + } + } + ] + }, + { + "name": "Claude Opus 5", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 5 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-opus-5(?:$|[@:\\[]|-v\\d|-\\d{8}|-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "anthropic.response.speed", + "gen_ai.response.speed", + "anthropic.request.speed", + "gen_ai.request.speed", + "param.speed" + ], + "op": "in", + "values": [ + "fast" + ] + } + ], + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Sonnet 5.5", + "provider": "Anthropic", + "description": "Anthropic Claude Sonnet 5.5 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-sonnet-5-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "Claude Sonnet 5", + "provider": "Anthropic", + "description": "Anthropic Claude Sonnet 5 - 1M context window, no long-context premium. The $2/$10 launch price is now the standard price (the planned 2026-09-01 rise to $3/$15 was cancelled)", + "match_pattern": "(?i)claude-sonnet-5(?:$|[@:\\[]|-v\\d|-\\d{8}|-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "Claude Fable 5.1", + "provider": "Anthropic", + "description": "Anthropic Claude Fable 5.1 \u2014 1M context window", + "match_pattern": "(?i)claude-fable-5-1", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 2.5e-07, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Fable 5", + "provider": "Anthropic", + "description": "Anthropic Claude Fable 5 \u2014 most capable widely released model, 1M context window", + "match_pattern": "(?i)claude-fable-5(?:$|[@:\\[]|-v\\d|-\\d{8}|-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Mythos 5.1", + "provider": "Anthropic", + "description": "Anthropic Claude Mythos 5.1 \u2014 same pricing as Fable 5.1, 1M context window", + "match_pattern": "(?i)claude-mythos-5-1", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 2.5e-07, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Mythos 5", + "provider": "Anthropic", + "description": "Anthropic Claude Mythos 5 \u2014 Project Glasswing, same pricing as Fable 5, 1M context window", + "match_pattern": "(?i)claude-mythos-5(?:$|[@:\\[]|-v\\d|-\\d{8}|-latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Opus 4.8", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4.8 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-opus-4-8", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + }, + { + "name": "Fast mode", + "rules": [ + { + "keys": [ + "anthropic.response.speed", + "gen_ai.response.speed", + "anthropic.request.speed", + "gen_ai.request.speed", + "param.speed" + ], + "op": "in", + "values": [ + "fast" + ] + } + ], + "prices": { + "input": 1e-05, + "output": 5e-05, + "cache_read_input_tokens": 1e-06, + "cache_creation_input_tokens": 1.25e-05 + } + } + ] + }, + { + "name": "Claude Opus 4.7", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4.7 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-opus-4-7", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + } + ] + }, + { + "name": "Claude Opus 4.6", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4.6 \u2014 1M context window, no long-context premium", + "match_pattern": "(?i)claude-opus-4-6", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + } + ] + }, + { + "name": "Claude Opus 4.5", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4.5", + "match_pattern": "(?i)claude-opus-4-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-06, + "output": 2.5e-05, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 6.25e-06 + } + } + ] + }, + { + "name": "Claude Opus 4.1", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4.1", + "match_pattern": "(?i)claude-opus-4-1", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.5e-06, + "cache_creation_input_tokens": 1.875e-05 + } + } + ] + }, + { + "name": "Claude Opus 4", + "provider": "Anthropic", + "description": "Anthropic Claude Opus 4", + "match_pattern": "(?i)claude-opus-4(?:-0|-20\\d{6})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.5e-06, + "cache_creation_input_tokens": 1.875e-05 + } + } + ] + }, + { + "name": "Claude Sonnet 4.6", + "provider": "Anthropic", + "description": "Anthropic Claude Sonnet 4.6 - 1M context window, no long-context premium", + "match_pattern": "(?i)claude-sonnet-4-6", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + } + ] + }, + { + "name": "Claude Sonnet 4.5", + "provider": "Anthropic", + "description": "Anthropic Claude Sonnet 4.5 \u2014 extended context pricing at 200k+ input tokens", + "match_pattern": "(?i)claude-sonnet-4-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + }, + { + "name": "Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 6e-06, + "output": 2.25e-05, + "cache_read_input_tokens": 6e-07, + "cache_creation_input_tokens": 7.5e-06 + } + } + ] + }, + { + "name": "Claude Sonnet 4", + "provider": "Anthropic", + "description": "Anthropic Claude Sonnet 4", + "match_pattern": "(?i)claude-sonnet-4(?:-0|-20\\d{6}|-latest)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + }, + { + "name": "Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 6e-06, + "output": 2.25e-05, + "cache_read_input_tokens": 6e-07, + "cache_creation_input_tokens": 7.5e-06 + } + } + ] + }, + { + "name": "Claude 3 Opus", + "provider": "Anthropic", + "description": "Anthropic Claude 3 Opus", + "match_pattern": "(?i)claude-3-opus", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-05, + "output": 7.5e-05, + "cache_read_input_tokens": 1.5e-06, + "cache_creation_input_tokens": 1.875e-05 + } + } + ] + }, + { + "name": "Claude 3.7 Sonnet", + "provider": "Anthropic", + "description": "Anthropic Claude 3.7 Sonnet", + "match_pattern": "(?i)claude-3[-.]7-sonnet", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + } + ] + }, + { + "name": "Claude 3.5 Sonnet", + "provider": "Anthropic", + "description": "Anthropic Claude 3.5 Sonnet", + "match_pattern": "(?i)claude-3[-.]5-sonnet", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + } + ] + }, + { + "name": "Claude 3 Sonnet", + "provider": "Anthropic", + "description": "Anthropic Claude 3 Sonnet", + "match_pattern": "(?i)claude-3-sonnet(?:-20\\d{6})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3.75e-06 + } + } + ] + }, + { + "name": "Claude Haiku 4.5", + "provider": "Anthropic", + "description": "Anthropic Claude Haiku 4.5", + "match_pattern": "(?i)claude-haiku-4-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 5e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "Claude 3.5 Haiku", + "provider": "Anthropic", + "description": "Anthropic Claude 3.5 Haiku", + "match_pattern": "(?i)claude-(?:haiku-3-5|3[-.]5-haiku)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 8e-07, + "output": 4e-06, + "cache_read_input_tokens": 8e-08, + "cache_creation_input_tokens": 1e-06 + } + } + ] + }, + { + "name": "Claude 3 Haiku", + "provider": "Anthropic", + "description": "Anthropic Claude 3 Haiku", + "match_pattern": "(?i)claude-3-haiku", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-07, + "output": 1.25e-06, + "cache_read_input_tokens": 2.5e-08, + "cache_creation_input_tokens": 3.125e-07 + } + } + ] + }, + { + "name": "Claude Haiku 5.5", + "provider": "Anthropic", + "description": "Anthropic Claude Haiku 5.5 - prompts over 100,000 tokens (counting cache reads and writes) pay the higher rate for the whole request", + "match_pattern": "(?i)claude-haiku-5-5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 5e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 1.25e-07 + } + }, + { + "name": "Long Prompt (100k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 100001 + }, + "prices": { + "input": 5e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 6.25e-07 + } + } + ] + }, + { + "name": "Claude Mythos Preview", + "provider": "Anthropic", + "description": "Anthropic Claude Mythos Preview (Project Glasswing, limited access) - price applies after research-preview credits; no cache price published", + "match_pattern": "(?i)claude-mythos-preview", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-05, + "output": 0.000125 + } + } + ] + }, + { + "name": "Gemini 3.8 Flash", + "provider": "Google", + "description": "Google Gemini 3.8 Flash", + "match_pattern": "(?i)gemini-3\\.8-flash", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-07, + "output": 3.75e-06, + "cache_read_input_tokens": 7.5e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 1.35e-06, + "output": 6.75e-06, + "cache_read_input_tokens": 1.35e-07 + } + } + ] + }, + { + "name": "Gemini 3.7 Flash", + "provider": "Google", + "description": "Google Gemini 3.7 Flash", + "match_pattern": "(?i)gemini-3\\.7-flash", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-07, + "output": 3.75e-06, + "cache_read_input_tokens": 7.5e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 1.35e-06, + "output": 6.75e-06, + "cache_read_input_tokens": 1.35e-07 + } + } + ] + }, + { + "name": "Gemini 3.6 Flash", + "provider": "Google", + "description": "Google Gemini 3.6 Flash", + "match_pattern": "(?i)gemini-3\\.6-flash", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-07, + "output": 3.75e-06, + "cache_read_input_tokens": 7.5e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 1.35e-06, + "output": 6.75e-06, + "cache_read_input_tokens": 1.35e-07 + } + } + ] + }, + { + "name": "Gemini 3.5 Flash Lite", + "provider": "Google", + "description": "Google Gemini 3.5 Flash Lite", + "match_pattern": "(?i)gemini-3\\.5-flash-lite", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 3e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 5.4e-07, + "output": 4.5e-06, + "cache_read_input_tokens": 5e-08 + } + } + ] + }, + { + "name": "Gemini 3.5 Flash", + "provider": "Google", + "description": "Google Gemini 3.5 Flash", + "match_pattern": "(?i)gemini-3\\.5-flash(?:$|-preview|-latest|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-06, + "output": 9e-06, + "cache_read_input_tokens": 1.5e-07 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 2.7e-06, + "output": 1.62e-05, + "cache_read_input_tokens": 2.7e-07 + } + } + ] + }, + { + "name": "Gemini 3.1 Pro", + "provider": "Google", + "description": "Google Gemini 3.1 Pro \u2014 extended context pricing at 200k+ input tokens", + "match_pattern": "(?i)gemini-3\\.1-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1.2e-05, + "cache_read_input_tokens": 2e-07 + } + }, + { + "name": "Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 4e-06, + "output": 1.8e-05, + "cache_read_input_tokens": 4e-07 + } + }, + { + "name": "Priority \u00b7 Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 7.2e-06, + "output": 3.24e-05, + "cache_read_input_tokens": 7.2e-07 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 3.6e-06, + "output": 2.16e-05, + "cache_read_input_tokens": 3.6e-07 + } + } + ] + }, + { + "name": "Gemini 3.1 Flash Lite", + "provider": "Google", + "description": "Google Gemini 3.1 Flash Lite", + "match_pattern": "(?i)gemini-3\\.1-flash-lite", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-07, + "output": 1.5e-06, + "cache_read_input_tokens": 2.5e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 4.5e-07, + "output": 2.7e-06, + "cache_read_input_tokens": 4.5e-08 + } + } + ] + }, + { + "name": "Gemini 3 Pro", + "provider": "Google", + "description": "Google Gemini 3 Pro \u2014 extended context pricing at 200k+ input tokens", + "match_pattern": "(?i)gemini-3-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1.2e-05, + "cache_read_input_tokens": 2e-07 + } + }, + { + "name": "Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 4e-06, + "output": 1.8e-05, + "cache_read_input_tokens": 4e-07 + } + } + ] + }, + { + "name": "Gemini 3 Flash", + "provider": "Google", + "description": "Google Gemini 3 Flash", + "match_pattern": "(?i)gemini-3-flash", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 3e-06, + "cache_read_input_tokens": 5e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 9e-07, + "output": 5.4e-06, + "cache_read_input_tokens": 9e-08 + } + } + ] + }, + { + "name": "Gemini 2.5 Pro", + "provider": "Google", + "description": "Google Gemini 2.5 Pro \u2014 extended context pricing at 200k+ input tokens", + "match_pattern": "(?i)gemini-2\\.5-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 1e-05, + "cache_read_input_tokens": 1.25e-07 + } + }, + { + "name": "Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 2.5e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 2.5e-07 + } + }, + { + "name": "Priority \u00b7 Extended Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 4.5e-06, + "output": 2.7e-05, + "cache_read_input_tokens": 4.5e-07 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 2.25e-06, + "output": 1.8e-05, + "cache_read_input_tokens": 2.25e-07 + } + } + ] + }, + { + "name": "Gemini Flash Lite Latest", + "provider": "Google", + "description": "Google Gemini Flash Lite (rolling alias \u2014 currently 2.5 Flash Lite pricing)", + "match_pattern": "(?i)gemini-flash-lite-latest", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 2.5e-08 + } + } + ] + }, + { + "name": "Gemini Flash Latest", + "provider": "Google", + "description": "Google Gemini Flash rolling alias - resolves to 3.6 Flash pricing ($0.75/$3.75, cache $0.075) through 2026-12-31; 3.6 Flash doubles from 2027-01-01", + "match_pattern": "(?i)gemini-flash-latest", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-07, + "output": 3.75e-06, + "cache_read_input_tokens": 7.5e-08 + } + } + ] + }, + { + "name": "Gemini 2.5 Flash Lite", + "provider": "Google", + "description": "Google Gemini 2.5 Flash Lite", + "match_pattern": "(?i)gemini-2\\.5-flash-lite", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 1e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 1.8e-07, + "output": 7.2e-07, + "cache_read_input_tokens": 1.8e-08 + } + } + ] + }, + { + "name": "Gemini 2.5 Flash", + "provider": "Google", + "description": "Google Gemini 2.5 Flash", + "match_pattern": "(?i)gemini-2\\.5-flash$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 3e-08 + } + }, + { + "name": "Priority", + "rules": [ + { + "keys": [ + "openai.response.service_tier", + "gen_ai.openai.response.service_tier", + "gen_ai.response.service_tier", + "openai.request.service_tier", + "gen_ai.openai.request.service_tier", + "gen_ai.request.service_tier", + "param.service_tier" + ], + "op": "in", + "values": [ + "priority" + ] + } + ], + "prices": { + "input": 5.4e-07, + "output": 4.5e-06, + "cache_read_input_tokens": 5.4e-08 + } + } + ] + }, + { + "name": "Gemini 2.0 Flash Lite", + "provider": "Google", + "description": "Google Gemini 2.0 Flash Lite", + "match_pattern": "(?i)gemini-2\\.0-flash-lite", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-08, + "output": 3e-07 + } + } + ] + }, + { + "name": "Gemini 2.0 Flash", + "provider": "Google", + "description": "Google Gemini 2.0 Flash", + "match_pattern": "(?i)gemini-2\\.0-flash(?:-\\d{3})?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 2.5e-08 + } + } + ] + }, + { + "name": "Gemini 1.5 Flash 8B", + "provider": "Google", + "description": "Google Gemini 1.5 Flash 8B \u2014 extended context pricing at 128k+ input tokens", + "match_pattern": "(?i)gemini-(?:flash-1\\.5-8b|1\\.5-flash-8b)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3.75e-08, + "output": 1.5e-07, + "cache_read_input_tokens": 1e-08 + } + }, + { + "name": "Extended Context (128k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 128000 + }, + "prices": { + "input": 7.5e-08, + "output": 3e-07, + "cache_read_input_tokens": 2e-08 + } + } + ] + }, + { + "name": "Gemini 1.5 Pro", + "provider": "Google", + "description": "Google Gemini 1.5 Pro", + "match_pattern": "(?i)gemini-1\\.5-pro", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 5e-06 + } + } + ] + }, + { + "name": "Gemini 1.5 Flash", + "provider": "Google", + "description": "Google Gemini 1.5 Flash", + "match_pattern": "(?i)gemini-1\\.5-flash(?:$|-\\d{3}|-latest|-preview)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7.5e-08, + "output": 3e-07 + } + } + ] + }, + { + "name": "Gemini Pro", + "provider": "Google", + "description": "Google Gemini Pro", + "match_pattern": "(?i)gemini-pro$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 1.5e-06 + } + } + ] + }, + { + "name": "Gemini Embedding", + "provider": "Google", + "description": "Google Gemini Embedding (gemini-embedding-001)", + "match_pattern": "(?i)gemini-embedding(?:$|-0|-exp)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07 + } + } + ] + }, + { + "name": "Gemini 4 Argon", + "provider": "Google", + "description": "Google Gemini 4 Argon - 1M context, implicit caching only. Introductory price through 2026-11-30; doubles to $4/$20 (cache $0.20) from 2026-12-01", + "match_pattern": "(?i)gemini-4-argon", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 1e-07 + } + } + ] + }, + { + "name": "Gemini Embedding 2", + "provider": "Google", + "description": "Google Gemini Embedding 2 multimodal embedding - text input price (image/audio/video input priced higher)", + "match_pattern": "(?i)gemini-embedding-2", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07 + } + } + ] + }, + { + "name": "Gemini Robotics ER 2", + "provider": "Google", + "description": "Google Gemini Robotics ER 2 Preview and Streaming Preview - price through 2026-12-31; doubles to $2/$10 (cache $0.20) from 2027-01-01", + "match_pattern": "(?i)gemini-robotics-er-2", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 5e-06, + "cache_read_input_tokens": 1e-07 + } + } + ] + }, + { + "name": "DeepSeek V4 Flash", + "provider": "DeepSeek", + "description": "DeepSeek Flash (deepseek-flash, served by DeepSeek-V4.1-Flash-1017; legacy deepseek-v4-flash / deepseek-v4-flash-vision-exp are routed to it and billed at the Flash price) - 1M context. Peak hours are 01:00-04:00 and 06:00-10:00 UTC Monday-Friday excluding Chinese public holidays; all other hours, including weekends, are off-peak at half the peak rate. utc_windows cannot express the weekday rule, so weekend traffic in those windows is overpriced at peak.", + "match_pattern": "(?i)deepseek-(?:v4(?:\\.1)?-)?flash", + "tiers": [ + { + "name": "Off-Peak", + "prices": { + "input": 1.5e-07, + "output": 6e-07, + "cache_read_input_tokens": 3e-09 + } + }, + { + "name": "Peak (01:00-04:00, 06:00-10:00 UTC)", + "utc_windows": [ + { + "start_minute": 60, + "end_minute": 240 + }, + { + "start_minute": 360, + "end_minute": 600 + } + ], + "prices": { + "input": 3e-07, + "output": 1.2e-06, + "cache_read_input_tokens": 6e-09 + } + } + ] + }, + { + "name": "DeepSeek V4 Pro", + "provider": "DeepSeek", + "description": "DeepSeek V4 Pro (DeepSeek-V4-Pro-0813) - 1M context. Peak hours are 01:00-04:00 and 06:00-10:00 UTC Monday-Friday excluding Chinese public holidays; all other hours, including weekends, are off-peak at half the peak rate. utc_windows cannot express the weekday rule, so weekend traffic in those windows is overpriced at peak.", + "match_pattern": "(?i)deepseek-v4-pro", + "tiers": [ + { + "name": "Off-Peak", + "prices": { + "input": 6.6e-07, + "output": 1.98e-06, + "cache_read_input_tokens": 2.2e-08 + } + }, + { + "name": "Peak (01:00-04:00, 06:00-10:00 UTC)", + "utc_windows": [ + { + "start_minute": 60, + "end_minute": 240 + }, + { + "start_minute": 360, + "end_minute": 600 + } + ], + "prices": { + "input": 1.32e-06, + "output": 3.96e-06, + "cache_read_input_tokens": 4.4e-08 + } + } + ] + }, + { + "name": "DeepSeek V3", + "provider": "DeepSeek", + "description": "DeepSeek V3 (deepseek-chat)", + "match_pattern": "(?i)deepseek-(?:v3|chat)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.7e-07, + "output": 1.1e-06, + "cache_read_input_tokens": 7e-08 + } + } + ] + }, + { + "name": "DeepSeek R1", + "provider": "DeepSeek", + "description": "DeepSeek R1 reasoning model (deepseek-reasoner)", + "match_pattern": "(?i)deepseek-(?:r1|reasoner)(?:-\\d[\\d-]*)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5.5e-07, + "output": 2.19e-06, + "cache_read_input_tokens": 1.4e-07 + } + } + ] + }, + { + "name": "DeepSeek R1 Distill Llama 8B", + "provider": "DeepSeek", + "description": "DeepSeek R1 distilled on Llama 8B", + "match_pattern": "(?i)deepseek-r1-distill-llama-8b", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-08, + "output": 4e-08 + } + } + ] + }, + { + "name": "DeepSeek R1 Distill Qwen 32B", + "provider": "DeepSeek", + "description": "DeepSeek R1 distilled on Qwen 32B", + "match_pattern": "(?i)deepseek-r1-distill-qwen-32b", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.9e-07, + "output": 2.9e-07 + } + } + ] + }, + { + "name": "Kimi K2.7", + "provider": "Moonshot", + "description": "Kimi K2.7 Code (K2 series, retiring end of Dec 2026)", + "match_pattern": "(?i)kimi-k2\\.7(?:-code)?(?:$|-preview|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 9.5e-07, + "output": 4e-06, + "cache_read_input_tokens": 1.9e-07 + } + } + ] + }, + { + "name": "Kimi K2.6", + "provider": "Moonshot", + "description": "Kimi K2.6 multimodal model", + "match_pattern": "(?i)kimi-k2\\.6", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 9.5e-07, + "output": 4e-06, + "cache_read_input_tokens": 1.6e-07 + } + } + ] + }, + { + "name": "Kimi K2.5", + "provider": "Moonshot", + "description": "Kimi K2.5 multimodal model", + "match_pattern": "(?i)kimi-k2\\.5", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 6e-07, + "output": 3e-06, + "cache_read_input_tokens": 1e-07 + } + } + ] + }, + { + "name": "Kimi K2 Turbo", + "provider": "Moonshot", + "description": "Kimi K2 Turbo / Thinking Turbo variants", + "match_pattern": "(?i)kimi-k2-(?:turbo|thinking-turbo)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.15e-06, + "output": 8e-06, + "cache_read_input_tokens": 1.5e-07 + } + } + ] + }, + { + "name": "Kimi K2", + "provider": "Moonshot", + "description": "Kimi K2 generation model (0711, 0905, thinking variants)", + "match_pattern": "(?i)kimi-k2(?:-(?:0711|0905|thinking))?(?:-preview)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 6e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 1.5e-07 + } + } + ] + }, + { + "name": "Moonshot V1 128k", + "provider": "Moonshot", + "description": "Moonshot V1 classic model \u2014 128k context", + "match_pattern": "(?i)moonshot-v1-128k", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 5e-06 + } + } + ] + }, + { + "name": "Moonshot V1 32k", + "provider": "Moonshot", + "description": "Moonshot V1 classic model \u2014 32k context", + "match_pattern": "(?i)moonshot-v1-32k", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 3e-06 + } + } + ] + }, + { + "name": "Moonshot V1 8k", + "provider": "Moonshot", + "description": "Moonshot V1 classic model \u2014 8k context", + "match_pattern": "(?i)moonshot-v1-8k", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07, + "output": 2e-06 + } + } + ] + }, + { + "name": "Kimi K3.1 Pro Highspeed", + "provider": "Moonshot", + "description": "Kimi K3.1 Pro high-speed variant - 1M context; cache write price is the 5m TTL rate (1h writes $8/MTok)", + "match_pattern": "(?i)kimi-k3\\.1-pro-highspeed", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-06, + "output": 2e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 4e-06 + } + } + ] + }, + { + "name": "Kimi K3.1 Pro", + "provider": "Moonshot", + "description": "Kimi K3.1 Pro - 1M context; cache write price is the 5m TTL rate (1h writes $4/MTok)", + "match_pattern": "(?i)kimi-k3\\.1-pro(?:$|-preview|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 1e-05, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2e-06 + } + } + ] + }, + { + "name": "Kimi K3", + "provider": "Moonshot", + "description": "Kimi K3 - 1M context; cache write price is the 5m TTL rate (1h writes $6/MTok)", + "match_pattern": "(?i)kimi-k3(?:$|-preview|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05, + "cache_read_input_tokens": 3e-07, + "cache_creation_input_tokens": 3e-06 + } + } + ] + }, + { + "name": "Kimi K2.7 Code Highspeed", + "provider": "Moonshot", + "description": "Kimi K2.7 Code high-speed variant (K2 series, retiring end of Dec 2026)", + "match_pattern": "(?i)kimi-k2\\.7-code-highspeed", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.9e-06, + "output": 8e-06, + "cache_read_input_tokens": 3.8e-07 + } + } + ] + }, + { + "name": "Grok 4.6 / 4.7", + "provider": "xAI", + "description": "xAI Grok 4.7 and Grok 4.6 (same price) - prompts of 200k+ tokens bill every token at the long-context rate", + "match_pattern": "(?i)grok-4\\.[67](?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 6e-06, + "cache_read_input_tokens": 5e-07 + } + }, + { + "name": "Long Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 4e-06, + "output": 1.2e-05, + "cache_read_input_tokens": 1e-06 + } + } + ] + }, + { + "name": "Grok 4.5", + "provider": "xAI", + "description": "xAI Grok 4.5 (aliases grok-4.5-latest, grok-build-latest) - prompts of 200k+ tokens bill every token at the long-context rate", + "match_pattern": "(?i)grok-(?:4\\.5|build-latest)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 6e-06, + "cache_read_input_tokens": 3e-07 + } + }, + { + "name": "Long Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 4e-06, + "output": 1.2e-05, + "cache_read_input_tokens": 6e-07 + } + } + ] + }, + { + "name": "Grok 4.3 / 4.20", + "provider": "xAI", + "description": "xAI Grok 4.3 and Grok 4.20 reasoning, non-reasoning and multi-agent (all aliases, same price) - prompts of 200k+ tokens bill every token at the long-context rate", + "match_pattern": "(?i)grok-4\\.(?:3|20)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.25e-06, + "output": 2.5e-06, + "cache_read_input_tokens": 2e-07 + } + }, + { + "name": "Long Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 2.5e-06, + "output": 5e-06, + "cache_read_input_tokens": 4e-07 + } + } + ] + }, + { + "name": "Grok Build 0.1", + "provider": "xAI", + "description": "xAI grok-build-0.1 (aliases grok-code-fast, grok-code-fast-1, grok-code-fast-1-0825) - prompts of 200k+ tokens bill every token at the long-context rate", + "match_pattern": "(?i)grok-(?:build-0\\.1|code-fast)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 2e-06, + "cache_read_input_tokens": 2e-07 + } + }, + { + "name": "Long Context (200k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 200000 + }, + "prices": { + "input": 2e-06, + "output": 4e-06, + "cache_read_input_tokens": 4e-07 + } + } + ] + }, + { + "name": "Mistral Large 4", + "provider": "Mistral", + "description": "Mistral Large 4 (v26.10, public preview; ids mistral-large-4, mistral-large-4-0)", + "match_pattern": "(?i)mistral-large-4(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.36e-06, + "output": 4.18e-06, + "cache_read_input_tokens": 1.4e-07 + } + } + ] + }, + { + "name": "Mistral Large 3", + "provider": "Mistral", + "description": "Mistral Large 3 (v25.12; ids mistral-large-2512, mistral-large-latest)", + "match_pattern": "(?i)mistral-large-(?:2512|3|latest)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 1.5e-06, + "cache_read_input_tokens": 5e-08 + } + } + ] + }, + { + "name": "Mistral Medium 3.5", + "provider": "Mistral", + "description": "Mistral Medium 3.5 (v26.04; ids mistral-medium-3-5, mistral-medium-3, mistral-medium-latest)", + "match_pattern": "(?i)mistral-medium(?:-3[.-]5|-3|-2604|-latest)?(?:$|[:@])", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 1.5e-07 + } + } + ] + }, + { + "name": "Mistral Small 4", + "provider": "Mistral", + "description": "Mistral Small 4 (v26.03; ids mistral-small-2603, mistral-small-latest)", + "match_pattern": "(?i)mistral-small-(?:2603|4|latest)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 6e-07, + "cache_read_input_tokens": 1.5e-08 + } + } + ] + }, + { + "name": "Ministral 3 14B", + "provider": "Mistral", + "description": "Mistral Ministral 3 14B (v25.12; ids ministral-14b-2512, ministral-14b-latest)", + "match_pattern": "(?i)ministral-(?:3-)?14b", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07, + "output": 2e-07, + "cache_read_input_tokens": 2e-08 + } + } + ] + }, + { + "name": "Ministral 3 8B", + "provider": "Mistral", + "description": "Mistral Ministral 3 8B (v25.12; ids ministral-8b-2512, ministral-8b-latest)", + "match_pattern": "(?i)ministral-(?:3-)?8b-(?:2512|latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 1.5e-07, + "cache_read_input_tokens": 1.5e-08 + } + } + ] + }, + { + "name": "Ministral 3 3B", + "provider": "Mistral", + "description": "Mistral Ministral 3 3B (v25.12; ids ministral-3b-2512, ministral-3b-latest)", + "match_pattern": "(?i)ministral-(?:3-)?3b-(?:2512|latest)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 1e-07, + "cache_read_input_tokens": 1e-08 + } + } + ] + }, + { + "name": "Codestral", + "provider": "Mistral", + "description": "Mistral Codestral (v25.08; ids codestral-2508, codestral-latest)", + "match_pattern": "(?i)codestral-(?:2508|latest)(?:$|-)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 9e-07, + "cache_read_input_tokens": 3e-08 + } + } + ] + }, + { + "name": "Codestral Embed", + "provider": "Mistral", + "description": "Mistral Codestral Embed (ids codestral-embed-2505, codestral-embed)", + "match_pattern": "(?i)codestral-embed", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "cache_read_input_tokens": 1.5e-08 + } + } + ] + }, + { + "name": "Mistral Embed", + "provider": "Mistral", + "description": "Mistral Embed (ids mistral-embed-2312, mistral-embed)", + "match_pattern": "(?i)mistral-embed", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "cache_read_input_tokens": 1e-08 + } + } + ] + }, + { + "name": "GLM-5.1 / 5.2 / 5.3", + "provider": "Z.ai", + "description": "Z.ai GLM-5.1, GLM-5.2 and GLM-5.3 (same list price)", + "match_pattern": "(?i)glm-5\\.[123](?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.4e-06, + "output": 4.4e-06, + "cache_read_input_tokens": 2.6e-07 + } + } + ] + }, + { + "name": "GLM-5.3 FlashX", + "provider": "Z.ai", + "description": "Z.ai GLM-5.3-FlashX and GLM-5.3-FlashX-Legal", + "match_pattern": "(?i)glm-5\\.3-flashx", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3.7e-07, + "output": 1.25e-06, + "cache_read_input_tokens": 7.5e-08 + } + } + ] + }, + { + "name": "GLM-5.3 Flash", + "provider": "Z.ai", + "description": "Z.ai GLM-5.3-Flash", + "match_pattern": "(?i)glm-5\\.3-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 5e-07, + "cache_read_input_tokens": 3e-08 + } + } + ] + }, + { + "name": "GLM-5", + "provider": "Z.ai", + "description": "Z.ai GLM-5", + "match_pattern": "(?i)glm-5(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 3.2e-06, + "cache_read_input_tokens": 2e-07 + } + } + ] + }, + { + "name": "GLM-4.5 / 4.6 / 4.7", + "provider": "Z.ai", + "description": "Z.ai GLM-4.5, GLM-4.6 and GLM-4.7 (same list price)", + "match_pattern": "(?i)glm-4\\.[567](?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 6e-07, + "output": 2.2e-06, + "cache_read_input_tokens": 1.1e-07 + } + } + ] + }, + { + "name": "GLM-4.7 FlashX", + "provider": "Z.ai", + "description": "Z.ai GLM-4.7-FlashX (GLM-4.7-Flash is free and not priced)", + "match_pattern": "(?i)glm-4\\.7-flashx", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 7e-08, + "output": 4e-07, + "cache_read_input_tokens": 1e-08 + } + } + ] + }, + { + "name": "GLM-4.5 X", + "provider": "Z.ai", + "description": "Z.ai GLM-4.5-X", + "match_pattern": "(?i)glm-4\\.5-x(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.2e-06, + "output": 8.9e-06, + "cache_read_input_tokens": 4.5e-07 + } + } + ] + }, + { + "name": "GLM-4.5 AirX", + "provider": "Z.ai", + "description": "Z.ai GLM-4.5-AirX", + "match_pattern": "(?i)glm-4\\.5-airx", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.1e-06, + "output": 4.5e-06, + "cache_read_input_tokens": 2.2e-07 + } + } + ] + }, + { + "name": "GLM-4.5 Air", + "provider": "Z.ai", + "description": "Z.ai GLM-4.5-Air", + "match_pattern": "(?i)glm-4\\.5-air(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-07, + "output": 1.1e-06, + "cache_read_input_tokens": 3e-08 + } + } + ] + }, + { + "name": "GLM-4 32B", + "provider": "Z.ai", + "description": "Z.ai GLM-4-32B-0414-128K", + "match_pattern": "(?i)glm-4-32b-0414", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 1e-07 + } + } + ] + }, + { + "name": "GLM-4.6V FlashX", + "provider": "Z.ai", + "description": "Z.ai GLM-4.6V-FlashX vision model", + "match_pattern": "(?i)glm-4\\.6v-flashx", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-08, + "output": 4e-07, + "cache_read_input_tokens": 4e-09 + } + } + ] + }, + { + "name": "GLM-4.6V", + "provider": "Z.ai", + "description": "Z.ai GLM-4.6V vision model", + "match_pattern": "(?i)glm-4\\.6v(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 9e-07, + "cache_read_input_tokens": 5e-08 + } + } + ] + }, + { + "name": "GLM-4.5V", + "provider": "Z.ai", + "description": "Z.ai GLM-4.5V vision model", + "match_pattern": "(?i)glm-4\\.5v(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 6e-07, + "output": 1.8e-06, + "cache_read_input_tokens": 1.1e-07 + } + } + ] + }, + { + "name": "MiniMax M3", + "provider": "MiniMax", + "description": "MiniMax-M3 Standard tier; higher rate above 512k input tokens. Priority service tier (1.5x) is not modelled.", + "match_pattern": "(?i)minimax-m3(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 1.2e-06, + "cache_read_input_tokens": 6e-08 + } + }, + { + "name": "Long Context (512k+)", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 512000 + }, + "prices": { + "input": 6e-07, + "output": 2.4e-06, + "cache_read_input_tokens": 1.2e-07 + } + } + ] + }, + { + "name": "MiniMax M3.1 Flash", + "provider": "MiniMax", + "description": "MiniMax-M3.1-Flash", + "match_pattern": "(?i)minimax-m3\\.1-flash", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 6e-07, + "cache_read_input_tokens": 1.5e-08 + } + } + ] + }, + { + "name": "Command R+ 08-2024", + "provider": "Cohere", + "description": "Cohere Command R+ 08-2024", + "match_pattern": "(?i)command-r-plus-08-2024", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-06, + "output": 1e-05 + } + } + ] + }, + { + "name": "Command R+ 04-2024", + "provider": "Cohere", + "description": "Cohere Command R+ 04-2024 (legacy, existing customers)", + "match_pattern": "(?i)command-r-plus-04-2024", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-06, + "output": 1.5e-05 + } + } + ] + }, + { + "name": "Command R 03-2024", + "provider": "Cohere", + "description": "Cohere Command R 03-2024 (legacy, existing customers)", + "match_pattern": "(?i)command-r-03-2024", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 1.5e-06 + } + } + ] + }, + { + "name": "Command Light", + "provider": "Cohere", + "description": "Cohere Command-light (legacy, existing customers)", + "match_pattern": "(?i)(?:^|[/.])command-light$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 6e-07 + } + } + ] + }, + { + "name": "Command", + "provider": "Cohere", + "description": "Cohere Command (legacy, existing customers)", + "match_pattern": "(?i)(?:^|[/.])command$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-06, + "output": 2e-06 + } + } + ] + }, + { + "name": "Aya Expanse", + "provider": "Cohere", + "description": "Cohere Aya Expanse 8B and 32B on the Cohere API", + "match_pattern": "(?i)aya-expanse-(?:8b|32b)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 1.5e-06 + } + } + ] + }, + { + "name": "Qwen3.8 Max", + "provider": "Alibaba", + "description": "Qwen3.8-Max (International). Cache creation is 125% of input; the cache-hit price is not published.", + "match_pattern": "(?i)qwen3\\.8-max(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2e-06, + "output": 6e-06, + "cache_creation_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "Qwen3.8 Flash", + "provider": "Alibaba", + "description": "Qwen3.8-Flash (International). Cache prices are not published.", + "match_pattern": "(?i)qwen3\\.8-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.5e-07, + "output": 4.7e-07 + } + } + ] + }, + { + "name": "Qwen3.7 Max", + "provider": "Alibaba", + "description": "Qwen3.7-Max incl. snapshots and preview (International). Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen3\\.7-max(?:$|-\\d|-preview)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-06, + "output": 7.5e-06, + "cache_read_input_tokens": 5e-07, + "cache_creation_input_tokens": 3.125e-06 + } + } + ] + }, + { + "name": "Qwen Max", + "provider": "Alibaba", + "description": "Qwen-Max (International, non-thinking only). Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen-max(?:$|-latest|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1.6e-06, + "output": 6.4e-06, + "cache_read_input_tokens": 3.2e-07 + } + } + ] + }, + { + "name": "Qwen3.7 Plus", + "provider": "Alibaba", + "description": "Qwen3.7-Plus (International); higher rate above 256K input. Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen3\\.7-plus(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-07, + "output": 1.6e-06, + "cache_read_input_tokens": 8e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 1.2e-06, + "output": 4.8e-06, + "cache_read_input_tokens": 2.4e-07, + "cache_creation_input_tokens": 1.5e-06 + } + } + ] + }, + { + "name": "Qwen3.6 Plus", + "provider": "Alibaba", + "description": "Qwen3.6-Plus (International); higher rate above 256K input. Explicit cache only (hit 10%).", + "match_pattern": "(?i)qwen3\\.6-plus(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-07, + "output": 3e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 6.25e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 2e-06, + "output": 6e-06, + "cache_read_input_tokens": 2e-07, + "cache_creation_input_tokens": 2.5e-06 + } + } + ] + }, + { + "name": "Qwen3.5 Plus", + "provider": "Alibaba", + "description": "Qwen3.5-Plus (International); higher rate above 256K input. Explicit cache only (hit 10%).", + "match_pattern": "(?i)qwen3\\.5-plus(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-07, + "output": 2.4e-06, + "cache_read_input_tokens": 4e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 5e-07, + "output": 3e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 6.25e-07 + } + } + ] + }, + { + "name": "Qwen Plus", + "provider": "Alibaba", + "description": "Qwen-Plus / -latest / 2025-12-01 (International); non-thinking output price (thinking output is $4/$12 per 1M). Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen-plus(?:-latest|-2025-12-01)?$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-07, + "output": 1.2e-06, + "cache_read_input_tokens": 8e-08, + "cache_creation_input_tokens": 5e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 1.2e-06, + "output": 3.6e-06, + "cache_read_input_tokens": 2.4e-07, + "cache_creation_input_tokens": 1.5e-06 + } + } + ] + }, + { + "name": "Qwen Plus (2025-01/04/07 snapshots)", + "provider": "Alibaba", + "description": "Older Qwen-Plus snapshots, no tiered pricing; non-thinking output price (thinking output is $4 per 1M).", + "match_pattern": "(?i)qwen-plus-2025-0[147]-\\d\\d$", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 4e-07, + "output": 1.2e-06 + } + } + ] + }, + { + "name": "Qwen3.7 Flash", + "provider": "Alibaba", + "description": "Qwen3.7-Flash (International), three input-length tiers. Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen3\\.7-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-08, + "output": 1.3e-07, + "cache_read_input_tokens": 6e-09, + "cache_creation_input_tokens": 3.75e-08 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 2e-07, + "output": 8e-07, + "cache_read_input_tokens": 4e-08, + "cache_creation_input_tokens": 2.5e-07 + } + }, + { + "name": "Input 32K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 32000 + }, + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 2e-08, + "cache_creation_input_tokens": 1.25e-07 + } + } + ] + }, + { + "name": "Qwen3.6 Flash", + "provider": "Alibaba", + "description": "Qwen3.6-Flash (International); higher rate above 256K input. Explicit cache only (hit 10%).", + "match_pattern": "(?i)qwen3\\.6-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 2.5e-07, + "output": 1.5e-06, + "cache_read_input_tokens": 2.5e-08, + "cache_creation_input_tokens": 3.125e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 1e-06, + "output": 4e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 1.25e-06 + } + } + ] + }, + { + "name": "Qwen3.5 Flash", + "provider": "Alibaba", + "description": "Qwen3.5-Flash (International). Explicit cache only (hit 10%).", + "match_pattern": "(?i)qwen3\\.5-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 1e-07, + "output": 4e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 1.25e-07 + } + } + ] + }, + { + "name": "Qwen Flash", + "provider": "Alibaba", + "description": "Qwen-Flash (International); higher rate above 256K input. Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 5e-08, + "output": 4e-07, + "cache_read_input_tokens": 1e-08, + "cache_creation_input_tokens": 6.25e-08 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 2.5e-07, + "output": 2e-06, + "cache_read_input_tokens": 5e-08, + "cache_creation_input_tokens": 3.125e-07 + } + } + ] + }, + { + "name": "Qwen3 Coder Flash", + "provider": "Alibaba", + "description": "Qwen3-Coder-Flash (International), four input-length tiers. Cache read is the implicit-cache rate (20%).", + "match_pattern": "(?i)qwen3-coder-flash(?:$|-\\d)", + "tiers": [ + { + "name": "Default", + "prices": { + "input": 3e-07, + "output": 1.5e-06, + "cache_read_input_tokens": 6e-08, + "cache_creation_input_tokens": 3.75e-07 + } + }, + { + "name": "Input 256K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 256000 + }, + "prices": { + "input": 1.6e-06, + "output": 9.6e-06, + "cache_read_input_tokens": 3.2e-07, + "cache_creation_input_tokens": 2e-06 + } + }, + { + "name": "Input 128K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 128000 + }, + "prices": { + "input": 8e-07, + "output": 4e-06, + "cache_read_input_tokens": 1.6e-07, + "cache_creation_input_tokens": 1e-06 + } + }, + { + "name": "Input 32K+", + "condition": { + "usage_key": "input", + "operator": "gte", + "value": 32000 + }, + "prices": { + "input": 5e-07, + "output": 2.5e-06, + "cache_read_input_tokens": 1e-07, + "cache_creation_input_tokens": 6.25e-07 + } + } + ] + } +] diff --git a/src/config/src/config.rs b/src/config/src/config.rs index 2bf46229e3..d2f2f5cec6 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -131,7 +131,8 @@ pub type RwBTreeMap = tokio::sync::RwLock>; // 131: org domain mapping migrations // 132: create oncall_chat_messages; add oncall_teams slack columns. // 133: add status page run streaks to synthetics. -pub const DB_SCHEMA_VERSION: u64 = 133; +// 134: add write_version to llm_dataset_items, so one write request is one Dataset version. +pub const DB_SCHEMA_VERSION: u64 = 134; pub const DB_SCHEMA_KEY: &str = "/db_schema_version/"; // global version variables diff --git a/src/config/src/meta/eval_job_health.rs b/src/config/src/meta/eval_job_health.rs index f73b0af9bf..b86b5b5606 100644 --- a/src/config/src/meta/eval_job_health.rs +++ b/src/config/src/meta/eval_job_health.rs @@ -22,11 +22,12 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "snake_case")] pub enum EvalJobHealth { Healthy, - /// More than 20% of at least 5 recent scorer runs failed. + /// More than 20% of at least 5 recent scorer runs failed, or nothing ran recently and the + /// lookback's newest outcome is an error. Degraded, /// The newest scorer outcome is a `provider_auth` error. WaitingForCredentials, - /// No scorer ran in the recent window. + /// No scorer ran in the 24-hour lookback. NoRecentRuns, } diff --git a/src/config/src/meta/model_pricing.rs b/src/config/src/meta/model_pricing.rs index ce04b30d52..8dc5622641 100644 --- a/src/config/src/meta/model_pricing.rs +++ b/src/config/src/meta/model_pricing.rs @@ -117,6 +117,35 @@ impl MatchKind { } } +/// Stream column that marks how ingest priced an LLM span. Exact non-zero prices and +/// client-reported costs leave it unset, so streams written before it existed have no such +/// column. +pub const COST_STATUS_COLUMN: &str = "gen_ai_usage_cost_status"; + +/// How ingest priced an LLM span whose cost is not an exact non-zero price; stored in +/// [`COST_STATUS_COLUMN`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CostStatus { + /// No pricing definition matched the model: the span keeps its tokens and has no cost. + Unpriced, + /// A pattern matched only part of the model name; the cost uses that pattern's prices. + Approximate, + /// The model matched a price exactly and the span costs nothing (a free or muted model). + /// The zero cost is a real price, unlike the zero legacy spans stored for no price. + Free, +} + +impl CostStatus { + pub fn as_str(self) -> &'static str { + match self { + CostStatus::Unpriced => "unpriced", + CostStatus::Approximate => "approximate", + CostStatus::Free => "free", + } + } +} + /// A model pricing definition that maps model names (via regex) to pricing tiers. #[derive(Clone, Debug, Serialize, Deserialize, Default, ToSchema)] #[serde(default)] diff --git a/src/config/src/meta/self_reporting/llm_experiments.rs b/src/config/src/meta/self_reporting/llm_experiments.rs index 1eb0668f6a..f58d6aef80 100644 --- a/src/config/src/meta/self_reporting/llm_experiments.rs +++ b/src/config/src/meta/self_reporting/llm_experiments.rs @@ -103,6 +103,11 @@ pub struct ExperimentExecutionRecord { pub output: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_message: Option, + /// Failure class from the shared evaluation taxonomy (`provider_auth`, + /// `timeout`, `execution_error`, …). Absent on records written before it + /// existed, which group by message alone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_kind: Option, /// Permanent reason an intentionally ineligible slot was not executed. #[serde(skip_serializing_if = "Option::is_none")] pub skip_reason: Option, @@ -148,6 +153,7 @@ impl ExperimentExecutionRecord { Self { output: Some(serde_json::Value::String(String::new())), error_message: Some(String::new()), + error_kind: Some(String::new()), skip_reason: Some(ExperimentSkipReason::NoReference), error_attempt_count: Some(0), latency_ms: Some(0), @@ -334,6 +340,7 @@ mod tests { status: ExperimentExecutionStatus::Ok, output: Some(json!("It ships tomorrow")), error_message: None, + error_kind: None, skip_reason: None, error_attempt_count: None, latency_ms: Some(42), @@ -367,6 +374,7 @@ mod tests { "status", "output", "error_message", + "error_kind", "skip_reason", "error_attempt_count", "latency_ms", diff --git a/src/config/src/router.rs b/src/config/src/router.rs index 884ce404d8..d2c9ad5e9c 100644 --- a/src/config/src/router.rs +++ b/src/config/src/router.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . /// usize indicates the number of parts to skip based on their actual paths. -const QUERIER_ROUTES: [(&str, usize); 43] = [ +const QUERIER_ROUTES: [(&str, usize); 47] = [ ("config", 0), // /config (unauthenticated bootstrap) ("config", 2), // /api/{org_id}/config (authenticated full) ("summary", 2), // /api/{org_id}/summary @@ -56,11 +56,16 @@ const QUERIER_ROUTES: [(&str, usize); 43] = [ ("chat_stream", 3), /* /api/{org_id}/ai/chat_stream * {label_name}/ * values */ - ("discovery", 2), // /api/{org_id}/discovery - ("annotation_queues", 2), // /api/{org_id}/annotation_queues/... - ("playground", 2), // /api/{org_id}/playground/... - ("service_streams", 2), // /api/{org_id}/service_streams/... - ("node/list", 2), // /api/_meta/node/list + ("discovery", 2), // /api/{org_id}/discovery + ("annotation_queues", 2), // /api/{org_id}/annotation_queues/... + ("playground", 2), // /api/{org_id}/playground/... + ("service_streams", 2), // /api/{org_id}/service_streams/... + ("node/list", 2), // /api/_meta/node/list + ("gen_ai/data_health", 2), // /api/{org_id}/gen_ai/data_health + ("llm/models/unpriced", 2), // /api/{org_id}/llm/models/unpriced + // Queriers own the built-in GitHub sync, so its node-local sync time lives there. + ("llm/models/built-in", 2), // /api/{org_id}/llm/models/built-in + ("llm/models/refresh-built-in", 2), // /api/{org_id}/llm/models/refresh-built-in ]; const QUERIER_ROUTES_BY_BODY: [&str; 9] = [ "/_search", @@ -240,6 +245,12 @@ mod tests { assert!(is_querier_route( "/api/org1/traces/time_range?trace_id=a,b,c" )); + // Built-in catalogue status and refresh read the querier's sync state. + assert!(is_querier_route( + "/api/org1/llm/models/built-in?metadata_only=true" + )); + assert!(is_querier_route("/api/org1/llm/models/refresh-built-in")); + assert!(!is_querier_route("/api/org1/llm/models")); // the org-level entry must not swallow the OTLP ingest route assert!(!is_querier_route("/api/org1/traces")); for leaf in ["meta", "tag_values", "series", "merge", "diff"] { diff --git a/src/core/src/traces/mod.rs b/src/core/src/traces/mod.rs index df1154700d..81ae9c66b2 100644 --- a/src/core/src/traces/mod.rs +++ b/src/core/src/traces/mod.rs @@ -88,7 +88,7 @@ const SERVICE: &str = "service"; const PARENT_SPAN_ID: &str = "reference.parent_span_id"; const PARENT_TRACE_ID: &str = "reference.parent_trace_id"; const REF_TYPE: &str = "reference.ref_type"; -const RESERVED_SPAN_FIELDS: [&str; 33] = [ +const RESERVED_SPAN_FIELDS: [&str; 34] = [ "trace_id", "span_id", "flags", @@ -122,6 +122,10 @@ const RESERVED_SPAN_FIELDS: [&str; 33] = [ crate::db_monitoring::O2_DB_ENV, crate::db_monitoring::O2_DB_STMT_CLASS, crate::db_monitoring::O2_DB_BATCH_MULTIPLIER, + // The ingest pricing marker: any spelling a client sends (`gen_ai.usage.cost_status`, + // `gen_ai_usage_cost_status`, …) on any span gets the attr_ prefix, so only the + // processor's own value reaches the column. + config::meta::model_pricing::COST_STATUS_COLUMN, "events", "links", TIMESTAMP_COL_NAME, @@ -359,15 +363,18 @@ async fn queue_gen_ai_agent_observations(org_id: &str, mut observations: AgentOb .await; } -/// Strip client-supplied derived-identity keys (`o2_db_*`, `infer_service_*`) -/// from a JSON-path record BEFORE re-derivation (design D1 condition 1): +/// Strip client-supplied derived keys (`o2_db_*`, `infer_service_*`, the ingest pricing +/// marker) from a JSON-path record BEFORE re-derivation (design D1 condition 1): /// `RESERVED_SPAN_FIELDS` protects the OTLP path only — the JSON ingest path /// flattens the caller's record keys directly, so a spoofed `o2_db_fingerprint` /// (or `infer_service_name`, fixed in the same PR per the design) would land as /// a trusted aggregation key. Legitimate values written by a first-pass OTLP /// enrichment re-derive identically from the raw `db_*` / peer attributes that -/// are still on the record (the derivations are deterministic). +/// are still on the record (the derivations are deterministic). This path does not +/// price spans, so it never sets `gen_ai_usage_cost_status`; a value on the record is +/// not ingest's. fn strip_client_supplied_derived_fields(record_val: &mut Map) { + record_val.remove(config::meta::model_pricing::COST_STATUS_COLUMN); // one key scan instead of 20 map removals: a record almost never carries a derived key if !record_val .keys() @@ -873,15 +880,12 @@ pub async fn handle_otlp_request( let otel_processor = OtelIngestionProcessor::new(); // Pre-load user-defined model pricing entries for this org (in-memory cache, no I/O). - // When ZO_MODEL_PRICING_ENABLED=false, no pricing applies and spans keep only reported costs. - static EMPTY_PRICING: std::sync::OnceLock< - std::sync::Arc>, - > = std::sync::OnceLock::new(); - let org_pricing_entries = if config::get_config().common.model_pricing_enabled { - crate::db::model_pricing::get_org_pricing_entries(org_id) - } else { - std::sync::Arc::clone(EMPTY_PRICING.get_or_init(|| std::sync::Arc::new(vec![]))) - }; + // When ZO_MODEL_PRICING_ENABLED=false, nothing is priced or marked and spans keep only + // reported costs. + let org_pricing_entries = config::get_config() + .common + .model_pricing_enabled + .then(|| crate::db::model_pricing::get_org_pricing_entries(org_id)); let gen_ai_agent_mapping_config = crate::db::system_settings::get_gen_ai_agent_mapping_config(org_id).await; @@ -967,7 +971,7 @@ pub async fn handle_otlp_request( &service_att_map, scope_name, &events, - &org_pricing_entries, + org_pricing_entries.as_deref().map(Vec::as_slice), start_time, &gen_ai_agent_mapping_config, ); @@ -2554,7 +2558,7 @@ mod tests { #[test] fn test_reserved_span_fields() { let reserved_span_fields = &super::RESERVED_SPAN_FIELDS; - assert_eq!(reserved_span_fields.len(), 33); + assert_eq!(reserved_span_fields.len(), 34); // All eleven DBM identity columns are reserved (design D1 condition 1). for field in crate::db_monitoring::ALL_DB_FIELDS { assert!( @@ -2980,6 +2984,37 @@ mod tests { } } + #[test] + fn test_span_attribute_key_blocks_cost_status_spoofing() { + // Only the processor's own marker may reach gen_ai_usage_cost_status; a client's + // value in any spelling, on any span, keeps its data under an attr_ key. + let service_att_map = std::collections::HashMap::new(); + for raw in [ + "gen_ai.usage.cost_status", + "gen_ai_usage_cost_status", + "Gen_AI.Usage.Cost-Status", + ] { + assert_eq!(attr_key(raw, &service_att_map), format!("attr_{raw}")); + } + } + + #[test] + fn test_strip_client_cost_status_on_json_path() { + use config::utils::json; + + let mut record_val: json::Map = json::Map::new(); + record_val.insert( + config::meta::model_pricing::COST_STATUS_COLUMN.to_string(), + json::json!("unpriced"), + ); + record_val.insert("gen_ai_usage_cost".to_string(), json::json!(0.25)); + + super::strip_client_supplied_derived_fields(&mut record_val); + + assert!(!record_val.contains_key(config::meta::model_pricing::COST_STATUS_COLUMN)); + assert!(record_val.contains_key("gen_ai_usage_cost")); + } + #[test] fn test_strip_client_supplied_derived_fields_on_json_path() { // The JSON ingest path flattens client keys directly — RESERVED_SPAN_FIELDS diff --git a/src/core/src/traces/otel/attributes.rs b/src/core/src/traces/otel/attributes.rs index 491db8baaf..b7bbea40e9 100644 --- a/src/core/src/traces/otel/attributes.rs +++ b/src/core/src/traces/otel/attributes.rs @@ -151,6 +151,9 @@ impl GenAiExtensions { pub const USAGE_COST_CACHE_READ_SAVINGS: &'static str = "gen_ai.usage.cost.cache_read_savings"; /// Net cost impact after cache reads and writes (Float64). OO extension. pub const USAGE_COST_NET_CACHE_IMPACT: &'static str = "gen_ai.usage.cost.net_cache_impact"; + /// How ingest priced the span (`unpriced` / `approximate`); unset for exact prices and + /// client-reported costs. Flattens to `config::meta::model_pricing::COST_STATUS_COLUMN`. + pub const USAGE_COST_STATUS: &'static str = "gen_ai.usage.cost_status"; } pub struct LLMAttributes; diff --git a/src/core/src/traces/otel/extractors/input_output.rs b/src/core/src/traces/otel/extractors/input_output.rs index ec468a6401..8749605d69 100644 --- a/src/core/src/traces/otel/extractors/input_output.rs +++ b/src/core/src/traces/otel/extractors/input_output.rs @@ -29,6 +29,8 @@ use crate::{ }; const MAX_INDEX: usize = 100; +const TRACELOOP_PROMPT_PREFIX: &str = "gen_ai.prompt"; +const TRACELOOP_COMPLETION_PREFIX: &str = "gen_ai.completion"; pub struct InputOutputExtractor; @@ -218,11 +220,28 @@ impl InputOutputExtractor { return true; } - // Check prefixes for nested attributes - key.starts_with("gen_ai.prompt") - || key.starts_with("gen_ai.completion") - || key.starts_with("llm.input_messages") - || key.starts_with("llm.output_messages") + // OpenInference nested messages + if key.starts_with("llm.input_messages") || key.starts_with("llm.output_messages") { + return true; + } + + // TraceLoop nested prompt/completion (`gen_ai.prompt.0.content`, …) and + // other `gen_ai.prompt.*` / `gen_ai.completion.*` keys such as prompt + // template variables are not stored as columns. The prompt reference + // (name/version/label) is not a message; the processor overwrites it + // with the normalised value, or removes it when the value is invalid. + if matches!( + key, + GenAiAttributes::PROMPT_NAME + | GenAiAttributes::PROMPT_VERSION + | GenAiAttributes::PROMPT_LABEL + ) { + return false; + } + key == TRACELOOP_PROMPT_PREFIX + || key == TRACELOOP_COMPLETION_PREFIX + || key.starts_with("gen_ai.prompt.") + || key.starts_with("gen_ai.completion.") } fn extract_vercel_ai_sdk( @@ -404,13 +423,13 @@ impl InputOutputExtractor { ) -> (Option, Option) { let input_attrs: HashMap = attributes .iter() - .filter(|(k, _)| k.starts_with("gen_ai.prompt")) + .filter(|(k, _)| is_traceloop_nested_key(k, TRACELOOP_PROMPT_PREFIX)) .map(|(k, v)| (k.clone(), v.clone())) .collect(); let output_attrs: HashMap = attributes .iter() - .filter(|(k, _)| k.starts_with("gen_ai.completion")) + .filter(|(k, _)| is_traceloop_nested_key(k, TRACELOOP_COMPLETION_PREFIX)) .map(|(k, v)| (k.clone(), v.clone())) .collect(); @@ -421,7 +440,7 @@ impl InputOutputExtractor { let input = if !input_attrs.is_empty() { Some(convert_key_path_to_nested_object( &input_attrs, - "gen_ai.prompt", + TRACELOOP_PROMPT_PREFIX, )) } else { None @@ -430,7 +449,7 @@ impl InputOutputExtractor { let output = if !output_attrs.is_empty() { Some(convert_key_path_to_nested_object( &output_attrs, - "gen_ai.completion", + TRACELOOP_COMPLETION_PREFIX, )) } else { None @@ -482,6 +501,24 @@ impl InputOutputExtractor { } } +/// True for TraceLoop's legacy indexed message keys: `` itself or +/// `.[.…]` (e.g. `gen_ai.prompt.0.content`). Named keys under the +/// same prefix, such as the prompt reference `gen_ai.prompt.name`, are not +/// TraceLoop messages. +fn is_traceloop_nested_key(key: &str, prefix: &str) -> bool { + let Some(rest) = key.strip_prefix(prefix) else { + return false; + }; + if rest.is_empty() { + return true; + } + let Some(rest) = rest.strip_prefix('.') else { + return false; + }; + let index = rest.split('.').next().unwrap_or_default(); + !index.is_empty() && index.bytes().all(|b| b.is_ascii_digit()) +} + /// Converts nested key paths (e.g., "gen_ai.prompt.0.role") to nested objects/arrays /// This is critical for handling TraceLoop and OpenInference attributes fn convert_key_path_to_nested_object( @@ -650,6 +687,92 @@ mod tests { assert!(!extractor.is_input_output_attribute("gen_ai.request.model")); } + /// Issue #3138: two chat spans, identical except span B carries the prompt + /// reference (`gen_ai.prompt.name|version|label`). Both must keep their + /// messages, and B must keep the reference attributes. + #[test] + fn test_prompt_reference_does_not_swallow_messages() { + let extractor = InputOutputExtractor; + let input_messages = + json::json!(r#"[{"role":"user","parts":[{"type":"text","content":"What is 2+2?"}]}]"#); + let output_messages = + json::json!(r#"[{"role":"assistant","parts":[{"type":"text","content":"4"}]}]"#); + let span_a: HashMap = HashMap::from([ + ("gen_ai.operation.name".to_string(), json::json!("chat")), + ( + GenAiAttributes::INPUT_MESSAGES.to_string(), + input_messages.clone(), + ), + ( + GenAiAttributes::OUTPUT_MESSAGES.to_string(), + output_messages.clone(), + ), + ]); + let mut span_b = span_a.clone(); + span_b.insert( + GenAiAttributes::PROMPT_NAME.to_string(), + json::json!("support-answer"), + ); + span_b.insert( + GenAiAttributes::PROMPT_VERSION.to_string(), + json::json!("3"), + ); + span_b.insert( + GenAiAttributes::PROMPT_LABEL.to_string(), + json::json!("production"), + ); + + for attrs in [&span_a, &span_b] { + let (input, output) = extractor.extract(&[], attrs, ""); + assert_eq!(input.as_ref(), Some(&input_messages)); + assert_eq!(output.as_ref(), Some(&output_messages)); + } + + let kept: Vec<&str> = span_b + .keys() + .map(String::as_str) + .filter(|k| !extractor.is_input_output_attribute(k)) + .collect(); + for key in [ + GenAiAttributes::PROMPT_NAME, + GenAiAttributes::PROMPT_VERSION, + GenAiAttributes::PROMPT_LABEL, + ] { + assert!(kept.contains(&key), "{key} must be kept"); + } + assert!(!kept.contains(&GenAiAttributes::INPUT_MESSAGES)); + } + + #[test] + fn test_traceloop_nested_ignores_prompt_reference() { + let extractor = InputOutputExtractor; + let attrs = HashMap::from([ + ("gen_ai.prompt.0.role".to_string(), json::json!("user")), + ("gen_ai.prompt.0.content".to_string(), json::json!("Hello")), + ("gen_ai.completion.0.content".to_string(), json::json!("Hi")), + ( + GenAiAttributes::PROMPT_NAME.to_string(), + json::json!("support-answer"), + ), + ( + "gen_ai.prompt.variable.customer".to_string(), + json::json!("acme"), + ), + ]); + + let (input, output) = extractor.extract(&[], &attrs, ""); + assert_eq!( + input, + Some(json::json!([{"role": "user", "content": "Hello"}])) + ); + assert_eq!(output, Some(json::json!([{"content": "Hi"}]))); + + assert!(extractor.is_input_output_attribute("gen_ai.prompt.0.content")); + assert!(extractor.is_input_output_attribute("gen_ai.completion.0.content")); + assert!(extractor.is_input_output_attribute("gen_ai.prompt.variable.customer")); + assert!(!extractor.is_input_output_attribute(GenAiAttributes::PROMPT_NAME)); + } + #[test] fn test_extract_traceloop_nested() { let extractor = InputOutputExtractor; diff --git a/src/core/src/traces/otel/mod.rs b/src/core/src/traces/otel/mod.rs index 176c89a67d..dbe476fbda 100644 --- a/src/core/src/traces/otel/mod.rs +++ b/src/core/src/traces/otel/mod.rs @@ -55,7 +55,7 @@ //! &resource_attributes, //! Some("openai"), //! &events, -//! &[], // pass org pricing entries here in production +//! Some(&[]), // org pricing entries in production; None when pricing is disabled //! span_start_nanos, // span.start_time_unix_nano from OTLP payload //! &GenAiAgentMappingConfig::default(), //! ); @@ -84,7 +84,7 @@ //! &resource_attrs, //! Some("openai"), //! &events, -//! &[], +//! Some(&[]), //! 0, //! &GenAiAgentMappingConfig::default(), //! ); diff --git a/src/core/src/traces/otel/processor.rs b/src/core/src/traces/otel/processor.rs index 04cf004224..a7e3d5572f 100644 --- a/src/core/src/traces/otel/processor.rs +++ b/src/core/src/traces/otel/processor.rs @@ -23,7 +23,9 @@ use std::collections::HashMap; use config::{ meta::{ gen_ai::GenAiAgentMappingConfig, - model_pricing::{ModelPricingDefinition, PricingContext}, + model_pricing::{ + COST_STATUS_COLUMN, CostStatus, MatchKind, ModelPricingDefinition, PricingContext, + }, }, utils::{json, time::parse_timestamp_micro_from_value}, }; @@ -136,12 +138,14 @@ impl OtelIngestionProcessor { resource_attributes, scope_name, events, - org_pricing_entries, + Some(org_pricing_entries), span_start_nanos, &GenAiAgentMappingConfig::default(), ); } + /// Enriches an LLM span. `org_pricing_entries` is `None` when model pricing is disabled + /// (`ZO_MODEL_PRICING_ENABLED=false`): no cost is computed and no cost status is written. #[allow(clippy::too_many_arguments)] pub fn process_span_with_pricing_and_agent_mapping( &self, @@ -149,7 +153,7 @@ impl OtelIngestionProcessor { resource_attributes: &HashMap, scope_name: Option<&str>, events: &[Event], - org_pricing_entries: &[CachedModelPricing], + org_pricing_entries: Option<&[CachedModelPricing]>, span_start_nanos: u64, agent_mapping_config: &GenAiAgentMappingConfig, ) { @@ -167,7 +171,7 @@ impl OtelIngestionProcessor { .retain(|key, _| !self.input_output_extractor.is_input_output_attribute(key)); // Phase 2: Compute derived values — token counts, cost estimates, defaults. - let (usage, cost) = self.compute_usage_and_cost( + let (usage, cost, cost_status) = self.compute_usage_and_cost( &extracted, span_attributes, resource_attributes, @@ -176,7 +180,7 @@ impl OtelIngestionProcessor { ); // Phase 3: Write enriched attributes back to the span. - self.emit_enriched_attributes(span_attributes, &extracted, &usage, &cost); + self.emit_enriched_attributes(span_attributes, &extracted, &usage, &cost, cost_status); } // ── Phase 1: Extract raw data ────────────────────────────────────────── @@ -260,17 +264,27 @@ impl OtelIngestionProcessor { // ── Phase 2: Compute usage, cost, and defaults ───────────────────────── + /// Returns token usage, cost components and, for a model not priced exactly at a non-zero + /// cost, its [`CostStatus`]. An unpriced span gets no cost entries, not a zero total. + /// `org_pricing_entries` is `None` when model pricing is disabled: nothing is priced and + /// no span is marked. fn compute_usage_and_cost( &self, extracted: &SpanExtractions, span_attributes: &HashMap, resource_attributes: &HashMap, - org_pricing_entries: &[CachedModelPricing], + org_pricing_entries: Option<&[CachedModelPricing]>, span_start_nanos: u64, - ) -> (HashMap, HashMap) { + ) -> ( + HashMap, + HashMap, + Option, + ) { let mut usage = extracted.usage.clone(); let mut cost = extracted.cost.clone(); let mut input_includes_cache = extracted.input_includes_cache; + let mut cost_status = None; + let mut zero_priced = false; if is_generation_or_embedding(extracted.op_name) { // Zero means the payload carried no start time; treat it as unknown rather @@ -305,42 +319,69 @@ impl OtelIngestionProcessor { let (billable_usage, tier_usage) = build_pricing_usage(&usage, input_includes_cache); // Resolve only for spans without a client-reported cost; the lookup runs regexes. + // A span with tokens but no model name has nothing to price: it is unpriced, not + // free. One without tokens stays unmarked. With pricing disabled nothing is priced + // or marked: the span keeps only a reported cost. if cost.is_empty() - && let Some(resolved) = extracted.model_name.as_ref().and_then(|mn| { - crate::db::model_pricing::find_pricing_sync_at( - org_pricing_entries, - mn, - span_ts_micros, - ) - }) + && org_pricing_entries.is_some() + && extracted.model_name.is_none() + && usage.values().any(|&v| v > 0) { - let pricing_def = &resolved.definition; - let ctx = pricing_context( - pricing_def, - span_attributes, - resource_attributes, - &extracted.model_params, + cost_status = Some(CostStatus::Unpriced); + } else if cost.is_empty() + && let Some(org_pricing_entries) = org_pricing_entries + && let Some(model_name) = extracted.model_name.as_deref() + { + let resolved = crate::db::model_pricing::find_pricing_sync_at( + org_pricing_entries, + model_name, + span_ts_micros, ); - let result = - crate::db::model_pricing::calculate_cost_from_definition_with_tier_usage( + let mut priced = false; + if let Some(resolved) = resolved { + let pricing_def = &resolved.definition; + let ctx = pricing_context( pricing_def, - &billable_usage, - &tier_usage, - span_ts_micros, - &ctx, + span_attributes, + resource_attributes, + &extracted.model_params, ); - if !result.cost.is_empty() { - log::debug!( - "[model_pricing] model='{}' pattern='{}' match={:?} tier='{}' total_cost={:.8}", - extracted.model_name.as_deref().unwrap_or(""), - pricing_def.match_pattern, - resolved.match_kind, - result.tier_name, - result.cost.get("total").copied().unwrap_or(0.0), - ); - cost = result.cost; - add_cache_derived_costs(&mut cost, &tier_usage, &result.prices); - cost.values_mut().for_each(|v| *v = round_cost(*v)); + let result = + crate::db::model_pricing::calculate_cost_from_definition_with_tier_usage( + pricing_def, + &billable_usage, + &tier_usage, + span_ts_micros, + &ctx, + ); + // A selected tier prices the span even when its cost is zero (no tokens, + // or a free model); only "no definition / no tier" is unpriced. + priced = result.tier_index.is_some(); + if priced && resolved.match_kind == MatchKind::Approximate { + cost_status = Some(CostStatus::Approximate); + } + // Free means the exact match's selected tier prices every unit at $0, not + // that this span happened to cost $0 (a priced model's zero-token span). + zero_priced = priced + && resolved.match_kind == MatchKind::Exact + && !result.prices.is_empty() + && result.prices.values().all(|&price| price == 0.0); + if !result.cost.is_empty() { + log::debug!( + "[model_pricing] model='{}' pattern='{}' match={:?} tier='{}' total_cost={:.8}", + model_name, + pricing_def.match_pattern, + resolved.match_kind, + result.tier_name, + result.cost.get("total").copied().unwrap_or(0.0), + ); + cost = result.cost; + add_cache_derived_costs(&mut cost, &tier_usage, &result.prices); + cost.values_mut().for_each(|v| *v = round_cost(*v)); + } + } + if !priced && usage.values().any(|&tokens| tokens > 0) { + cost_status = Some(CostStatus::Unpriced); } } } @@ -363,13 +404,20 @@ impl OtelIngestionProcessor { usage.insert("total".to_string(), input + cache_input + output); } - // Ensure cost total (sum all components, not just input+output). - if !cost.contains_key("total") { + // Ensure cost total (sum all components, not just input+output). An unpriced span keeps + // no cost at all so readers can tell "no price" from "free". + if cost_status != Some(CostStatus::Unpriced) && !cost.contains_key("total") { let total = actual_cost_total(&cost); cost.insert("total".to_string(), total); } + // An exact all-zero price (a free or muted model) is still a price; mark it so readers + // do not take the zero for a legacy unpriced span. A priced model's zero-token span + // stays unmarked. + if zero_priced && cost.get("total").copied() == Some(0.0) { + cost_status = Some(CostStatus::Free); + } - (usage, cost) + (usage, cost, cost_status) } // ── Phase 3: Emit enriched attributes ────────────────────────────────── @@ -380,6 +428,7 @@ impl OtelIngestionProcessor { extracted: &SpanExtractions, usage: &HashMap, cost: &HashMap, + cost_status: Option, ) { span_attributes.insert( GenAiAttributes::OPERATION_NAME.to_string(), @@ -512,6 +561,20 @@ impl OtelIngestionProcessor { if let Some(&v) = cost.get("total") { insert_if_absent(span_attributes, GenAiAttributes::USAGE_COST, json::json!(v)); } + // Only ingest sets the marker; a client-sent value in either spelling (both flatten to + // the same column) would fake one or collide with the ingest value. + span_attributes.remove(GenAiExtensions::USAGE_COST_STATUS); + span_attributes.remove(COST_STATUS_COLUMN); + if let Some(status) = cost_status { + if status == CostStatus::Unpriced { + // A client-sent zero is not a price; leave the cost null. + span_attributes.remove(GenAiAttributes::USAGE_COST); + } + span_attributes.insert( + GenAiExtensions::USAGE_COST_STATUS.to_string(), + json::json!(status.as_str()), + ); + } if let Some(uid) = &extracted.user_id { span_attributes.insert(OtelAttributes::USER_ID.to_string(), json::json!(uid)); @@ -524,20 +587,27 @@ impl OtelIngestionProcessor { ); } - if let Some(pname) = &extracted.prompt_name { - span_attributes.insert(GenAiAttributes::PROMPT_NAME.to_string(), json::json!(pname)); - } - if let Some(version) = extracted.prompt_version { - span_attributes.insert( - GenAiAttributes::PROMPT_VERSION.to_string(), - json::json!(version), - ); - } - if let Some(label) = &extracted.prompt_label { - span_attributes.insert( - GenAiAttributes::PROMPT_LABEL.to_string(), - json::json!(label), - ); + // The raw prompt reference survives extraction; replace it with the normalised value, + // or drop it when the extractor rejected it (a non-string name, a version that is not a + // positive integer), so `gen_ai_prompt_version` stays an Int64 column. + for (key, value) in [ + ( + GenAiAttributes::PROMPT_NAME, + extracted.prompt_name.as_deref().map(|v| json::json!(v)), + ), + ( + GenAiAttributes::PROMPT_VERSION, + extracted.prompt_version.map(|v| json::json!(v)), + ), + ( + GenAiAttributes::PROMPT_LABEL, + extracted.prompt_label.as_deref().map(|v| json::json!(v)), + ), + ] { + match value { + Some(value) => span_attributes.insert(key.to_string(), value), + None => span_attributes.remove(key), + }; } // TTFT: convert microseconds → Float64 seconds. @@ -1011,7 +1081,7 @@ mod tests { &HashMap::new(), None, &[], - &[], + Some(&[]), 0, &config, ); @@ -1233,6 +1303,51 @@ mod tests { assert!(!span_attrs.contains_key("gen_ai_prompt_variable_customer")); } + #[test] + fn process_span_replaces_or_drops_raw_prompt_reference() { + let processor = OtelIngestionProcessor::new(); + // Rejected by the extractor: a non-numeric version and a non-string name. + let mut rejected = HashMap::from([ + (GenAiAttributes::PROMPT_NAME.to_string(), json::json!(42)), + ( + GenAiAttributes::PROMPT_VERSION.to_string(), + json::json!("latest"), + ), + ( + GenAiAttributes::PROMPT_LABEL.to_string(), + json::json!("production"), + ), + ]); + processor.process_span(&mut rejected, &HashMap::new(), None, &[]); + assert!(!rejected.contains_key(GenAiAttributes::PROMPT_VERSION)); + assert!(!rejected.contains_key(GenAiAttributes::PROMPT_NAME)); + assert_eq!( + rejected.get(GenAiAttributes::PROMPT_LABEL), + Some(&json::json!("production")) + ); + + // A numeric string version is stored as the integer it names. + let mut accepted = HashMap::from([ + ( + GenAiAttributes::PROMPT_NAME.to_string(), + json::json!("support-answer"), + ), + ( + GenAiAttributes::PROMPT_VERSION.to_string(), + json::json!("3"), + ), + ]); + processor.process_span(&mut accepted, &HashMap::new(), None, &[]); + assert_eq!( + accepted.get(GenAiAttributes::PROMPT_VERSION), + Some(&json::json!(3)) + ); + assert_eq!( + accepted.get(GenAiAttributes::PROMPT_NAME), + Some(&json::json!("support-answer")) + ); + } + #[test] fn test_process_span_calculates_cost_from_tokens() { let processor = OtelIngestionProcessor::new(); @@ -1508,39 +1623,93 @@ mod tests { ); } - #[test] - fn test_process_span_no_cost_calculation_for_unknown_model() { - let processor = OtelIngestionProcessor::new(); - + fn chat_span(model: &str) -> HashMap { let mut span_attrs = HashMap::new(); span_attrs.insert("gen_ai.operation.name".to_string(), json::json!("chat")); - span_attrs.insert( - "gen_ai.request.model".to_string(), - json::json!("unknown-model-xyz-2024"), - ); - span_attrs.insert( - "gen_ai.input.messages".to_string(), - json::json!("[{\"role\":\"user\"}]"), - ); - span_attrs.insert( - "gen_ai.output.messages".to_string(), - json::json!("[{\"role\":\"assistant\"}]"), - ); + span_attrs.insert("gen_ai.request.model".to_string(), json::json!(model)); span_attrs.insert("gen_ai.usage.input_tokens".to_string(), json::json!(1000)); span_attrs.insert("gen_ai.usage.output_tokens".to_string(), json::json!(500)); + span_attrs + } - let resource_attrs = HashMap::new(); - let events = vec![]; + fn cost_status(span_attrs: &HashMap) -> Option<&str> { + span_attrs + .get(GenAiExtensions::USAGE_COST_STATUS) + .and_then(|v| v.as_str()) + } - processor.process_span(&mut span_attrs, &resource_attrs, None, &events); + #[test] + fn test_unpriced_model_keeps_tokens_and_has_no_cost() { + let processor = OtelIngestionProcessor::new(); + let mut span_attrs = chat_span("unknown-model-xyz-2024"); + // A client-sent zero is not a price either. + span_attrs.insert("gen_ai.usage.cost".to_string(), json::json!(0)); - // No pricing definition matched for unknown model — total cost should be zero. + processor.process_span(&mut span_attrs, &HashMap::new(), None, &[]); + + assert_eq!(cost_status(&span_attrs), Some("unpriced")); + assert!(!span_attrs.contains_key(GenAiAttributes::USAGE_COST)); + assert!(!span_attrs.contains_key(GenAiExtensions::USAGE_COST_INPUT)); + assert!(!span_attrs.contains_key(GenAiExtensions::USAGE_COST_OUTPUT)); assert_eq!( - span_attrs + span_attrs.get(GenAiAttributes::USAGE_TOTAL_TOKENS), + Some(&json::json!(1500)) + ); + } + + #[test] + fn test_approximate_match_keeps_cost_and_is_marked() { + let processor = OtelIngestionProcessor::new(); + // `(?i)claude-sonnet-4-6` covers only the start of the dated name. + let mut span_attrs = chat_span("claude-sonnet-4-6-20260101"); + + processor.process_span(&mut span_attrs, &HashMap::new(), None, &[]); + + assert_eq!(cost_status(&span_attrs), Some("approximate")); + let total = span_attrs + .get(GenAiAttributes::USAGE_COST) + .and_then(|v| v.as_f64()) + .unwrap(); + assert!((total - (1000.0 * 3e-06 + 500.0 * 1.5e-05)).abs() < 1e-12); + } + + #[test] + fn test_exact_match_and_client_cost_write_no_marker() { + let processor = OtelIngestionProcessor::new(); + let mut exact = chat_span("gpt-4o"); + exact.insert( + "gen_ai.usage.cost_status".to_string(), + json::json!("unpriced"), + ); + processor.process_span(&mut exact, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&exact), None); + assert!(exact.contains_key(GenAiAttributes::USAGE_COST)); + + let mut client_cost = chat_span("unknown-model-xyz-2024"); + client_cost.insert("gen_ai.usage.cost".to_string(), json::json!(0.25)); + processor.process_span(&mut client_cost, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&client_cost), None); + assert_eq!( + client_cost .get(GenAiAttributes::USAGE_COST) .and_then(|v| v.as_f64()), - Some(0.0) + Some(0.25) ); + + // The flattened spelling lands in the same column and must not survive either. + let mut flattened = chat_span("gpt-4o"); + flattened.insert(COST_STATUS_COLUMN.to_string(), json::json!("unpriced")); + processor.process_span(&mut flattened, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&flattened), None); + assert!(!flattened.contains_key(COST_STATUS_COLUMN)); + assert!(flattened.contains_key(GenAiAttributes::USAGE_COST)); + + // An ingest-set marker replaces a client-sent flattened one instead of colliding. + let mut unpriced = chat_span("unknown-model-xyz-2024"); + unpriced.insert(COST_STATUS_COLUMN.to_string(), json::json!("approximate")); + processor.process_span(&mut unpriced, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&unpriced), Some("unpriced")); + assert!(!unpriced.contains_key(COST_STATUS_COLUMN)); } #[test] @@ -1594,6 +1763,152 @@ mod tests { ); } + #[test] + fn test_no_model_with_tokens_is_unpriced_without_tokens_unmarked() { + let processor = OtelIngestionProcessor::new(); + let mut with_tokens = chat_span("x"); + with_tokens.remove("gen_ai.request.model"); + processor.process_span(&mut with_tokens, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&with_tokens), Some("unpriced")); + assert!(!with_tokens.contains_key(GenAiAttributes::USAGE_COST)); + assert_eq!( + with_tokens.get(GenAiAttributes::USAGE_TOTAL_TOKENS), + Some(&json::json!(1500)) + ); + + let mut no_tokens = HashMap::new(); + no_tokens.insert("gen_ai.operation.name".to_string(), json::json!("chat")); + processor.process_span(&mut no_tokens, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&no_tokens), None); + no_tokens.insert( + "gen_ai.request.model".to_string(), + json::json!("unknown-zero-usage"), + ); + processor.process_span(&mut no_tokens, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&no_tokens), None); + + // A client-reported cost still wins over the missing model. + let mut client_cost = chat_span("x"); + client_cost.remove("gen_ai.request.model"); + client_cost.insert("gen_ai.usage.cost".to_string(), json::json!(0.25)); + processor.process_span(&mut client_cost, &HashMap::new(), None, &[]); + assert_eq!(cost_status(&client_cost), None); + } + + fn zero_price_entry(model: &str) -> CachedModelPricing { + use config::meta::model_pricing::PricingTierDefinition; + + let pattern = format!("^{model}$"); + CachedModelPricing { + definition: ModelPricingDefinition { + name: format!("{model} (muted)"), + match_pattern: pattern.clone(), + enabled: true, + tiers: vec![PricingTierDefinition { + name: "Default".to_string(), + condition: None, + prices: HashMap::from([ + ("input".to_string(), 0.0), + ("output".to_string(), 0.0), + ]), + utc_windows: Vec::new(), + rules: Vec::new(), + priority: 0, + }], + ..Default::default() + }, + compiled_regex: regex::Regex::new(&pattern).unwrap(), + } + } + + #[test] + fn test_exact_zero_price_with_tokens_is_marked_free() { + let processor = OtelIngestionProcessor::new(); + let entries = vec![zero_price_entry("acme-internal-llm")]; + + let mut muted = chat_span("acme-internal-llm"); + processor.process_span_with_pricing(&mut muted, &HashMap::new(), None, &[], &entries, 0); + assert_eq!(cost_status(&muted), Some("free")); + assert_eq!( + muted + .get(GenAiAttributes::USAGE_COST) + .and_then(|v| v.as_f64()), + Some(0.0) + ); + + // An exactly matched zero price is free even when no tokens were used. + let mut no_tokens = HashMap::new(); + no_tokens.insert("gen_ai.operation.name".to_string(), json::json!("chat")); + no_tokens.insert( + "gen_ai.request.model".to_string(), + json::json!("acme-internal-llm"), + ); + processor.process_span_with_pricing( + &mut no_tokens, + &HashMap::new(), + None, + &[], + &entries, + 0, + ); + assert_eq!(cost_status(&no_tokens), Some("free")); + } + + #[test] + fn test_zero_token_span_of_priced_model_is_not_free() { + let processor = OtelIngestionProcessor::new(); + let mut entry = zero_price_entry("acme-paid-llm"); + entry.definition.tiers[0].prices = HashMap::from([ + ("input".to_string(), 0.000001), + ("output".to_string(), 0.000002), + ]); + let entries = vec![entry]; + + let mut no_tokens = HashMap::new(); + no_tokens.insert("gen_ai.operation.name".to_string(), json::json!("chat")); + no_tokens.insert( + "gen_ai.request.model".to_string(), + json::json!("acme-paid-llm"), + ); + processor.process_span_with_pricing( + &mut no_tokens, + &HashMap::new(), + None, + &[], + &entries, + 0, + ); + assert_eq!(cost_status(&no_tokens), None); + } + + #[test] + fn test_pricing_disabled_writes_no_marker() { + let processor = OtelIngestionProcessor::new(); + for model in [Some("unknown-model-xyz-2024"), Some("gpt-4o"), None] { + let mut span_attrs = chat_span(model.unwrap_or("x")); + if model.is_none() { + span_attrs.remove("gen_ai.request.model"); + } + processor.process_span_with_pricing_and_agent_mapping( + &mut span_attrs, + &HashMap::new(), + None, + &[], + None, + 0, + &GenAiAgentMappingConfig::default(), + ); + assert_eq!(cost_status(&span_attrs), None, "{model:?}"); + assert!(!span_attrs.contains_key(COST_STATUS_COLUMN), "{model:?}"); + // Tokens are still counted; nothing is priced. + assert_eq!( + span_attrs.get(GenAiAttributes::USAGE_TOTAL_TOKENS), + Some(&json::json!(1500)) + ); + assert!(!span_attrs.contains_key(GenAiExtensions::USAGE_COST_INPUT)); + } + } + #[test] fn test_process_span_extracts_evaluation_scores() { let processor = OtelIngestionProcessor::new(); diff --git a/src/db/src/schema.rs b/src/db/src/schema.rs index c224dc51ae..59e45acc60 100644 --- a/src/db/src/schema.rs +++ b/src/db/src/schema.rs @@ -232,6 +232,12 @@ static GEN_AI_SCHEMA_FIELDS: std::sync::LazyLock> = std::sync::LazyLo // written for label-resolved calls, so it must be provisioned up front. Field::new("gen_ai_prompt_name", DataType::Utf8, true), Field::new("gen_ai_prompt_label", DataType::Utf8, true), + // Written only for unpriced/approximate spans, so provision it for the readers. + Field::new( + config::meta::model_pricing::COST_STATUS_COLUMN, + DataType::Utf8, + true, + ), // Integer fields Field::new("gen_ai_prompt_version", DataType::Int64, true), Field::new("gen_ai_usage_input_tokens", DataType::Int64, true), diff --git a/src/infra/src/table/entity/llm_dataset_items.rs b/src/infra/src/table/entity/llm_dataset_items.rs index 9c0caca924..3adba5b55d 100644 --- a/src/infra/src/table/entity/llm_dataset_items.rs +++ b/src/infra/src/table/entity/llm_dataset_items.rs @@ -19,6 +19,12 @@ pub struct Model { pub input: Json, pub expected_output: Json, pub global_version: i64, + /// The Dataset version shown for the write request that stored this row; every row of one + /// request shares it, while `global_version` stays unique per row. NULL for rows written + /// before the column existed or by an older node: such a row is its own write, numbered by + /// `global_version`. Display only: two regions can label different writes with one value. + #[serde(default)] + pub write_version: Option, pub is_deleted: bool, pub source: String, pub source_ref: Option, @@ -36,3 +42,32 @@ pub struct Model { pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replicated_rows_preserve_write_versions_and_accept_older_payloads() { + // EvalDatasetMessage carries this row as JSON; old nodes omit the new column. + let legacy = serde_json::json!({ + "row_id": "row-1", + "logical_id": "item-1", + "org_id": "org-1", + "dataset_id": "dataset-1", + "input": "question", + "expected_output": "answer", + "global_version": 59, + "is_deleted": false, + "source": "manual", + "updated_by": "owner@example.com", + "updated_at": 100 + }); + let mut row: Model = serde_json::from_value(legacy).unwrap(); + assert_eq!(row.write_version, None); + row.write_version = Some(57); + let payload = serde_json::to_value(&row).unwrap(); + assert_eq!(payload["write_version"], 57); + assert_eq!(serde_json::from_value::(payload).unwrap(), row); + } +} diff --git a/src/infra/src/table/migration/m20261028_000001_add_llm_dataset_item_write_version.rs b/src/infra/src/table/migration/m20261028_000001_add_llm_dataset_item_write_version.rs new file mode 100644 index 0000000000..fe062d1e48 --- /dev/null +++ b/src/infra/src/table/migration/m20261028_000001_add_llm_dataset_item_write_version.rs @@ -0,0 +1,222 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Adds the nullable `write_version` to `llm_dataset_items`: the Dataset version a row's write +//! request was shown as. NULL marks a row written before this column or by an older node. +//! The existing row-version indexes are untouched; one new index lets a write find the Dataset's +//! highest label (and the highest legacy row version) without scanning its history. + +use sea_orm_migration::prelude::*; + +const LLM_DATASET_ITEMS: &str = "llm_dataset_items"; +const WRITE_VERSION: &str = "write_version"; +const WRITE_VERSION_IDX: &str = "idx_llm_dataset_items_write_version"; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager.has_column(LLM_DATASET_ITEMS, WRITE_VERSION).await? { + manager + .alter_table( + Table::alter() + .table(LlmDatasetItems::Table) + .add_column( + ColumnDef::new(LlmDatasetItems::WriteVersion) + .big_integer() + .null(), + ) + .to_owned(), + ) + .await?; + } + if !manager + .has_index(LLM_DATASET_ITEMS, WRITE_VERSION_IDX) + .await? + { + manager + .create_index( + Index::create() + .name(WRITE_VERSION_IDX) + .table(LlmDatasetItems::Table) + .col(LlmDatasetItems::DatasetId) + .col(LlmDatasetItems::WriteVersion) + .col(LlmDatasetItems::GlobalVersion) + .to_owned(), + ) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager + .has_index(LLM_DATASET_ITEMS, WRITE_VERSION_IDX) + .await? + { + manager + .drop_index( + Index::drop() + .name(WRITE_VERSION_IDX) + .table(LlmDatasetItems::Table) + .to_owned(), + ) + .await?; + } + if !manager.has_column(LLM_DATASET_ITEMS, WRITE_VERSION).await? { + return Ok(()); + } + manager + .alter_table( + Table::alter() + .table(LlmDatasetItems::Table) + .drop_column(LlmDatasetItems::WriteVersion) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum LlmDatasetItems { + Table, + DatasetId, + WriteVersion, + GlobalVersion, +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; + use sea_orm_migration::MigrationName; + + use super::*; + + #[test] + fn test_migration_name() { + assert_eq!( + Migration.name(), + "m20261028_000001_add_llm_dataset_item_write_version" + ); + } + + async fn items_db() -> DatabaseConnection { + let db = Database::connect("sqlite::memory:").await.unwrap(); + for sql in [ + "CREATE TABLE llm_dataset_items (row_id TEXT PRIMARY KEY, dataset_id TEXT NOT NULL, \ + logical_id TEXT NOT NULL, global_version BIGINT NOT NULL)", + "CREATE UNIQUE INDEX uq_llm_dataset_items_version ON llm_dataset_items (dataset_id, \ + global_version)", + "CREATE INDEX idx_llm_dataset_items_latest ON llm_dataset_items (dataset_id, \ + logical_id, global_version)", + "INSERT INTO llm_dataset_items VALUES ('row-1', 'dataset-1', 'item-1', 1)", + ] { + db.execute_unprepared(sql).await.unwrap(); + } + db + } + + async fn index_names(db: &DatabaseConnection) -> Vec { + let rows = db + .query_all(Statement::from_string( + db.get_database_backend(), + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = \ + 'llm_dataset_items' AND sql IS NOT NULL ORDER BY name", + )) + .await + .unwrap(); + rows.iter() + .map(|row| row.try_get::("", "name").unwrap()) + .collect() + } + + async fn insert(db: &DatabaseConnection, row_id: &str, version: i64) -> bool { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO llm_dataset_items (row_id, dataset_id, logical_id, global_version, \ + write_version) VALUES (?, 'dataset-1', ?, ?, 2)", + [row_id.into(), row_id.into(), version.into()], + )) + .await + .is_ok() + } + + #[tokio::test] + async fn up_adds_a_nullable_column_and_one_head_index_keeping_the_version_indexes() { + let db = items_db().await; + let manager = SchemaManager::new(&db); + let mut indexes = index_names(&db).await; + + Migration.up(&manager).await.unwrap(); + Migration.up(&manager).await.unwrap(); + + assert!( + manager + .has_column(LLM_DATASET_ITEMS, WRITE_VERSION) + .await + .unwrap() + ); + indexes.push(WRITE_VERSION_IDX.to_string()); + indexes.sort(); + assert_eq!(index_names(&db).await, indexes); + // The pre-existing row reads as NULL: a legacy write. + let legacy = db + .query_one(Statement::from_string( + db.get_database_backend(), + "SELECT write_version FROM llm_dataset_items WHERE row_id = 'row-1'", + )) + .await + .unwrap() + .unwrap(); + assert_eq!( + legacy.try_get::>("", WRITE_VERSION).unwrap(), + None + ); + // Rows of one write share a write version but never a row version. + assert!(insert(&db, "row-2", 2).await); + assert!(insert(&db, "row-3", 3).await); + assert!(!insert(&db, "row-4", 3).await); + } + + #[tokio::test] + async fn down_drops_the_column_and_its_index() { + let db = items_db().await; + let manager = SchemaManager::new(&db); + let indexes = index_names(&db).await; + Migration.up(&manager).await.unwrap(); + assert!(insert(&db, "row-2", 2).await); + + Migration.down(&manager).await.unwrap(); + Migration.down(&manager).await.unwrap(); + + assert!( + !manager + .has_column(LLM_DATASET_ITEMS, WRITE_VERSION) + .await + .unwrap() + ); + assert_eq!(index_names(&db).await, indexes); + let rows = db + .query_all(Statement::from_string( + db.get_database_backend(), + "SELECT row_id FROM llm_dataset_items", + )) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + } +} diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs index 75d04ddf52..af3282a98e 100644 --- a/src/infra/src/table/migration/mod.rs +++ b/src/infra/src/table/migration/mod.rs @@ -243,6 +243,7 @@ mod m20261025_000005_add_held_action_to_slack_link_requests; mod m20261026_000001_backfill_v1_rca_pointer; mod m20261027_000001_alter_query_history; mod m20261028_000001_add_group_label_set_to_alert_states; +mod m20261028_000001_add_llm_dataset_item_write_version; mod m20261029_000001_widen_alert_dedup_fingerprint; mod m20261030_000001_add_suppress_repeats_to_alerts; mod m20261030_000001_create_org_domain_ownership_table; @@ -769,6 +770,7 @@ impl MigratorTrait for Migrator { Box::new(m20261030_000002_create_org_domain_ownership_req_table::Migration), Box::new(m20261101_000001_create_oncall_slack::Migration), Box::new(m20261103_000001_add_status_streaks_to_synthetics::Migration), + Box::new(m20261028_000001_add_llm_dataset_item_write_version::Migration), ] } } @@ -870,6 +872,7 @@ mod tests { ), (132, "m20261101_000001_create_oncall_slack"), (133, "m20261103_000001_add_status_streaks_to_synthetics"), + (134, "m20261028_000001_add_llm_dataset_item_write_version"), ]; // Two branches that each claim the next version merge cleanly, so a reused number must fail diff --git a/src/infra/src/table/model_pricing.rs b/src/infra/src/table/model_pricing.rs index e179ba7e0e..bf5825d709 100644 --- a/src/infra/src/table/model_pricing.rs +++ b/src/infra/src/table/model_pricing.rs @@ -21,8 +21,8 @@ use config::{ utils::json, }; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, - QueryOrder, QuerySelect, Set, TryIntoModel, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, ModelTrait, + QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel, sea_query::OnConflict, }; use crate::{ @@ -136,6 +136,49 @@ pub async fn put(item: ModelPricingDefinition) -> Result) -> Result { + insert_missing_built_ins_with_db(get_orm_client_rw().await, items).await +} + +async fn insert_missing_built_ins_with_db( + db: &C, + items: Vec, +) -> Result { + if items.is_empty() { + return Ok(0); + } + let now = chrono::Utc::now().timestamp_micros(); + let rows = items + .into_iter() + .map(|item| { + Ok(ActiveModel { + id: Set(ider::uuid()), + org: Set(config::meta::model_pricing::BUILT_IN_ORG.to_string()), + name: Set(item.name), + match_pattern: Set(item.match_pattern), + enabled: Set(item.enabled), + tiers: Set(json::to_value(&item.tiers)?), + valid_from: Set(item.valid_from), + sort_order: Set(item.sort_order), + source: Set(PricingSource::BuiltIn.as_str().to_string()), + provider: Set((!item.provider.is_empty()).then_some(item.provider)), + description: Set((!item.description.is_empty()).then_some(item.description)), + created_at: Set(now), + updated_at: Set(now), + }) + }) + .collect::, Error>>()?; + Ok(Entity::insert_many(rows) + .on_conflict( + OnConflict::columns([Column::Org, Column::Name]) + .do_nothing() + .to_owned(), + ) + .exec_without_returning(db) + .await?) +} + pub async fn get(org_id: &str, name: &str) -> Result, Error> { let client = get_orm_client_ro().await; match get_by_org_and_name(client, org_id, name).await? { @@ -153,9 +196,9 @@ pub async fn get_by_id(id: &str) -> Result, Error } /// Return the distinct org IDs that have at least one model pricing definition. -/// Used for startup cache warming. +/// Startup and coordinator-driven cache loads must see committed prices, not replica lag. pub async fn list_orgs() -> Result, Error> { - let client = get_orm_client_ro().await; + let client = get_orm_client_rw().await; let rows: Vec<(String,)> = Entity::find() .select_only() .column(Column::Org) @@ -167,7 +210,7 @@ pub async fn list_orgs() -> Result, Error> { } pub async fn list(org_id: &str) -> Result, Error> { - let client = get_orm_client_ro().await; + let client = get_orm_client_rw().await; let rows = Entity::find() .filter(Column::Org.eq(org_id)) .order_by(Column::Name, sea_orm::Order::Asc) @@ -217,9 +260,10 @@ pub async fn delete_by_org(org_id: &str) -> Result<(), Error> { } /// Return all model names belonging to the built-in org. -/// Used by the sync job to detect models removed from the upstream source. +/// Used for synchronization and seed initialization; read the primary so a +/// lagging replica cannot make a stored built-in list appear empty. pub async fn list_built_in_names() -> Result, Error> { - let client = get_orm_client_ro().await; + let client = get_orm_client_rw().await; let rows: Vec<(String,)> = Entity::find() .select_only() .column(Column::Name) @@ -266,6 +310,53 @@ mod tests { } } + #[tokio::test] + async fn seed_retries_missing_names_without_overwriting_synced_prices() { + let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap(); + let backend = db.get_database_backend(); + let schema = sea_orm::Schema::new(backend); + db.execute(backend.build(&schema.create_table_from_entity(Entity))) + .await + .unwrap(); + db.execute_unprepared("CREATE UNIQUE INDEX seed_model_names ON model_pricing (org, name)") + .await + .unwrap(); + let id = svix_ksuid::Ksuid::new(None, None).to_string(); + let mut stored: ModelPricingDefinition = make_model(&id).try_into().unwrap(); + stored.enabled = false; + stored.match_pattern = "^newer-synced-pattern$".to_string(); + assert_eq!( + insert_missing_built_ins_with_db(&db, vec![stored.clone()]) + .await + .unwrap(), + 1 + ); + let mut old_seed = stored.clone(); + old_seed.enabled = true; + old_seed.match_pattern = "^old-seed-pattern$".to_string(); + let mut missing = old_seed.clone(); + missing.name = "missing-model".to_string(); + assert_eq!( + insert_missing_built_ins_with_db(&db, vec![old_seed.clone(), missing.clone()]) + .await + .unwrap(), + 1 + ); + let saved = + get_by_org_and_name(&db, config::meta::model_pricing::BUILT_IN_ORG, &stored.name) + .await + .unwrap() + .unwrap(); + assert!(!saved.enabled); + assert_eq!(saved.match_pattern, "^newer-synced-pattern$"); + assert_eq!( + insert_missing_built_ins_with_db(&db, vec![old_seed, missing]) + .await + .unwrap(), + 0 + ); + } + #[test] fn test_try_from_model_valid_ksuid() { let id = svix_ksuid::Ksuid::new(None, None).to_string(); diff --git a/src/infra/src/table/score_configs.rs b/src/infra/src/table/score_configs.rs index 78b5f7546c..1e30531b70 100644 --- a/src/infra/src/table/score_configs.rs +++ b/src/infra/src/table/score_configs.rs @@ -245,27 +245,26 @@ pub async fn get_versions( Ok(records) } +/// Updating a score config deactivates its previous versions, so each entity +/// has one active row; the dedupe only guards legacy rows written before that +/// rule. Reads the primary: the web refetches this list right after a save, and a +/// lagging read-only replica would return the pre-save rows. pub async fn get_all_by_org(org_id: &str) -> Result, errors::Error> { - let client = get_orm_client_rw().await; - - // Get the latest version per logical entity. let records: Vec = Entity::find() .filter(Column::OrgId.eq(org_id)) .filter(Column::IsActive.eq(true)) .order_by(Column::Name, Order::Asc) .order_by(Column::Version, Order::Desc) - .all(client) + .all(get_orm_client_rw().await) .await?; - // Deduplicate: keep only the highest version per entity. + // Keep the highest version per logical entity. let mut seen = std::collections::HashSet::new(); - let records: Vec<_> = records + Ok(records .into_iter() .map(ScoreConfig::from) .filter(|c| seen.insert(c.entity_id.clone())) - .collect(); - - Ok(records) + .collect()) } pub async fn delete(entity_id: &str, org_id: &str) -> Result<(), errors::Error> { diff --git a/src/infra/src/table/scorers.rs b/src/infra/src/table/scorers.rs index ece9709437..52f91d4b0b 100644 --- a/src/infra/src/table/scorers.rs +++ b/src/infra/src/table/scorers.rs @@ -236,52 +236,45 @@ pub async fn get_versions(org_id: &str, entity_id: &str) -> Result, Ok(records) } +/// Updating a scorer deactivates its previous versions, so each entity has one +/// active row; the dedupe only guards legacy rows written before that rule. +/// Use the primary: provider deletion also uses this list to reject dependencies. pub async fn get_all_by_org(org_id: &str) -> Result, errors::Error> { - let client = get_orm_client_rw().await; - let records: Vec = Entity::find() .filter(Column::OrgId.eq(org_id)) .filter(Column::IsActive.eq(true)) .order_by(Column::Name, Order::Asc) .order_by(Column::Version, Order::Desc) - .all(client) + .all(get_orm_client_rw().await) .await?; - // Deduplicate: keep highest version per logical entity. - let mut seen = std::collections::HashSet::new(); - let records: Vec<_> = records - .into_iter() - .map(Scorer::from) - .filter(|s| seen.insert(s.entity_id.clone())) - .collect(); - - Ok(records) + Ok(latest_per_entity(records)) } pub async fn get_by_type( org_id: &str, scorer_type: &ScorerType, ) -> Result, errors::Error> { - let client = get_orm_client_rw().await; - let records: Vec = Entity::find() .filter(Column::OrgId.eq(org_id)) .filter(Column::ScorerType.eq(*scorer_type)) .filter(Column::IsActive.eq(true)) .order_by(Column::Name, Order::Asc) .order_by(Column::Version, Order::Desc) - .all(client) + .all(get_orm_client_ro().await) .await?; - // Deduplicate by logical entity. + Ok(latest_per_entity(records)) +} + +/// Keep the highest version per logical entity (rows arrive version-descending). +fn latest_per_entity(records: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); - let records: Vec<_> = records + records .into_iter() .map(Scorer::from) .filter(|s| seen.insert(s.entity_id.clone())) - .collect(); - - Ok(records) + .collect() } pub async fn has_active_by_score_config_id( diff --git a/src/jobs/src/job/mod.rs b/src/jobs/src/job/mod.rs index beeb3557f9..2d5ed08e8e 100644 --- a/src/jobs/src/job/mod.rs +++ b/src/jobs/src/job/mod.rs @@ -869,11 +869,15 @@ pub async fn init() -> Result<(), anyhow::Error> { o2_enterprise::enterprise::common::remote_defaults::spawn_refresher(); if config::get_config().common.model_pricing_enabled { + // Warm from committed seed rows even offline or when coordinator notification fails. + if let Err(e) = openobserve_builtins::model_pricing::load_seed().await { + log::error!("[model_pricing] loading seed prices failed: {e}"); + } db::model_pricing::cache() .await .expect("model pricing cache failed"); - // Sync built-in model pricing from GitHub (initial + periodic) + // Only queriers perform the initial and periodic GitHub sync. if LOCAL_NODE.is_querier() { tokio::task::spawn(async { if let Err(e) = diff --git a/src/super_cluster_queue/src/eval_datasets.rs b/src/super_cluster_queue/src/eval_datasets.rs index b08f7e0db9..a9eeb7fb71 100644 --- a/src/super_cluster_queue/src/eval_datasets.rs +++ b/src/super_cluster_queue/src/eval_datasets.rs @@ -11,13 +11,16 @@ use infra::{ table::entity::{llm_dataset_items as dataset_items, llm_datasets as datasets}, }; use o2_enterprise::enterprise::super_cluster::queue::{EvalDatasetMessage, Message, MessageType}; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set, + TransactionTrait, sea_query::Expr, +}; pub(crate) async fn process(msg: Message) -> Result<()> { match msg.message_type { MessageType::EvalDatasetPut => { let EvalDatasetMessage::Put { dataset, items } = msg.try_into()?; - apply_put(dataset, items).await?; + apply_put(get_orm_client_rw().await, dataset, items).await?; } MessageType::EvalDatasetDelete => { let (org_id, dataset_id) = @@ -36,7 +39,11 @@ pub(crate) async fn process(msg: Message) -> Result<()> { Ok(()) } -async fn apply_put(dataset: datasets::Model, items: Vec) -> Result<()> { +async fn apply_put( + db: &C, + dataset: datasets::Model, + items: Vec, +) -> Result<()> { // Item messages carry the parent only as a version watermark. Dataset // metadata is authoritative only in a metadata-only snapshot, preventing // an item written from stale regional state from reverting a rename. @@ -50,7 +57,6 @@ async fn apply_put(dataset: datasets::Model, items: Vec) - )); } - let db = get_orm_client_rw().await; let txn = db.begin().await?; match datasets::Entity::find_by_id(&dataset.id).one(&txn).await? { Some(current) if current.org_id != dataset.org_id => { @@ -81,11 +87,25 @@ async fn apply_put(dataset: datasets::Model, items: Vec) - .one(&txn) .await? { - if current != item { + // This region may hold the row without its display label (an older node's echo, + // or a row from before the column). Fill in that one column so MAX(write_version) + // and the snapshot pins agree with the origin region; a stored label stays. + let missing_label = item + .write_version + .filter(|_| current.write_version.is_none()); + if !compatible_item_rows(current, &item) { return Err(Error::Message( "Dataset Item row ID contains conflicting data".to_string(), )); } + if let Some(label) = missing_label { + dataset_items::Entity::update_many() + .col_expr(dataset_items::Column::WriteVersion, Expr::value(label)) + .filter(dataset_items::Column::RowId.eq(&item.row_id)) + .filter(dataset_items::Column::WriteVersion.is_null()) + .exec(&txn) + .await?; + } continue; } if dataset_items::Entity::find() @@ -107,6 +127,18 @@ async fn apply_put(dataset: datasets::Model, items: Vec) - Ok(()) } +/// Older nodes omit the display label when echoing an otherwise identical immutable row, so a +/// missing label on either side is not a conflict; two different labels still are. +fn compatible_item_rows( + mut current: dataset_items::Model, + incoming: &dataset_items::Model, +) -> bool { + if current.write_version.is_none() || incoming.write_version.is_none() { + current.write_version = incoming.write_version; + } + current == *incoming +} + async fn apply_delete(org_id: &str, dataset_id: &str) -> Result<()> { let db = get_orm_client_rw().await; let txn = db.begin().await?; @@ -123,3 +155,115 @@ async fn apply_delete(org_id: &str, dataset_id: &str) -> Result<()> { txn.commit().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectOptions, Database, DatabaseConnection, Schema}; + + use super::*; + + fn row(write_version: Option) -> dataset_items::Model { + serde_json::from_value(serde_json::json!({ + "row_id": "row-1", + "logical_id": "item-1", + "org_id": "org-1", + "dataset_id": "dataset-1", + "input": "question", + "expected_output": "answer", + "global_version": 59, + "write_version": write_version, + "is_deleted": false, + "source": "telemetry", + "updated_by": "owner@example.com", + "updated_at": 100 + })) + .unwrap() + } + + #[test] + fn an_older_node_can_echo_a_row_without_its_display_label() { + assert!(compatible_item_rows(row(Some(57)), &row(None))); + assert!(compatible_item_rows(row(None), &row(Some(57)))); + assert!(compatible_item_rows(row(Some(57)), &row(Some(57)))); + assert!(!compatible_item_rows(row(Some(57)), &row(Some(58)))); + } + + #[test] + fn missing_display_labels_do_not_hide_immutable_content_conflicts() { + let mut changed = row(None); + changed.input = serde_json::json!("different question"); + assert!(!compatible_item_rows(row(Some(57)), &changed)); + let mut changed = row(None); + changed.global_version = 60; + assert!(!compatible_item_rows(row(Some(57)), &changed)); + } + + // One connection: separate connections to `sqlite::memory:` get separate databases. + async fn db() -> DatabaseConnection { + let mut opts = ConnectOptions::new("sqlite::memory:".to_string()); + opts.max_connections(1); + let db = Database::connect(opts).await.unwrap(); + let backend = db.get_database_backend(); + let schema = Schema::new(backend); + for stmt in [ + schema.create_table_from_entity(datasets::Entity), + schema.create_table_from_entity(dataset_items::Entity), + ] { + db.execute(backend.build(&stmt)).await.unwrap(); + } + db + } + + fn dataset() -> datasets::Model { + datasets::Model { + id: "dataset-1".to_string(), + org_id: "org-1".to_string(), + name: "Golden set".to_string(), + description: None, + tags: None, + global_version: 59, + created_by: "owner@example.com".to_string(), + created_at: 1, + updated_by: "owner@example.com".to_string(), + updated_at: 1, + } + } + + async fn stored(db: &DatabaseConnection) -> dataset_items::Model { + dataset_items::Entity::find_by_id("row-1") + .one(db) + .await + .unwrap() + .unwrap() + } + + #[tokio::test] + async fn a_replayed_label_reaches_a_row_stored_without_one() { + let db = db().await; + apply_put(&db, dataset(), vec![row(None)]).await.unwrap(); + apply_put(&db, dataset(), vec![row(Some(57))]) + .await + .unwrap(); + assert_eq!(stored(&db).await, row(Some(57))); + } + + #[tokio::test] + async fn an_unlabelled_echo_never_erases_a_stored_label() { + let db = db().await; + apply_put(&db, dataset(), vec![row(Some(57))]) + .await + .unwrap(); + apply_put(&db, dataset(), vec![row(None)]).await.unwrap(); + assert_eq!(stored(&db).await.write_version, Some(57)); + } + + #[tokio::test] + async fn a_label_is_not_filled_in_beside_conflicting_content() { + let db = db().await; + apply_put(&db, dataset(), vec![row(None)]).await.unwrap(); + let mut changed = row(Some(57)); + changed.input = serde_json::json!("different question"); + assert!(apply_put(&db, dataset(), vec![changed]).await.is_err()); + assert_eq!(stored(&db).await, row(None)); + } +} diff --git a/web/src/components/common/KpiCard.vue b/web/src/components/common/KpiCard.vue index ea4304d72d..2fdfa36ed7 100644 --- a/web/src/components/common/KpiCard.vue +++ b/web/src/components/common/KpiCard.vue @@ -30,15 +30,15 @@ along with this program. If not, see . diff --git a/web/src/enterprise/views/AIObservability/AgentGraphPage.spec.ts b/web/src/enterprise/views/AIObservability/AgentGraphPage.spec.ts index f74cac5270..ff8f9d6923 100644 --- a/web/src/enterprise/views/AIObservability/AgentGraphPage.spec.ts +++ b/web/src/enterprise/views/AIObservability/AgentGraphPage.spec.ts @@ -18,6 +18,9 @@ import { reactive } from "vue"; const mockListAgents = vi.fn(); +const mockGetStreams = vi.fn(); +const mockRouteQuery: Record = {}; +const mockRouterReplace = vi.fn(() => Promise.resolve()); // Module-scoped traces store singleton (mirrors useTraces()). const mockSearchObj = reactive({ @@ -52,6 +55,19 @@ vi.mock("vuex", () => ({ })), })); +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ getStreams: (...args: any[]) => mockGetStreams(...args) }), +})); + +vi.mock("vue-router", () => ({ + useRoute: () => ({ query: mockRouteQuery }), + useRouter: () => ({ replace: mockRouterReplace }), +})); + +vi.mock("@/enterprise/components/AIObservability/AiNoAgentIdentityBanner.vue", () => ({ + default: { name: "AiNoAgentIdentityBanner", template: `
` }, +})); + vi.mock("vue-i18n", () => ({ useI18n: vi.fn(() => ({ t: (key: string) => key, @@ -168,6 +184,7 @@ vi.mock("@/components/shared/SkeletonBox.vue", () => ({ import { describe, it, expect, vi, beforeEach } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; import AgentGraphPage from "./AgentGraphPage.vue"; +import { queryClient } from "@/composables/query/queryClient"; const AGENT = { name: "sre_agent", @@ -185,6 +202,12 @@ async function mountPage() { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + // The agents query is cached per window bucket; each test brings its own list. + queryClient.clear(); + for (const k of Object.keys(mockRouteQuery)) delete mockRouteQuery[k]; + mockGetStreams.mockResolvedValue({ + list: [{ name: "llm_traces", settings: { is_llm_stream: true } }], + }); }); describe("AgentGraphPage — agent-mode stream gating", () => { @@ -200,18 +223,129 @@ describe("AgentGraphPage — agent-mode stream gating", () => { expect(sg.attributes("data-stream")).not.toBe("default"); }); - it("shows the 'no agents' empty state — NOT the default service graph — when no agents are discovered", async () => { + it("opens in Stream mode on the LLM stream with the no-agent-identity banner when no agents are discovered", async () => { mockListAgents.mockResolvedValue({ agents: [] }); const wrapper = await mountPage(); - // The regression: ServiceGraph must NOT render (it would have queried the - // fallback `default` stream and drawn the wrong graph). + // An LLM stream without agent identity is still graphed (never `default`). + const sg = wrapper.find('[data-test="service-graph-stub"]'); + expect(sg.exists()).toBe(true); + expect(sg.attributes("data-stream")).toBe("llm_traces"); + expect((wrapper.vm as any).filterMode).toBe("stream"); + expect(wrapper.findComponent({ name: "AiNoAgentIdentityBanner" }).exists()).toBe(true); + }); + + it("shows the 'no agents' empty state — NOT the default service graph — when there are no agents and no LLM stream", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + mockGetStreams.mockResolvedValue({ list: [] }); + + const wrapper = await mountPage(); + expect(wrapper.find('[data-test="service-graph-stub"]').exists()).toBe(false); - // The empty state is shown instead. expect(wrapper.find('[data-test="agent-graph-no-agents"]').exists()).toBe(true); }); + it("keeps an explicit Agent choice from the URL even when no agents are discovered", async () => { + mockRouteQuery.type = "agent"; + mockListAgents.mockResolvedValue({ agents: [] }); + + const wrapper = await mountPage(); + + expect((wrapper.vm as any).filterMode).toBe("agent"); + expect(wrapper.find('[data-test="service-graph-stub"]').exists()).toBe(false); + expect(wrapper.find('[data-test="agent-graph-no-agents"]').exists()).toBe(true); + }); + + async function pickWindow(wrapper: any, userChangedValue: boolean, startTime: number) { + wrapper.findComponent({ name: "AiPageShell" }).vm.$emit("date-change", { + valueType: "absolute", + startTime, + endTime: startTime + 1_000_000, + userChangedValue, + }); + await flushPromises(); + } + + it("decides an auto mode again when the user picks a window with no agents", async () => { + mockListAgents.mockResolvedValue({ agents: [AGENT] }); + const wrapper = await mountPage(); + expect((wrapper.vm as any).filterMode).toBe("agent"); + + // A replay (not the user's pick) of a window with no agents decides nothing. + mockListAgents.mockResolvedValue({ agents: [] }); + await pickWindow(wrapper, false, 1_600_000_000_000_000); + expect((wrapper.vm as any).filterMode).toBe("agent"); + + await pickWindow(wrapper, true, 1_500_000_000_000_000); + expect((wrapper.vm as any).filterMode).toBe("stream"); + }); + + it("keeps an explicit Agent choice when the user picks a window with no agents", async () => { + mockRouteQuery.type = "agent"; + mockListAgents.mockResolvedValue({ agents: [AGENT] }); + const wrapper = await mountPage(); + + mockListAgents.mockResolvedValue({ agents: [] }); + await pickWindow(wrapper, true, 1_500_000_000_000_000); + expect((wrapper.vm as any).filterMode).toBe("agent"); + }); + + it("opens on the agent, env and version named in the URL, not the first agent", async () => { + mockRouteQuery.agent = "omp"; + mockRouteQuery.env = "production"; + mockRouteQuery.version = "2"; + mockListAgents.mockResolvedValue({ + agents: [ + { + name: "a23ca1dd38a1ff350", + id: "a23ca1dd38a1ff350", + source_stream: "s1", + source_stream_type: "traces", + env: "staging", + version: "1", + }, + { + name: "omp", + id: "agent-omp", + source_stream: "s_staging", + source_stream_type: "traces", + env: "staging", + version: "2", + }, + { + name: "omp", + id: "agent-omp", + source_stream: "s_prod", + source_stream_type: "traces", + env: "production", + version: "1", + }, + { + name: "omp", + id: "agent-omp", + source_stream: "s_prod2", + source_stream_type: "traces", + env: "production", + version: "2", + }, + ], + }); + + const wrapper = await mountPage(); + + const vm = wrapper.vm as any; + expect(vm.filterMode).toBe("agent"); + expect([vm.selectedEnv, vm.selectedAgentName, vm.selectedVersion]).toEqual([ + "production", + "omp", + "2", + ]); + expect(wrapper.find('[data-test="service-graph-stub"]').attributes("data-stream")).toBe( + "s_prod2", + ); + }); + it("does not render ServiceGraph while agents are still loading", async () => { // Never-resolving listAgents keeps agentsLoaded=false. mockListAgents.mockReturnValue(new Promise(() => {})); diff --git a/web/src/enterprise/views/AIObservability/AgentGraphPage.vue b/web/src/enterprise/views/AIObservability/AgentGraphPage.vue index af2c7c1526..6f85640ba8 100644 --- a/web/src/enterprise/views/AIObservability/AgentGraphPage.vue +++ b/web/src/enterprise/views/AIObservability/AgentGraphPage.vue @@ -59,6 +59,7 @@ along with this program. If not, see . :streams-loaded="true" :agents-loaded="agentsLoaded" @filter-mode-change="onFilterModeChange" + @agent-pick="modeExplicit = true" > + + + +
+ +
+
. + + diff --git a/web/src/plugins/traces/LLMDataHealthStrip.spec.ts b/web/src/plugins/traces/LLMDataHealthStrip.spec.ts new file mode 100644 index 0000000000..2a4a0966e5 --- /dev/null +++ b/web/src/plugins/traces/LLMDataHealthStrip.spec.ts @@ -0,0 +1,152 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mount, type VueWrapper } from "@vue/test-utils"; +import { createStore } from "vuex"; +import type { DataHealthCheck, DataHealthModelUsage } from "@/services/gen-ai-data-health.service"; +import LLMDataHealthStrip from "./LLMDataHealthStrip.vue"; + +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: vi.fn(), currentRoute: { value: { query: {} } } }), +})); + +const ODrawerStub = { name: "ODrawer", template: "
" }; + +function mountStrip( + models: DataHealthModelUsage[], + totals: Partial = {}, + { pricingEnabled = true, extraChecks = [] as DataHealthCheck[] } = {}, +) { + const store = createStore({ + state: { + selectedOrganization: { identifier: "test-org" }, + zoConfig: { model_pricing_enabled: pricingEnabled }, + }, + }); + return mount(LLMDataHealthStrip, { + props: { + health: { + checked_at: 0, + checks: [ + { id: "unpriced_models", status: "warning", affected: 12, total: 40, models, ...totals }, + ...extraChecks, + ], + }, + stream: "default", + samples: null, + }, + global: { provide: { store }, mocks: { $store: store }, stubs: { ODrawer: ODrawerStub } }, + }); +} + +const cardText = (w: VueWrapper) => + w.find('[data-test="llm-data-health-card-unpriced_models"]').text(); + +describe("LLMDataHealthStrip unpriced models detail", () => { + let wrapper: VueWrapper; + + afterEach(() => wrapper?.unmount()); + + it("uses the singular form for one unpriced model", () => { + wrapper = mountStrip([{ model: "acme", spans: 12, tokens: 1000 }]); + expect(cardText(wrapper)).toContain("1 model ·"); + }); + + it("uses the plural form for several unpriced models", () => { + wrapper = mountStrip([ + { model: "acme", spans: 6, tokens: 500 }, + { model: "beta", spans: 6, tokens: 500 }, + ]); + expect(cardText(wrapper)).toContain("2 models ·"); + }); + + it("counts every unpriced model and token, not just the listed top 20", () => { + const listed = Array.from({ length: 20 }, (_, i) => ({ + model: `model-${i}`, + spans: 1, + tokens: 10, + })); + wrapper = mountStrip(listed, { + model_count: 25, + unpriced_tokens: 5000, + models_truncated: true, + }); + expect(wrapper.find('[data-test="llm-data-health-chip-unpriced_models"]').text()).toContain( + "· 25", + ); + expect(cardText(wrapper)).toContain("25 models ·"); + expect(cardText(wrapper)).toContain("5.0K tokens"); + expect(wrapper.find('[data-test="llm-data-health-unpriced-truncated"]').text()).toBe( + "Top 20 of 25 by tokens", + ); + }); + + it("shows no truncation note when every model is listed", () => { + wrapper = mountStrip([{ model: "acme", spans: 12, tokens: 1000 }], { + model_count: 1, + unpriced_tokens: 1000, + models_truncated: false, + }); + expect(wrapper.find('[data-test="llm-data-health-unpriced-truncated"]').exists()).toBe(false); + }); + + it("hides the unpriced check when model pricing is disabled", () => { + const models = [{ model: "acme", spans: 12, tokens: 1000 }]; + wrapper = mountStrip(models, {}, { pricingEnabled: false }); + // Nothing else is failing: no strip at all. + expect(wrapper.find('[data-test="llm-data-health-strip"]').exists()).toBe(false); + wrapper.unmount(); + + wrapper = mountStrip( + models, + {}, + { + pricingEnabled: false, + extraChecks: [{ id: "zero_duration_spans", status: "warning", affected: 3, total: 40 }], + }, + ); + expect(wrapper.find('[data-test="llm-data-health-strip"]').exists()).toBe(true); + expect(wrapper.find('[data-test="llm-data-health-chip-unpriced_models"]').exists()).toBe(false); + expect(wrapper.find('[data-test="llm-data-health-card-unpriced_models"]').exists()).toBe(false); + expect(wrapper.find('[data-test="llm-data-health-chip-zero_duration_spans"]').exists()).toBe( + true, + ); + }); +}); + +describe("LLMDataHealthStrip summary row", () => { + let wrapper: VueWrapper; + + afterEach(() => wrapper?.unmount()); + + it("gives only failing checks a chip and folds passing ones into one link", () => { + wrapper = mountStrip( + [{ model: "acme", spans: 12, tokens: 1000 }], + {}, + { + extraChecks: [ + { id: "zero_duration_spans", status: "pass", affected: 0, total: 40 }, + { id: "missing_session_id", status: "pass", affected: 0, total: 40 }, + ], + }, + ); + expect(wrapper.find('[data-test="llm-data-health-chip-unpriced_models"]').exists()).toBe(true); + expect(wrapper.find('[data-test="llm-data-health-chip-zero_duration_spans"]').exists()).toBe( + false, + ); + expect(wrapper.find('[data-test="llm-data-health-passing"]').text()).toBe("2 passing"); + }); +}); diff --git a/web/src/plugins/traces/LLMDataHealthStrip.vue b/web/src/plugins/traces/LLMDataHealthStrip.vue new file mode 100644 index 0000000000..07d27d7e47 --- /dev/null +++ b/web/src/plugins/traces/LLMDataHealthStrip.vue @@ -0,0 +1,378 @@ + + + + + + diff --git a/web/src/plugins/traces/LLMErrorTable.spec.ts b/web/src/plugins/traces/LLMErrorTable.spec.ts new file mode 100644 index 0000000000..555cde6ce7 --- /dev/null +++ b/web/src/plugins/traces/LLMErrorTable.spec.ts @@ -0,0 +1,132 @@ +// Copyright 2026 OpenObserve Inc. +// +// @vitest-environment jsdom +// +// "Recent errors" must not claim "No errors" before its query has answered: +// it shows its loading state until the first query returns, the empty message +// only after an empty result, and a load-failure message when the query fails. + +const mockExecuteQuery = vi.fn(); + +vi.mock("./composables/useLLMStreamQuery", () => ({ + useLLMStreamQuery: () => ({ executeQuery: mockExecuteQuery, cancelAll: vi.fn() }), +})); + +vi.mock("vuex", () => ({ + useStore: () => ({ state: { timezone: "UTC", selectedOrganization: { identifier: "o" } } }), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/core/Table/OTable.vue", () => ({ + default: { + name: "OTable", + props: ["data", "loading", "emptyMessage"], + template: `
+ + {{ emptyMessage }} + {{ data.length }} +
`, + }, +})); + +vi.mock("./LLMPanelCard.vue", () => ({ + default: { + name: "LLMPanelCard", + template: `
`, + methods: { + getRootEl(this: { $el: Element }) { + return this.$el; + }, + }, + }, +})); + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import LLMErrorTable from "./LLMErrorTable.vue"; +import { LLM_INSIGHTS_PANELS } from "./config/llmInsightsPanels"; +import { errorRowsCache } from "./llmInsightsCache"; + +const panel = LLM_INSIGHTS_PANELS.find((p) => p.type === "table")!; + +// The panel loads when it scrolls into view; make "in view" a test switch. +let reportVisible: ((visible: boolean) => void) | null = null; +class FakeIntersectionObserver { + constructor(cb: (entries: { isIntersecting: boolean }[]) => void) { + reportVisible = (visible) => cb([{ isIntersecting: visible }]); + } + observe() {} + disconnect() {} +} + +async function mountTable(streamName = "llm_traces") { + const wrapper = mount(LLMErrorTable, { + props: { panel, streamName, startTime: 1, endTime: 2, cacheKey: `k-${Math.random()}` }, + }); + await new Promise((r) => setTimeout(r, 0)); + await flushPromises(); + return wrapper; +} + +beforeEach(() => { + vi.clearAllMocks(); + errorRowsCache.clear(); + reportVisible = null; + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); +}); + +describe("LLMErrorTable — loading vs empty", () => { + it("shows loading, not the empty message, before the panel has queried", async () => { + const wrapper = await mountTable(); + expect(mockExecuteQuery).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="table-loading"]').exists()).toBe(true); + expect(wrapper.find('[data-test="table-empty"]').exists()).toBe(false); + }); + + it("stays loading while there is no stream to query yet", async () => { + const wrapper = await mountTable(""); + reportVisible!(true); + await flushPromises(); + expect(mockExecuteQuery).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="table-loading"]').exists()).toBe(true); + }); + + it("shows loading until the query returns, then the empty message for no rows", async () => { + const q = Promise.withResolvers(); + mockExecuteQuery.mockReturnValue(q.promise); + const wrapper = await mountTable(); + + reportVisible!(true); + await flushPromises(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-test="table-loading"]').exists()).toBe(true); + + q.resolve([]); + await flushPromises(); + expect(wrapper.find('[data-test="table-empty"]').text()).toBe( + "traces.lLMErrorTable.noErrorsInRange", + ); + }); + + it("shows the rows when the query returns errors", async () => { + mockExecuteQuery.mockResolvedValue([{ trace_id: "t1" }, { trace_id: "t2" }]); + const wrapper = await mountTable(); + reportVisible!(true); + await flushPromises(); + expect(wrapper.find('[data-test="table-rows"]').text()).toBe("2"); + }); + + it("says the errors could not load when the query fails — never 'No errors'", async () => { + mockExecuteQuery.mockRejectedValue(new Error("boom")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const wrapper = await mountTable(); + reportVisible!(true); + await flushPromises(); + expect(wrapper.find('[data-test="table-empty"]').text()).toBe( + "traces.lLMErrorTable.loadFailed", + ); + }); +}); diff --git a/web/src/plugins/traces/LLMErrorTable.vue b/web/src/plugins/traces/LLMErrorTable.vue index 8101d7ec49..3e3aa6b31a 100644 --- a/web/src/plugins/traces/LLMErrorTable.vue +++ b/web/src/plugins/traces/LLMErrorTable.vue @@ -43,7 +43,11 @@ along with this program. If not, see . show-index pagination="none" :empty-message=" - panel.emptyStateKey ? t(panel.emptyStateKey) : t('traces.lLMErrorTable.noData') + loadFailed + ? t('traces.lLMErrorTable.loadFailed') + : panel.emptyStateKey + ? t(panel.emptyStateKey) + : t('traces.lLMErrorTable.noData') " @row-click="onRowClick" data-test="llm-recent-errors-table" @@ -117,7 +121,16 @@ const { executeQuery, cancelAll } = useLLMStreamQuery(); const timezone = computed(() => store.state.timezone || "UTC"); const rows = ref([]); -const loading = ref(false); +// True until the query for the current inputs has returned. The panel loads +// lazily (on entering the viewport) and may mount before its stream is known, +// so "no rows" says nothing until then — the table shows its loading state, and +// "No errors in this time range" only after a query really came back empty. +const loading = ref(true); +// Bumped per load so a superseded response can't overwrite newer rows or end +// the newer load's loading state. +let loadSeq = 0; +// The last query failed: the empty table says so instead of "No errors". +const loadFailed = ref(false); const columns: OTableColumnDef[] = [ { @@ -168,11 +181,14 @@ async function loadErrors() { // Serve this exact selection+window from the in-memory cache if we have it — // a tab toggle back here skips the query entirely. Keyed on the dashboard's // canonical selection id (`cacheKey`); skip caching only if it wasn't passed. + const seq = ++loadSeq; const key = props.cacheKey; if (key) { const cached = errorRowsCache.get(key); if (cached) { rows.value = cached; + loadFailed.value = false; + loading.value = false; return; } } @@ -188,16 +204,21 @@ async function loadErrors() { }); try { const hits = await executeQuery(sql, props.startTime, props.endTime); + if (seq !== loadSeq) return; // OTable needs a stable row-key; error spans can repeat a trace_id, so key // by position instead. rows.value = (hits as any[]).map((h, i) => ({ ...h, id: i })); + loadFailed.value = false; // Snapshot for this selection+window so a return visit restores instantly. if (key) errorRowsCache.set(key, rows.value); } catch (e: any) { + if (seq !== loadSeq) return; + // A failed query is not "no errors": say it could not load. rows.value = []; + loadFailed.value = true; console.error(`[LLM panel ${props.panel.id}] fetch error`, e); } finally { - loading.value = false; + if (seq === loadSeq) loading.value = false; } } @@ -215,6 +236,9 @@ watch( () => [props.streamName, props.startTime, props.endTime, props.agentFilter], () => { needsReload.value = true; + // The rows on screen belong to the old inputs: back to loading until the + // new query (run now, or when the panel scrolls into view) returns. + loading.value = true; if (isVisible.value) loadErrors(); }, ); diff --git a/web/src/plugins/traces/LLMInsightsDashboard.spec.ts b/web/src/plugins/traces/LLMInsightsDashboard.spec.ts index b77ee0e537..8809854ce9 100644 --- a/web/src/plugins/traces/LLMInsightsDashboard.spec.ts +++ b/web/src/plugins/traces/LLMInsightsDashboard.spec.ts @@ -36,8 +36,14 @@ const mockGetStreams = vi.fn().mockResolvedValue({ ], }); const mockRouterPush = vi.fn(); +const mockRouterReplace = vi.fn(() => Promise.resolve()); +const mockListAgents = vi.fn(); +// Mutable per test; the default (set in beforeEach) is `?type=stream`. +const mockRouteQuery: Record = {}; import { reactive, ref } from "vue"; +import type { DataHealthResponse } from "@/services/gen-ai-data-health.service"; +import type { GenAiAgentListItem } from "@/services/gen-ai-agent-mapping.service"; const mockStoreState = reactive({ selectedOrganization: { identifier: "test-org" } }); @@ -64,6 +70,7 @@ const mockError = ref(null); const mockHasLoadedOnce = ref(false); const mockAvailableStreams = ref([]); const mockStreamsLoaded = ref(false); +const mockHealth = ref(null); vi.mock("./composables/useLLMInsights", () => ({ useLLMInsights: () => ({ @@ -80,6 +87,19 @@ vi.mock("./composables/useLLMInsights", () => ({ }), })); +vi.mock("./composables/useLLMDataHealth", () => ({ + useLLMDataHealth: () => ({ + health: mockHealth, + loading: ref(false), + error: ref(false), + samples: ref(null), + samplesLoading: ref(false), + fetchHealth: vi.fn(), + fetchSamples: vi.fn(), + reset: vi.fn(), + }), +})); + vi.mock("@/composables/useStreams", () => ({ default: () => ({ getStreams: mockGetStreams, @@ -93,11 +113,17 @@ vi.mock("vue-i18n", () => ({ })); vi.mock("vue-router", () => ({ - useRouter: () => ({ push: mockRouterPush, replace: vi.fn(() => Promise.resolve()) }), - // This suite exercises the stream-mode paths. The dashboard now defaults to - // Agent scope and only `?type=stream` (NOT a saved preference) opts into - // Stream mode, so drive stream mode through the URL the way the component reads it. - useRoute: () => ({ query: { type: "stream" } }), + useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace }), + // Most of this suite exercises the stream-mode paths, driven through the URL + // (`?type=stream`) the way the component reads it; scope tests override it. + useRoute: () => ({ query: mockRouteQuery }), +})); + +vi.mock("@/services/gen-ai-agent-mapping.service", () => ({ + default: { + listAgents: (...args: any[]) => mockListAgents(...args), + listVersionsForCompare: vi.fn().mockResolvedValue([]), + }, })); // Partial-mock vuex so `createStore` (used by `src/stores/index.ts`) @@ -113,16 +139,25 @@ vi.mock("vuex", async (importOriginal) => { // --------------------------------------------------------------------------- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mount, flushPromises } from "@vue/test-utils"; +import { mount, flushPromises, enableAutoUnmount } from "@vue/test-utils"; import LLMInsightsDashboard from "./LLMInsightsDashboard.vue"; import config from "@/aws-exports"; // The KPI cache is a module singleton (survives remounts in the app); clear it // between tests so a warmed entry from one mount doesn't suppress the fetch in // the next. import { kpiCache } from "./llmInsightsCache"; +import { queryClient } from "@/composables/query/queryClient"; +import { SCOPE_MODE_CHOICE_LS_KEY } from "@/enterprise/composables/useAgentScope"; const STREAM_LS_KEY = "llmInsights_streamFilter"; +// The stream each panel mount was given, in mount order — a remount (which +// cancels the panel's in-flight query) shows up as a second entry. +let panelMounts: string[] = []; + +// Earlier tests' dashboards would otherwise stay mounted and react to the shared mock refs. +enableAutoUnmount(afterEach); + /** Mount the dashboard with default props that satisfy the loadInsights guard. */ function mountDashboard( propsOverrides: Partial<{ streamName: string; startTime: number; endTime: number }> = {}, @@ -139,8 +174,15 @@ function mountDashboard( // Children — all stubbed so we don't try to render echarts / // sparkline math during dashboard-level tests. LLMSchemaPanel: { template: '
' }, - LLMErrorTable: { template: '
' }, + LLMErrorTable: { + props: ["streamName"], + template: '
', + mounted(this: { streamName: string }) { + panelMounts.push(this.streamName); + }, + }, KpiSparkline: { template: '
' }, + LLMDataHealthStrip: { template: '
' }, LLMInsightsSkeleton: { template: '
' }, OButton: { template: "", @@ -155,6 +197,11 @@ beforeEach(() => { // Fresh mock state for every test. vi.clearAllMocks(); kpiCache.clear(); + queryClient.clear(); + panelMounts = []; + for (const k of Object.keys(mockRouteQuery)) delete mockRouteQuery[k]; + mockRouteQuery.type = "stream"; + mockListAgents.mockResolvedValue({ agents: [] }); mockStoreState.selectedOrganization = { identifier: "test-org" }; mockKpi.value = { requestCount: 0, @@ -177,6 +224,7 @@ beforeEach(() => { mockHasLoadedOnce.value = false; mockAvailableStreams.value = []; mockStreamsLoaded.value = false; + mockHealth.value = null; // Reset localStorage between tests so the dashboard's stream // initialisation doesn't bleed across cases. localStorage.clear(); @@ -533,6 +581,7 @@ describe("LLMInsightsDashboard — OSS builds (neither isEnterprise nor isCloud it("keeps the Stream/Agent toggle (Agent locked) and hides the Compare entry — both need the enterprise-only agent-mapping API", async () => { config.isEnterprise = "false"; const wrapper = mountDashboard(); + await (wrapper.vm as any).refresh(); await flushPromises(); expect(wrapper.find("[data-test='llm-insights-filter-mode']").exists()).toBe(true); expect(wrapper.find("[data-test='llm-insights-compare-entry']").exists()).toBe(false); @@ -542,7 +591,326 @@ describe("LLMInsightsDashboard — OSS builds (neither isEnterprise nor isCloud config.isEnterprise = "false"; config.isCloud = "true"; const wrapper = mountDashboard(); + await (wrapper.vm as any).refresh(); await flushPromises(); expect(wrapper.find("[data-test='llm-insights-filter-mode']").exists()).toBe(true); }); }); + +// =========================================================================== +// Scope: mode resolution (AIO-02/03), URL scope (AIO-08), one agents fetch (AIO-18) +// =========================================================================== + +const agentAt = (name: string, stream: string, env: string, version: string) => ({ + name, + id: `id-${name}`, + source_stream: stream, + source_stream_type: "traces", + env, + version, +}); + +/** Mount and run the parent's mount-time load (DateTime's window replay: no force). */ +async function mountAndEnter() { + const wrapper = mountDashboard(); + await (wrapper.vm as any).refresh(undefined, undefined, false); + await flushPromises(); + return wrapper; +} + +// Agent mode needs the enterprise-only agent-mapping API, so these run as an +// enterprise build regardless of the local `.env` (CI has none, so it is OSS). +describe("LLMInsightsDashboard — scope resolution", () => { + const originalIsEnterprise = config.isEnterprise; + + beforeEach(() => { + config.isEnterprise = "true"; + delete mockRouteQuery.type; + }); + + afterEach(() => { + config.isEnterprise = originalIsEnterprise; + }); + + it("opens in Stream mode with the 'No agent identity found' banner when the window has no agents but an LLM stream exists", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const wrapper = await mountAndEnter(); + + expect((wrapper.vm as any).filterMode).toBe("stream"); + expect(mockFetchAll).toHaveBeenCalledWith( + "default", + 1_700_000_000_000_000, + 1_700_001_000_000_000, + null, + ); + expect(wrapper.find("[data-test='llm-insights-no-agent-identity']").exists()).toBe(true); + // The auto-picked mode stays out of the URL, so a reload re-decides. + const lastQuery = (mockRouterReplace.mock.calls.at(-1) as any[])[0].query; + expect(lastQuery.type).toBeUndefined(); + expect(lastQuery.stream).toBe("default"); + }); + + it("opens in Agent mode when the window has agents", async () => { + mockListAgents.mockResolvedValue({ agents: [agentAt("omp", "s1", "production", "1")] }); + + const wrapper = await mountAndEnter(); + + expect((wrapper.vm as any).filterMode).toBe("agent"); + // The auto-picked agent stays out of the URL too, so a reload with no agents ends in Stream. + const lastQuery = (mockRouterReplace.mock.calls.at(-1) as any[])[0].query; + expect([lastQuery.type, lastQuery.agent, lastQuery.env, lastQuery.version]).toEqual([ + undefined, + undefined, + undefined, + undefined, + ]); + expect(wrapper.find("[data-test='llm-insights-no-agent-identity']").exists()).toBe(false); + }); + + it("mounts the panels once, with the final scope — never the first agent then the linked one", async () => { + Object.assign(mockRouteQuery, { agent: "omp", env: "production", version: "2" }); + mockListAgents.mockResolvedValue({ + agents: [ + agentAt("a23ca1dd38a1ff350", "s_hash", "staging", "1"), + agentAt("omp", "s_staging", "staging", "2"), + agentAt("omp", "s_prod", "production", "2"), + ], + }); + + const wrapper = await mountAndEnter(); + + const vm = wrapper.vm as any; + expect([vm.filterMode, vm.selectedEnv, vm.selectedAgentName, vm.selectedVersion]).toEqual([ + "agent", + "production", + "omp", + "2", + ]); + expect(panelMounts).toEqual(["s_prod"]); + expect(mockFetchAll).toHaveBeenCalledTimes(1); + }); + + it("keeps a remembered explicit Agent choice even when the window has no agents", async () => { + localStorage.setItem(SCOPE_MODE_CHOICE_LS_KEY, "agent"); + mockListAgents.mockResolvedValue({ agents: [] }); + + const wrapper = await mountAndEnter(); + + expect((wrapper.vm as any).filterMode).toBe("agent"); + expect(wrapper.find("[data-test='llm-insights-agent-empty']").exists()).toBe(true); + }); + + it("keeps the URL's explicit Stream choice even when the window has agents", async () => { + mockRouteQuery.type = "stream"; + mockListAgents.mockResolvedValue({ agents: [agentAt("omp", "s1", "production", "1")] }); + + const wrapper = await mountAndEnter(); + + expect((wrapper.vm as any).filterMode).toBe("stream"); + }); + + it.each([{ env: "production" }, { version: "2" }])( + "keeps an env/version-only URL scope over the remembered Stream choice: %o", + async (query) => { + localStorage.setItem(SCOPE_MODE_CHOICE_LS_KEY, "stream"); + Object.assign(mockRouteQuery, query); + mockListAgents.mockResolvedValue({ + agents: [ + agentAt("first", "s_staging", "staging", "1"), + agentAt("linked", "s_prod", "production", "2"), + ], + }); + const wrapper = await mountAndEnter(); + // Vue Test Utils omits script-setup state from its public-instance type. + const vm = wrapper.vm as unknown as { + filterMode: string; + selectedEnv: string; + selectedVersion: string; + }; + expect([vm.filterMode, vm.selectedEnv, vm.selectedVersion]).toEqual([ + "agent", + "production", + "2", + ]); + expect(panelMounts).toEqual(["s_prod"]); + expect(mockRouterReplace).toHaveBeenLastCalledWith({ + query: expect.objectContaining(query), + }); + }, + ); + + it("shows explicit Stream panels while the agents registry is still pending", async () => { + mockRouteQuery.type = "stream"; + const agentsResult = Promise.withResolvers<{ agents: GenAiAgentListItem[] }>(); + mockListAgents.mockReturnValue(agentsResult.promise); + const wrapper = mountDashboard(); + // Vue Test Utils omits the script-setup method from its public-instance type. + const vm = wrapper.vm as unknown as { + refresh: (start?: number, end?: number, force?: boolean) => Promise; + }; + const pending = vm.refresh(undefined, undefined, false); + await flushPromises(); + expect(panelMounts).toEqual(["default"]); + agentsResult.resolve({ agents: [agentAt("omp", "s1", "production", "1")] }); + await pending; + await flushPromises(); + expect(panelMounts).toEqual(["default"]); + }); + + it("remembers a toggle click as the explicit choice and writes it to the URL", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + const wrapper = await mountAndEnter(); + + (wrapper.vm as any).onFilterModeChange("agent"); + await flushPromises(); + + expect(localStorage.getItem(SCOPE_MODE_CHOICE_LS_KEY)).toBe("agent"); + expect((mockRouterReplace.mock.calls.at(-1) as any[])[0].query.type).toBe("agent"); + }); + + it("fetches gen_ai/agents once per view across mount replays and mode toggles", async () => { + mockListAgents.mockResolvedValue({ agents: [agentAt("omp", "s1", "production", "1")] }); + const wrapper = mountDashboard(); + const vm = wrapper.vm as any; + // DateTime may replay its window more than once on mount, concurrently. + await Promise.all([ + vm.refresh(undefined, undefined, false), + vm.refresh(undefined, undefined, false), + ]); + await flushPromises(); + vm.onFilterModeChange("stream"); + await flushPromises(); + vm.onFilterModeChange("agent"); + await flushPromises(); + + expect(mockListAgents).toHaveBeenCalledTimes(1); + }); + + it("decides an auto mode again when the user picks a window with no agents", async () => { + mockListAgents.mockResolvedValue({ agents: [agentAt("omp", "s1", "production", "1")] }); + const wrapper = await mountAndEnter(); + const vm = wrapper.vm as any; + expect(vm.filterMode).toBe("agent"); + + // A mount-time replay of another window decides nothing. + mockListAgents.mockResolvedValue({ agents: [] }); + await vm.refresh(1_600_000_000_000_000, 1_600_001_000_000_000, false); + await flushPromises(); + expect(vm.filterMode).toBe("agent"); + + // The user's own pick does: the new window has no agents, so Stream. + await vm.refresh(1_500_000_000_000_000, 1_500_001_000_000_000, true, true); + await flushPromises(); + expect(vm.filterMode).toBe("stream"); + expect(mockFetchAll.mock.calls.at(-1)).toEqual([ + "default", + 1_500_000_000_000_000, + 1_500_001_000_000_000, + null, + ]); + }); + + it("keeps an explicit Agent choice when the user picks a window with no agents", async () => { + mockRouteQuery.type = "agent"; + mockListAgents.mockResolvedValue({ agents: [agentAt("omp", "s1", "production", "1")] }); + const wrapper = await mountAndEnter(); + const vm = wrapper.vm as any; + + mockListAgents.mockResolvedValue({ agents: [] }); + await vm.refresh(1_500_000_000_000_000, 1_500_001_000_000_000, true, true); + await flushPromises(); + expect(vm.filterMode).toBe("agent"); + }); +}); + +describe("LLMInsightsDashboard — Total Cost tile", () => { + async function costTileText(cost: number, tokens: number, unpricedTokens: number) { + // A window with requests, so the tiles render rather than the empty state. + mockKpi.value = { + ...mockKpi.value, + requestCount: 10, + traceCount: 5, + totalCost: cost, + totalTokens: tokens, + }; + mockHealth.value = + unpricedTokens > 0 + ? { + checked_at: 1, + checks: [ + { + id: "unpriced_models", + status: "warning", + affected: 3, + total: 10, + models: [{ model: "mystery-model", spans: 3, tokens: unpricedTokens }], + }, + ], + } + : null; + mockHasLoadedOnce.value = true; + const wrapper = mountDashboard(); + await (wrapper.vm as any).refresh(undefined, undefined, false); + await flushPromises(); + return wrapper.find("[data-test='llm-insights-kpi-value']").text(); + } + + it("reads 'cost unknown' when none of the tokens were priced", async () => { + const text = await costTileText(0, 1000, 1000); + expect(text).toContain("llmDataTrust.cost.unknown"); + expect(text).not.toContain("≥"); + expect(text).not.toContain("0.00"); + }); + + it("reads as a lower bound when some tokens were priced", async () => { + const text = await costTileText(1.5, 1000, 400); + expect(text).toContain("≥"); + expect(text).toContain("1.50"); + }); + + it("reads plain when every token was priced", async () => { + const text = await costTileText(1.5, 1000, 0); + expect(text).not.toContain("≥"); + expect(text).toContain("1.50"); + }); + + it("reads the server's uncapped unpriced tokens, not the sum of the listed models", async () => { + mockKpi.value = { + ...mockKpi.value, + requestCount: 10, + traceCount: 5, + totalCost: 1.5, + totalTokens: 1000, + }; + // The listed top models hold 400 tokens; models past the list cap hold the other 600. + mockHealth.value = { + checked_at: 1, + checks: [ + { + id: "unpriced_models", + status: "warning", + affected: 30, + total: 40, + models: [{ model: "mystery-model", spans: 3, tokens: 400 }], + model_count: 25, + unpriced_tokens: 1000, + models_truncated: true, + }, + ], + }; + mockHasLoadedOnce.value = true; + const wrapper = mountDashboard(); + // Exposed by the component for tests; the cast names only the method used here. + const vm = wrapper.vm as unknown as { refresh: (...args: unknown[]) => Promise }; + await vm.refresh(undefined, undefined, false); + await flushPromises(); + expect(wrapper.find("[data-test='llm-insights-kpi-value']").text()).toContain( + "llmDataTrust.cost.unknown", + ); + // The tooltip names the listed models as the top ones of all 25. + const tooltip = wrapper.find("[data-test='llm-insights-cost-unpriced']").attributes("title"); + expect(tooltip).toContain("llmDataTrust.topModels"); + expect(tooltip).toContain('\\"shown\\":1,\\"total\\":\\"25\\"'); + }); +}); diff --git a/web/src/plugins/traces/LLMInsightsDashboard.vue b/web/src/plugins/traces/LLMInsightsDashboard.vue index 13fda7af79..72cdd614ad 100644 --- a/web/src/plugins/traces/LLMInsightsDashboard.vue +++ b/web/src/plugins/traces/LLMInsightsDashboard.vue @@ -31,7 +31,7 @@ along with this program. If not, see . holds a picker-shaped skeleton until the agents list first lands; solidAgentTrigger keeps the "All Agents" empty state in solid text. --> . :streams-loaded="true" :agents-loaded="agentsLoaded" @filter-mode-change="onFilterModeChange" + @agent-pick="modeExplicit = true" @stream-change="onStreamChange" > + + + . empty states / dashboard content stays intact without adding a DOM wrapper node. -->