-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[OPIK-7717] [BE] fix: map OTel semconv provider names to Opik canonical providers #7909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
054c8f8
c3d9311
47e61d5
1940391
c0931dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,9 @@ public final class GenAIMappingRules { | |
|
|
||
| public static final String SOURCE = "GenAI"; | ||
|
|
||
| /** Current semantic-convention provider attribute; replaced the deprecated {@code gen_ai.system}. */ | ||
| public static final String PROVIDER_NAME_ATTR = "gen_ai.provider.name"; | ||
|
|
||
| private static final List<OpenTelemetryMappingRule> RULES = List.of( | ||
| OpenTelemetryMappingRule.builder() | ||
| .rule("gen_ai.prompt").source(SOURCE).outcome(OpenTelemetryMappingRule.Outcome.INPUT).build(), | ||
|
|
@@ -45,6 +48,12 @@ public final class GenAIMappingRules { | |
| OpenTelemetryMappingRule.builder() | ||
| .rule("gen_ai.system").source(SOURCE).outcome(OpenTelemetryMappingRule.Outcome.PROVIDER) | ||
| .spanType(SpanType.llm).build(), | ||
| // Replacement for the deprecated `gen_ai.system`. Instrumentations migrating to the | ||
| // current semconv emit this one (and often both). OpenTelemetryMapper keeps | ||
| // `gen_ai.system` authoritative when present; see PROVIDER_NAME_ATTR there. | ||
| OpenTelemetryMappingRule.builder() | ||
| .rule(PROVIDER_NAME_ATTR).source(SOURCE).outcome(OpenTelemetryMappingRule.Outcome.PROVIDER) | ||
| .spanType(SpanType.llm).build(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. correctness — Unlike Failure: an
|
||
| OpenTelemetryMappingRule.builder() | ||
| .rule("gen_ai.usage.cost").source(SOURCE) | ||
| .outcome(OpenTelemetryMappingRule.Outcome.COST).spanType(SpanType.llm).build(), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package com.comet.opik.domain.mapping.otel; | ||
|
|
||
| import lombok.experimental.UtilityClass; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.apache.commons.lang3.StringUtils; | ||
|
|
||
| import java.util.Locale; | ||
| import java.util.Map; | ||
|
|
||
| /** | ||
| * Maps the OTel GenAI semantic-convention provider vocabulary onto the canonical Opik provider | ||
| * names used as price-table keys. | ||
| * <p> | ||
| * Instrumentations report the provider via {@code gen_ai.system} (deprecated) or its replacement | ||
| * {@code gen_ai.provider.name}, using the values from the OTel registry. Several of those spell | ||
| * the same provider differently from Opik: {@code vertex_ai} vs {@code google_vertexai}, | ||
| * {@code aws.bedrock} vs {@code bedrock}, {@code x_ai} vs {@code xai}. A value that reaches | ||
| * {@code CostService} unmapped matches no pricing row, so the span silently costs 0 (OPIK-7717). | ||
| * <p> | ||
| * Only unambiguous 1:1 renames belong here — values naming more than one backend are excluded: | ||
| * <ul> | ||
| * <li>{@code google} and {@code gcp.gen_ai} ("specific backend is unknown" per the semconv) | ||
| * need the endpoint host and are handled by {@link GoogleProviderResolver} instead.</li> | ||
| * <li>{@code azure.ai.inference} / {@code az.ai.inference} front either Azure OpenAI | ||
| * (priced under {@code azure}) or Azure AI Foundry models such as Claude and Llama, which | ||
| * LiteLLM prices under a separate {@code azure_ai} provider that Opik does not load at all. | ||
| * Aliasing them to {@code azure} would price a Foundry model against the OpenAI table.</li> | ||
| * </ul> | ||
| * Values already matching the Opik vocabulary ({@code openai}, {@code anthropic}, {@code groq}, | ||
| * {@code deepseek}, {@code perplexity}) need no entry and pass through unchanged, as do values | ||
| * Opik has no pricing for at all ({@code cohere}, {@code ibm.watsonx.ai}). | ||
| * | ||
| * @see <a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/">OTel GenAI attribute registry</a> | ||
| */ | ||
| @UtilityClass | ||
| @Slf4j | ||
| public class GenAiProviderAliasResolver { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably abstract all those resolvers and find a way select the ones that must be applied |
||
|
|
||
| private static final Map<String, String> ALIASES = Map.ofEntries( | ||
| // Legacy spellings the semconv renamed but instrumentation still emits (OPIK-7717): | ||
| // `vertex_ai` -> `gcp.vertex_ai`, `az.ai.openai` -> `azure.ai.openai`. | ||
| Map.entry("vertex_ai", GoogleProviderResolver.GOOGLE_VERTEX_AI), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. correctness — Vertex fronts Anthropic models, which Opik prices under a different canonical provider: Failure: This is precisely the multi-backend ambiguity for which |
||
| Map.entry("az.ai.openai", "azure"), | ||
| // Current spellings, each scoped to a single backend | ||
| Map.entry("gcp.vertex_ai", GoogleProviderResolver.GOOGLE_VERTEX_AI), | ||
| Map.entry("gcp.gemini", GoogleProviderResolver.GOOGLE_AI), | ||
| Map.entry("aws.bedrock", "bedrock"), | ||
| Map.entry("azure.ai.openai", "azure"), | ||
| Map.entry("mistral_ai", "mistral"), | ||
| Map.entry("x_ai", "xai")); | ||
|
|
||
| /** | ||
| * Returns the canonical Opik provider for a semantic-convention provider value, or the | ||
| * provider unchanged when it needs no aliasing. | ||
| */ | ||
| public static String resolve(String provider) { | ||
| if (StringUtils.isBlank(provider)) { | ||
| return provider; | ||
| } | ||
|
|
||
| String resolved = ALIASES.get(StringUtils.trimToEmpty(provider).toLowerCase(Locale.ROOT)); | ||
| if (resolved == null) { | ||
| return provider; | ||
| } | ||
|
|
||
| log.debug("Aliased OTel provider '{}' to canonical '{}'", provider, resolved); | ||
| return resolved; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,59 +6,100 @@ | |
| import org.apache.commons.lang3.StringUtils; | ||
|
|
||
| import java.util.Locale; | ||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * Disambiguates the generic {@code "google"} provider that PydanticAI / the google-genai OTel | ||
| * instrumentation emits (via {@code gen_ai.system}) into the Opik canonical provider used for | ||
| * cost lookup. | ||
| * <p> | ||
| * The provider attribute alone is identical for both Google backends, so cost resolution can't | ||
| * match the price rows keyed on {@code google_vertexai} / {@code google_ai}. The only signal that | ||
| * tells them apart is the endpoint host carried in {@code server.address}: | ||
| * Disambiguates the Google provider values that name no specific backend into the Opik canonical | ||
| * provider used for cost lookup. Two values qualify: | ||
| * <ul> | ||
| * <li>{@code google} — emitted by PydanticAI / the google-genai OTel instrumentation</li> | ||
| * <li>{@code gcp.gen_ai} — the semantic convention's own "specific backend is unknown" value</li> | ||
| * </ul> | ||
| * Neither identifies a backend on its own, so cost resolution can't match the price rows keyed on | ||
| * {@code google_vertexai} / {@code google_ai}. The only signal that tells them apart is the | ||
| * endpoint host carried in {@code server.address}: | ||
| * <ul> | ||
| * <li>{@code *-aiplatform.googleapis.com} -> Vertex AI -> {@code google_vertexai}</li> | ||
| * <li>{@code generativelanguage.googleapis.com} -> Gemini Developer API -> {@code google_ai}</li> | ||
| * </ul> | ||
| * When the host is absent or unrecognized we default to {@code google_ai} so a cost is still | ||
| * computed (the two price tables are currently equal for Gemini models, but may diverge). | ||
| * <p> | ||
| * Google values that <em>do</em> name a backend ({@code vertex_ai}, {@code gcp.vertex_ai}, | ||
| * {@code gcp.gemini}) need no host and are aliased directly by {@link GenAiProviderAliasResolver}. | ||
| */ | ||
| @UtilityClass | ||
| @Slf4j | ||
| public class GoogleProviderResolver { | ||
|
|
||
| public static final String GOOGLE_PROVIDER = "google"; | ||
| public static final String GCP_GEN_AI_PROVIDER = "gcp.gen_ai"; | ||
| public static final String GOOGLE_VERTEX_AI = "google_vertexai"; | ||
| public static final String GOOGLE_AI = "google_ai"; | ||
|
|
||
| private static final Set<String> AMBIGUOUS_PROVIDERS = Set.of(GOOGLE_PROVIDER, GCP_GEN_AI_PROVIDER); | ||
|
awkoy marked this conversation as resolved.
|
||
|
|
||
| private static final String VERTEX_AI_HOST_MARKER = "aiplatform.googleapis.com"; | ||
| private static final String GEMINI_API_HOST_MARKER = "generativelanguage.googleapis.com"; | ||
|
|
||
| /** | ||
| * If the provider is the generic {@code "google"}, returns the canonical Google provider | ||
| * If the provider names no specific Google backend, returns the canonical Google provider | ||
| * resolved from the {@code server.address} stored in metadata. Otherwise returns the provider | ||
| * unchanged. | ||
| */ | ||
| public static String resolve(String provider, ObjectNode metadata) { | ||
| if (!GOOGLE_PROVIDER.equalsIgnoreCase(StringUtils.trimToEmpty(provider))) { | ||
| if (!AMBIGUOUS_PROVIDERS.contains(StringUtils.trimToEmpty(provider).toLowerCase(Locale.ROOT))) { | ||
| return provider; | ||
| } | ||
|
|
||
| String serverAddress = metadata != null && metadata.hasNonNull(GeneralMappingRules.SERVER_ADDRESS_ATTR) | ||
| ? metadata.get(GeneralMappingRules.SERVER_ADDRESS_ATTR).asText().toLowerCase(Locale.ROOT) | ||
| : ""; | ||
| String host = extractHost(serverAddress); | ||
|
|
||
| String resolved; | ||
| if (serverAddress.contains(VERTEX_AI_HOST_MARKER)) { | ||
| boolean recognized = true; | ||
| if (host.endsWith(VERTEX_AI_HOST_MARKER)) { | ||
| resolved = GOOGLE_VERTEX_AI; | ||
| } else if (serverAddress.contains(GEMINI_API_HOST_MARKER)) { | ||
| } else if (host.endsWith(GEMINI_API_HOST_MARKER)) { | ||
| resolved = GOOGLE_AI; | ||
| } else { | ||
| resolved = GOOGLE_AI; | ||
| log.debug("Provider 'google' with unrecognized server.address '{}', defaulting to '{}'", | ||
| serverAddress, resolved); | ||
| recognized = false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low — with Previously a The class javadoc carries this caveat for |
||
| } | ||
|
|
||
| log.debug("Resolved provider 'google' to '{}' from server.address '{}'", resolved, serverAddress); | ||
| log.debug("Resolved provider '{}' to '{}' from {} server.address host '{}'", | ||
| provider, resolved, recognized ? "recognized" : "unrecognized (defaulted)", host); | ||
| return resolved; | ||
| } | ||
|
|
||
| /** | ||
| * Reduces a {@code server.address} to its bare host so the markers can be matched on a domain | ||
| * boundary rather than anywhere in the string — {@code contains} would classify | ||
| * {@code evil-aiplatform.googleapis.com.attacker.test} as Vertex AI. | ||
| * <p> | ||
| * The semconv defines {@code server.address} as a host name, but scheme, path, port and a | ||
| * trailing FQDN dot are tolerated so a well-formed address is never rejected by the stricter | ||
| * suffix match. | ||
| */ | ||
| private static String extractHost(String serverAddress) { | ||
| String host = serverAddress; | ||
|
|
||
| int scheme = host.indexOf("://"); | ||
| if (scheme >= 0) { | ||
| host = host.substring(scheme + 3); | ||
| } | ||
| int path = host.indexOf('/'); | ||
| if (path >= 0) { | ||
| host = host.substring(0, path); | ||
| } | ||
| // Only strip a genuine numeric port, so an IPv6 literal isn't truncated at its last colon. | ||
| int port = host.lastIndexOf(':'); | ||
| if (port >= 0 && port < host.length() - 1 | ||
| && host.substring(port + 1).chars().allMatch(Character::isDigit)) { | ||
| host = host.substring(0, port); | ||
| } | ||
|
|
||
| return StringUtils.removeEnd(host, "."); | ||
| } | ||
|
Comment on lines
+85
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to use a URLParser |
||
| } | ||
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.