-
Notifications
You must be signed in to change notification settings - Fork 67
feat(codec): add OCI Generative AI provider support #554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rapids-bot
merged 31 commits into
NVIDIA:main
from
fede-kamel:feat/oci-codec-1-types-response
Aug 12, 2026
Merged
Changes from 2 commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
1b8a31f
feat(codec): add OCI GenAI typed variants and response decoding
fede-kamel ad9f167
feat(codec): cover all OCI GenAI transports and live-observed shapes
fede-kamel 7282cdd
refactor(codec): restrict OCI GenAI decode to the REST wire format
fede-kamel eca6f0b
test(codec): cover rejected CLI data envelope in wire-only contract test
fede-kamel 2ab7e34
feat(codec): decode OCI COHEREV2 responses and preserve unmodeled fields
fede-kamel 08ef006
Merge branch 'main' into feat/oci-codec-1-types-response
fede-kamel 9cae2fb
feat(codec): preserve choice- and message-level OCI response fields
fede-kamel 0766e46
fix(observability): map OCIGenAI requests to an OTel provider name
fede-kamel 07ded78
test(codec): note live-service confirmation of the COHEREV2 wire shape
fede-kamel eec144e
feat(codec): implement OCI GenAI request decode and encode
fede-kamel d3c75e3
feat(codec): register OCI GenAI provider surface and streaming codec
fede-kamel 311977a
feat(bindings): expose OCIGenAIChatCodec to Python and Node
fede-kamel fe141f1
fix(python): add OCIGenAIChatCodec to the codecs type stub
fede-kamel 3ee8a38
fix(codec): address review findings on OCI encode, streaming, and ove…
fede-kamel 2fec51b
chore: drop locally generated fixture lockfile from the branch
fede-kamel 16d0022
fix(codec): reject OCI api_format edits instead of partially converting
fede-kamel 8fcb215
Merge remote-tracking branch 'origin/main' into feat/oci-codec-1-type…
fede-kamel 1553870
fix(codec): close post-merge review gaps in OCI encode and PII overlay
fede-kamel 5ca29b2
fix(codec): take the terminal COHERE stream event's text as authorita…
fede-kamel ee26247
fix(codec): finalize COHEREV2 streams natively and harden overlay/res…
fede-kamel f988000
fix(codec): preserve non-TEXT typed parts in OCI stream accumulation
fede-kamel a80fc39
test(codec): cover GENERIC stream typed-part preservation
fede-kamel 02f3889
Merge branch 'main' into feat/oci-codec-1-types-response
fede-kamel 45ea334
fix: address CI failures and full-review findings
fede-kamel 8be4bb9
chore: restore package-lock.json to main's contents
fede-kamel 3573bc2
test: cover remaining OCI codec and overlay paths for the patch gate
fede-kamel 895842a
fix: address final review round for the OCI codec
fede-kamel 98b4c75
Merge branch 'main' into feat/oci-codec-1-types-response
willkill07 c2048f2
fix: surface the OCI GenAI codec identity to sanitizers and plugin SDKs
fede-kamel a9492c8
Merge branch 'main' into feat/oci-codec-1-types-response
willkill07 e88e4e4
test: fix flaky host-string release assertion in the OCI identity test
fede-kamel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,379 @@ | ||
| // 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 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`. | ||
|
|
||
| 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<String, Json>, 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<String, Json>, key: &str) -> Option<String> { | ||
| 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 (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" | "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<Option<MessageContent>> { | ||
| 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::<Result<Vec<_>>>()?; | ||
| 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<String> { | ||
| 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<ContentPart> { | ||
| 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, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // LlmResponseCodec implementation | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| impl LlmResponseCodec for OCIGenAIChatCodec { | ||
| fn decode_response(&self, response: &Json) -> Result<AnnotatedLlmResponse> { | ||
| 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() | ||
| }); | ||
| }; | ||
|
|
||
| // 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), | ||
| 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" { | ||
|
willkill07 marked this conversation as resolved.
Outdated
|
||
| 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(), | ||
|
willkill07 marked this conversation as resolved.
Outdated
|
||
| }) | ||
| } | ||
| } | ||
|
|
||
| type ResponseBody = ( | ||
| Option<MessageContent>, | ||
| Option<Vec<ResponseToolCall>>, | ||
| Option<String>, | ||
| ); | ||
|
|
||
| fn decode_generic_response_body( | ||
| chat_response: &serde_json::Map<String, Json>, | ||
| ) -> Result<ResponseBody> { | ||
| 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| decode_response_tool_calls(calls)) | ||
| .filter(|calls: &Vec<ResponseToolCall>| !calls.is_empty()); | ||
| Ok((message, tool_calls, finish_reason)) | ||
| } | ||
|
|
||
| fn decode_cohere_response_body(chat_response: &serde_json::Map<String, Json>) -> 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| decode_response_tool_calls(calls)) | ||
| .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 list into [`ResponseToolCall`]s. | ||
| fn decode_response_tool_calls(calls: &[Json]) -> Vec<ResponseToolCall> { | ||
| 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. | ||
| fn decode_response_tool_call(index: usize, value: &Json) -> Option<ResponseToolCall> { | ||
| 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::<Json>(text).unwrap_or_else(|_| Json::String(text.clone())) | ||
| } | ||
| 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, | ||
| 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<String, Json>) -> 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, | ||
| cache_write_tokens: None, | ||
| cost: None, | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "../../tests/unit/codec/oci_genai_tests.rs"] | ||
| mod tests; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.