diff --git a/crates/core/src/codec/mod.rs b/crates/core/src/codec/mod.rs index a92f00826..cd688f907 100644 --- a/crates/core/src/codec/mod.rs +++ b/crates/core/src/codec/mod.rs @@ -16,6 +16,7 @@ pub mod anthropic; pub mod model_pricing; +pub mod oci_genai; pub mod openai_chat; pub mod openai_responses; pub mod optimization; diff --git a/crates/core/src/codec/oci_genai.rs b/crates/core/src/codec/oci_genai.rs new file mode 100644 index 000000000..167e98943 --- /dev/null +++ b/crates/core/src/codec/oci_genai.rs @@ -0,0 +1,453 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Built-in codec for the Oracle Cloud Infrastructure (OCI) Generative AI chat API. +//! +//! Implements [`LlmResponseCodec`] (response decode) for the OCI Generative AI +//! chat format. +//! +//! # OCI-specific patterns handled +//! +//! - **Three API formats** selected by the response `apiFormat`: `GENERIC` +//! (`choices`-based, OpenAI-style), `COHERE` (`text`-based), and `COHEREV2` +//! (single `message` with typed content parts and nested `function` tool +//! calls, per the OCI `CohereChatResponseV2` schema). +//! - **Responses**: `ChatResult` payloads (`modelId`, `chatResponse`); `usage` +//! counters are `promptTokens`/`completionTokens`/`totalTokens`. +//! - **Unmodeled fields are preserved**: envelope and chat-response fields the +//! normalized shape does not model (`timeCreated`, future provider fields) +//! are carried in `extra` rather than discarded, consistent with the other +//! response codecs. Unmodeled fields of the decoded choice and assistant +//! message — `logprobs`, `serviceTier`, `groundingMetadata`, +//! `reasoningContent`, `refusal` (GENERIC) and `toolPlan`, `citations` +//! (COHEREV2) per the OCI schema — are namespaced in `extra` under +//! `"choice"` and `"message"`. +//! +//! The codec accepts the REST wire format only: camelCase keys, as documented +//! in the OCI API reference. Alternate renderings produced by Oracle tooling +//! (the CLI's kebab-case `data` envelope, `oci.util.to_dict()` snake_case +//! dicts) are the caller's responsibility to convert. + +use crate::error::{FlowError, Result}; +use crate::json::Json; + +use super::request::{ContentPart, MessageContent, ProviderNativeComponent}; +use super::response::{ + AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, ResponseToolCall, Usage, +}; +use super::traits::LlmResponseCodec; + +// --------------------------------------------------------------------------- +// Public codec struct +// --------------------------------------------------------------------------- + +/// Built-in codec for the OCI Generative AI chat API. +pub struct OCIGenAIChatCodec; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Map an OCI finish reason string to normalized [`FinishReason`]. +/// +/// GENERIC responses use OpenAI-style lowercase reasons (Gemini models emit +/// `max_tokens` for the length stop); COHERE and COHEREV2 responses use +/// UPPERCASE Cohere reasons (`TOOL_CALL` and `STOP_SEQUENCE` are V2-only). +fn map_oci_finish_reason(reason: &str) -> FinishReason { + match reason { + "stop" | "COMPLETE" | "STOP_SEQUENCE" => FinishReason::Complete, + "length" | "max_tokens" | "MAX_TOKENS" => FinishReason::Length, + "tool_calls" | "TOOL_CALL" => FinishReason::ToolUse, + "content_filter" => FinishReason::ContentFilter, + other => FinishReason::Unknown(other.to_string()), + } +} + +/// Collect the fields of `obj` that are not in `modeled` for `extra` carriage. +fn unmodeled_fields( + obj: &serde_json::Map, + modeled: &[&str], +) -> serde_json::Map { + obj.iter() + .filter(|(key, _)| !modeled.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn native_component(value: &Json) -> ProviderNativeComponent { + ProviderNativeComponent { + provider: "oci_genai".to_string(), + kind: value + .get("type") + .and_then(Json::as_str) + .unwrap_or("unknown") + .to_string(), + value: value.clone(), + } +} + +// --------------------------------------------------------------------------- +// GENERIC content conversion +// --------------------------------------------------------------------------- + +/// Flatten a GENERIC content value into normalized [`MessageContent`]. +/// +/// A content-part list whose parts are all `{"type": "TEXT", "text": ...}` is +/// flattened to plain text; lists carrying any non-text part are preserved as +/// typed parts so image or future block types survive losslessly. +fn decode_generic_content(value: Option<&Json>) -> Result> { + let value = match value { + None | Some(Json::Null) => return Ok(None), + Some(value) => value, + }; + if let Some(text) = value.as_str() { + return Ok(Some(MessageContent::Text(text.to_string()))); + } + let parts = value.as_array().ok_or_else(|| { + FlowError::InvalidArgument( + "OCI GenAI GENERIC message content must be a string, an array, or null".into(), + ) + })?; + if parts.is_empty() { + // Tool-call-only messages carry `"content": []`; there is no content. + return Ok(None); + } + if let Some(text) = flatten_all_text_parts(parts) { + return Ok(Some(MessageContent::Text(text))); + } + let parts = parts + .iter() + .map(decode_generic_content_part) + .collect::>>()?; + Ok(Some(MessageContent::Parts(parts))) +} + +/// Join a part list into plain text when every part is a `TEXT` part. +fn flatten_all_text_parts(parts: &[Json]) -> Option { + let mut text = String::new(); + for part in parts { + let obj = part.as_object()?; + if obj.get("type").and_then(Json::as_str) != Some("TEXT") { + return None; + } + match obj.get("text") { + None | Some(Json::Null) => {} + Some(Json::String(part_text)) => text.push_str(part_text), + Some(_) => return None, + } + } + Some(text) +} + +fn decode_generic_content_part(value: &Json) -> Result { + let Some(obj) = value.as_object() else { + return Err(FlowError::InvalidArgument( + "OCI GenAI GENERIC content part must be an object".into(), + )); + }; + match obj.get("type").and_then(Json::as_str) { + Some("TEXT") => Ok(ContentPart::Text { + text: obj + .get("text") + .and_then(Json::as_str) + .unwrap_or_default() + .to_string(), + extra: obj + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "type" | "text")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + }), + _ => { + let native = native_component(value); + Ok(ContentPart::ProviderNative { + provider: native.provider, + kind: native.kind, + value: native.value, + }) + } + } +} + +// --------------------------------------------------------------------------- +// LlmResponseCodec implementation +// --------------------------------------------------------------------------- + +impl LlmResponseCodec for OCIGenAIChatCodec { + fn decode_response(&self, response: &Json) -> Result { + let Some(obj) = response.as_object() else { + // Non-object responses are preserved raw so observability still + // captures whatever the provider path produced. + let mut extra = serde_json::Map::new(); + extra.insert("raw".to_string(), response.clone()); + return Ok(AnnotatedLlmResponse { + extra, + ..AnnotatedLlmResponse::default() + }); + }; + + let (envelope, chat_response) = match obj.get("chatResponse").and_then(Json::as_object) { + Some(chat_response) => (Some(obj), chat_response), + None => (None, obj), + }; + + let model = envelope + .and_then(|envelope| envelope.get("modelId")) + .and_then(Json::as_str) + .map(str::to_string); + let model_version = envelope + .and_then(|envelope| envelope.get("modelVersion")) + .and_then(Json::as_str) + .map(str::to_string); + let api_format = chat_response + .get("apiFormat") + .and_then(Json::as_str) + .unwrap_or("GENERIC") + .to_uppercase(); + + let (message, tool_calls, finish_reason, nested_extra) = match api_format.as_str() { + "COHERE" => decode_cohere_response_body(chat_response), + "COHEREV2" => decode_cohere_v2_response_body(chat_response)?, + _ => decode_generic_response_body(chat_response)?, + }; + + let id = if api_format == "COHEREV2" { + chat_response + .get("id") + .and_then(Json::as_str) + .map(str::to_string) + } else { + None + }; + + let usage = chat_response + .get("usage") + .and_then(Json::as_object) + .map(decode_oci_usage); + + // Preserve fields the normalized shape does not model so observability + // keeps timeCreated, service tiers, grounding metadata, and future + // provider fields. + let modeled_response_keys: &[&str] = match api_format.as_str() { + "COHERE" => &["apiFormat", "text", "finishReason", "toolCalls", "usage"], + "COHEREV2" => &["apiFormat", "id", "message", "finishReason", "usage"], + _ => &["apiFormat", "choices", "usage"], + }; + let mut extra = match envelope { + Some(envelope) => { + unmodeled_fields(envelope, &["chatResponse", "modelId", "modelVersion"]) + } + None => serde_json::Map::new(), + }; + extra.extend(unmodeled_fields(chat_response, modeled_response_keys)); + extra.extend(nested_extra); + + Ok(AnnotatedLlmResponse { + id, + model, + message, + tool_calls, + finish_reason: finish_reason.as_deref().map(map_oci_finish_reason), + usage, + optimization_summary: None, + api_specific: Some(ApiSpecificResponse::OCIGenAI { + api_format: Some(api_format), + model_version, + }), + extra, + }) + } +} + +type ResponseBody = ( + Option, + Option>, + Option, + serde_json::Map, +); + +/// Keys of the decoded GENERIC choice consumed by the normalized shape. +/// +/// `index` is excluded from `extra` carriage as well: it is positional trivia +/// (always `0` for the single decoded choice) rather than provider data. +const MODELED_CHOICE_KEYS: &[&str] = &["message", "finishReason", "index"]; + +/// Keys of a decoded assistant message consumed by the normalized shape. +const MODELED_MESSAGE_KEYS: &[&str] = &["role", "content", "toolCalls"]; + +/// Namespace unmodeled fields of a decoded nested container under `key`. +/// +/// The choice-level fields of GENERIC responses (`logprobs`, `usage`, +/// `groundingMetadata`, `serviceTier`) and the message-level fields of +/// GENERIC (`refusal`, `annotations`, `reasoningContent`) and COHEREV2 +/// (`toolPlan`, `citations`) responses are documented in the OCI schema but +/// not normalized; they are carried in `extra` under the container's wire key +/// so their origin stays unambiguous. +fn nest_unmodeled_fields( + extra: &mut serde_json::Map, + key: &str, + obj: &serde_json::Map, + modeled: &[&str], +) { + let unmodeled = unmodeled_fields(obj, modeled); + if !unmodeled.is_empty() { + extra.insert(key.to_string(), Json::Object(unmodeled)); + } +} + +fn decode_generic_response_body( + chat_response: &serde_json::Map, +) -> Result { + let mut nested_extra = serde_json::Map::new(); + let Some(first_choice) = chat_response + .get("choices") + .and_then(Json::as_array) + .and_then(|choices| choices.first()) + .and_then(Json::as_object) + else { + return Ok((None, None, None, nested_extra)); + }; + nest_unmodeled_fields( + &mut nested_extra, + "choice", + first_choice, + MODELED_CHOICE_KEYS, + ); + let finish_reason = first_choice + .get("finishReason") + .and_then(Json::as_str) + .map(str::to_string); + let Some(raw_message) = first_choice.get("message").and_then(Json::as_object) else { + return Ok((None, None, finish_reason, nested_extra)); + }; + nest_unmodeled_fields( + &mut nested_extra, + "message", + raw_message, + MODELED_MESSAGE_KEYS, + ); + let message = decode_generic_content(raw_message.get("content"))?; + let tool_calls = raw_message + .get("toolCalls") + .and_then(Json::as_array) + .map(|calls| decode_response_tool_calls(calls)) + .filter(|calls: &Vec| !calls.is_empty()); + Ok((message, tool_calls, finish_reason, nested_extra)) +} + +fn decode_cohere_response_body(chat_response: &serde_json::Map) -> ResponseBody { + let message = chat_response + .get("text") + .and_then(Json::as_str) + .map(|text| MessageContent::Text(text.to_string())); + let tool_calls = chat_response + .get("toolCalls") + .and_then(Json::as_array) + .map(|calls| decode_response_tool_calls(calls)) + .filter(|calls| !calls.is_empty()); + let finish_reason = chat_response + .get("finishReason") + .and_then(Json::as_str) + .map(str::to_string); + // COHERE (v1) is flat: unmodeled fields live directly on the chat + // response and are already carried by the chat-response-level pass. + (message, tool_calls, finish_reason, serde_json::Map::new()) +} + +/// Decode a COHEREV2 (`CohereChatResponseV2`) body: a single assistant +/// `message` whose `content` is a typed part list (`TEXT`, `THINKING`, +/// `IMAGE_URL`, `DOCUMENT`) and whose tool calls nest an OpenAI-style +/// `function` object. +fn decode_cohere_v2_response_body( + chat_response: &serde_json::Map, +) -> Result { + let mut nested_extra = serde_json::Map::new(); + let finish_reason = chat_response + .get("finishReason") + .and_then(Json::as_str) + .map(str::to_string); + let Some(raw_message) = chat_response.get("message").and_then(Json::as_object) else { + return Ok((None, None, finish_reason, nested_extra)); + }; + nest_unmodeled_fields( + &mut nested_extra, + "message", + raw_message, + MODELED_MESSAGE_KEYS, + ); + let message = decode_generic_content(raw_message.get("content"))?; + let tool_calls = raw_message + .get("toolCalls") + .and_then(Json::as_array) + .map(|calls| decode_response_tool_calls(calls)) + .filter(|calls: &Vec| !calls.is_empty()); + Ok((message, tool_calls, finish_reason, nested_extra)) +} + +/// Convert an OCI response tool-call list into [`ResponseToolCall`]s. +fn decode_response_tool_calls(calls: &[Json]) -> Vec { + calls + .iter() + .enumerate() + .filter_map(|(index, call)| decode_response_tool_call(index, call)) + .collect() +} + +/// Convert an OCI response tool call into [`ResponseToolCall`]. +/// +/// GENERIC calls are flat (`{id, type, name, arguments}`) with `arguments` as a +/// JSON-encoded string; COHERE calls carry `name` plus parsed `parameters` and +/// no `id`, so a positional `call_{index}` id is synthesized to keep parallel +/// calls distinguishable; COHEREV2 calls nest `name`/`arguments` under an +/// OpenAI-style `function` object next to the `id`. +fn decode_response_tool_call(index: usize, value: &Json) -> Option { + let obj = value.as_object()?; + let body = obj.get("function").and_then(Json::as_object).unwrap_or(obj); + let name = body.get("name")?.as_str()?.to_string(); + let arguments = match body.get("arguments") { + Some(Json::String(text)) => { + // CRITICAL: GENERIC arguments arrive JSON-encoded; parse for the + // normalized shape, preserving the raw string when unparseable. + serde_json::from_str::(text).unwrap_or_else(|_| Json::String(text.clone())) + } + Some(other) => other.clone(), + None => body.get("parameters").cloned().unwrap_or(Json::Null), + }; + let id = match obj.get("id").and_then(Json::as_str) { + Some(id) => id.to_string(), + None => format!("call_{index}"), + }; + Some(ResponseToolCall { + id, + name, + arguments, + }) +} + +/// Map OCI usage counters onto the normalized [`Usage`] field names. +/// +/// OpenAI and xAI models report cache hits under +/// `promptTokensDetails.cachedTokens`. +fn decode_oci_usage(usage: &serde_json::Map) -> Usage { + let cache_read_tokens = usage + .get("promptTokensDetails") + .and_then(Json::as_object) + .and_then(|details| details.get("cachedTokens")) + .and_then(Json::as_u64); + Usage { + prompt_tokens: usage.get("promptTokens").and_then(Json::as_u64), + completion_tokens: usage.get("completionTokens").and_then(Json::as_u64), + total_tokens: usage.get("totalTokens").and_then(Json::as_u64), + cache_read_tokens, + cache_write_tokens: None, + cost: None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[path = "../../tests/unit/codec/oci_genai_tests.rs"] +mod tests; diff --git a/crates/core/src/observability/otel_genai.rs b/crates/core/src/observability/otel_genai.rs index aab9698e2..df3d16bb4 100644 --- a/crates/core/src/observability/otel_genai.rs +++ b/crates/core/src/observability/otel_genai.rs @@ -394,6 +394,9 @@ fn provider_from_normalized_request(event: &Event) -> Option<&'static str> { ApiSpecificRequest::OpenAIChat { .. } | ApiSpecificRequest::OpenAIResponses { .. } => { Some("openai") } + // Not an OTel well-known value yet; follows the dotted cloud-provider + // convention (`aws.bedrock`, `gcp.gemini`). + ApiSpecificRequest::OCIGenAI { .. } => Some("oci.genai"), ApiSpecificRequest::Custom { .. } => None, } } diff --git a/crates/core/tests/integration/pipeline_tests.rs b/crates/core/tests/integration/pipeline_tests.rs index 1bc192b4b..74cb044f1 100644 --- a/crates/core/tests/integration/pipeline_tests.rs +++ b/crates/core/tests/integration/pipeline_tests.rs @@ -30,6 +30,7 @@ use nemo_relay::api::runtime::{create_scope_stack, set_thread_scope_stack}; use nemo_relay::api::scope::ScopeType; use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::codec::anthropic::AnthropicMessagesCodec; +use nemo_relay::codec::oci_genai::OCIGenAIChatCodec; use nemo_relay::codec::openai_chat::OpenAIChatCodec; use nemo_relay::codec::optimization::{ LlmOptimizationContribution, LlmOptimizationKind, LlmOptimizationModel, @@ -1430,6 +1431,90 @@ async fn test_response_codec_populates_annotated_response() { deregister_subscriber("resp_codec_sub").unwrap(); } +#[tokio::test] +async fn test_oci_genai_response_codec_populates_annotated_response() { + let _lock = TEST_MUTEX.lock().unwrap(); + reset_global(); + setup_isolated_thread(); + + let events = Arc::new(Mutex::new(Vec::new())); + let ec = events.clone(); + register_subscriber( + "oci_resp_codec_sub", + Arc::new(move |e: &Event| { + ec.lock().unwrap().push(e.clone()); + }), + ) + .unwrap(); + + // Shape observed from a live OCI Generative AI GENERIC tool-call response. + let func: LlmExecutionNextFn = Arc::new(|_req| { + Box::pin(async move { + Ok(json!({ + "modelId": "meta.llama-4-maverick-17b-128e-instruct-fp8", + "modelVersion": "1.0.0", + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{ + "index": 0, + "message": { + "role": "ASSISTANT", + "toolCalls": [{ + "type": "FUNCTION", + "id": "chatcmpl-tool-bda9d62eab5cea3c", + "name": "get_weather", + "arguments": "{\"city\": \"Paris\"}" + }] + }, + "finishReason": "tool_calls" + }], + "usage": {"promptTokens": 627, "completionTokens": 13, "totalTokens": 640} + } + })) + }) + }); + let response_codec: Arc = Arc::new(OCIGenAIChatCodec); + + let _result = llm_call_execute( + LlmCallExecuteParams::builder() + .name("oci_genai") + .request(make_llm_request( + json!({"messages": [{"role": "USER", "content": [{"type": "TEXT", "text": "hi"}]}]}), + )) + .func(func) + .response_codec(response_codec) + .build(), + ) + .await + .unwrap(); + + let captured = captured_events_snapshot(&events); + let end_event = captured + .iter() + .find(|e| is_scope_event(e, ScopeType::Llm, ScopeCategory::End)) + .expect("expected LlmEnd event"); + + let annotated = end_event + .annotated_response() + .expect("annotated_response should be Some when the OCI codec is active"); + assert_eq!( + annotated.model.as_deref(), + Some("meta.llama-4-maverick-17b-128e-instruct-fp8") + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::ToolUse)); + assert_eq!(annotated.message, None); + let tool_calls = annotated.tool_calls.as_ref().expect("tool calls decoded"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].name, "get_weather"); + assert_eq!(tool_calls[0].arguments, json!({"city": "Paris"})); + let usage = annotated.usage.as_ref().expect("usage decoded"); + assert_eq!(usage.prompt_tokens, Some(627)); + assert_eq!(usage.completion_tokens, Some(13)); + assert_eq!(usage.total_tokens, Some(640)); + + deregister_subscriber("oci_resp_codec_sub").unwrap(); +} + #[tokio::test] async fn test_response_codec_annotation_uses_sanitized_managed_response() { let _lock = TEST_MUTEX.lock().unwrap(); diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs new file mode 100644 index 000000000..3b4c9e62b --- /dev/null +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -0,0 +1,507 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for the OCI Generative AI codec in the NeMo Relay core crate. + +use super::*; +use serde_json::json; + +use super::super::request::{ContentPart, MessageContent}; +use super::super::response::{ApiSpecificResponse, FinishReason}; + +// ------------------------------------------------------------------- +// Helpers and fixtures +// ------------------------------------------------------------------- + +const DEDICATED_ENDPOINT: &str = "ocid1.generativeaiendpoint.oc1.us-chicago-1.example"; + +/// Shape observed from a live dedicated-endpoint chat (imported NVIDIA Nemotron 3). +fn generic_chat_result() -> Json { + json!({ + "modelId": DEDICATED_ENDPOINT, + "modelVersion": "1.0", + "chatResponse": { + "apiFormat": "GENERIC", + "timeCreated": "2026-07-23T22:59:00.000Z", + "choices": [ + { + "index": 0, + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "NEMOTRON3_OK"}] + }, + "finishReason": "stop" + } + ], + "usage": {"promptTokens": 18, "completionTokens": 5, "totalTokens": 23} + } + }) +} + +fn cohere_chat_result() -> Json { + json!({ + "modelId": "cohere.command-a-03-2025", + "chatResponse": { + "apiFormat": "COHERE", + "text": "Sunny and 72.", + "finishReason": "COMPLETE", + "usage": {"promptTokens": 12, "completionTokens": 4, "totalTokens": 16} + } + }) +} + +// =================================================================== +// Response decode tests +// =================================================================== + +#[test] +fn test_generic_chat_result() { + let annotated = OCIGenAIChatCodec + .decode_response(&generic_chat_result()) + .unwrap(); + + assert_eq!(annotated.model.as_deref(), Some(DEDICATED_ENDPOINT)); + assert_eq!( + annotated.message, + Some(MessageContent::Text("NEMOTRON3_OK".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); + + let usage = annotated.usage.as_ref().unwrap(); + assert_eq!(usage.prompt_tokens, Some(18)); + assert_eq!(usage.completion_tokens, Some(5)); + assert_eq!(usage.total_tokens, Some(23)); + + assert_eq!( + annotated.api_specific, + Some(ApiSpecificResponse::OCIGenAI { + api_format: Some("GENERIC".into()), + model_version: Some("1.0".into()), + }) + ); +} + +#[test] +fn test_cohere_chat_result() { + let annotated = OCIGenAIChatCodec + .decode_response(&cohere_chat_result()) + .unwrap(); + + assert_eq!( + annotated.message, + Some(MessageContent::Text("Sunny and 72.".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); + assert_eq!(annotated.model.as_deref(), Some("cohere.command-a-03-2025")); + assert_eq!( + annotated.api_specific, + Some(ApiSpecificResponse::OCIGenAI { + api_format: Some("COHERE".into()), + model_version: None, + }) + ); +} + +#[test] +fn test_non_wire_renderings_are_not_decoded() { + // The codec accepts the REST wire format only (camelCase). Alternate + // renderings from Oracle tooling (CLI kebab-case, SDK-dict snake_case) + // are the caller's responsibility to convert first. + let snake_cased = json!({ + "model_id": DEDICATED_ENDPOINT, + "chat_response": { + "api_format": "GENERIC", + "choices": [{ + "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hello"}]}, + "finish_reason": "stop" + }] + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&snake_cased).unwrap(); + + assert_eq!(annotated.model, None); + assert_eq!(annotated.message, None); + assert_eq!(annotated.finish_reason, None); + + // CLI output: kebab-case keys wrapped in a `data` envelope. + let cli_shaped = json!({ + "data": { + "model-id": DEDICATED_ENDPOINT, + "chat-response": { + "api-format": "GENERIC", + "choices": [{ + "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hello"}]}, + "finish-reason": "stop" + }], + "usage": {"total-tokens": 9} + } + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&cli_shaped).unwrap(); + + assert_eq!(annotated.model, None); + assert_eq!(annotated.message, None); + assert_eq!(annotated.finish_reason, None); + assert_eq!(annotated.usage, None); +} + +#[test] +fn test_non_dict_response() { + let annotated = OCIGenAIChatCodec + .decode_response(&json!("plain text")) + .unwrap(); + assert_eq!(annotated.extra.get("raw"), Some(&json!("plain text"))); + assert_eq!(annotated.message, None); +} + +#[test] +fn test_response_tool_calls_parse_string_arguments() { + let raw = json!({ + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{ + "message": { + "role": "ASSISTANT", + "content": [], + "toolCalls": [{ + "id": "call-9", + "type": "FUNCTION", + "name": "get_weather", + "arguments": "{\"city\": \"NYC\"}" + }] + }, + "finishReason": "tool_calls" + }] + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&raw).unwrap(); + + assert_eq!(annotated.finish_reason, Some(FinishReason::ToolUse)); + let tool_calls = annotated.tool_calls.as_ref().unwrap(); + assert_eq!(tool_calls[0].id, "call-9"); + assert_eq!(tool_calls[0].name, "get_weather"); + assert_eq!(tool_calls[0].arguments, json!({"city": "NYC"})); + // A tool-call-only message with `"content": []` has no assistant content. + assert_eq!(annotated.message, None); +} + +#[test] +fn test_non_text_parts_preserved_as_provider_native() { + let response = json!({ + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{ + "message": { + "role": "ASSISTANT", + "content": [ + {"type": "TEXT", "text": "see image"}, + {"type": "IMAGE", "imageUrl": {"url": "https://example.com/x.png"}} + ] + }, + "finishReason": "stop" + }] + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + let Some(MessageContent::Parts(parts)) = annotated.message else { + panic!("expected typed parts, got {:?}", annotated.message); + }; + assert_eq!(parts.len(), 2); + assert!(matches!(&parts[0], ContentPart::Text { text, .. } if text == "see image")); + let ContentPart::ProviderNative { + provider, + kind, + value, + } = &parts[1] + else { + panic!("expected ProviderNative part, got {:?}", parts[1]); + }; + assert_eq!(provider, "oci_genai"); + assert_eq!(kind, "IMAGE"); + assert_eq!(value["imageUrl"]["url"], json!("https://example.com/x.png")); +} + +#[test] +fn test_invalid_generic_content_shape_errors() { + for bad_content in [json!(42), json!({"type": "TEXT"}), json!([17])] { + let response = json!({ + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{ + "message": {"role": "ASSISTANT", "content": bad_content}, + "finishReason": "stop" + }] + } + }); + let error = OCIGenAIChatCodec.decode_response(&response).unwrap_err(); + assert!( + matches!(error, crate::error::FlowError::InvalidArgument(_)), + "expected InvalidArgument, got {error:?}" + ); + } +} + +#[test] +fn test_cohere_parallel_tool_calls_get_positional_ids() { + // Shape observed live: COHERE tool calls carry no `id`, so parallel calls + // must receive distinct synthesized ids. + let response = json!({ + "modelId": "cohere.command-r-08-2024", + "chatResponse": { + "apiFormat": "COHERE", + "text": "I will use the tool for each city.", + "finishReason": "COMPLETE", + "toolCalls": [ + {"name": "get_weather", "parameters": {"city": "Paris"}}, + {"name": "get_weather", "parameters": {"city": "Rome"}} + ] + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + let tool_calls = annotated.tool_calls.as_ref().unwrap(); + assert_eq!(tool_calls.len(), 2); + assert_eq!(tool_calls[0].id, "call_0"); + assert_eq!(tool_calls[1].id, "call_1"); + assert_eq!(tool_calls[0].arguments, json!({"city": "Paris"})); + assert_eq!(tool_calls[1].arguments, json!({"city": "Rome"})); +} + +#[test] +fn test_usage_cached_tokens_mapped_to_cache_read() { + // Shape observed live from OpenAI and xAI models on OCI: cache hits are + // reported under `promptTokensDetails.cachedTokens`. + let response = json!({ + "modelId": "xai.grok-3-mini", + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{ + "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hi"}]}, + "finishReason": "stop" + }], + "usage": { + "promptTokens": 13, + "completionTokens": 8, + "totalTokens": 607, + "promptTokensDetails": {"cachedTokens": 3}, + "completionTokensDetails": {"reasoningTokens": 586} + } + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + let usage = annotated.usage.as_ref().unwrap(); + assert_eq!(usage.prompt_tokens, Some(13)); + assert_eq!(usage.cache_read_tokens, Some(3)); + assert_eq!(usage.cache_write_tokens, None); +} + +#[test] +fn test_cohere_v2_chat_result() { + // Shape per the OCI `CohereChatResponseV2` schema (apiFormat COHEREV2): + // a single assistant message with typed content parts and nested-function + // tool calls. Confirmed against the live service (us-chicago-1, + // 2026-07-29): the wire matches this schema, including provider-supplied + // nested-function tool-call ids, JSON-encoded string arguments, and + // message-level toolPlan/citations. + let response = json!({ + "modelId": "cohere.command-a-03-2025", + "modelVersion": "2.0", + "chatResponse": { + "apiFormat": "COHEREV2", + "id": "resp-v2-123", + "message": { + "role": "ASSISTANT", + "content": [ + {"type": "THINKING", "thinking": "I should call the tool."}, + {"type": "TEXT", "text": "Checking the weather."} + ], + "toolCalls": [{ + "id": "call-v2-1", + "type": "FUNCTION", + "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"} + }], + // Message-level per the OCI CohereAssistantMessageV2 schema. + "toolPlan": "I will check the weather.", + "citations": [{"start": 0, "end": 8, "text": "Checking"}] + }, + "finishReason": "TOOL_CALL", + "usage": {"promptTokens": 20, "completionTokens": 15, "totalTokens": 35} + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + // Grounding metadata and the tool plan are not normalized but must + // survive, namespaced under the message they came from. + assert_eq!( + annotated.extra.get("message"), + Some(&json!({ + "toolPlan": "I will check the weather.", + "citations": [{"start": 0, "end": 8, "text": "Checking"}] + })) + ); + + assert_eq!(annotated.id.as_deref(), Some("resp-v2-123")); + assert_eq!(annotated.model.as_deref(), Some("cohere.command-a-03-2025")); + assert_eq!(annotated.finish_reason, Some(FinishReason::ToolUse)); + + let Some(MessageContent::Parts(parts)) = &annotated.message else { + panic!("expected typed parts, got {:?}", annotated.message); + }; + assert_eq!(parts.len(), 2); + assert!( + matches!(&parts[0], ContentPart::ProviderNative { kind, .. } if kind == "THINKING"), + "THINKING content should be preserved as a provider-native part" + ); + assert!(matches!(&parts[1], ContentPart::Text { text, .. } if text == "Checking the weather.")); + + let tool_calls = annotated.tool_calls.as_ref().unwrap(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id, "call-v2-1"); + assert_eq!(tool_calls[0].name, "get_weather"); + assert_eq!(tool_calls[0].arguments, json!({"city": "Paris"})); + + let usage = annotated.usage.as_ref().unwrap(); + assert_eq!(usage.total_tokens, Some(35)); + assert_eq!( + annotated.api_specific, + Some(ApiSpecificResponse::OCIGenAI { + api_format: Some("COHEREV2".into()), + model_version: Some("2.0".into()), + }) + ); +} + +#[test] +fn test_cohere_v2_text_only_flattens() { + let response = json!({ + "chatResponse": { + "apiFormat": "COHEREV2", + "id": "resp-v2-456", + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "Sunny and 72."}] + }, + "finishReason": "COMPLETE" + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + assert_eq!(annotated.id.as_deref(), Some("resp-v2-456")); + assert_eq!( + annotated.message, + Some(MessageContent::Text("Sunny and 72.".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); +} + +#[test] +fn test_unmodeled_response_fields_preserved_in_extra() { + // GENERIC: timeCreated and serviceTier are not normalized but must + // survive; envelope-level unknown fields likewise. + let generic = json!({ + "modelId": DEDICATED_ENDPOINT, + "modelVersion": "1.0", + "futureEnvelopeField": {"nested": true}, + "chatResponse": { + "apiFormat": "GENERIC", + "timeCreated": "2026-07-27T17:27:25.871Z", + "choices": [{ + "index": 0, + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "hi"}], + "refusal": null, + "reasoningContent": "chain of thought" + }, + "finishReason": "stop", + // Choice-level per the OCI ChatChoice schema. + "serviceTier": "default", + "groundingMetadata": {"sources": ["doc-1"]}, + "logprobs": {"tokenLogprobs": [-0.1]} + }], + "usage": {"totalTokens": 9} + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&generic).unwrap(); + + assert_eq!( + annotated.extra.get("timeCreated"), + Some(&json!("2026-07-27T17:27:25.871Z")) + ); + assert_eq!( + annotated.extra.get("futureEnvelopeField"), + Some(&json!({"nested": true})) + ); + // Choice- and message-level unmodeled fields are namespaced by origin. + assert_eq!( + annotated.extra.get("choice"), + Some(&json!({ + "serviceTier": "default", + "groundingMetadata": {"sources": ["doc-1"]}, + "logprobs": {"tokenLogprobs": [-0.1]} + })) + ); + assert_eq!( + annotated.extra.get("message"), + Some(&json!({"refusal": null, "reasoningContent": "chain of thought"})) + ); + // Modeled fields stay normalized-only. + for modeled in [ + "apiFormat", + "choices", + "usage", + "chatResponse", + "modelId", + "modelVersion", + ] { + assert!( + !annotated.extra.contains_key(modeled), + "{modeled} should not be duplicated into extra" + ); + } + + // COHERE: chatHistory is not normalized and must survive. + let cohere = json!({ + "modelId": "cohere.command-r-08-2024", + "chatResponse": { + "apiFormat": "COHERE", + "text": "hi", + "chatHistory": [{"role": "USER", "message": "hello"}], + "finishReason": "COMPLETE" + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&cohere).unwrap(); + assert_eq!( + annotated.extra.get("chatHistory"), + Some(&json!([{"role": "USER", "message": "hello"}])) + ); +} + +#[test] +fn test_finish_reason_mapping() { + for (raw, expected) in [ + ("stop", FinishReason::Complete), + ("length", FinishReason::Length), + ("tool_calls", FinishReason::ToolUse), + ("content_filter", FinishReason::ContentFilter), + ("COMPLETE", FinishReason::Complete), + ("MAX_TOKENS", FinishReason::Length), + // Live Gemini-on-OCI responses use the lowercase spelling. + ("max_tokens", FinishReason::Length), + // COHEREV2 reasons per the OCI CohereChatResponseV2 schema. + ("TOOL_CALL", FinishReason::ToolUse), + ("STOP_SEQUENCE", FinishReason::Complete), + ("weird", FinishReason::Unknown("weird".into())), + ] { + let response = json!({ + "chatResponse": { + "apiFormat": "GENERIC", + "choices": [{"message": {"role": "ASSISTANT", "content": []}, "finishReason": raw}] + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + assert_eq!(annotated.finish_reason, Some(expected), "for {raw}"); + } +} diff --git a/crates/types/src/codec/request.rs b/crates/types/src/codec/request.rs index 1167b92e6..68bdca806 100644 --- a/crates/types/src/codec/request.rs +++ b/crates/types/src/codec/request.rs @@ -534,6 +534,19 @@ pub enum ApiSpecificRequest { #[serde(skip_serializing_if = "Option::is_none")] text: Option, }, + /// OCI Generative AI-specific request fields. + #[serde(rename = "oci_genai")] + OCIGenAI { + /// Compartment OCID from the `ChatDetails` envelope. + #[serde(skip_serializing_if = "Option::is_none")] + compartment_id: Option, + /// Serving mode object (`servingType` plus `modelId` or `endpointId`). + #[serde(skip_serializing_if = "Option::is_none")] + serving_mode: Option, + /// Chat request API format (`GENERIC` or `COHERE`). + #[serde(skip_serializing_if = "Option::is_none")] + api_format: Option, + }, /// Custom provider request fields. #[serde(rename = "custom")] Custom { diff --git a/crates/types/src/codec/response.rs b/crates/types/src/codec/response.rs index 731617966..17bc8752c 100644 --- a/crates/types/src/codec/response.rs +++ b/crates/types/src/codec/response.rs @@ -326,6 +326,17 @@ pub enum ApiSpecificResponse { content_blocks: Option>, }, + /// OCI Generative AI-specific fields. + #[serde(rename = "oci_genai")] + OCIGenAI { + /// Chat response API format (`GENERIC` or `COHERE`). + #[serde(skip_serializing_if = "Option::is_none")] + api_format: Option, + /// Model version reported on the `ChatResult` envelope. + #[serde(skip_serializing_if = "Option::is_none")] + model_version: Option, + }, + /// Custom/unknown API -- catch-all for user-implemented codecs. #[serde(rename = "custom")] Custom {