diff --git a/apps/web/src/components/attributes/commit-sha-attribute.tsx b/apps/web/src/components/attributes/commit-sha-attribute.tsx
index 5d13291e7..1fa50dd03 100644
--- a/apps/web/src/components/attributes/commit-sha-attribute.tsx
+++ b/apps/web/src/components/attributes/commit-sha-attribute.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"
import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card"
+import { formatGenAiCost, formatTokens } from "@maple/ui/lib/gen-ai"
// Resource/span attribute keys whose value is a git commit SHA. Wrapping these
// in the hover card mirrors the trace header (which enriches
@@ -10,20 +11,55 @@ import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card"
// (still copyable), so listing a key here is always safe.
const COMMIT_SHA_KEYS = new Set(["deployment.commit_sha", "vcs.ref.head.revision"])
+// GenAI (`gen_ai.*`) usage keys carry numbers stringified by the ingest encoder
+// ("42"), so the raw attribute table shows bare digits. Format them inline:
+// model ids as a chip, token counts with thousands separators, cost as currency.
+const GEN_AI_MODEL_KEYS = new Set(["gen_ai.request.model", "gen_ai.response.model"])
+const GEN_AI_TOKEN_KEYS = new Set([
+ "gen_ai.usage.input_tokens",
+ "gen_ai.usage.output_tokens",
+ "gen_ai.usage.total_tokens",
+ "gen_ai.usage.prompt_tokens",
+ "gen_ai.usage.completion_tokens",
+])
+
/**
- * `AttributesConfig.renderValue` implementation. Returns a hover-card-wrapped
- * value for known commit-SHA keys, or `null` to fall back to the default
- * copyable text.
+ * `AttributesConfig.renderValue` implementation. Returns a specialized node for
+ * known attribute keys (commit SHAs, GenAI model/token/cost), or `null` to fall
+ * back to the default copyable text.
*/
export function renderAttributeValue(attrKey: string, value: string): ReactNode | null {
- if (!COMMIT_SHA_KEYS.has(attrKey)) return null
- return (
-
- {value}
-
- )
+ if (COMMIT_SHA_KEYS.has(attrKey)) {
+ return (
+
+ {value}
+
+ )
+ }
+
+ if (GEN_AI_MODEL_KEYS.has(attrKey)) {
+ return (
+
+ {value}
+
+ )
+ }
+
+ if (GEN_AI_TOKEN_KEYS.has(attrKey)) {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return null
+ return {formatTokens(n)}
+ }
+
+ if (attrKey === "gen_ai.usage.cost") {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return null
+ return {formatGenAiCost(n)}
+ }
+
+ return null
}
diff --git a/docs/openrouter-tracing.md b/docs/openrouter-tracing.md
index 580dcc736..d6f275fa7 100644
--- a/docs/openrouter-tracing.md
+++ b/docs/openrouter-tracing.md
@@ -67,6 +67,8 @@ OpenRouter only emits Broadcast traces for traffic under the OpenRouter account
OpenRouter Broadcast traces use standard GenAI semantic convention attributes such as `gen_ai.*` for model, usage, and cost data. Maple also receives the custom metadata above under OpenRouter's `trace.metadata.*` namespace.
+Maple renders these as first-class LLM calls: any span carrying `gen_ai.*` attributes is badged with a sparkle/provider icon in the waterfall, timeline, and flow views, and the span-detail panel shows a summary block with the provider, operation, model, input/output tokens, cost, request params, and finish reason. In the attribute table, `gen_ai.request.model` / `gen_ai.response.model` render as model chips, token counts are grouped, and `gen_ai.usage.cost` renders as currency. Because OTel renamed `gen_ai.system` → `gen_ai.provider.name`, a provider filter matches spans using either spelling.
+
Useful filters:
```text
@@ -74,6 +76,8 @@ trace.metadata.orgId = ""
trace.metadata.operation = "chat.turn"
trace.metadata.mode = "dashboard_builder"
session.id = ":"
+gen_ai.provider.name = "openai" # also matches the legacy gen_ai.system spelling
+gen_ai.request.model = "openai/gpt-4o"
```
If prompt or completion content should not leave OpenRouter, enable Privacy Mode for the OpenRouter observability destination. OpenRouter's docs state that Privacy Mode excludes prompt and completion content while still sending timing, model, token usage, cost, and metadata.
diff --git a/docs/otel-coverage-roadmap.md b/docs/otel-coverage-roadmap.md
index 6ea624022..818f578cb 100644
--- a/docs/otel-coverage-roadmap.md
+++ b/docs/otel-coverage-roadmap.md
@@ -59,8 +59,15 @@ data exists:
Histogram datasource has bounds + bucket counts; registry has a generic histogram/heatmap but nothing wired to OTel histogram buckets.
- [ ] **12. trace_state / sampling filter** — _S, trace filtering_
`TraceState` + `SampleRate` stored; useful for "show only head-sampled" debugging. Lower demand.
-- [ ] **13. GenAI / LLM semantic conventions** (`gen_ai.*`) — _M, filtering + visuals_
- Token usage, model, system land in the generic attrs map — no dedicated facets or token/cost visuals. Emerging, high-interest namespace.
+- [~] **13. GenAI / LLM semantic conventions** (`gen_ai.*`) — _M, filtering + visuals_
+ **Trace-level enrichment shipped:** LLM spans (OpenRouter Broadcast et al.) are detected via
+ `getGenAiInfo` (`packages/ui/src/lib/gen-ai.ts`) + a `genAiAdapter`
+ (`packages/ui/src/lib/cloud-platforms/gen-ai.ts`), so the waterfall/flame/flow views badge
+ them and the span-detail panel shows a model/provider/token/cost summary; `gen_ai.*` keys are
+ projected into the trace views (`TREE_SPAN_ATTR_KEYS`), `gen_ai.system`↔`gen_ai.provider.name`
+ coalesce in attribute filters (`traces-shared.ts`), and model/token/cost render formatted in
+ the attribute table. **Deferred:** dedicated token/cost aggregation + AI page (needs the
+ `gen_ai_usage_hourly` rollup and the numeric-attribute aggregation wiring — see below).
## Tier 3 — Needs ingest-encoder changes (`apps/ingest/src/telemetry.rs`)
diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts
index cf6174b30..d59205d92 100644
--- a/packages/query-engine/src/ch/ch.test.ts
+++ b/packages/query-engine/src/ch/ch.test.ts
@@ -234,6 +234,23 @@ describe("tracesTimeseriesQuery", () => {
expect(sql).toContain("0 AS p50Duration")
})
+ it("coalesces gen_ai.provider.name and legacy gen_ai.system in an attribute filter", () => {
+ // An org filtering LLM spans by provider must match whichever spelling the
+ // span carries — OTel renamed gen_ai.system → gen_ai.provider.name.
+ const q = tracesTimeseriesQuery({
+ metric: "count",
+ needsSampling: false,
+ attributeFilters: [{ key: "gen_ai.provider.name", value: "openai", mode: "equals" }],
+ })
+ const { sql } = compileCH(q, baseParams)
+ // A span-level attribute filter reads the raw traces table (not the MV)...
+ expect(sql).toContain("FROM traces")
+ // ...and coalesces both spellings to the same value.
+ expect(sql).toContain("SpanAttributes['gen_ai.provider.name']")
+ expect(sql).toContain("SpanAttributes['gen_ai.system']")
+ expect(sql).toContain("= 'openai'")
+ })
+
it("builds apdex timeseries with threshold", () => {
const q = tracesTimeseriesQuery({ metric: "apdex", needsSampling: false, apdexThresholdMs: 250 })
const { sql } = compileCH(q, baseParams)
@@ -1194,6 +1211,10 @@ describe("converted queries", () => {
expect(sql).not.toContain("toJSONString(ResourceAttributes)")
expect(sql).toContain("'http.route', SpanAttributes['http.route']")
expect(sql).toContain("'cache.result', SpanAttributes['cache.result']")
+ // GenAI/LLM keys are projected so the trace views can badge LLM spans.
+ expect(sql).toContain("'gen_ai.operation.name', SpanAttributes['gen_ai.operation.name']")
+ expect(sql).toContain("'gen_ai.request.model', SpanAttributes['gen_ai.request.model']")
+ expect(sql).toContain("'gen_ai.usage.cost', SpanAttributes['gen_ai.usage.cost']")
expect(sql).toContain("'deployment.environment', ResourceAttributes['deployment.environment']")
expect(sql).toContain("AS spanAttributes")
expect(sql).toContain("AS resourceAttributes")
diff --git a/packages/query-engine/src/ch/queries/errors.ts b/packages/query-engine/src/ch/queries/errors.ts
index 9d6ef93b4..0fc764131 100644
--- a/packages/query-engine/src/ch/queries/errors.ts
+++ b/packages/query-engine/src/ch/queries/errors.ts
@@ -166,6 +166,21 @@ const TREE_SPAN_ATTR_KEYS = [
"cloudflare.colo",
"faas.invoked_region",
"cloudflare.outcome",
+ // GenAI / LLM client spans (OpenRouter Broadcast + OTel `gen_ai.*` semconv) —
+ // the `gen_ai.operation.name` value (with provider + model + token/cost usage)
+ // lets the trace views detect an LLM call and render its badge without waiting
+ // for the per-span lazy detail fetch. The full set (finish reasons, request
+ // params, prompt/completion) is loaded lazily by `spanDetailQuery` for the
+ // detail panel. Both the new `gen_ai.provider.name` and the legacy
+ // `gen_ai.system` spellings are projected so the detector coalesces either.
+ "gen_ai.operation.name",
+ "gen_ai.provider.name",
+ "gen_ai.system",
+ "gen_ai.request.model",
+ "gen_ai.response.model",
+ "gen_ai.usage.input_tokens",
+ "gen_ai.usage.output_tokens",
+ "gen_ai.usage.cost",
] as const
/**
@@ -568,7 +583,8 @@ export function tracesFacetsQuery(opts: TracesFacetsOpts): CHUnionQuery makeFacetQuery("SpanName", "spanName", ($) => $.SpanName.neq(""), 20),
httpMethod: () => makeFacetQuery("HttpMethod", "httpMethod", ($) => $.HttpMethod.neq(""), 20),
httpStatus: () => makeFacetQuery("HttpStatusCode", "httpStatus", ($) => $.HttpStatusCode.neq(""), 20),
- deploymentEnv: () => makeFacetQuery("DeploymentEnv", "deploymentEnv", ($) => $.DeploymentEnv.neq(""), 20),
+ deploymentEnv: () =>
+ makeFacetQuery("DeploymentEnv", "deploymentEnv", ($) => $.DeploymentEnv.neq(""), 20),
serviceNamespace: () =>
makeFacetQuery("ServiceNamespace", "serviceNamespace", ($) => $.ServiceNamespace.neq(""), 20),
}
diff --git a/packages/query-engine/src/traces-shared.ts b/packages/query-engine/src/traces-shared.ts
index 8e721e1eb..30469e0c5 100644
--- a/packages/query-engine/src/traces-shared.ts
+++ b/packages/query-engine/src/traces-shared.ts
@@ -45,23 +45,29 @@ export const TRACE_LIST_MV_RESOURCE_MAP: Record = {
import * as CH from "@maple-dev/clickhouse-builder/expr"
// ---------------------------------------------------------------------------
-// HTTP semconv coalescing
+// Semconv coalescing
//
-// OpenTelemetry renamed several HTTP span attributes in the stable semconv:
-// http.method → http.request.method
-// http.status_code → http.response.status_code
-// `trace_list_mv` coalesces both spellings when it pre-extracts its columns
-// (see materializations.ts), so the quick-filter facet counts cover spans that
-// use *either* key. Filters that read the raw `traces` table must coalesce the
-// same way — otherwise a facet shows a count while applying it matches zero
-// rows (the data carries the new key, the filter looked up the old one).
+// OpenTelemetry renamed several span attributes across semconv versions:
+// http.method → http.request.method
+// http.status_code → http.response.status_code
+// gen_ai.system → gen_ai.provider.name (GenAI/LLM spans)
+// Emitters in the wild still send either spelling. `trace_list_mv` coalesces the
+// HTTP pair when it pre-extracts its columns (see materializations.ts), so the
+// quick-filter facet counts cover spans that use *either* key. Filters that read
+// the raw `traces` table must coalesce the same way — otherwise a facet shows a
+// count while applying it matches zero rows (the data carries the new key, the
+// filter looked up the old one). The GenAI pair has no MV column yet; coalescing
+// it here still makes provider filters match regardless of which name a span uses.
// ---------------------------------------------------------------------------
-const HTTP_SEMCONV_ALIASES: Record = {
+const SEMCONV_ALIASES: Record = {
"http.method": ["http.method", "http.request.method"],
"http.request.method": ["http.method", "http.request.method"],
"http.status_code": ["http.status_code", "http.response.status_code"],
"http.response.status_code": ["http.status_code", "http.response.status_code"],
+ // Prefer the new `gen_ai.provider.name` spelling first (first non-empty wins).
+ "gen_ai.provider.name": ["gen_ai.provider.name", "gen_ai.system"],
+ "gen_ai.system": ["gen_ai.provider.name", "gen_ai.system"],
}
/** `if(map[k0] != '', map[k0], if(map[k1] != '', …))` — first non-empty alias. */
@@ -115,7 +121,7 @@ export function buildAttrFilterCondition(
const mapExpr = CH.dynamicColumn>(mapName)
// Span attributes renamed across OTel semconv versions match either spelling,
// mirroring trace_list_mv. Resource attributes have no such aliases.
- const keys = mapName === "SpanAttributes" ? (HTTP_SEMCONV_ALIASES[af.key] ?? [af.key]) : [af.key]
+ const keys = mapName === "SpanAttributes" ? (SEMCONV_ALIASES[af.key] ?? [af.key]) : [af.key]
const colExpr: CH.Expr = coalescedMapGet(mapExpr, keys)
const value = af.value ?? ""
diff --git a/packages/ui/src/components/icons/index.ts b/packages/ui/src/components/icons/index.ts
index 3db3cadb9..9e8810856 100644
--- a/packages/ui/src/components/icons/index.ts
+++ b/packages/ui/src/components/icons/index.ts
@@ -38,6 +38,7 @@ export { NetworkNodesIcon } from "./network-nodes"
export { PulseIcon } from "./pulse"
export { RadioCheckedIcon } from "./radio-checked"
export { SidebarLeftIcon } from "./sidebar-left"
+export { SparklesIcon } from "./sparkles"
export { TextWrapIcon } from "./text-wrap"
export { ThumbtackIcon } from "./thumbtack"
export { XmarkIcon } from "./xmark"
diff --git a/packages/ui/src/components/icons/sparkles.tsx b/packages/ui/src/components/icons/sparkles.tsx
new file mode 100644
index 000000000..e174641a1
--- /dev/null
+++ b/packages/ui/src/components/icons/sparkles.tsx
@@ -0,0 +1,35 @@
+import type { IconProps } from "./icon"
+
+// A two-star "sparkles" glyph — the conventional mark for AI / LLM features.
+// Used to badge GenAI (`gen_ai.*`) client spans across the trace views.
+const paths: ReadonlyArray = [
+ "M9.5 6.5 L11 11.5 L16 13 L11 14.5 L9.5 19.5 L8 14.5 L3 13 L8 11.5 Z",
+ "M17.5 3 L18.3 5.2 L20.5 6 L18.3 6.8 L17.5 9 L16.7 6.8 L14.5 6 L16.7 5.2 Z",
+]
+
+function SparklesIcon({ size = 24, className, ...props }: IconProps) {
+ return (
+
+ )
+}
+export { SparklesIcon }
diff --git a/packages/ui/src/lib/__tests__/gen-ai.test.ts b/packages/ui/src/lib/__tests__/gen-ai.test.ts
new file mode 100644
index 000000000..7b8fb1b2f
--- /dev/null
+++ b/packages/ui/src/lib/__tests__/gen-ai.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from "vitest"
+
+import { getCloudPlatform } from "../cloud-platforms"
+import { formatGenAiCost, formatTokens, getGenAiInfo } from "../gen-ai"
+
+// A realistic OpenRouter Broadcast span. Every value is a string because the
+// ingest encoder stringifies non-string OTLP attribute values.
+const OPENROUTER_SPAN: Record = {
+ "gen_ai.operation.name": "chat",
+ "gen_ai.provider.name": "openai",
+ "gen_ai.request.model": "openai/gpt-4o",
+ "gen_ai.response.model": "gpt-4o-2024-08-06",
+ "gen_ai.usage.input_tokens": "1234",
+ "gen_ai.usage.output_tokens": "567",
+ "gen_ai.usage.cost": "0.0091",
+ "gen_ai.request.temperature": "0.7",
+ "gen_ai.request.top_p": "1",
+ "gen_ai.request.max_tokens": "2048",
+ "gen_ai.response.finish_reasons": '["stop"]',
+}
+
+describe("getGenAiInfo", () => {
+ it("normalizes a full OpenRouter span", () => {
+ const info = getGenAiInfo(OPENROUTER_SPAN)
+ expect(info).not.toBeNull()
+ expect(info!.operation).toBe("chat")
+ expect(info!.provider).toBe("openai")
+ expect(info!.requestModel).toBe("openai/gpt-4o")
+ expect(info!.responseModel).toBe("gpt-4o-2024-08-06")
+ // Display model prefers the model that actually served the request.
+ expect(info!.model).toBe("gpt-4o-2024-08-06")
+ expect(info!.inputTokens).toBe(1234)
+ expect(info!.outputTokens).toBe(567)
+ expect(info!.totalTokens).toBe(1801)
+ expect(info!.cost).toBeCloseTo(0.0091)
+ expect(info!.temperature).toBe(0.7)
+ expect(info!.topP).toBe(1)
+ expect(info!.maxTokens).toBe(2048)
+ expect(info!.finishReasons).toEqual(["stop"])
+ })
+
+ it("coalesces the legacy gen_ai.system spelling into provider", () => {
+ const info = getGenAiInfo({ "gen_ai.operation.name": "chat", "gen_ai.system": "anthropic" })
+ expect(info?.provider).toBe("anthropic")
+ })
+
+ it("rejects a span whose gen_ai keys are all empty (projection footgun)", () => {
+ // The trimmed tree projection emits requested keys with empty-string values
+ // on EVERY span — detection must key on a real value, never key presence.
+ const info = getGenAiInfo({
+ "gen_ai.operation.name": "",
+ "gen_ai.request.model": "",
+ "gen_ai.response.model": "",
+ "gen_ai.usage.cost": "",
+ })
+ expect(info).toBeNull()
+ })
+
+ it("returns null for a non-LLM span", () => {
+ expect(getGenAiInfo({ "http.request.method": "GET" })).toBeNull()
+ })
+
+ it("degrades cleanly under OpenRouter Privacy Mode (no prompt/completion/finish reasons)", () => {
+ const { "gen_ai.response.finish_reasons": _omit, ...privacy } = OPENROUTER_SPAN
+ const info = getGenAiInfo(privacy)
+ expect(info).not.toBeNull()
+ expect(info!.finishReasons).toEqual([])
+ // Token/cost/model data still present.
+ expect(info!.inputTokens).toBe(1234)
+ expect(info!.cost).toBeCloseTo(0.0091)
+ expect(info!.model).toBe("gpt-4o-2024-08-06")
+ })
+
+ it("parses finish reasons from a JSON array, a CSV string, and a bare value", () => {
+ const json = getGenAiInfo({
+ "gen_ai.operation.name": "chat",
+ "gen_ai.response.finish_reasons": '["stop","length"]',
+ })
+ expect(json?.finishReasons).toEqual(["stop", "length"])
+
+ const csv = getGenAiInfo({
+ "gen_ai.operation.name": "chat",
+ "gen_ai.response.finish_reasons": "stop, length",
+ })
+ expect(csv?.finishReasons).toEqual(["stop", "length"])
+
+ const bare = getGenAiInfo({
+ "gen_ai.operation.name": "chat",
+ "gen_ai.response.finish_reasons": "stop",
+ })
+ expect(bare?.finishReasons).toEqual(["stop"])
+ })
+
+ it("falls back to total_tokens and legacy token keys", () => {
+ const total = getGenAiInfo({
+ "gen_ai.operation.name": "chat",
+ "gen_ai.usage.total_tokens": "900",
+ })
+ expect(total?.totalTokens).toBe(900)
+
+ const legacy = getGenAiInfo({
+ "gen_ai.operation.name": "chat",
+ "gen_ai.usage.prompt_tokens": "100",
+ "gen_ai.usage.completion_tokens": "50",
+ })
+ expect(legacy?.inputTokens).toBe(100)
+ expect(legacy?.outputTokens).toBe(50)
+ expect(legacy?.totalTokens).toBe(150)
+ })
+
+ it("detects an LLM span from model alone (no operation name)", () => {
+ const info = getGenAiInfo({ "gen_ai.request.model": "claude-sonnet-5" })
+ expect(info?.model).toBe("claude-sonnet-5")
+ expect(info?.operation).toBeNull()
+ })
+})
+
+describe("getCloudPlatform — gen_ai adapter", () => {
+ it("routes an LLM span through the gen_ai adapter", () => {
+ const info = getCloudPlatform(OPENROUTER_SPAN)
+ expect(info).not.toBeNull()
+ expect(info!.id).toBe("gen_ai")
+ expect(info!.label).toBe("OpenAI")
+ expect(info!.kind).toBe("Chat")
+ expect(info!.edge).toBeNull()
+ })
+
+ it("exposes model, tokens, and cost as fields", () => {
+ const fields = getCloudPlatform(OPENROUTER_SPAN)!.fields
+ const byLabel = Object.fromEntries(fields.map((f) => [f.label, f]))
+ expect(byLabel["Model"]).toMatchObject({ value: "gpt-4o-2024-08-06", copyable: true })
+ expect(byLabel["Requested"]?.value).toBe("openai/gpt-4o")
+ expect(byLabel["Tokens"]?.value).toBe("1,234 in · 567 out")
+ expect(byLabel["Cost"]?.value).toBe("$0.0091")
+ expect(byLabel["Finish"]?.value).toBe("stop")
+ })
+
+ it("flags a content_filter / error finish reason as a bad outcome", () => {
+ const filtered = getCloudPlatform({
+ ...OPENROUTER_SPAN,
+ "gen_ai.response.finish_reasons": '["content_filter"]',
+ })
+ expect(filtered!.outcome).toEqual({ value: "content_filter", bad: true })
+ // A normal "stop" is never flagged.
+ expect(getCloudPlatform(OPENROUTER_SPAN)!.outcome).toBeNull()
+ })
+
+ it("does not shadow database or non-LLM spans", () => {
+ expect(getCloudPlatform({ "db.system.name": "postgresql" })?.id).toBe("database")
+ expect(getCloudPlatform({ "http.request.method": "GET" })).toBeNull()
+ })
+})
+
+describe("gen_ai formatters", () => {
+ it("formats token counts with thousands separators", () => {
+ expect(formatTokens(1234567)).toBe("1,234,567")
+ })
+
+ it("formats cost with adaptive precision, trimming padding zeros", () => {
+ expect(formatGenAiCost(0)).toBe("$0")
+ expect(formatGenAiCost(0.0091)).toBe("$0.0091")
+ expect(formatGenAiCost(0.25)).toBe("$0.25")
+ expect(formatGenAiCost(12.5)).toBe("$12.50")
+ })
+})
diff --git a/packages/ui/src/lib/cloud-platforms/gen-ai.ts b/packages/ui/src/lib/cloud-platforms/gen-ai.ts
new file mode 100644
index 000000000..7923c5901
--- /dev/null
+++ b/packages/ui/src/lib/cloud-platforms/gen-ai.ts
@@ -0,0 +1,99 @@
+import { SparklesIcon } from "../../components/icons"
+import {
+ formatGenAiCost,
+ formatTokens,
+ getGenAiInfo,
+ humanizeGenAiOperation,
+ humanizeGenAiProvider,
+} from "../gen-ai"
+import type { CloudPlatformAdapter, CloudPlatformField } from "./types"
+
+// GenAI / LLM client spans (OpenRouter Broadcast + OTel `gen_ai.*` semconv).
+// Reuses the same span-annotation registry as the cloud-platform adapters: the
+// normalized `CloudPlatformInfo` shape describes an LLM call (provider, model,
+// tokens, cost) as well as it describes a serverless invocation, so registering
+// this adapter lights up the model/provider badge across every trace view AND
+// the span-detail summary block with no component changes.
+
+/** Brand-ish accent per provider; a small tint on the icon, muted otherwise. */
+const PROVIDER_ACCENTS: Record = {
+ openai: "text-[#10A37F]",
+ azure: "text-[#0078D4]",
+ anthropic: "text-[#D97757]",
+ openrouter: "text-[#6467F2]",
+ google: "text-[#4285F4]",
+ gemini: "text-[#4285F4]",
+ "gcp.gemini": "text-[#4285F4]",
+ cohere: "text-[#39594D]",
+ mistral: "text-[#FF7000]",
+ mistral_ai: "text-[#FF7000]",
+ groq: "text-[#F55036]",
+ perplexity: "text-[#20808D]",
+ deepseek: "text-[#4D6BFE]",
+ bedrock: "text-[#FF9900]",
+ "aws.bedrock": "text-[#FF9900]",
+ fireworks: "text-[#6B2FA5]",
+}
+
+// Finish reasons that read as a failure of the generation itself (distinct from
+// the OTel span status). Everything else — "stop", "tool_calls" — is a normal
+// completion and never flagged.
+const BAD_FINISH_REASONS = new Set(["error", "content_filter"])
+
+export const genAiAdapter: CloudPlatformAdapter = {
+ id: "gen_ai",
+ detect(attrs) {
+ const info = getGenAiInfo(attrs)
+ if (!info) return null
+
+ const fields: CloudPlatformField[] = []
+ if (info.model) fields.push({ label: "Model", value: info.model, copyable: true })
+ // Only surface the requested model when it differs from what actually served.
+ if (info.requestModel && info.requestModel !== info.model) {
+ fields.push({ label: "Requested", value: info.requestModel, copyable: true })
+ }
+
+ const tokens = formatTokenSummary(info.inputTokens, info.outputTokens, info.totalTokens)
+ if (tokens) fields.push({ label: "Tokens", value: tokens })
+ if (info.cost != null) fields.push({ label: "Cost", value: formatGenAiCost(info.cost) })
+ if (info.temperature != null) fields.push({ label: "Temperature", value: String(info.temperature) })
+ if (info.topP != null) fields.push({ label: "Top P", value: String(info.topP) })
+ if (info.maxTokens != null) {
+ fields.push({ label: "Max tokens", value: formatTokens(info.maxTokens) })
+ }
+ if (info.finishReasons.length > 0) {
+ fields.push({ label: "Finish", value: info.finishReasons.join(", ") })
+ }
+
+ const badReason = info.finishReasons.find((r) => BAD_FINISH_REASONS.has(r.toLowerCase()))
+ const provider = info.provider
+
+ return {
+ id: "gen_ai",
+ label: provider ? humanizeGenAiProvider(provider) : "LLM",
+ kind: info.operation ? humanizeGenAiOperation(info.operation) : "LLM",
+ Icon: SparklesIcon,
+ accentClassName: provider
+ ? (PROVIDER_ACCENTS[provider.toLowerCase()] ?? "text-muted-foreground")
+ : "text-muted-foreground",
+ edge: null,
+ location: null,
+ outcome: badReason ? { value: badReason, bad: true } : null,
+ fields,
+ }
+ },
+}
+
+/** "1,234 in · 567 out" / "1,801 total" / "1,234 in" — whatever is present. */
+function formatTokenSummary(
+ input: number | null,
+ output: number | null,
+ total: number | null,
+): string | null {
+ const parts: string[] = []
+ if (input != null) parts.push(`${formatTokens(input)} in`)
+ if (output != null) parts.push(`${formatTokens(output)} out`)
+ if (parts.length > 0) return parts.join(" · ")
+ if (total != null) return `${formatTokens(total)} total`
+ return null
+}
diff --git a/packages/ui/src/lib/cloud-platforms/index.ts b/packages/ui/src/lib/cloud-platforms/index.ts
index 332a23b76..a089c7fda 100644
--- a/packages/ui/src/lib/cloud-platforms/index.ts
+++ b/packages/ui/src/lib/cloud-platforms/index.ts
@@ -1,6 +1,7 @@
import type { CloudPlatformInfo } from "./types"
import { cloudflareAdapter } from "./cloudflare"
import { databaseAdapter } from "./database"
+import { genAiAdapter } from "./gen-ai"
export type {
CloudPlatformAdapter,
@@ -22,10 +23,13 @@ export { outcomeBadgeStyle } from "./types"
// 2. import it here and add it to `ADAPTERS`.
// Order matters only if two adapters could match the same span; keep the most
// specific first. The `databaseAdapter` (generic `db.*` semconv) is a broad
-// last resort — a serverless span and a DB-client span are disjoint in practice,
-// but keep provider adapters ahead of it regardless.
-const ADAPTERS: ReadonlyArray<{ detect: (a: Record) => CloudPlatformInfo | null }> =
- [cloudflareAdapter, databaseAdapter]
+// last resort — a serverless span, an LLM span, and a DB-client span are
+// disjoint in practice, but keep provider adapters ahead of it regardless.
+const ADAPTERS: ReadonlyArray<{ detect: (a: Record) => CloudPlatformInfo | null }> = [
+ cloudflareAdapter,
+ genAiAdapter,
+ databaseAdapter,
+]
/** First adapter that recognizes these span attributes, normalized; else null. */
export function getCloudPlatform(attrs: Record): CloudPlatformInfo | null {
diff --git a/packages/ui/src/lib/gen-ai.ts b/packages/ui/src/lib/gen-ai.ts
new file mode 100644
index 000000000..a13323864
--- /dev/null
+++ b/packages/ui/src/lib/gen-ai.ts
@@ -0,0 +1,178 @@
+// GenAI / LLM span annotations (OpenTelemetry `gen_ai.*` semantic conventions,
+// as emitted by OpenRouter Broadcast and other LLM instrumentations). This
+// normalizes the raw attribute map into a single `GenAiInfo` the trace views and
+// span-detail panel consume, coalescing the semconv drift
+// (`gen_ai.system` → `gen_ai.provider.name`) and tolerating OpenRouter Privacy
+// Mode, where prompt/completion/finish-reason fields are absent while token,
+// cost, model, and provider data remain.
+
+export interface GenAiInfo {
+ /** `gen_ai.operation.name`, e.g. "chat" | "embeddings" | "execute_tool". */
+ operation: string | null
+ /** `gen_ai.provider.name`, falling back to legacy `gen_ai.system`. */
+ provider: string | null
+ /** `gen_ai.request.model` — the requested model id. */
+ requestModel: string | null
+ /** `gen_ai.response.model` — the model that actually served the request. */
+ responseModel: string | null
+ /** Best single model to display: response model if present, else request. */
+ model: string | null
+ inputTokens: number | null
+ outputTokens: number | null
+ /** input + output when both present, else `gen_ai.usage.total_tokens`. */
+ totalTokens: number | null
+ /** `gen_ai.usage.cost` (OpenRouter) in the account's currency (USD). */
+ cost: number | null
+ temperature: number | null
+ topP: number | null
+ maxTokens: number | null
+ /** `gen_ai.response.finish_reasons` — parsed from a JSON array or CSV string. */
+ finishReasons: ReadonlyArray
+}
+
+/** First present, non-blank value among `keys`. */
+function pick(attrs: Record, ...keys: string[]): string | null {
+ for (const key of keys) {
+ const value = attrs[key]
+ if (value != null && value.trim() !== "") return value
+ }
+ return null
+}
+
+/** Parse a stringified number, returning null for blank/non-finite values. */
+function num(attrs: Record, ...keys: string[]): number | null {
+ const raw = pick(attrs, ...keys)
+ if (raw == null) return null
+ const n = Number(raw)
+ return Number.isFinite(n) ? n : null
+}
+
+/**
+ * `gen_ai.response.finish_reasons` is spec'd as a string array, but arrives here
+ * as a stringified map value — usually a JSON array (`["stop"]`), occasionally a
+ * bare/CSV string. Parse either into a clean list; empty on absence.
+ */
+function parseFinishReasons(raw: string | null): string[] {
+ if (raw == null) return []
+ const trimmed = raw.trim()
+ if (trimmed === "") return []
+ if (trimmed.startsWith("[")) {
+ try {
+ const parsed: unknown = JSON.parse(trimmed)
+ if (Array.isArray(parsed)) {
+ return parsed.map((r) => String(r).trim()).filter((r) => r !== "")
+ }
+ } catch {
+ // fall through to CSV handling
+ }
+ }
+ return trimmed
+ .split(",")
+ .map((r) => r.trim())
+ .filter((r) => r !== "")
+}
+
+/**
+ * Normalized GenAI info when `attrs` describe an LLM span, else null.
+ *
+ * Detection keys on a real, non-empty `gen_ai.operation.name` **or** model VALUE
+ * — never key-presence. The trimmed tree-view projection emits requested keys
+ * with empty-string values on every span, so a presence check would flag
+ * non-LLM spans (the same footgun the cloud-platform adapters guard against).
+ */
+export function getGenAiInfo(attrs: Record): GenAiInfo | null {
+ const operation = pick(attrs, "gen_ai.operation.name")
+ const requestModel = pick(attrs, "gen_ai.request.model")
+ const responseModel = pick(attrs, "gen_ai.response.model")
+ if (operation == null && requestModel == null && responseModel == null) return null
+
+ const inputTokens = num(attrs, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens")
+ const outputTokens = num(attrs, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens")
+ const totalTokens =
+ inputTokens != null && outputTokens != null
+ ? inputTokens + outputTokens
+ : num(attrs, "gen_ai.usage.total_tokens")
+
+ return {
+ operation,
+ provider: pick(attrs, "gen_ai.provider.name", "gen_ai.system"),
+ requestModel,
+ responseModel,
+ model: responseModel ?? requestModel,
+ inputTokens,
+ outputTokens,
+ totalTokens,
+ cost: num(attrs, "gen_ai.usage.cost"),
+ temperature: num(attrs, "gen_ai.request.temperature"),
+ topP: num(attrs, "gen_ai.request.top_p"),
+ maxTokens: num(attrs, "gen_ai.request.max_tokens"),
+ finishReasons: parseFinishReasons(pick(attrs, "gen_ai.response.finish_reasons")),
+ }
+}
+
+/** Display names for well-known `gen_ai.provider.name` / `gen_ai.system` values. */
+const PROVIDER_LABELS: Record = {
+ openai: "OpenAI",
+ azure: "Azure OpenAI",
+ "azure.ai.openai": "Azure OpenAI",
+ anthropic: "Anthropic",
+ openrouter: "OpenRouter",
+ google: "Google",
+ "gcp.gemini": "Gemini",
+ "gcp.vertex_ai": "Vertex AI",
+ gemini: "Gemini",
+ cohere: "Cohere",
+ mistral: "Mistral",
+ mistral_ai: "Mistral",
+ groq: "Groq",
+ perplexity: "Perplexity",
+ deepseek: "DeepSeek",
+ xai: "xAI",
+ "aws.bedrock": "Bedrock",
+ bedrock: "Bedrock",
+ ollama: "Ollama",
+ fireworks: "Fireworks",
+ together: "Together",
+ replicate: "Replicate",
+}
+
+/** "openai" → "OpenAI"; unknown → title-cased last dotted segment. */
+export function humanizeGenAiProvider(provider: string): string {
+ const known = PROVIDER_LABELS[provider.toLowerCase()]
+ if (known) return known
+ const tail = provider.split(".").pop() ?? provider
+ return tail
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ")
+}
+
+/** "chat" → "Chat"; "execute_tool" → "Execute Tool". */
+export function humanizeGenAiOperation(operation: string): string {
+ return operation
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ")
+}
+
+/** Compact token counts, e.g. "1,234 → 567" (input → output). */
+export function formatTokens(n: number): string {
+ return n.toLocaleString("en-US")
+}
+
+/**
+ * Format an LLM cost as a currency string with adaptive precision — sub-cent
+ * costs (the common per-call case) keep significant digits instead of rounding
+ * to "$0.00".
+ */
+export function formatGenAiCost(cost: number): string {
+ if (cost === 0) return "$0"
+ const abs = Math.abs(cost)
+ // Whole-dollar costs keep the conventional two-decimal cents; sub-dollar
+ // costs get more precision but trim padding zeros ("$0.009100" → "$0.0091").
+ if (abs >= 1) return `$${cost.toFixed(2)}`
+ const trimmed = cost.toFixed(abs >= 0.01 ? 4 : 6).replace(/\.?0+$/, "")
+ return `$${trimmed}`
+}