diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 568536e..7ccdf4d 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -30,6 +30,7 @@ import { import { agentAttrs, errorSummary, + genAiProviderName, setBoundedMap, accumulateSessionTotals, getSessionAgentMeta, @@ -163,6 +164,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext "session.id": sessionID, model: modelID, provider: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), ...agentAttrs(agentName, agentType), error: errorSummary(assistant.error), duration_ms: duration, @@ -189,6 +191,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext "session.id": sessionID, model: modelID, provider: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), ...agentAttrs(agentName, agentType), cost_usd: assistant.cost, duration_ms: duration, @@ -441,6 +444,7 @@ export function startMessageSpan( "agent.type": agentType, [LLM_SYSTEM]: providerID, [LLM_PROVIDER]: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), [LLM_MODEL_NAME]: modelID, ...(inputText ? { diff --git a/src/util.ts b/src/util.ts index 3dc1ce5..b5ab133 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,6 +2,17 @@ import { trace } from "@opentelemetry/api" import { MAX_PENDING } from "./types.ts" import type { HandlerContext, SessionAgentType } from "./types.ts" +const GEN_AI_PROVIDER_NAMES: Readonly> = { + "amazon-bedrock": "aws.bedrock", + azure: "azure.ai.openai", + "azure-cognitive-services": "azure.ai.openai", + google: "gcp.gemini", + "google-vertex": "gcp.vertex_ai", + "google-vertex-anthropic": "gcp.vertex_ai", + mistral: "mistral_ai", + xai: "x_ai", +} + /** Returns a human-readable summary string from an opencode error object. */ export function errorSummary(err: { name: string; data?: unknown } | undefined): string { if (!err) return "unknown" @@ -11,6 +22,11 @@ export function errorSummary(err: { name: string; data?: unknown } | undefined): return err.name } +/** Returns the canonical OTel GenAI provider name, preserving unknown provider IDs. */ +export function genAiProviderName(providerID: string): string { + return GEN_AI_PROVIDER_NAMES[providerID] ?? providerID +} + /** * Inserts a key/value pair into `map`, evicting the oldest entry first when the map * has reached `MAX_PENDING` capacity to prevent unbounded memory growth. diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts index f5afb39..0fed30a 100644 --- a/tests/handlers/message.test.ts +++ b/tests/handlers/message.test.ts @@ -212,19 +212,23 @@ describe("handleMessageUpdated", () => { test("emits api_request log record on success", async () => { const { ctx, logger } = makeCtx("proj_test", [], [], true, { team: "platform" }) - await handleMessageUpdated(makeAssistantMessageUpdated({}), ctx) + await handleMessageUpdated(makeAssistantMessageUpdated({ providerID: "amazon-bedrock" }), ctx) expect(logger.records).toHaveLength(1) expect(logger.records.at(0)!.body).toBe("api_request") + expect(logger.records.at(0)!.attributes?.["provider"]).toBe("amazon-bedrock") + expect(logger.records.at(0)!.attributes?.["gen_ai.provider.name"]).toBe("aws.bedrock") expect(logger.records.at(0)!.attributes?.["team"]).toBe("platform") }) test("emits api_error log record on error", async () => { const { ctx, logger, pluginLog } = makeCtx() await handleMessageUpdated( - makeAssistantMessageUpdated({ error: { name: "APIError" } }), + makeAssistantMessageUpdated({ providerID: "google-vertex", error: { name: "APIError" } }), ctx, ) expect(logger.records.at(0)!.body).toBe("api_error") + expect(logger.records.at(0)!.attributes?.["provider"]).toBe("google-vertex") + expect(logger.records.at(0)!.attributes?.["gen_ai.provider.name"]).toBe("gcp.vertex_ai") expect(logger.records.at(0)!.attributes?.["error"]).toBe("APIError") expect(pluginLog.calls.find(c => c.level === "error")?.level).toBe("error") }) diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index f80a7fa..b7f52bf 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -356,11 +356,12 @@ describe("message (LLM) spans", () => { test("startMessageSpan sets OpenInference LLM attributes", () => { const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "gpt-4o", "openai", 1000, ctx) + startMessageSpan("ses_1", "msg_1", "user_1", "claude-sonnet-4", "amazon-bedrock", 1000, ctx) expect(tracer.spans[0]!.attributes[OPENINFERENCE_SPAN_KIND]).toBe(OpenInferenceSpanKind.LLM) - expect(tracer.spans[0]!.attributes[LLM_SYSTEM]).toBe("openai") - expect(tracer.spans[0]!.attributes[LLM_PROVIDER]).toBe("openai") - expect(tracer.spans[0]!.attributes[LLM_MODEL_NAME]).toBe("gpt-4o") + expect(tracer.spans[0]!.attributes[LLM_SYSTEM]).toBe("amazon-bedrock") + expect(tracer.spans[0]!.attributes[LLM_PROVIDER]).toBe("amazon-bedrock") + expect(tracer.spans[0]!.attributes["gen_ai.provider.name"]).toBe("aws.bedrock") + expect(tracer.spans[0]!.attributes[LLM_MODEL_NAME]).toBe("claude-sonnet-4") }) test("startMessageSpan is a no-op when span already exists for sessionID:messageID", () => { diff --git a/tests/util.test.ts b/tests/util.test.ts index 72c0103..1fc7050 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../src/util.ts" +import { errorSummary, genAiProviderName, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../src/util.ts" import { MAX_PENDING } from "../src/types.ts" describe("errorSummary", () => { @@ -26,6 +26,26 @@ describe("errorSummary", () => { }) }) +describe("genAiProviderName", () => { + test.each([ + ["amazon-bedrock", "aws.bedrock"], + ["azure", "azure.ai.openai"], + ["azure-cognitive-services", "azure.ai.openai"], + ["google", "gcp.gemini"], + ["google-vertex", "gcp.vertex_ai"], + ["google-vertex-anthropic", "gcp.vertex_ai"], + ["mistral", "mistral_ai"], + ["xai", "x_ai"], + ])("maps %s to %s", (providerID, expected) => { + expect(genAiProviderName(providerID)).toBe(expected) + }) + + test("preserves provider IDs without a canonical mapping", () => { + expect(genAiProviderName("openrouter")).toBe("openrouter") + expect(genAiProviderName("custom-gateway")).toBe("custom-gateway") + }) +}) + describe("setBoundedMap", () => { test("adds an entry to the map", () => { const map = new Map()