diff --git a/Cargo.lock b/Cargo.lock index a6f13960..6b30664f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,7 +2269,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hub" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "async-stream", @@ -3273,9 +3273,9 @@ dependencies = [ [[package]] name = "opentelemetry-semantic-conventions" -version = "0.27.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" [[package]] name = "opentelemetry_sdk" diff --git a/Cargo.toml b/Cargo.toml index 0b173d1d..b23b56fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ opentelemetry_sdk = { version = "0.27", default-features = false, features = [ "trace", "rt-tokio", ] } -opentelemetry-semantic-conventions = { version = "0.27.0", features = [ +opentelemetry-semantic-conventions = { version = "0.31.0", features = [ "semconv_experimental", ] } opentelemetry-otlp = { version = "0.27.0", features = [ diff --git a/docs/superpowers/specs/2026-05-27-otel-messages-finish-reasons-tests-design.md b/docs/superpowers/specs/2026-05-27-otel-messages-finish-reasons-tests-design.md new file mode 100644 index 00000000..f2f8b8c7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-otel-messages-finish-reasons-tests-design.md @@ -0,0 +1,153 @@ +# OTel `gen_ai.*messages` / `finish_reasons` test coverage + +## Background + +Branch `dk/otel-semconv-migration` migrates the OTel span emission in +`src/pipelines/otel.rs` to the GenAI semconv: + +- `gen_ai.input.messages` and `gen_ai.output.messages` are emitted as + serialized JSON arrays (replacing the previous per-message `gen_ai.prompt.N.*` + and `gen_ai.completion.N.*` attributes). +- `gen_ai.response.finish_reasons` is emitted as a top-level string array, + with values normalized through `map_finish_reason` (provider-specific values + like `tool_calls`, `function_call`, `tool_use`, `end_turn`, `stop_sequence`, + `max_tokens` map to the spec's `tool_call` / `stop` / `length`). +- Provider names use the well-known `gen_ai.provider.name` values + (`openai`, `anthropic`, `azure.ai.openai`, `aws.bedrock`, `gcp.vertex_ai`). + +The current `#[cfg(test)] mod tests` block covers the happy paths for +`map_finish_reason`, `top_level_finish_reasons`, and the most common +`build_input_messages` / `build_output_messages` shapes. It does not cover +empty inputs, multi-message conversations, the `name` field, simultaneous +content + tool_calls, multi-choice output, the `CompletionRequest` / +`CompletionResponse` JSON shapes, or the streaming accumulator. + +## Goal + +Add unit and streaming-integration tests so that the message-serialization +and finish-reason behavior is comprehensively covered, including the +streaming path that builds a `ChatCompletion` from a sequence of +`ChatCompletionChunk`s. + +Out of scope: capturing real span attributes via an in-memory exporter +(introduces global tracer-provider state). Tests assert on the JSON the +helpers produce — which is exactly what gets set on the span. + +## Design + +### Refactor (test seams) + +`CompletionRequest::record_span` and `CompletionResponse::record_span` +currently build their JSON inline. Extract two free helpers next to +`build_input_messages` / `build_output_messages`: + +- `fn build_completion_input_messages(prompt: &str) -> String` +- `fn build_completion_output_messages(choices: &[CompletionChoice]) -> String` + +Each `record_span` impl calls the helper inside `if get_trace_content_enabled()`. +Pure mechanical extraction — no behavior change. + +### Helper unit tests + +All added to the existing `#[cfg(test)] mod tests` in `src/pipelines/otel.rs`. + +**`top_level_finish_reasons`** + +- `[Some("stop")]` → `Some(vec!["stop"])`. +- All reasons mapped 1:1 when none are empty: `[Some("stop"), Some("max_tokens"), Some("tool_calls")]` + → `Some(vec!["stop", "length", "tool_call"])`. + +**`build_input_messages`** + +- Empty slice → `"[]"`. +- Realistic conversation `system → user → assistant(content + tool_calls) → tool`: + ordering preserved; roles emitted; assistant message's parts contain text + before tool_call; tool message uses `tool_call_response`. +- Message with `name` field → JSON object includes `"name"`. +- Assistant message with both content and tool_calls → text parts precede + tool_call parts. +- Multiple tool_calls in one message → each is its own part with its own + id / name / arguments. +- Tool role with `Array` content → all `text` part `.text` values joined into + the `response` string. +- Tool role with no `tool_call_id` → falls back to the generic path; no + `tool_call_response` part is emitted; content surfaces as text parts. + +**`build_output_messages`** + +- Empty choices → `"[]"`. +- Multi-choice with mixed finish reasons (`stop`, `max_tokens`, `tool_calls`, + `None`) → JSON array length matches; each `finish_reason` mapped correctly. +- Choice with `name` field → JSON object includes `"name"`. +- Choice with content + tool_calls → both kinds of parts present in order. + +**`build_completion_input_messages`** + +- Non-empty prompt → single user message with a single text part containing + the prompt. +- Empty prompt → still a well-formed single-message array with an empty + `content` string. + +**`build_completion_output_messages`** + +- Empty choices → `"[]"`. +- Multi-choice with `max_tokens` / `stop` / `None` → finish reasons mapped + to `length` / `stop` / `""`; each choice carries one `text` part with + `c.text` as its `content`. + +### Streaming-integration tests + +These drive `OtelTracer::log_chunk` and assert on the private +`accumulated_completion` field (the `tests` mod has access to private +fields — no extra accessor needed). Each test then feeds +`accumulated_completion.unwrap()` through `build_output_messages` and +`top_level_finish_reasons` to verify the JSON the real `streaming_end()` +would emit. + +A small fixture helper builds a `ChatCompletionChunk` with a single +`Choice` from `(index, role, content, tool_calls, finish_reason)`. + +Cases: + +1. **Single chunk** with `role=assistant`, `content="Hi"`, `finish_reason="stop"` + → `choices[0].message.content == Some(String("Hi"))`, + `choices[0].finish_reason == Some("stop")`. + `build_output_messages` yields one message with `finish_reason: "stop"`. + +2. **Multi-chunk content concat**: chunk1 sets `role=assistant`, + `content="Hello "`; chunk2 `content="world"`; chunk3 + `finish_reason="tool_calls"` only → concatenated content `"Hello world"`; + final `finish_reason` mapped to `"tool_call"`. + +3. **Tool-call streaming**: chunk1 `role=assistant`; chunk2 delivers + `tool_calls=[ChatMessageToolCall { id: "c1", name: "f", arguments: "{}" }]`; + chunk3 `finish_reason="tool_use"` → accumulated choice has `tool_calls` + set; `build_output_messages` includes a `tool_call` part and + `finish_reason: "tool_call"`. + +4. **Multi-choice interleaved**: chunks alternate `index: 0` and `index: 1`; + choice 0 closes with `finish_reason="end_turn"`, choice 1 with `"max_tokens"` + → both accumulate independently; `top_level_finish_reasons` over them + returns `Some(vec!["stop", "length"])`. + +5. **`streaming_end` no-op**: calling `streaming_end()` on a fresh + `OtelTracer` with no chunks logged does not panic and leaves + `accumulated_completion == None`. + +## Files touched + +- `src/pipelines/otel.rs` + - Add `build_completion_input_messages` and `build_completion_output_messages`. + - Call them from `RecordSpan for CompletionRequest` and + `RecordSpan for CompletionResponse` (replacing the inline `json!` blocks). + - Expand `#[cfg(test)] mod tests` with the cases listed above. + +No other files change. + +## Risks / non-goals + +- Tests do not assert on real `KeyValue` attributes set on a `BoxedSpan`. + The helpers' return values *are* the strings that get set, so this is + equivalent in practice and avoids global tracer-provider state. +- Tests do not exercise `OtelTracer::init` / exporter setup — unchanged + by this work. diff --git a/src/pipelines/otel.rs b/src/pipelines/otel.rs index d71e4411..52589099 100644 --- a/src/pipelines/otel.rs +++ b/src/pipelines/otel.rs @@ -1,20 +1,42 @@ use crate::config::lib::get_trace_content_enabled; use crate::models::chat::{ChatCompletion, ChatCompletionChoice, ChatCompletionRequest}; -use crate::models::completion::{CompletionRequest, CompletionResponse}; +use crate::models::completion::{CompletionChoice, CompletionRequest, CompletionResponse}; use crate::models::content::{ChatCompletionMessage, ChatMessageContent}; -use crate::models::embeddings::{EmbeddingsInput, EmbeddingsRequest, EmbeddingsResponse}; +use crate::models::embeddings::{EmbeddingsRequest, EmbeddingsResponse}; use crate::models::streaming::ChatCompletionChunk; +use crate::models::tool_calls::ChatMessageToolCall; use crate::models::usage::{EmbeddingUsage, Usage}; use opentelemetry::global::{BoxedSpan, ObjectSafeSpan}; use opentelemetry::trace::{SpanKind, Status, Tracer}; -use opentelemetry::{KeyValue, global}; +use opentelemetry::{Array, KeyValue, StringValue, Value as OtelValue, global}; use opentelemetry_otlp::{SpanExporter, WithExportConfig, WithHttpConfig}; use opentelemetry_sdk::propagation::TraceContextPropagator; use opentelemetry_sdk::trace::TracerProvider; -use opentelemetry_semantic_conventions::attribute::GEN_AI_REQUEST_MODEL; -use opentelemetry_semantic_conventions::trace::*; +use opentelemetry_semantic_conventions::trace::{ + GEN_AI_OPERATION_NAME, GEN_AI_REQUEST_ENCODING_FORMATS, GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_TEMPERATURE, GEN_AI_REQUEST_TOP_P, GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_MODEL, GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, +}; +use serde_json::{Value, json}; use std::collections::HashMap; +// Attributes not exported by opentelemetry-semantic-conventions v0.31: +// - `gen_ai.provider.name`, `gen_ai.input.messages`, `gen_ai.output.messages` are +// Development-stage spec additions still absent from the Rust crate. +// - `gen_ai.usage.total_tokens` is defined by the spec but the Rust crate removed +// its constant in v0.31. Swap these out when upstream catches up. +const GEN_AI_PROVIDER_NAME: &str = "gen_ai.provider.name"; +const GEN_AI_INPUT_MESSAGES: &str = "gen_ai.input.messages"; +const GEN_AI_OUTPUT_MESSAGES: &str = "gen_ai.output.messages"; +const GEN_AI_USAGE_TOTAL_TOKENS: &str = "gen_ai.usage.total_tokens"; + +fn string_array(values: Vec) -> OtelValue { + let v: Vec = values.into_iter().map(StringValue::from).collect(); + OtelValue::Array(Array::String(v)) +} + pub trait RecordSpan { fn record_span(&self, span: &mut BoxedSpan); } @@ -176,13 +198,167 @@ impl OtelTracer { pub fn set_vendor(&mut self, vendor: &str) { self.span - .set_attribute(KeyValue::new(GEN_AI_SYSTEM, vendor.to_string())); + .set_attribute(KeyValue::new(GEN_AI_PROVIDER_NAME, vendor.to_string())); + } +} + +fn map_finish_reason(reason: Option<&str>) -> String { + match reason { + None => String::new(), + Some(raw) => match raw { + "" => String::new(), + "tool_calls" | "function_call" | "tool_use" => "tool_call".to_string(), + "end_turn" | "stop_sequence" => "stop".to_string(), + "max_tokens" => "length".to_string(), + other => other.to_string(), + }, + } +} + +fn top_level_finish_reasons<'a, I>(reasons: I) -> Option> +where + I: IntoIterator>, +{ + let mapped: Vec = reasons + .into_iter() + .map(map_finish_reason) + .filter(|s| !s.is_empty()) + .collect(); + if mapped.is_empty() { None } else { Some(mapped) } +} + +fn parse_tool_arguments(raw: &str) -> Value { + serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())) +} + +fn content_to_text_parts(content: &ChatMessageContent) -> Vec { + match content { + ChatMessageContent::String(s) => vec![json!({ "type": "text", "content": s })], + ChatMessageContent::Array(parts) => parts + .iter() + .map(|p| json!({ "type": p.r#type, "content": p.text })) + .collect(), + } +} + +fn tool_call_parts(calls: &[ChatMessageToolCall]) -> Vec { + calls + .iter() + .map(|c| { + json!({ + "type": "tool_call", + "id": c.id, + "name": c.function.name, + "arguments": parse_tool_arguments(&c.function.arguments), + }) + }) + .collect() +} + +fn input_message_json(message: &ChatCompletionMessage) -> Value { + // Tool-role messages carry a tool_call_response part keyed by tool_call_id. + if message.role == "tool" { + if let Some(id) = &message.tool_call_id { + let response_text = match &message.content { + Some(ChatMessageContent::String(s)) => s.clone(), + Some(ChatMessageContent::Array(parts)) => parts + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join(""), + None => String::new(), + }; + let mut obj = json!({ + "role": "tool", + "parts": [ { "type": "tool_call_response", "id": id, "response": response_text } ], + }); + if let Some(name) = &message.name { + obj["name"] = Value::String(name.clone()); + } + return obj; + } + } + + let mut parts = match &message.content { + Some(content) => content_to_text_parts(content), + None => Vec::new(), + }; + if let Some(calls) = &message.tool_calls { + parts.extend(tool_call_parts(calls)); + } + + let mut obj = json!({ "role": message.role, "parts": parts }); + if let Some(name) = &message.name { + obj["name"] = Value::String(name.clone()); + } + obj +} + +fn build_input_messages(messages: &[ChatCompletionMessage]) -> String { + let arr: Vec = messages.iter().map(input_message_json).collect(); + serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) +} + +fn output_message_json(choice: &ChatCompletionChoice) -> Value { + let mut parts = match &choice.message.content { + Some(content) => content_to_text_parts(content), + None => Vec::new(), + }; + if let Some(calls) = &choice.message.tool_calls { + parts.extend(tool_call_parts(calls)); + } + let mut obj = json!({ + "role": choice.message.role, + "parts": parts, + "finish_reason": map_finish_reason(choice.finish_reason.as_deref()), + }); + if let Some(name) = &choice.message.name { + obj["name"] = Value::String(name.clone()); + } + obj +} + +fn build_output_messages(choices: &[ChatCompletionChoice]) -> String { + let arr: Vec = choices.iter().map(output_message_json).collect(); + serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) +} + +fn build_completion_input_messages(prompt: &str) -> String { + let messages = json!([ + { "role": "user", "parts": [ { "type": "text", "content": prompt } ] } + ]); + messages.to_string() +} + +fn build_completion_output_messages(choices: &[CompletionChoice]) -> String { + let arr: Vec = choices + .iter() + .map(|c| { + json!({ + "role": "assistant", + "parts": [ { "type": "text", "content": c.text } ], + "finish_reason": map_finish_reason(c.finish_reason.as_deref()), + }) + }) + .collect(); + serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) +} + +fn set_finish_reasons<'a, I>(span: &mut BoxedSpan, reasons: I) +where + I: IntoIterator>, +{ + if let Some(values) = top_level_finish_reasons(reasons) { + span.set_attribute(KeyValue::new( + GEN_AI_RESPONSE_FINISH_REASONS, + string_array(values), + )); } } impl RecordSpan for ChatCompletionRequest { fn record_span(&self, span: &mut BoxedSpan) { - span.set_attribute(KeyValue::new("llm.request.type", "chat")); + span.set_attribute(KeyValue::new(GEN_AI_OPERATION_NAME, "chat")); span.set_attribute(KeyValue::new(GEN_AI_REQUEST_MODEL, self.model.clone())); if let Some(freq_penalty) = self.frequency_penalty { @@ -203,25 +379,15 @@ impl RecordSpan for ChatCompletionRequest { if let Some(temp) = self.temperature { span.set_attribute(KeyValue::new(GEN_AI_REQUEST_TEMPERATURE, temp as f64)); } + if let Some(max_tokens) = self.max_tokens { + span.set_attribute(KeyValue::new(GEN_AI_REQUEST_MAX_TOKENS, max_tokens as i64)); + } if get_trace_content_enabled() { - for (i, message) in self.messages.iter().enumerate() { - if let Some(content) = &message.content { - span.set_attribute(KeyValue::new( - format!("gen_ai.prompt.{i}.role"), - message.role.clone(), - )); - span.set_attribute(KeyValue::new( - format!("gen_ai.prompt.{i}.content"), - match &content { - ChatMessageContent::String(content) => content.clone(), - ChatMessageContent::Array(content) => { - serde_json::to_string(content).unwrap_or_default() - } - }, - )); - } - } + span.set_attribute(KeyValue::new( + GEN_AI_INPUT_MESSAGES, + build_input_messages(&self.messages), + )); } } } @@ -233,37 +399,24 @@ impl RecordSpan for ChatCompletion { self.usage.record_span(span); + set_finish_reasons( + span, + self.choices.iter().map(|c| c.finish_reason.as_deref()), + ); + if get_trace_content_enabled() { - for choice in &self.choices { - if let Some(content) = &choice.message.content { - span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.role", choice.index), - choice.message.role.clone(), - )); - span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.content", choice.index), - match &content { - ChatMessageContent::String(content) => content.clone(), - ChatMessageContent::Array(content) => { - serde_json::to_string(content).unwrap_or_default() - } - }, - )); - } - span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.finish_reason", choice.index), - choice.finish_reason.clone().unwrap_or_default(), - )); - } + span.set_attribute(KeyValue::new( + GEN_AI_OUTPUT_MESSAGES, + build_output_messages(&self.choices), + )); } } } impl RecordSpan for CompletionRequest { fn record_span(&self, span: &mut BoxedSpan) { - span.set_attribute(KeyValue::new("llm.request.type", "completion")); + span.set_attribute(KeyValue::new(GEN_AI_OPERATION_NAME, "text_completion")); span.set_attribute(KeyValue::new(GEN_AI_REQUEST_MODEL, self.model.clone())); - span.set_attribute(KeyValue::new("gen_ai.prompt", self.prompt.clone())); if let Some(freq_penalty) = self.frequency_penalty { span.set_attribute(KeyValue::new( @@ -283,6 +436,16 @@ impl RecordSpan for CompletionRequest { if let Some(temp) = self.temperature { span.set_attribute(KeyValue::new(GEN_AI_REQUEST_TEMPERATURE, temp as f64)); } + if let Some(max_tokens) = self.max_tokens { + span.set_attribute(KeyValue::new(GEN_AI_REQUEST_MAX_TOKENS, max_tokens as i64)); + } + + if get_trace_content_enabled() { + span.set_attribute(KeyValue::new( + GEN_AI_INPUT_MESSAGES, + build_completion_input_messages(&self.prompt), + )); + } } } @@ -293,18 +456,15 @@ impl RecordSpan for CompletionResponse { self.usage.record_span(span); - for choice in &self.choices { - span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.role", choice.index), - "assistant".to_string(), - )); - span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.content", choice.index), - choice.text.clone(), - )); + set_finish_reasons( + span, + self.choices.iter().map(|c| c.finish_reason.as_deref()), + ); + + if get_trace_content_enabled() { span.set_attribute(KeyValue::new( - format!("gen_ai.completion.{}.finish_reason", choice.index), - choice.finish_reason.clone().unwrap_or_default(), + GEN_AI_OUTPUT_MESSAGES, + build_completion_output_messages(&self.choices), )); } } @@ -312,48 +472,18 @@ impl RecordSpan for CompletionResponse { impl RecordSpan for EmbeddingsRequest { fn record_span(&self, span: &mut BoxedSpan) { - span.set_attribute(KeyValue::new("llm.request.type", "embeddings")); + span.set_attribute(KeyValue::new(GEN_AI_OPERATION_NAME, "embeddings")); span.set_attribute(KeyValue::new(GEN_AI_REQUEST_MODEL, self.model.clone())); - if get_trace_content_enabled() { - match &self.input { - EmbeddingsInput::Single(text) => { - span.set_attribute(KeyValue::new("llm.prompt.0.content", text.clone())); - } - EmbeddingsInput::Multiple(texts) => { - for (i, text) in texts.iter().enumerate() { - span.set_attribute(KeyValue::new( - format!("llm.prompt.{i}.role"), - "user".to_string(), - )); - span.set_attribute(KeyValue::new( - format!("llm.prompt.{i}.content"), - text.clone(), - )); - } - } - EmbeddingsInput::SingleTokenIds(token_ids) => { - span.set_attribute(KeyValue::new( - "llm.prompt.0.content", - format!("{token_ids:?}"), - )); - } - EmbeddingsInput::MultipleTokenIds(token_ids) => { - for (i, token_ids) in token_ids.iter().enumerate() { - span.set_attribute(KeyValue::new( - format!("llm.prompt.{i}.role"), - "user".to_string(), - )); - span.set_attribute(KeyValue::new( - format!("llm.prompt.{i}.content"), - format!("{token_ids:?}"), - )); - } - } - } + if let Some(encoding_format) = &self.encoding_format { + span.set_attribute(KeyValue::new( + GEN_AI_REQUEST_ENCODING_FORMATS, + string_array(vec![encoding_format.clone()]), + )); } } } + impl RecordSpan for EmbeddingsResponse { fn record_span(&self, span: &mut BoxedSpan) { span.set_attribute(KeyValue::new(GEN_AI_RESPONSE_MODEL, self.model.clone())); @@ -365,15 +495,15 @@ impl RecordSpan for EmbeddingsResponse { impl RecordSpan for Usage { fn record_span(&self, span: &mut BoxedSpan) { span.set_attribute(KeyValue::new( - "gen_ai.usage.prompt_tokens", + GEN_AI_USAGE_INPUT_TOKENS, self.prompt_tokens as i64, )); span.set_attribute(KeyValue::new( - "gen_ai.usage.completion_tokens", + GEN_AI_USAGE_OUTPUT_TOKENS, self.completion_tokens as i64, )); span.set_attribute(KeyValue::new( - "gen_ai.usage.total_tokens", + GEN_AI_USAGE_TOTAL_TOKENS, self.total_tokens as i64, )); } @@ -382,11 +512,11 @@ impl RecordSpan for Usage { impl RecordSpan for EmbeddingUsage { fn record_span(&self, span: &mut BoxedSpan) { span.set_attribute(KeyValue::new( - "gen_ai.usage.prompt_tokens", + GEN_AI_USAGE_INPUT_TOKENS, self.prompt_tokens.unwrap_or(0) as i64, )); span.set_attribute(KeyValue::new( - "gen_ai.usage.total_tokens", + GEN_AI_USAGE_TOTAL_TOKENS, self.total_tokens.unwrap_or(0) as i64, )); } @@ -395,34 +525,709 @@ impl RecordSpan for EmbeddingUsage { #[cfg(test)] mod tests { use super::*; + use crate::models::content::ChatMessageContentPart; + use crate::models::tool_calls::FunctionCall; use crate::providers::provider::get_vendor_name; use crate::types::ProviderType; #[test] fn test_get_vendor_name_mappings() { - // Test all provider type mappings to standardized vendor names assert_eq!(get_vendor_name(&ProviderType::OpenAI), "openai"); - assert_eq!(get_vendor_name(&ProviderType::Azure), "Azure"); - assert_eq!(get_vendor_name(&ProviderType::Anthropic), "Anthropic"); - assert_eq!(get_vendor_name(&ProviderType::Bedrock), "AWS"); - assert_eq!(get_vendor_name(&ProviderType::VertexAI), "Google"); + assert_eq!(get_vendor_name(&ProviderType::Azure), "azure.ai.openai"); + assert_eq!(get_vendor_name(&ProviderType::Anthropic), "anthropic"); + assert_eq!(get_vendor_name(&ProviderType::Bedrock), "aws.bedrock"); + assert_eq!(get_vendor_name(&ProviderType::VertexAI), "gcp.vertex_ai"); } #[test] fn test_set_vendor_method_exists() { - // Test that set_vendor method compiles and can be called - // This ensures the method signature is correct let mut tracer = OtelTracer { span: opentelemetry::global::tracer("test").start("test"), accumulated_completion: None, }; - // Call set_vendor with different vendor names - this tests the method exists and accepts strings - tracer.set_vendor("OpenAI"); - tracer.set_vendor("Anthropic"); - tracer.set_vendor("Azure"); + tracer.set_vendor("openai"); + tracer.set_vendor("anthropic"); + tracer.set_vendor("azure.ai.openai"); + } + + #[test] + fn test_map_finish_reason_provider_variants() { + assert_eq!(map_finish_reason(None), ""); + assert_eq!(map_finish_reason(Some("")), ""); + assert_eq!(map_finish_reason(Some("stop")), "stop"); + assert_eq!(map_finish_reason(Some("length")), "length"); + assert_eq!(map_finish_reason(Some("content_filter")), "content_filter"); + assert_eq!(map_finish_reason(Some("tool_calls")), "tool_call"); + assert_eq!(map_finish_reason(Some("function_call")), "tool_call"); + assert_eq!(map_finish_reason(Some("tool_use")), "tool_call"); + assert_eq!(map_finish_reason(Some("end_turn")), "stop"); + assert_eq!(map_finish_reason(Some("stop_sequence")), "stop"); + assert_eq!(map_finish_reason(Some("max_tokens")), "length"); + assert_eq!(map_finish_reason(Some("custom_reason")), "custom_reason"); + } + + #[test] + fn test_top_level_finish_reasons_omits_when_all_missing() { + let none: Vec> = vec![None, Some(""), None]; + assert!(top_level_finish_reasons(none).is_none()); + } + + #[test] + fn test_top_level_finish_reasons_keeps_order_and_maps() { + let reasons = vec![Some("stop"), None, Some("tool_calls"), Some("end_turn")]; + let out = top_level_finish_reasons(reasons).unwrap(); + assert_eq!(out, vec!["stop", "tool_call", "stop"]); + } + + fn user_text_message(text: &str) -> ChatCompletionMessage { + ChatCompletionMessage { + role: "user".to_string(), + content: Some(ChatMessageContent::String(text.to_string())), + name: None, + tool_calls: None, + tool_call_id: None, + refusal: None, + } + } + + #[test] + fn test_build_input_messages_string_content() { + let msgs = vec![user_text_message("hi")]; + let json_str = build_input_messages(&msgs); + let v: Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(v[0]["role"], "user"); + assert_eq!(v[0]["parts"][0]["type"], "text"); + assert_eq!(v[0]["parts"][0]["content"], "hi"); + } + + #[test] + fn test_build_input_messages_array_content_preserves_parts() { + let msg = ChatCompletionMessage { + role: "user".to_string(), + content: Some(ChatMessageContent::Array(vec![ + ChatMessageContentPart { + r#type: "text".to_string(), + text: "first".to_string(), + }, + ChatMessageContentPart { + r#type: "text".to_string(), + text: "second".to_string(), + }, + ])), + name: None, + tool_calls: None, + tool_call_id: None, + refusal: None, + }; + let json_str = build_input_messages(&[msg]); + let v: Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(v[0]["parts"].as_array().unwrap().len(), 2); + assert_eq!(v[0]["parts"][0]["content"], "first"); + assert_eq!(v[0]["parts"][1]["content"], "second"); + } + + #[test] + fn test_build_input_messages_assistant_with_tool_call_parses_arguments() { + let msg = ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + name: None, + tool_calls: Some(vec![ChatMessageToolCall { + id: "call_1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "get_weather".to_string(), + arguments: r#"{"city":"NYC"}"#.to_string(), + }, + }]), + tool_call_id: None, + refusal: None, + }; + let v: Value = serde_json::from_str(&build_input_messages(&[msg])).unwrap(); + assert_eq!(v[0]["role"], "assistant"); + let part = &v[0]["parts"][0]; + assert_eq!(part["type"], "tool_call"); + assert_eq!(part["id"], "call_1"); + assert_eq!(part["name"], "get_weather"); + assert_eq!(part["arguments"]["city"], "NYC"); + } + + #[test] + fn test_build_input_messages_tool_call_arguments_fallback_to_string() { + let msg = ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + name: None, + tool_calls: Some(vec![ChatMessageToolCall { + id: "call_1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "noop".to_string(), + arguments: "not json".to_string(), + }, + }]), + tool_call_id: None, + refusal: None, + }; + let v: Value = serde_json::from_str(&build_input_messages(&[msg])).unwrap(); + assert_eq!(v[0]["parts"][0]["arguments"], "not json"); + } + + #[test] + fn test_build_input_messages_tool_role_becomes_tool_call_response() { + let msg = ChatCompletionMessage { + role: "tool".to_string(), + content: Some(ChatMessageContent::String("72F sunny".to_string())), + name: None, + tool_calls: None, + tool_call_id: Some("call_1".to_string()), + refusal: None, + }; + let v: Value = serde_json::from_str(&build_input_messages(&[msg])).unwrap(); + assert_eq!(v[0]["role"], "tool"); + assert_eq!(v[0]["parts"][0]["type"], "tool_call_response"); + assert_eq!(v[0]["parts"][0]["id"], "call_1"); + assert_eq!(v[0]["parts"][0]["response"], "72F sunny"); + } + + #[test] + fn test_build_output_messages_finish_reason_required_even_when_absent() { + let choice = ChatCompletionChoice { + index: 0, + message: ChatCompletionMessage { + role: "assistant".to_string(), + content: Some(ChatMessageContent::String("hello".to_string())), + name: None, + tool_calls: None, + tool_call_id: None, + refusal: None, + }, + finish_reason: None, + logprobs: None, + }; + let v: Value = serde_json::from_str(&build_output_messages(&[choice])).unwrap(); + assert_eq!(v[0]["finish_reason"], ""); + } + + #[test] + fn test_build_output_messages_maps_finish_reason() { + let choice = ChatCompletionChoice { + index: 0, + message: ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + name: None, + tool_calls: Some(vec![ChatMessageToolCall { + id: "c1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "f".to_string(), + arguments: "{}".to_string(), + }, + }]), + tool_call_id: None, + refusal: None, + }, + finish_reason: Some("tool_calls".to_string()), + logprobs: None, + }; + let v: Value = serde_json::from_str(&build_output_messages(&[choice])).unwrap(); + assert_eq!(v[0]["finish_reason"], "tool_call"); + assert_eq!(v[0]["parts"][0]["type"], "tool_call"); + } + + fn msg( + role: &str, + content: Option, + name: Option<&str>, + tool_calls: Option>, + tool_call_id: Option<&str>, + ) -> ChatCompletionMessage { + ChatCompletionMessage { + role: role.to_string(), + content, + name: name.map(|s| s.to_string()), + tool_calls, + tool_call_id: tool_call_id.map(|s| s.to_string()), + refusal: None, + } + } + + #[test] + fn test_top_level_finish_reasons_single() { + let out = top_level_finish_reasons(vec![Some("stop")]).unwrap(); + assert_eq!(out, vec!["stop"]); + } + + #[test] + fn test_top_level_finish_reasons_all_mapped() { + let out = top_level_finish_reasons(vec![ + Some("stop"), + Some("max_tokens"), + Some("tool_calls"), + ]) + .unwrap(); + assert_eq!(out, vec!["stop", "length", "tool_call"]); + } + + #[test] + fn test_build_input_messages_empty_slice() { + assert_eq!(build_input_messages(&[]), "[]"); + } + + #[test] + fn test_build_input_messages_multi_turn_conversation() { + let messages = vec![ + msg( + "system", + Some(ChatMessageContent::String("be brief".to_string())), + None, + None, + None, + ), + msg( + "user", + Some(ChatMessageContent::String("weather?".to_string())), + None, + None, + None, + ), + msg( + "assistant", + Some(ChatMessageContent::String("Looking it up".to_string())), + None, + Some(vec![ChatMessageToolCall { + id: "call_1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "get_weather".to_string(), + arguments: r#"{"city":"NYC"}"#.to_string(), + }, + }]), + None, + ), + msg( + "tool", + Some(ChatMessageContent::String("72F".to_string())), + None, + None, + Some("call_1"), + ), + ]; + let v: Value = serde_json::from_str(&build_input_messages(&messages)).unwrap(); + let arr = v.as_array().unwrap(); + assert_eq!(arr.len(), 4); + assert_eq!(arr[0]["role"], "system"); + assert_eq!(arr[1]["role"], "user"); + assert_eq!(arr[2]["role"], "assistant"); + assert_eq!(arr[3]["role"], "tool"); + + let assistant_parts = arr[2]["parts"].as_array().unwrap(); + assert_eq!(assistant_parts.len(), 2); + assert_eq!(assistant_parts[0]["type"], "text"); + assert_eq!(assistant_parts[0]["content"], "Looking it up"); + assert_eq!(assistant_parts[1]["type"], "tool_call"); - // If this test passes, it means set_vendor() is working without panicking - assert!(true); + assert_eq!(arr[3]["parts"][0]["type"], "tool_call_response"); + } + + #[test] + fn test_build_input_messages_name_field() { + let m = msg( + "user", + Some(ChatMessageContent::String("hi".to_string())), + Some("alice"), + None, + None, + ); + let v: Value = serde_json::from_str(&build_input_messages(&[m])).unwrap(); + assert_eq!(v[0]["name"], "alice"); + } + + #[test] + fn test_build_input_messages_assistant_content_and_tool_calls() { + let m = msg( + "assistant", + Some(ChatMessageContent::String("calling".to_string())), + None, + Some(vec![ChatMessageToolCall { + id: "c1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "f".to_string(), + arguments: "{}".to_string(), + }, + }]), + None, + ); + let v: Value = serde_json::from_str(&build_input_messages(&[m])).unwrap(); + let parts = v[0]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0]["type"], "text"); + assert_eq!(parts[1]["type"], "tool_call"); + } + + #[test] + fn test_build_input_messages_multiple_tool_calls() { + let m = msg( + "assistant", + None, + None, + Some(vec![ + ChatMessageToolCall { + id: "c1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "a".to_string(), + arguments: "{}".to_string(), + }, + }, + ChatMessageToolCall { + id: "c2".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "b".to_string(), + arguments: "{}".to_string(), + }, + }, + ]), + None, + ); + let v: Value = serde_json::from_str(&build_input_messages(&[m])).unwrap(); + let parts = v[0]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0]["id"], "c1"); + assert_eq!(parts[0]["name"], "a"); + assert_eq!(parts[1]["id"], "c2"); + assert_eq!(parts[1]["name"], "b"); + } + + #[test] + fn test_build_input_messages_tool_role_array_content_joined() { + let m = msg( + "tool", + Some(ChatMessageContent::Array(vec![ + ChatMessageContentPart { + r#type: "text".to_string(), + text: "a".to_string(), + }, + ChatMessageContentPart { + r#type: "text".to_string(), + text: "b".to_string(), + }, + ])), + None, + None, + Some("call_1"), + ); + let v: Value = serde_json::from_str(&build_input_messages(&[m])).unwrap(); + assert_eq!(v[0]["parts"][0]["type"], "tool_call_response"); + assert_eq!(v[0]["parts"][0]["response"], "ab"); + } + + #[test] + fn test_build_input_messages_tool_role_without_id_fallback() { + let m = msg( + "tool", + Some(ChatMessageContent::String("result".to_string())), + None, + None, + None, + ); + let v: Value = serde_json::from_str(&build_input_messages(&[m])).unwrap(); + let parts = v[0]["parts"].as_array().unwrap(); + for part in parts { + assert_ne!(part["type"], "tool_call_response"); + } + assert_eq!(parts[0]["type"], "text"); + } + + #[test] + fn test_build_output_messages_empty_choices() { + assert_eq!(build_output_messages(&[]), "[]"); + } + + #[test] + fn test_build_output_messages_multi_choice_mixed_reasons() { + let make = |reason: Option<&str>| ChatCompletionChoice { + index: 0, + message: ChatCompletionMessage { + role: "assistant".to_string(), + content: Some(ChatMessageContent::String("x".to_string())), + name: None, + tool_calls: None, + tool_call_id: None, + refusal: None, + }, + finish_reason: reason.map(|s| s.to_string()), + logprobs: None, + }; + let choices = vec![ + make(Some("stop")), + make(Some("max_tokens")), + make(Some("tool_calls")), + make(None), + ]; + let v: Value = serde_json::from_str(&build_output_messages(&choices)).unwrap(); + let arr = v.as_array().unwrap(); + assert_eq!(arr.len(), 4); + assert_eq!(arr[0]["finish_reason"], "stop"); + assert_eq!(arr[1]["finish_reason"], "length"); + assert_eq!(arr[2]["finish_reason"], "tool_call"); + assert_eq!(arr[3]["finish_reason"], ""); + } + + #[test] + fn test_build_output_messages_name_field() { + let choice = ChatCompletionChoice { + index: 0, + message: ChatCompletionMessage { + role: "assistant".to_string(), + content: Some(ChatMessageContent::String("hi".to_string())), + name: Some("bob".to_string()), + tool_calls: None, + tool_call_id: None, + refusal: None, + }, + finish_reason: Some("stop".to_string()), + logprobs: None, + }; + let v: Value = serde_json::from_str(&build_output_messages(&[choice])).unwrap(); + assert_eq!(v[0]["name"], "bob"); + } + + #[test] + fn test_build_output_messages_content_and_tool_calls() { + let choice = ChatCompletionChoice { + index: 0, + message: ChatCompletionMessage { + role: "assistant".to_string(), + content: Some(ChatMessageContent::String("calling".to_string())), + name: None, + tool_calls: Some(vec![ChatMessageToolCall { + id: "c1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "f".to_string(), + arguments: "{}".to_string(), + }, + }]), + tool_call_id: None, + refusal: None, + }, + finish_reason: Some("tool_calls".to_string()), + logprobs: None, + }; + let v: Value = serde_json::from_str(&build_output_messages(&[choice])).unwrap(); + let parts = v[0]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0]["type"], "text"); + assert_eq!(parts[1]["type"], "tool_call"); + } + + #[test] + fn test_build_completion_input_messages_basic() { + let v: Value = + serde_json::from_str(&build_completion_input_messages("hello")).unwrap(); + let arr = v.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["role"], "user"); + let parts = arr[0]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0]["type"], "text"); + assert_eq!(parts[0]["content"], "hello"); + } + + #[test] + fn test_build_completion_input_messages_empty_prompt() { + let v: Value = + serde_json::from_str(&build_completion_input_messages("")).unwrap(); + assert_eq!(v[0]["parts"][0]["content"], ""); + } + + #[test] + fn test_build_completion_output_messages_empty() { + assert_eq!(build_completion_output_messages(&[]), "[]"); + } + + #[test] + fn test_build_completion_output_messages_multi_choice_mapping() { + let choices = vec![ + CompletionChoice { + text: "first".to_string(), + index: 0, + logprobs: None, + finish_reason: Some("max_tokens".to_string()), + }, + CompletionChoice { + text: "second".to_string(), + index: 1, + logprobs: None, + finish_reason: Some("stop".to_string()), + }, + CompletionChoice { + text: "third".to_string(), + index: 2, + logprobs: None, + finish_reason: None, + }, + ]; + let v: Value = + serde_json::from_str(&build_completion_output_messages(&choices)).unwrap(); + let arr = v.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(arr[0]["finish_reason"], "length"); + assert_eq!(arr[0]["parts"][0]["content"], "first"); + assert_eq!(arr[1]["finish_reason"], "stop"); + assert_eq!(arr[1]["parts"][0]["content"], "second"); + assert_eq!(arr[2]["finish_reason"], ""); + assert_eq!(arr[2]["parts"][0]["content"], "third"); + } + + use crate::models::streaming::{Choice, ChoiceDelta}; + + fn chunk( + index: u32, + role: Option<&str>, + content: Option<&str>, + tool_calls: Option>, + finish_reason: Option<&str>, + ) -> ChatCompletionChunk { + ChatCompletionChunk { + id: "chunk_id".to_string(), + model: "test".to_string(), + created: 0, + service_tier: None, + system_fingerprint: None, + usage: None, + choices: vec![Choice { + index, + delta: ChoiceDelta { + role: role.map(|s| s.to_string()), + content: content.map(|s| s.to_string()), + tool_calls, + reasoning: None, + }, + finish_reason: finish_reason.map(|s| s.to_string()), + logprobs: None, + }], + } + } + + fn fresh_tracer() -> OtelTracer { + OtelTracer { + span: opentelemetry::global::tracer("test").start("test"), + accumulated_completion: None, + } + } + + #[test] + fn test_streaming_single_chunk_with_finish_reason() { + let mut tracer = fresh_tracer(); + tracer.log_chunk(&chunk(0, Some("assistant"), Some("Hi"), None, Some("stop"))); + + let completion = tracer.accumulated_completion.as_ref().unwrap(); + assert_eq!(completion.choices.len(), 1); + let choice = &completion.choices[0]; + match &choice.message.content { + Some(ChatMessageContent::String(s)) => assert_eq!(s, "Hi"), + _ => panic!("expected string content"), + } + assert_eq!(choice.finish_reason.as_deref(), Some("stop")); + + let v: Value = + serde_json::from_str(&build_output_messages(&completion.choices)).unwrap(); + assert_eq!(v[0]["finish_reason"], "stop"); + assert_eq!(v[0]["parts"][0]["type"], "text"); + assert_eq!(v[0]["parts"][0]["content"], "Hi"); + } + + #[test] + fn test_streaming_multi_chunk_content_concat() { + let mut tracer = fresh_tracer(); + tracer.log_chunk(&chunk(0, Some("assistant"), Some("Hello "), None, None)); + tracer.log_chunk(&chunk(0, None, Some("world"), None, None)); + tracer.log_chunk(&chunk(0, None, None, None, Some("tool_calls"))); + + let completion = tracer.accumulated_completion.as_ref().unwrap(); + let choice = &completion.choices[0]; + match &choice.message.content { + Some(ChatMessageContent::String(s)) => assert_eq!(s, "Hello world"), + _ => panic!("expected string content"), + } + + let v: Value = + serde_json::from_str(&build_output_messages(&completion.choices)).unwrap(); + assert_eq!(v[0]["finish_reason"], "tool_call"); + } + + #[test] + fn test_streaming_tool_call_chunk() { + let mut tracer = fresh_tracer(); + tracer.log_chunk(&chunk(0, Some("assistant"), None, None, None)); + tracer.log_chunk(&chunk( + 0, + None, + None, + Some(vec![ChatMessageToolCall { + id: "call_1".to_string(), + r#type: "function".to_string(), + function: FunctionCall { + name: "get_weather".to_string(), + arguments: r#"{"city":"NYC"}"#.to_string(), + }, + }]), + None, + )); + tracer.log_chunk(&chunk(0, None, None, None, Some("tool_use"))); + + let completion = tracer.accumulated_completion.as_ref().unwrap(); + let choice = &completion.choices[0]; + assert!(choice.message.tool_calls.is_some()); + + let v: Value = + serde_json::from_str(&build_output_messages(&completion.choices)).unwrap(); + assert_eq!(v[0]["finish_reason"], "tool_call"); + let parts = v[0]["parts"].as_array().unwrap(); + assert!(parts.iter().any(|p| p["type"] == "tool_call")); + } + + #[test] + fn test_streaming_multi_choice_interleaved() { + let mut tracer = fresh_tracer(); + tracer.log_chunk(&chunk(0, Some("assistant"), Some("a-"), None, None)); + tracer.log_chunk(&chunk(1, Some("assistant"), Some("b-"), None, None)); + tracer.log_chunk(&chunk(0, None, Some("first"), None, None)); + tracer.log_chunk(&chunk(1, None, Some("second"), None, None)); + tracer.log_chunk(&chunk(0, None, None, None, Some("end_turn"))); + tracer.log_chunk(&chunk(1, None, None, None, Some("max_tokens"))); + + let completion = tracer.accumulated_completion.as_ref().unwrap(); + assert_eq!(completion.choices.len(), 2); + match &completion.choices[0].message.content { + Some(ChatMessageContent::String(s)) => assert_eq!(s, "a-first"), + _ => panic!("expected string content"), + } + match &completion.choices[1].message.content { + Some(ChatMessageContent::String(s)) => assert_eq!(s, "b-second"), + _ => panic!("expected string content"), + } + + let out = top_level_finish_reasons( + completion + .choices + .iter() + .map(|c| c.finish_reason.as_deref()), + ) + .unwrap(); + assert_eq!(out, vec!["stop", "length"]); + } + + #[test] + fn test_streaming_end_is_safe_with_no_chunks() { + let mut tracer = fresh_tracer(); + tracer.streaming_end(); + assert!(tracer.accumulated_completion.is_none()); } } diff --git a/src/pipelines/pipeline.rs b/src/pipelines/pipeline.rs index 459ca0fe..e3ce1ac5 100644 --- a/src/pipelines/pipeline.rs +++ b/src/pipelines/pipeline.rs @@ -452,10 +452,10 @@ mod tests { #[test] fn test_vendor_mapping_integration() { assert_eq!(get_vendor_name(&ProviderType::OpenAI), "openai"); - assert_eq!(get_vendor_name(&ProviderType::Anthropic), "Anthropic"); - assert_eq!(get_vendor_name(&ProviderType::Azure), "Azure"); - assert_eq!(get_vendor_name(&ProviderType::Bedrock), "AWS"); - assert_eq!(get_vendor_name(&ProviderType::VertexAI), "Google"); + assert_eq!(get_vendor_name(&ProviderType::Anthropic), "anthropic"); + assert_eq!(get_vendor_name(&ProviderType::Azure), "azure.ai.openai"); + assert_eq!(get_vendor_name(&ProviderType::Bedrock), "aws.bedrock"); + assert_eq!(get_vendor_name(&ProviderType::VertexAI), "gcp.vertex_ai"); } // ── Configurable mock provider for header tests ────────────────────── diff --git a/src/providers/provider.rs b/src/providers/provider.rs index 0aada038..05329bf7 100644 --- a/src/providers/provider.rs +++ b/src/providers/provider.rs @@ -35,13 +35,13 @@ pub trait Provider: Send + Sync { ) -> Result; } -/// Maps provider type enum to standardized vendor names for OTEL reporting +/// Maps provider type enum to OTel GenAI `gen_ai.provider.name` well-known values. pub fn get_vendor_name(provider_type: &ProviderType) -> Cow<'static, str> { match provider_type { ProviderType::OpenAI => Cow::Borrowed("openai"), - ProviderType::Azure => Cow::Borrowed("Azure"), - ProviderType::Anthropic => Cow::Borrowed("Anthropic"), - ProviderType::Bedrock => Cow::Borrowed("AWS"), - ProviderType::VertexAI => Cow::Borrowed("Google"), + ProviderType::Azure => Cow::Borrowed("azure.ai.openai"), + ProviderType::Anthropic => Cow::Borrowed("anthropic"), + ProviderType::Bedrock => Cow::Borrowed("aws.bedrock"), + ProviderType::VertexAI => Cow::Borrowed("gcp.vertex_ai"), } }