Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.comet.opik.api.Span.SpanBuilder;
import com.comet.opik.domain.mapping.OpenTelemetryMappingRuleFactory;
import com.comet.opik.domain.mapping.otel.ElasticInferenceServiceResolver;
import com.comet.opik.domain.mapping.otel.GenAIMappingRules;
import com.comet.opik.domain.mapping.otel.GenAiProviderAliasResolver;
import com.comet.opik.domain.mapping.otel.GeneralMappingRules;
import com.comet.opik.domain.mapping.otel.GoogleProviderResolver;
import com.comet.opik.domain.retention.RetentionUtils;
Expand Down Expand Up @@ -167,6 +169,9 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
// Claude Code is Anthropic-only and never sends a provider attribute, so set it directly.
String model = null;
String provider = isClaudeCode ? "anthropic" : null;
// Provider reported via the current `gen_ai.provider.name`, held separately so the
// deprecated `gen_ai.system` stays authoritative. See the PROVIDER case below.
String providerName = null;

if (StringUtils.isNotBlank(integrationName)) {
metadata.put("integration", integrationName);
Expand Down Expand Up @@ -202,7 +207,17 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
break;

case PROVIDER :
provider = value.getStringValue();
// Two attributes carry the provider: the deprecated `gen_ai.system` and its
// replacement `gen_ai.provider.name`. Instrumentations mid-migration emit both,
// and their vocabularies differ (e.g. `xai` vs `x_ai`), so pin which one wins
// rather than letting OTLP attribute order decide. `gen_ai.system` stays
// authoritative; the newer attribute only fills in when it is absent, which
// keeps this strictly additive for every span that already resolves a provider.
if (GenAIMappingRules.PROVIDER_NAME_ATTR.equals(rule.getRule())) {
providerName = value.getStringValue();
} else {
provider = value.getStringValue();
}
Comment thread
awkoy marked this conversation as resolved.
break;

case USAGE :
Expand Down Expand Up @@ -265,15 +280,28 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
extractToolOutputEvent(events, output);
}

// Fall back to the current `gen_ai.provider.name` only when the deprecated `gen_ai.system`
// did not report a provider.
// Both sides must be non-blank: a non-string or empty `gen_ai.provider.name` yields ""
// from getStringValue(), and assigning that would persist an empty provider where the
// span previously carried none at all.
if (StringUtils.isBlank(provider) && StringUtils.isNotBlank(providerName)) {
provider = providerName;
}
Comment thread
awkoy marked this conversation as resolved.

// Rewrite Elastic Inference Service model/provider into the underlying provider so
// that cost lookup and provider-based filtering see the real upstream. Records the
// original values in metadata for traceability. Returns the (possibly unchanged) pair.
var resolved = ElasticInferenceServiceResolver.resolve(model, provider, metadata);
model = resolved.model();
provider = resolved.provider();

// Disambiguate the generic 'google' provider (PydanticAI / google-genai) into the Vertex AI
// vs Gemini API canonical name using server.address, so cost lookup can match a price row.
// Map the OTel semantic-convention provider vocabulary onto Opik's canonical names
// (e.g. 'vertex_ai' -> 'google_vertexai'), otherwise cost lookup matches no price row.
provider = GenAiProviderAliasResolver.resolve(provider);
Comment thread
awkoy marked this conversation as resolved.

// Values that name no specific Google backend ('google', 'gcp.gen_ai') can only be
// disambiguated into Vertex AI vs Gemini API by server.address.
provider = GoogleProviderResolver.resolve(provider, metadata);

// Agent-run spans (gen_ai.operation.name=invoke_agent) are not LLM calls. Other attributes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctness.spanType(SpanType.llm) on this rule will mistype execute_tool spans.

Unlike gen_ai.system, gen_ai.provider.name is defined in current semconv on execute_tool and invoke_agent spans too, not only inference spans. enrichSpanWithAttributes applies rule.getSpanType() unconditionally while walking the attribute list, so the last spanType-bearing rule wins.

Failure: an execute_tool span from an instrumentation that has fully migrated to the new attribute (so it emits no gen_ai.system) carrying only gen_ai.tool.name (METADATA, no spanType) is typed llm instead of tool — and the type also flips whenever gen_ai.provider.name is iterated after gen_ai.tool.call.arguments. Before this PR the key had no rule at all, so such a span kept tool/general deterministically.

invoke_agent is protected by the explicit operation-name guard in OpenTelemetryMapper (~L281); execute_tool has no equivalent. Either drop spanType from this rule, or extend that guard to cover execute_tool.

OpenTelemetryMappingRule.builder()
.rule("gen_ai.usage.cost").source(SOURCE)
.outcome(OpenTelemetryMappingRule.Outcome.COST).spanType(SpanType.llm).build(),
Expand Down
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctnessvertex_aigoogle_vertexai isn't the unambiguous 1:1 rename the javadoc describes.

Vertex fronts Anthropic models, which Opik prices under a different canonical provider: anthropic_vertexai, keyed vertex_ai-anthropic_models in CostService.PROVIDERS_MAPPING (asserted in CostServiceTest:143).

Failure: gen_ai.system=vertex_ai + gen_ai.request.model=claude-haiku-4-5 now stores google_vertexai. findModelPrice("claude-haiku-4-5", "google_vertexai") misses every fallback — the prefix fallback needs a / in the model name, and PROVIDERS_MAPPING has no vertex_ai key — so the span still costs $0, and is now additionally mislabeled and grouped/filtered as a Google provider.

This is precisely the multi-backend ambiguity for which azure.ai.inference was deliberately excluded, so the exclusion rationale is applied inconsistently here. Consider disambiguating by model family (claude*anthropic_vertexai), or at minimum documenting the gap. Same applies to the gcp.vertex_ai entry below.

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
Expand Up @@ -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} -&gt; Vertex AI -&gt; {@code google_vertexai}</li>
* <li>{@code generativelanguage.googleapis.com} -&gt; Gemini Developer API -&gt; {@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);
Comment thread
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — with gcp.gen_ai added to AMBIGUOUS_PROVIDERS, this default branch now affirmatively mislabels rather than leaving the value alone.

Previously a gcp.gen_ai span with no (or an unrecognized) server.address was stored verbatim as gcp.gen_ai — cost 0, but obviously unmapped. It now stores google_ai, so a Vertex call from an instrumentation that omits server.address is persisted as Gemini Developer API traffic: provider filtering and grouping silently attribute it to the wrong backend, and if the two price tables ever diverge the cost is wrong rather than merely absent.

The class javadoc carries this caveat for google; it should state it for gcp.gen_ai too.

}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use a URLParser

}

This file was deleted.

Loading
Loading