From 1b8a31f1019160a2e8e73145de70d847416ab7ea Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Fri, 24 Jul 2026 11:15:37 -0400 Subject: [PATCH 1/8] feat(codec): add OCI GenAI typed variants and response decoding Introduce the first layer of the OCI Generative AI provider codec: typed ApiSpecificRequest::OCIGenAI and ApiSpecificResponse::OCIGenAI variants, plus an OCIGenAIChatCodec implementing LlmResponseCodec. Response decode covers ChatResult ({modelId, chatResponse}) and bare chat responses in both apiFormat variants (GENERIC choices-based and COHERE text-based), tolerates camelCase, kebab-case, and snake_case key conventions (SDK vs CLI shapes), parses GENERIC string-encoded tool-call arguments, maps promptTokens/completionTokens/totalTokens into Usage, and maps finish reasons (stop/COMPLETE -> complete, length/MAX_TOKENS -> length, tool_calls -> tool_use, else unknown). Request encode/decode, streaming, and provider-surface registration follow in subsequent changes. Signed-off-by: Federico Kamelhar --- crates/core/src/codec/mod.rs | 1 + crates/core/src/codec/oci_genai.rs | 353 ++++++++++++++++++ .../core/tests/unit/codec/oci_genai_tests.rs | 247 ++++++++++++ crates/types/src/codec/request.rs | 13 + crates/types/src/codec/response.rs | 11 + 5 files changed, 625 insertions(+) create mode 100644 crates/core/src/codec/oci_genai.rs create mode 100644 crates/core/tests/unit/codec/oci_genai_tests.rs 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..82d6cfd21 --- /dev/null +++ b/crates/core/src/codec/oci_genai.rs @@ -0,0 +1,353 @@ +// 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 +//! +//! - **Two API formats** selected by the response `apiFormat`: `GENERIC` +//! (`choices`-based, OpenAI-style) and `COHERE` (`text`-based). +//! - **Key conventions**: The OCI SDKs emit camelCase JSON while the OCI CLI +//! emits kebab-case; decode tolerates camelCase, kebab-case, and snake_case. +//! - **Responses**: `ChatResult` payloads (`modelId`, `chatResponse`) where the +//! chat response is `choices`-based for `GENERIC` and `text`-based for +//! `COHERE`; `usage` counters are `promptTokens`/`completionTokens`/`totalTokens`. + +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; + +// --------------------------------------------------------------------------- +// Key-convention helpers +// --------------------------------------------------------------------------- + +/// Convert a camelCase key to its kebab-case form (`maxTokens` -> `max-tokens`). +fn camel_to_kebab(key: &str) -> String { + camel_with_separator(key, '-') +} + +/// Convert a camelCase key to its snake_case form (`maxTokens` -> `max_tokens`). +fn camel_to_snake(key: &str) -> String { + camel_with_separator(key, '_') +} + +fn camel_with_separator(key: &str, separator: char) -> String { + let mut out = String::with_capacity(key.len() + 4); + for c in key.chars() { + if c.is_ascii_uppercase() { + out.push(separator); + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// Return the value for the first present key spelling across naming conventions. +/// +/// The OCI SDKs emit camelCase JSON while the OCI CLI emits kebab-case; callers +/// pass camelCase keys and kebab-case/snake_case fallbacks are derived. +fn get_first<'a>(obj: &'a serde_json::Map, key: &str) -> Option<&'a Json> { + present_key(obj, key).and_then(|present| obj.get(&present)) +} + +/// Return the concrete key spelling present in `obj` for a camelCase `key`. +fn present_key(obj: &serde_json::Map, key: &str) -> Option { + if obj.contains_key(key) { + return Some(key.to_string()); + } + let kebab = camel_to_kebab(key); + if obj.contains_key(&kebab) { + return Some(kebab); + } + let snake = camel_to_snake(key); + if obj.contains_key(&snake) { + return Some(snake); + } + None +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Map an OCI finish reason string to normalized [`FinishReason`]. +/// +/// GENERIC responses use OpenAI-style lowercase reasons; COHERE responses use +/// UPPERCASE Cohere reasons. +fn map_oci_finish_reason(reason: &str) -> FinishReason { + match reason { + "stop" | "COMPLETE" => FinishReason::Complete, + "length" | "MAX_TOKENS" => FinishReason::Length, + "tool_calls" => FinishReason::ToolUse, + other => FinishReason::Unknown(other.to_string()), + } +} + +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 get_first(obj, "type").and_then(Json::as_str) != Some("TEXT") { + return None; + } + match get_first(obj, "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 get_first(obj, "type").and_then(Json::as_str) { + Some("TEXT") => Ok(ContentPart::Text { + text: get_first(obj, "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 get_first(obj, "chatResponse").and_then(Json::as_object) { + Some(chat_response) => (Some(obj), chat_response), + None => (None, obj), + }; + + let model = envelope + .and_then(|envelope| get_first(envelope, "modelId")) + .and_then(Json::as_str) + .map(str::to_string); + let model_version = envelope + .and_then(|envelope| get_first(envelope, "modelVersion")) + .and_then(Json::as_str) + .map(str::to_string); + let api_format = get_first(chat_response, "apiFormat") + .and_then(Json::as_str) + .unwrap_or("GENERIC") + .to_uppercase(); + + let (message, tool_calls, finish_reason) = if api_format == "COHERE" { + decode_cohere_response_body(chat_response) + } else { + decode_generic_response_body(chat_response)? + }; + + let usage = get_first(chat_response, "usage") + .and_then(Json::as_object) + .map(decode_oci_usage); + + Ok(AnnotatedLlmResponse { + id: None, + 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: serde_json::Map::new(), + }) + } +} + +type ResponseBody = ( + Option, + Option>, + Option, +); + +fn decode_generic_response_body( + chat_response: &serde_json::Map, +) -> Result { + let Some(first_choice) = get_first(chat_response, "choices") + .and_then(Json::as_array) + .and_then(|choices| choices.first()) + .and_then(Json::as_object) + else { + return Ok((None, None, None)); + }; + let finish_reason = get_first(first_choice, "finishReason") + .and_then(Json::as_str) + .map(str::to_string); + let Some(raw_message) = get_first(first_choice, "message").and_then(Json::as_object) else { + return Ok((None, None, finish_reason)); + }; + let message = decode_generic_content(get_first(raw_message, "content"))?; + let tool_calls = get_first(raw_message, "toolCalls") + .and_then(Json::as_array) + .map(|calls| calls.iter().filter_map(decode_response_tool_call).collect()) + .filter(|calls: &Vec| !calls.is_empty()); + Ok((message, tool_calls, finish_reason)) +} + +fn decode_cohere_response_body(chat_response: &serde_json::Map) -> ResponseBody { + let message = get_first(chat_response, "text") + .and_then(Json::as_str) + .map(|text| MessageContent::Text(text.to_string())); + let tool_calls = get_first(chat_response, "toolCalls") + .and_then(Json::as_array) + .map(|calls| { + calls + .iter() + .filter_map(decode_response_tool_call) + .collect::>() + }) + .filter(|calls| !calls.is_empty()); + let finish_reason = get_first(chat_response, "finishReason") + .and_then(Json::as_str) + .map(str::to_string); + (message, tool_calls, finish_reason) +} + +/// 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`. +fn decode_response_tool_call(value: &Json) -> Option { + let obj = value.as_object()?; + let name = get_first(obj, "name")?.as_str()?.to_string(); + let arguments = match get_first(obj, "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 => get_first(obj, "parameters").cloned().unwrap_or(Json::Null), + }; + Some(ResponseToolCall { + id: get_first(obj, "id") + .and_then(Json::as_str) + .unwrap_or_default() + .to_string(), + name, + arguments, + }) +} + +/// Map OCI usage counters onto the normalized [`Usage`] field names. +fn decode_oci_usage(usage: &serde_json::Map) -> Usage { + Usage { + prompt_tokens: get_first(usage, "promptTokens").and_then(Json::as_u64), + completion_tokens: get_first(usage, "completionTokens").and_then(Json::as_u64), + total_tokens: get_first(usage, "totalTokens").and_then(Json::as_u64), + cache_read_tokens: None, + cache_write_tokens: None, + cost: None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[path = "../../tests/unit/codec/oci_genai_tests.rs"] +mod tests; 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..bbaf26818 --- /dev/null +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -0,0 +1,247 @@ +// 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_kebab_case_cli_shape() { + let cli_shaped = json!({ + "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.as_deref(), Some(DEDICATED_ENDPOINT)); + assert_eq!( + annotated.message, + Some(MessageContent::Text("hello".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); + assert_eq!(annotated.usage.as_ref().unwrap().total_tokens, Some(9)); +} + +#[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_finish_reason_mapping() { + for (raw, expected) in [ + ("stop", FinishReason::Complete), + ("length", FinishReason::Length), + ("tool_calls", FinishReason::ToolUse), + ("COMPLETE", FinishReason::Complete), + ("MAX_TOKENS", FinishReason::Length), + ("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 { From ad9f167663ca4785b78b4fc30cd8020d1059bc11 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Mon, 27 Jul 2026 14:35:37 -0400 Subject: [PATCH 2/8] feat(codec): cover all OCI GenAI transports and live-observed shapes Validated the codec against live OCI Generative AI responses across model families (Meta, OpenAI, Google, xAI, Cohere) and the three renderings Oracle tooling emits for the documented camelCase schema. Changes from that sweep: - Unwrap the OCI CLI data envelope so captured CLI output decodes like wire and SDK payloads; document the transport matrix in the module doc (REST wire/SDKs camelCase, oci.util.to_dict() snake_case, CLI kebab-case in a data envelope) - Map the lowercase max_tokens finish reason emitted by Gemini models to FinishReason::Length - Map promptTokensDetails.cachedTokens (OpenAI/xAI models) to cache_read_tokens instead of dropping it - Synthesize positional call_{index} ids for COHERE tool calls, which carry no id on the wire, so parallel calls stay distinguishable - Add unit tests for each live-observed shape and a pipeline integration test decoding an OCI GENERIC tool-call response through llm_call_execute Signed-off-by: Federico Kamelhar --- crates/core/src/codec/oci_genai.rs | 64 ++++++--- .../core/tests/integration/pipeline_tests.rs | 85 +++++++++++ .../core/tests/unit/codec/oci_genai_tests.rs | 134 ++++++++++++++++++ 3 files changed, 264 insertions(+), 19 deletions(-) diff --git a/crates/core/src/codec/oci_genai.rs b/crates/core/src/codec/oci_genai.rs index 82d6cfd21..80aa42d30 100644 --- a/crates/core/src/codec/oci_genai.rs +++ b/crates/core/src/codec/oci_genai.rs @@ -10,8 +10,12 @@ //! //! - **Two API formats** selected by the response `apiFormat`: `GENERIC` //! (`choices`-based, OpenAI-style) and `COHERE` (`text`-based). -//! - **Key conventions**: The OCI SDKs emit camelCase JSON while the OCI CLI -//! emits kebab-case; decode tolerates camelCase, kebab-case, and snake_case. +//! - **Key conventions**: The same documented schema reaches the codec in the +//! three renderings Oracle tooling emits: the REST wire and most SDKs use +//! camelCase, `oci.util.to_dict()` on Python SDK models yields snake_case, +//! and the OCI CLI prints kebab-case wrapped in a `data` envelope. Decode +//! derives the kebab/snake spellings mechanically from the camelCase key and +//! unwraps the CLI `data` envelope. //! - **Responses**: `ChatResult` payloads (`modelId`, `chatResponse`) where the //! chat response is `choices`-based for `GENERIC` and `text`-based for //! `COHERE`; `usage` counters are `promptTokens`/`completionTokens`/`totalTokens`. @@ -89,12 +93,13 @@ fn present_key(obj: &serde_json::Map, key: &str) -> Option /// Map an OCI finish reason string to normalized [`FinishReason`]. /// -/// GENERIC responses use OpenAI-style lowercase reasons; COHERE responses use -/// UPPERCASE Cohere reasons. +/// GENERIC responses use OpenAI-style lowercase reasons (Gemini models emit +/// `max_tokens` for the length stop); COHERE responses use UPPERCASE Cohere +/// reasons. fn map_oci_finish_reason(reason: &str) -> FinishReason { match reason { "stop" | "COMPLETE" => FinishReason::Complete, - "length" | "MAX_TOKENS" => FinishReason::Length, + "length" | "max_tokens" | "MAX_TOKENS" => FinishReason::Length, "tool_calls" => FinishReason::ToolUse, other => FinishReason::Unknown(other.to_string()), } @@ -211,6 +216,13 @@ impl LlmResponseCodec for OCIGenAIChatCodec { }); }; + // The OCI CLI wraps chat output in a `data` envelope; unwrap it so + // captured CLI payloads decode like SDK and wire payloads. + let obj = match obj.get("data").and_then(Json::as_object) { + Some(data) if present_key(data, "chatResponse").is_some() => data, + _ => obj, + }; + let (envelope, chat_response) = match get_first(obj, "chatResponse").and_then(Json::as_object) { Some(chat_response) => (Some(obj), chat_response), @@ -282,7 +294,7 @@ fn decode_generic_response_body( let message = decode_generic_content(get_first(raw_message, "content"))?; let tool_calls = get_first(raw_message, "toolCalls") .and_then(Json::as_array) - .map(|calls| calls.iter().filter_map(decode_response_tool_call).collect()) + .map(|calls| decode_response_tool_calls(calls)) .filter(|calls: &Vec| !calls.is_empty()); Ok((message, tool_calls, finish_reason)) } @@ -293,12 +305,7 @@ fn decode_cohere_response_body(chat_response: &serde_json::Map) -> .map(|text| MessageContent::Text(text.to_string())); let tool_calls = get_first(chat_response, "toolCalls") .and_then(Json::as_array) - .map(|calls| { - calls - .iter() - .filter_map(decode_response_tool_call) - .collect::>() - }) + .map(|calls| decode_response_tool_calls(calls)) .filter(|calls| !calls.is_empty()); let finish_reason = get_first(chat_response, "finishReason") .and_then(Json::as_str) @@ -306,11 +313,22 @@ fn decode_cohere_response_body(chat_response: &serde_json::Map) -> (message, tool_calls, finish_reason) } +/// 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`. -fn decode_response_tool_call(value: &Json) -> Option { +/// 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. +fn decode_response_tool_call(index: usize, value: &Json) -> Option { let obj = value.as_object()?; let name = get_first(obj, "name")?.as_str()?.to_string(); let arguments = match get_first(obj, "arguments") { @@ -322,23 +340,31 @@ fn decode_response_tool_call(value: &Json) -> Option { Some(other) => other.clone(), None => get_first(obj, "parameters").cloned().unwrap_or(Json::Null), }; + let id = match get_first(obj, "id").and_then(Json::as_str) { + Some(id) => id.to_string(), + None => format!("call_{index}"), + }; Some(ResponseToolCall { - id: get_first(obj, "id") - .and_then(Json::as_str) - .unwrap_or_default() - .to_string(), + 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 = get_first(usage, "promptTokensDetails") + .and_then(Json::as_object) + .and_then(|details| get_first(details, "cachedTokens")) + .and_then(Json::as_u64); Usage { prompt_tokens: get_first(usage, "promptTokens").and_then(Json::as_u64), completion_tokens: get_first(usage, "completionTokens").and_then(Json::as_u64), total_tokens: get_first(usage, "totalTokens").and_then(Json::as_u64), - cache_read_tokens: None, + cache_read_tokens, cache_write_tokens: None, cost: None, } diff --git a/crates/core/tests/integration/pipeline_tests.rs b/crates/core/tests/integration/pipeline_tests.rs index 918407663..b14d44b22 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 index bbaf26818..323a74abb 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -128,6 +128,83 @@ fn test_kebab_case_cli_shape() { assert_eq!(annotated.usage.as_ref().unwrap().total_tokens, Some(9)); } +#[test] +fn test_cli_data_envelope_unwrapped() { + // Shape observed from live `oci generative-ai-inference chat-result chat` + // output: kebab-case keys wrapped in a `data` envelope. + let cli_output = json!({ + "data": { + "model-id": "meta.llama-4-maverick-17b-128e-instruct-fp8", + "model-version": "1.0.0", + "chat-response": { + "api-format": "GENERIC", + "choices": [ + { + "finish-reason": "stop", + "index": 0, + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "Hello, how are you today?"}], + "tool-calls": [] + } + } + ], + "usage": {"completion-tokens": 8, "prompt-tokens": 16, "total-tokens": 24} + } + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&cli_output).unwrap(); + + assert_eq!( + annotated.model.as_deref(), + Some("meta.llama-4-maverick-17b-128e-instruct-fp8") + ); + assert_eq!( + annotated.message, + Some(MessageContent::Text("Hello, how are you today?".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); + assert_eq!(annotated.usage.as_ref().unwrap().prompt_tokens, Some(16)); + assert_eq!(annotated.tool_calls, None); +} + +#[test] +fn test_snake_case_sdk_dict_shape() { + // Shape observed from live `oci.util.to_dict(response.data)` on a Python + // SDK ChatResult: snake_case keys, no envelope wrapper. + let sdk_dict = json!({ + "model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8", + "model_version": "1.0.0", + "chat_response": { + "api_format": "GENERIC", + "choices": [ + { + "index": 0, + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "Hello, it's nice to meet."}], + "tool_calls": [] + }, + "finish_reason": "stop" + } + ], + "usage": {"completion_tokens": 8, "prompt_tokens": 16, "total_tokens": 24} + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&sdk_dict).unwrap(); + + assert_eq!( + annotated.model.as_deref(), + Some("meta.llama-4-maverick-17b-128e-instruct-fp8") + ); + assert_eq!( + annotated.message, + Some(MessageContent::Text("Hello, it's nice to meet.".into())) + ); + assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); + assert_eq!(annotated.usage.as_ref().unwrap().total_tokens, Some(24)); +} + #[test] fn test_non_dict_response() { let annotated = OCIGenAIChatCodec @@ -225,6 +302,61 @@ fn test_invalid_generic_content_shape_errors() { } } +#[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_finish_reason_mapping() { for (raw, expected) in [ @@ -233,6 +365,8 @@ fn test_finish_reason_mapping() { ("tool_calls", FinishReason::ToolUse), ("COMPLETE", FinishReason::Complete), ("MAX_TOKENS", FinishReason::Length), + // Live Gemini-on-OCI responses use the lowercase spelling. + ("max_tokens", FinishReason::Length), ("weird", FinishReason::Unknown("weird".into())), ] { let response = json!({ From 7282cdd4c9c879814a76617f98eee543028b9c44 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Mon, 27 Jul 2026 17:53:44 -0400 Subject: [PATCH 3/8] refactor(codec): restrict OCI GenAI decode to the REST wire format Per review, Relay only interfaces with the wire format, so the codec now accepts the documented camelCase schema only. Removes the kebab-case/snake_case key derivation and the CLI data-envelope unwrapping; converting alternate renderings produced by Oracle tooling is the caller's responsibility. Adds a contract test asserting non-wire renderings are not decoded. Signed-off-by: Federico Kamelhar --- crates/core/src/codec/oci_genai.rs | 136 ++++++------------ .../core/tests/unit/codec/oci_genai_tests.rs | 109 ++------------ 2 files changed, 57 insertions(+), 188 deletions(-) diff --git a/crates/core/src/codec/oci_genai.rs b/crates/core/src/codec/oci_genai.rs index 80aa42d30..26db22c96 100644 --- a/crates/core/src/codec/oci_genai.rs +++ b/crates/core/src/codec/oci_genai.rs @@ -10,15 +10,14 @@ //! //! - **Two API formats** selected by the response `apiFormat`: `GENERIC` //! (`choices`-based, OpenAI-style) and `COHERE` (`text`-based). -//! - **Key conventions**: The same documented schema reaches the codec in the -//! three renderings Oracle tooling emits: the REST wire and most SDKs use -//! camelCase, `oci.util.to_dict()` on Python SDK models yields snake_case, -//! and the OCI CLI prints kebab-case wrapped in a `data` envelope. Decode -//! derives the kebab/snake spellings mechanically from the camelCase key and -//! unwraps the CLI `data` envelope. //! - **Responses**: `ChatResult` payloads (`modelId`, `chatResponse`) where the //! chat response is `choices`-based for `GENERIC` and `text`-based for //! `COHERE`; `usage` counters are `promptTokens`/`completionTokens`/`totalTokens`. +//! +//! 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; @@ -36,57 +35,6 @@ use super::traits::LlmResponseCodec; /// Built-in codec for the OCI Generative AI chat API. pub struct OCIGenAIChatCodec; -// --------------------------------------------------------------------------- -// Key-convention helpers -// --------------------------------------------------------------------------- - -/// Convert a camelCase key to its kebab-case form (`maxTokens` -> `max-tokens`). -fn camel_to_kebab(key: &str) -> String { - camel_with_separator(key, '-') -} - -/// Convert a camelCase key to its snake_case form (`maxTokens` -> `max_tokens`). -fn camel_to_snake(key: &str) -> String { - camel_with_separator(key, '_') -} - -fn camel_with_separator(key: &str, separator: char) -> String { - let mut out = String::with_capacity(key.len() + 4); - for c in key.chars() { - if c.is_ascii_uppercase() { - out.push(separator); - out.push(c.to_ascii_lowercase()); - } else { - out.push(c); - } - } - out -} - -/// Return the value for the first present key spelling across naming conventions. -/// -/// The OCI SDKs emit camelCase JSON while the OCI CLI emits kebab-case; callers -/// pass camelCase keys and kebab-case/snake_case fallbacks are derived. -fn get_first<'a>(obj: &'a serde_json::Map, key: &str) -> Option<&'a Json> { - present_key(obj, key).and_then(|present| obj.get(&present)) -} - -/// Return the concrete key spelling present in `obj` for a camelCase `key`. -fn present_key(obj: &serde_json::Map, key: &str) -> Option { - if obj.contains_key(key) { - return Some(key.to_string()); - } - let kebab = camel_to_kebab(key); - if obj.contains_key(&kebab) { - return Some(kebab); - } - let snake = camel_to_snake(key); - if obj.contains_key(&snake) { - return Some(snake); - } - None -} - // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -158,10 +106,10 @@ fn flatten_all_text_parts(parts: &[Json]) -> Option { let mut text = String::new(); for part in parts { let obj = part.as_object()?; - if get_first(obj, "type").and_then(Json::as_str) != Some("TEXT") { + if obj.get("type").and_then(Json::as_str) != Some("TEXT") { return None; } - match get_first(obj, "text") { + match obj.get("text") { None | Some(Json::Null) => {} Some(Json::String(part_text)) => text.push_str(part_text), Some(_) => return None, @@ -176,9 +124,10 @@ fn decode_generic_content_part(value: &Json) -> Result { "OCI GenAI GENERIC content part must be an object".into(), )); }; - match get_first(obj, "type").and_then(Json::as_str) { + match obj.get("type").and_then(Json::as_str) { Some("TEXT") => Ok(ContentPart::Text { - text: get_first(obj, "text") + text: obj + .get("text") .and_then(Json::as_str) .unwrap_or_default() .to_string(), @@ -216,28 +165,21 @@ impl LlmResponseCodec for OCIGenAIChatCodec { }); }; - // The OCI CLI wraps chat output in a `data` envelope; unwrap it so - // captured CLI payloads decode like SDK and wire payloads. - let obj = match obj.get("data").and_then(Json::as_object) { - Some(data) if present_key(data, "chatResponse").is_some() => data, - _ => obj, + let (envelope, chat_response) = match obj.get("chatResponse").and_then(Json::as_object) { + Some(chat_response) => (Some(obj), chat_response), + None => (None, obj), }; - let (envelope, chat_response) = - match get_first(obj, "chatResponse").and_then(Json::as_object) { - Some(chat_response) => (Some(obj), chat_response), - None => (None, obj), - }; - let model = envelope - .and_then(|envelope| get_first(envelope, "modelId")) + .and_then(|envelope| envelope.get("modelId")) .and_then(Json::as_str) .map(str::to_string); let model_version = envelope - .and_then(|envelope| get_first(envelope, "modelVersion")) + .and_then(|envelope| envelope.get("modelVersion")) .and_then(Json::as_str) .map(str::to_string); - let api_format = get_first(chat_response, "apiFormat") + let api_format = chat_response + .get("apiFormat") .and_then(Json::as_str) .unwrap_or("GENERIC") .to_uppercase(); @@ -248,7 +190,8 @@ impl LlmResponseCodec for OCIGenAIChatCodec { decode_generic_response_body(chat_response)? }; - let usage = get_first(chat_response, "usage") + let usage = chat_response + .get("usage") .and_then(Json::as_object) .map(decode_oci_usage); @@ -278,21 +221,24 @@ type ResponseBody = ( fn decode_generic_response_body( chat_response: &serde_json::Map, ) -> Result { - let Some(first_choice) = get_first(chat_response, "choices") + 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)); }; - let finish_reason = get_first(first_choice, "finishReason") + let finish_reason = first_choice + .get("finishReason") .and_then(Json::as_str) .map(str::to_string); - let Some(raw_message) = get_first(first_choice, "message").and_then(Json::as_object) else { + let Some(raw_message) = first_choice.get("message").and_then(Json::as_object) else { return Ok((None, None, finish_reason)); }; - let message = decode_generic_content(get_first(raw_message, "content"))?; - let tool_calls = get_first(raw_message, "toolCalls") + 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()); @@ -300,14 +246,17 @@ fn decode_generic_response_body( } fn decode_cohere_response_body(chat_response: &serde_json::Map) -> ResponseBody { - let message = get_first(chat_response, "text") + let message = chat_response + .get("text") .and_then(Json::as_str) .map(|text| MessageContent::Text(text.to_string())); - let tool_calls = get_first(chat_response, "toolCalls") + 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 = get_first(chat_response, "finishReason") + let finish_reason = chat_response + .get("finishReason") .and_then(Json::as_str) .map(str::to_string); (message, tool_calls, finish_reason) @@ -330,17 +279,17 @@ fn decode_response_tool_calls(calls: &[Json]) -> Vec { /// calls distinguishable. fn decode_response_tool_call(index: usize, value: &Json) -> Option { let obj = value.as_object()?; - let name = get_first(obj, "name")?.as_str()?.to_string(); - let arguments = match get_first(obj, "arguments") { + let name = obj.get("name")?.as_str()?.to_string(); + let arguments = match obj.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 => get_first(obj, "parameters").cloned().unwrap_or(Json::Null), + None => obj.get("parameters").cloned().unwrap_or(Json::Null), }; - let id = match get_first(obj, "id").and_then(Json::as_str) { + let id = match obj.get("id").and_then(Json::as_str) { Some(id) => id.to_string(), None => format!("call_{index}"), }; @@ -356,14 +305,15 @@ fn decode_response_tool_call(index: usize, value: &Json) -> Option) -> Usage { - let cache_read_tokens = get_first(usage, "promptTokensDetails") + let cache_read_tokens = usage + .get("promptTokensDetails") .and_then(Json::as_object) - .and_then(|details| get_first(details, "cachedTokens")) + .and_then(|details| details.get("cachedTokens")) .and_then(Json::as_u64); Usage { - prompt_tokens: get_first(usage, "promptTokens").and_then(Json::as_u64), - completion_tokens: get_first(usage, "completionTokens").and_then(Json::as_u64), - total_tokens: get_first(usage, "totalTokens").and_then(Json::as_u64), + 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, diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs index 323a74abb..bf0df8d0e 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -103,106 +103,25 @@ fn test_cohere_chat_result() { } #[test] -fn test_kebab_case_cli_shape() { - let cli_shaped = json!({ - "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.as_deref(), Some(DEDICATED_ENDPOINT)); - assert_eq!( - annotated.message, - Some(MessageContent::Text("hello".into())) - ); - assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); - assert_eq!(annotated.usage.as_ref().unwrap().total_tokens, Some(9)); -} - -#[test] -fn test_cli_data_envelope_unwrapped() { - // Shape observed from live `oci generative-ai-inference chat-result chat` - // output: kebab-case keys wrapped in a `data` envelope. - let cli_output = json!({ - "data": { - "model-id": "meta.llama-4-maverick-17b-128e-instruct-fp8", - "model-version": "1.0.0", - "chat-response": { - "api-format": "GENERIC", - "choices": [ - { - "finish-reason": "stop", - "index": 0, - "message": { - "role": "ASSISTANT", - "content": [{"type": "TEXT", "text": "Hello, how are you today?"}], - "tool-calls": [] - } - } - ], - "usage": {"completion-tokens": 8, "prompt-tokens": 16, "total-tokens": 24} - } - } - }); - let annotated = OCIGenAIChatCodec.decode_response(&cli_output).unwrap(); - - assert_eq!( - annotated.model.as_deref(), - Some("meta.llama-4-maverick-17b-128e-instruct-fp8") - ); - assert_eq!( - annotated.message, - Some(MessageContent::Text("Hello, how are you today?".into())) - ); - assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); - assert_eq!(annotated.usage.as_ref().unwrap().prompt_tokens, Some(16)); - assert_eq!(annotated.tool_calls, None); -} - -#[test] -fn test_snake_case_sdk_dict_shape() { - // Shape observed from live `oci.util.to_dict(response.data)` on a Python - // SDK ChatResult: snake_case keys, no envelope wrapper. - let sdk_dict = json!({ - "model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8", - "model_version": "1.0.0", +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": [ - { - "index": 0, - "message": { - "role": "ASSISTANT", - "content": [{"type": "TEXT", "text": "Hello, it's nice to meet."}], - "tool_calls": [] - }, - "finish_reason": "stop" - } - ], - "usage": {"completion_tokens": 8, "prompt_tokens": 16, "total_tokens": 24} + "choices": [{ + "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hello"}]}, + "finish_reason": "stop" + }] } }); - let annotated = OCIGenAIChatCodec.decode_response(&sdk_dict).unwrap(); + let annotated = OCIGenAIChatCodec.decode_response(&snake_cased).unwrap(); - assert_eq!( - annotated.model.as_deref(), - Some("meta.llama-4-maverick-17b-128e-instruct-fp8") - ); - assert_eq!( - annotated.message, - Some(MessageContent::Text("Hello, it's nice to meet.".into())) - ); - assert_eq!(annotated.finish_reason, Some(FinishReason::Complete)); - assert_eq!(annotated.usage.as_ref().unwrap().total_tokens, Some(24)); + assert_eq!(annotated.model, None); + assert_eq!(annotated.message, None); + assert_eq!(annotated.finish_reason, None); } #[test] From eca6f0b8219e5a9db7e2ee60d499055701f2b883 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Mon, 27 Jul 2026 20:14:44 -0400 Subject: [PATCH 4/8] test(codec): cover rejected CLI data envelope in wire-only contract test Extends the non-wire-rendering contract test with a kebab-case data-enveloped CLI-shaped response, guarding the REST-only boundary against regression on both alternate renderings. Signed-off-by: Federico Kamelhar --- .../core/tests/unit/codec/oci_genai_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs index bf0df8d0e..49776c144 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -122,6 +122,27 @@ fn test_non_wire_renderings_are_not_decoded() { 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] From 2ab7e345602675c4a683efa4ce2fce2f9661cfcc Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Tue, 28 Jul 2026 09:04:58 -0400 Subject: [PATCH 5/8] feat(codec): decode OCI COHEREV2 responses and preserve unmodeled fields Per review on the OCI codec: - Add a COHEREV2 decoder matching the OCI CohereChatResponseV2 schema: single assistant message with typed content parts (TEXT flattened, THINKING/IMAGE_URL/DOCUMENT preserved as provider-native), nested OpenAI-style function tool calls, response id, and the V2 finish reasons TOOL_CALL and STOP_SEQUENCE. The live service does not accept the format yet (probed us-chicago-1: HTTP 400), so the fixture mirrors the published SDK schema. - Preserve envelope and chat-response fields the normalized shape does not model (timeCreated, serviceTier, chatHistory, grounding metadata, future fields) in extra instead of discarding them, consistent with the other response codecs. Signed-off-by: Federico Kamelhar --- crates/core/src/codec/oci_genai.rs | 102 +++++++++--- .../core/tests/unit/codec/oci_genai_tests.rs | 149 ++++++++++++++++++ 2 files changed, 233 insertions(+), 18 deletions(-) diff --git a/crates/core/src/codec/oci_genai.rs b/crates/core/src/codec/oci_genai.rs index 26db22c96..fbcdbfa6c 100644 --- a/crates/core/src/codec/oci_genai.rs +++ b/crates/core/src/codec/oci_genai.rs @@ -8,11 +8,16 @@ //! //! # OCI-specific patterns handled //! -//! - **Two API formats** selected by the response `apiFormat`: `GENERIC` -//! (`choices`-based, OpenAI-style) and `COHERE` (`text`-based). -//! - **Responses**: `ChatResult` payloads (`modelId`, `chatResponse`) where the -//! chat response is `choices`-based for `GENERIC` and `text`-based for -//! `COHERE`; `usage` counters are `promptTokens`/`completionTokens`/`totalTokens`. +//! - **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`, `serviceTier`, grounding +//! metadata, future provider fields) are carried in `extra` rather than +//! discarded, consistent with the other response codecs. //! //! The codec accepts the REST wire format only: camelCase keys, as documented //! in the OCI API reference. Alternate renderings produced by Oracle tooling @@ -42,17 +47,28 @@ pub struct OCIGenAIChatCodec; /// 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 responses use UPPERCASE Cohere -/// reasons. +/// `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" => FinishReason::Complete, + "stop" | "COMPLETE" | "STOP_SEQUENCE" => FinishReason::Complete, "length" | "max_tokens" | "MAX_TOKENS" => FinishReason::Length, - "tool_calls" => FinishReason::ToolUse, + "tool_calls" | "TOOL_CALL" => FinishReason::ToolUse, 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(), @@ -184,10 +200,19 @@ impl LlmResponseCodec for OCIGenAIChatCodec { .unwrap_or("GENERIC") .to_uppercase(); - let (message, tool_calls, finish_reason) = if api_format == "COHERE" { - decode_cohere_response_body(chat_response) + let (message, tool_calls, finish_reason) = 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 { - decode_generic_response_body(chat_response)? + None }; let usage = chat_response @@ -195,8 +220,24 @@ impl LlmResponseCodec for OCIGenAIChatCodec { .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)); + Ok(AnnotatedLlmResponse { - id: None, + id, model, message, tool_calls, @@ -207,7 +248,7 @@ impl LlmResponseCodec for OCIGenAIChatCodec { api_format: Some(api_format), model_version, }), - extra: serde_json::Map::new(), + extra, }) } } @@ -262,6 +303,29 @@ fn decode_cohere_response_body(chat_response: &serde_json::Map) -> (message, tool_calls, finish_reason) } +/// 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 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)); + }; + 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)) +} + /// Convert an OCI response tool-call list into [`ResponseToolCall`]s. fn decode_response_tool_calls(calls: &[Json]) -> Vec { calls @@ -276,18 +340,20 @@ fn decode_response_tool_calls(calls: &[Json]) -> Vec { /// 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. +/// 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 name = obj.get("name")?.as_str()?.to_string(); - let arguments = match obj.get("arguments") { + 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 => obj.get("parameters").cloned().unwrap_or(Json::Null), + 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(), diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs index 49776c144..e79882b94 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -297,6 +297,152 @@ fn test_usage_cached_tokens_mapped_to_cache_read() { 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. Not yet accepted by the live service in us-chicago-1 + // (probed 2026-07-28: HTTP 400), so this fixture mirrors the spec. + 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\"}"} + }] + }, + "finishReason": "TOOL_CALL", + "usage": {"promptTokens": 20, "completionTokens": 15, "totalTokens": 35} + } + }); + let annotated = OCIGenAIChatCodec.decode_response(&response).unwrap(); + + 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", + "serviceTier": "default", + "choices": [{ + "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hi"}]}, + "finishReason": "stop" + }], + "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("serviceTier"), Some(&json!("default"))); + assert_eq!( + annotated.extra.get("futureEnvelopeField"), + Some(&json!({"nested": true})) + ); + // 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 [ @@ -307,6 +453,9 @@ fn test_finish_reason_mapping() { ("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!({ From 9cae2fb65b0c82f4e17536d6c8ff5804c56d1692 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 29 Jul 2026 00:45:03 -0400 Subject: [PATCH 6/8] feat(codec): preserve choice- and message-level OCI response fields Verified the decoder against the OCI SDK schema (ChatChoice, AssistantMessage, CohereAssistantMessageV2) and closed two gaps: - Unmodeled fields of the decoded GENERIC choice (logprobs, usage, groundingMetadata, serviceTier) and assistant message (refusal, annotations, reasoningContent), plus COHEREV2 message fields (toolPlan, citations), were silently dropped; they are now carried in extra, namespaced under "choice" and "message". - content_filter finish reasons from OpenAI-style GENERIC models now map to FinishReason::ContentFilter, matching the OpenAI codec. Signed-off-by: Federico Kamelhar --- crates/core/src/codec/oci_genai.rs | 78 ++++++++++++++++--- .../core/tests/unit/codec/oci_genai_tests.rs | 45 +++++++++-- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/crates/core/src/codec/oci_genai.rs b/crates/core/src/codec/oci_genai.rs index fbcdbfa6c..167e98943 100644 --- a/crates/core/src/codec/oci_genai.rs +++ b/crates/core/src/codec/oci_genai.rs @@ -15,9 +15,13 @@ //! - **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`, `serviceTier`, grounding -//! metadata, future provider fields) are carried in `extra` rather than -//! discarded, consistent with the other response codecs. +//! 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 @@ -54,6 +58,7 @@ fn map_oci_finish_reason(reason: &str) -> FinishReason { "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()), } } @@ -200,7 +205,7 @@ impl LlmResponseCodec for OCIGenAIChatCodec { .unwrap_or("GENERIC") .to_uppercase(); - let (message, tool_calls, finish_reason) = match api_format.as_str() { + 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)?, @@ -235,6 +240,7 @@ impl LlmResponseCodec for OCIGenAIChatCodec { None => serde_json::Map::new(), }; extra.extend(unmodeled_fields(chat_response, modeled_response_keys)); + extra.extend(nested_extra); Ok(AnnotatedLlmResponse { id, @@ -257,33 +263,76 @@ 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)); + 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)); + 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)) + Ok((message, tool_calls, finish_reason, nested_extra)) } fn decode_cohere_response_body(chat_response: &serde_json::Map) -> ResponseBody { @@ -300,7 +349,9 @@ fn decode_cohere_response_body(chat_response: &serde_json::Map) -> .get("finishReason") .and_then(Json::as_str) .map(str::to_string); - (message, tool_calls, finish_reason) + // 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 @@ -310,20 +361,27 @@ fn decode_cohere_response_body(chat_response: &serde_json::Map) -> 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)); + 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)) + Ok((message, tool_calls, finish_reason, nested_extra)) } /// Convert an OCI response tool-call list into [`ResponseToolCall`]s. diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs index e79882b94..b3df5c549 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -319,7 +319,10 @@ fn test_cohere_v2_chat_result() { "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} @@ -327,6 +330,16 @@ fn test_cohere_v2_chat_result() { }); 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)); @@ -392,10 +405,19 @@ fn test_unmodeled_response_fields_preserved_in_extra() { "chatResponse": { "apiFormat": "GENERIC", "timeCreated": "2026-07-27T17:27:25.871Z", - "serviceTier": "default", "choices": [{ - "message": {"role": "ASSISTANT", "content": [{"type": "TEXT", "text": "hi"}]}, - "finishReason": "stop" + "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} } @@ -406,11 +428,23 @@ fn test_unmodeled_response_fields_preserved_in_extra() { annotated.extra.get("timeCreated"), Some(&json!("2026-07-27T17:27:25.871Z")) ); - assert_eq!(annotated.extra.get("serviceTier"), Some(&json!("default"))); 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", @@ -449,6 +483,7 @@ fn test_finish_reason_mapping() { ("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. From 0766e468e3e7b90f1e43612bcfbcfddb3f51318a Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 29 Jul 2026 01:22:27 -0400 Subject: [PATCH 7/8] fix(observability): map OCIGenAI requests to an OTel provider name The merge from main brought in the unified OTel exporter (#556), whose exhaustive match on ApiSpecificRequest did not cover the OCIGenAI variant this branch introduces, breaking the workspace build. Map it to "oci.genai", following the dotted cloud-provider convention used by aws.bedrock and gcp.gemini. Signed-off-by: Federico Kamelhar --- crates/core/src/observability/otel_genai.rs | 3 +++ 1 file changed, 3 insertions(+) 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, } } From 07ded78fd409781e4a9fde5e99272f9a43fb86d4 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 29 Jul 2026 08:55:23 -0400 Subject: [PATCH 8/8] test(codec): note live-service confirmation of the COHEREV2 wire shape The service now accepts apiFormat COHEREV2 (it rejected it with HTTP 400 the day before); live text and parallel tool-call responses match the spec-mirrored fixture exactly, so the fixture comment no longer claims the format is spec-only. Signed-off-by: Federico Kamelhar --- crates/core/tests/unit/codec/oci_genai_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/core/tests/unit/codec/oci_genai_tests.rs b/crates/core/tests/unit/codec/oci_genai_tests.rs index b3df5c549..3b4c9e62b 100644 --- a/crates/core/tests/unit/codec/oci_genai_tests.rs +++ b/crates/core/tests/unit/codec/oci_genai_tests.rs @@ -301,8 +301,10 @@ fn test_usage_cached_tokens_mapped_to_cache_read() { 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. Not yet accepted by the live service in us-chicago-1 - // (probed 2026-07-28: HTTP 400), so this fixture mirrors the spec. + // 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",