feat: enforce full format field coverage audit

This commit is contained in:
elky
2026-06-03 21:18:49 +08:00
parent 7507af5829
commit b2f68bbaf7
8 changed files with 3121 additions and 121 deletions
@@ -15,7 +15,7 @@ use crate::{
apply_gemini_request_extensions, canonical_extension_object_mut,
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
gemini_generation_config_extra, gemini_google_search_grounding, gemini_openai_extra_body,
gemini_generation_config_extra, gemini_google_search_grounding,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
@@ -173,10 +173,6 @@ pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalReques
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_tool_config".to_string(), tool_config);
}
if let Some(extra_body) = gemini_openai_extra_body(request) {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("extra_body".to_string(), extra_body);
}
if let Some(web_search_options) = web_search_options {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("web_search_options".to_string(), web_search_options);
@@ -5,7 +5,7 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::{
map_thinking_budget_to_openai_reasoning_effort, OpenAiReasoningEffort,
map_thinking_budget_to_openai_reasoning_effort, OpenAiResponsesReasoningEffort,
},
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments,
@@ -660,7 +660,9 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
fn openai_responses_reasoning_effort(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"max" => Some("xhigh"),
value => OpenAiReasoningEffort::parse(value).map(OpenAiReasoningEffort::as_str),
value => {
OpenAiResponsesReasoningEffort::parse(value).map(OpenAiResponsesReasoningEffort::as_str)
}
}
}
@@ -2,40 +2,50 @@ use serde_json::{Map, Value};
use crate::formats::shared::model_directives::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
XHigh,
macro_rules! define_openai_reasoning_effort {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name {
None,
Minimal,
Low,
Medium,
High,
XHigh,
}
impl $name {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
};
}
impl OpenAiReasoningEffort {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
define_openai_reasoning_effort!(OpenAiChatReasoningEffort);
define_openai_reasoning_effort!(OpenAiResponsesReasoningEffort);
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
#[deprecated(note = "use OpenAiChatReasoningEffort or OpenAiResponsesReasoningEffort")]
pub type OpenAiReasoningEffort = OpenAiChatReasoningEffort;
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
File diff suppressed because it is too large Load Diff
@@ -5198,65 +5198,6 @@ pub(crate) fn gemini_generation_config_extra(
.collect()
}
pub(crate) fn gemini_openai_extra_body(request: &Map<String, Value>) -> Option<Value> {
let mut extra_body = Map::new();
if let Some(generation_config) = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object)
{
let mut google = Map::new();
if let Some(thinking_config) =
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
{
google.insert("thinking_config".to_string(), thinking_config);
}
if let Some(response_modalities) = gemini_value_by_case(
generation_config,
"responseModalities",
"response_modalities",
)
.cloned()
{
google.insert("response_modalities".to_string(), response_modalities);
}
if !google.is_empty() {
extra_body.insert("google".to_string(), Value::Object(google));
}
let generation_config_extra = gemini_generation_config_extra(generation_config);
if !generation_config_extra.is_empty() {
extra_body.insert(
"gemini".to_string(),
json!({ "generation_config_extra": generation_config_extra }),
);
}
}
if let Some(safety_settings) = request
.get("safetySettings")
.or_else(|| request.get("safety_settings"))
.cloned()
{
let gemini = extra_body
.entry("gemini".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
gemini.insert("safety_settings".to_string(), safety_settings);
}
if let Some(cached_content) = request
.get("cachedContent")
.or_else(|| request.get("cached_content"))
.cloned()
{
let gemini = extra_body
.entry("gemini".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
gemini.insert("cached_content".to_string(), cached_content);
}
(!extra_body.is_empty()).then_some(Value::Object(extra_body))
}
pub(crate) fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
+16 -13
View File
@@ -9,9 +9,11 @@ Statuses:
- `native`: emitted as a target-native field without semantic change.
- `mapped`: converted through canonical/provider-specific mapping.
- `extension-preserved`: preserved in same-format canonical roundtrip or target-approved extension namespace.
- `unsupported`: rejected because the source field is outside the audited source schema or conversion surface.
- `lossy-blocked`: conversion fails closed.
- `invalid-enum`: conversion fails closed because a provider enum value is not valid for the target mapping.
- `pending`: parser/emitter exists, but strict field-by-field audit is not complete.
Full schema field coverage is tracked in `docs/api/format-field-coverage-matrix.md`. That matrix is generated from `docs/api/provider-interface-definitions.md` and gives every documented OpenAI, Claude, and Gemini schema field a handling status. “Handled” means mapped, same-format/native preserved, extension-preserved, blocked with a structured error, or explicitly marked outside the canonical conversion surface.
## Implemented Boundary Changes
@@ -22,7 +24,8 @@ Statuses:
| Same-format provider path | Bypasses canonical conversion and copies the parsed JSON object before transport edits. |
| Cross-format same-format-provider path | Uses `convert_request_pure`, then applies model/body/stream edits in transport. |
| Conversion errors | Added `UnsupportedField`, `InvalidEnumValue`, `LossyConversionBlocked`, and `InvalidTargetField`. |
| Reporting | Added `ConversionReport` with field statuses. Current report is top-level request-field oriented; nested exhaustive reporting is pending. |
| Reporting | Added `ConversionReport` with field statuses. Runtime reports remain conversion-operation oriented; exhaustive nested schema coverage is enforced by `format-field-coverage-matrix.md`. |
| Source schema coverage | Cross-format request conversion rejects unknown source root fields before emit. Every documented schema field is covered by the field coverage matrix. |
| Tool schema roundtrip | Claude `input_schema` and Gemini `functionDeclarations.parameters` preserve raw same-format schema through provider-specific extensions. |
| Tool result ids | Chat `tool_call_id`, Responses `call_id`, Claude `tool_use_id`, and Gemini `functionResponse.id` are mapped through canonical tool IDs. |
@@ -62,7 +65,7 @@ Statuses:
| `safety_identifier` | OpenAI extension | `safety_identifier` | extension-preserved |
| `prompt_cache_key` | OpenAI extension | `prompt_cache_key` | extension-preserved |
| `user` | legacy Chat user field | none | lossy-blocked |
| unknown top-level fields | OpenAI extension | none unless target-approved | pending strict nested reporting |
| unknown top-level fields | source schema guard | none | unsupported |
## OpenAI Responses -> OpenAI Chat
@@ -99,11 +102,11 @@ Statuses:
| `conversation` | Responses-only | none | lossy-blocked |
| `background` | Responses-only | none | lossy-blocked |
| `max_tool_calls` | Responses-only | none | lossy-blocked |
| unknown top-level fields | Responses extension | none unless Chat-approved | pending strict nested reporting |
| unknown top-level fields | source schema guard | none | unsupported |
## Claude Messages <-> OpenAI
## Claude Messages <-> OpenAI Chat / Responses
Current parser/emitter coverage exists, but strict audit is pending for the third batch.
Claude to OpenAI Chat, Claude to OpenAI Responses, and the reverse directions are included in the field coverage matrix. Runtime strict guards cover request root fields, provider extension namespaces, thinking/cache/tool-result hazards, and target generation-field gaps. Fields without a lossless target equivalent fail closed instead of being dropped.
High-risk fields:
@@ -115,11 +118,12 @@ High-risk fields:
| `tools[].input_schema` | OpenAI tool parameters | mapped; raw same-format schema preservation implemented |
| `tool_choice.disable_parallel_tool_use` | OpenAI `parallel_tool_calls` | mapped, implemented |
| `tool_result` multi-block content | OpenAI tool output/content | same-format preserved; cross-format to Chat/Responses is lossy-blocked |
| `metadata` / container fields | OpenAI metadata or extension | field-by-field decision pending |
| `metadata` | OpenAI metadata | mapped when the target has metadata |
| `container`, `inference_geo`, `service_tier` | OpenAI target has no audited equivalent | lossy-blocked unless a target-approved mapping is added |
## Gemini GenerateContent <-> OpenAI/Claude
## Gemini GenerateContent <-> OpenAI Chat / Responses / Claude
Current parser/emitter coverage exists, but strict audit is pending for the fourth batch.
Gemini to OpenAI Chat, Gemini to OpenAI Responses, Gemini to Claude, and reverse generation paths are included in the field coverage matrix. Gemini-only request fields are preserved same-format and blocked cross-format unless the target mapping is explicitly audited.
High-risk fields:
@@ -138,8 +142,7 @@ High-risk fields:
## Embedding And Rerank
Fifth batch first pass is implemented for request parse/emit capability and strict target
guards. Nested per-field reporting is still pending.
Embedding and rerank request parse/emit capability and strict target guards are implemented. Provider schema fields outside these canonical conversion surfaces are marked `not-in-conversion-surface` in the field coverage matrix instead of being left implicit.
Embedding source capability:
@@ -199,8 +202,8 @@ still outside canonical conversion.
Sixth batch first pass is implemented for unknown event handling and runtime
same-format boundaries. Sync response finish/status parity has a first strict
pass; stream finish-reason guardrails are implemented for unknown/unmappable
terminal reasons, while nested stream field snapshots still need
provider-by-provider golden fixtures.
terminal reasons. Stream event schema fields are covered in the field coverage
matrix; provider-by-provider fixtures cover the runtime event behavior.
Current stream behavior:
+2 -1
View File
@@ -11,7 +11,7 @@ Status values used below:
## OpenAI Reasoning Effort
Provider-specific type: `OpenAiReasoningEffort`.
Provider-specific types: `OpenAiChatReasoningEffort` for Chat `reasoning_effort`, and `OpenAiResponsesReasoningEffort` for Responses `reasoning.effort`. They are intentionally separate even when their current value sets overlap; a value accepted by one field is not treated as valid for the other unless that field's own enum accepts it.
| Source field | Source value | Target field | Target value | Status |
| --- | --- | --- | --- | --- |
@@ -28,6 +28,7 @@ Provider-specific type: `OpenAiReasoningEffort`.
| Responses `reasoning.effort` | `medium` | Chat `reasoning_effort` | `medium` | native |
| Responses `reasoning.effort` | `high` | Chat `reasoning_effort` | `high` | native |
| Responses `reasoning.effort` | `xhigh` | Chat `reasoning_effort` | `xhigh` | native |
| Responses `reasoning.effort` | `max` | Chat `reasoning_effort` | none | blocked, invalid Responses enum |
| Responses `reasoning.summary` | any | Chat | none | blocked |
| Responses `reasoning.budget_tokens` | any | Chat | none | blocked |
File diff suppressed because it is too large Load Diff