#![allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::too_many_lines)]
use bytes::Bytes;
use entelix_core::codecs::{BoxByteStream, Codec, GeminiCodec};
use entelix_core::ir::{
ContentPart, Message, ModelRequest, ModelWarning, Role, StopReason, ToolChoice,
ToolResultContent, ToolSpec,
};
use entelix_core::stream::{StreamAggregator, StreamDelta};
use futures::StreamExt;
use serde_json::{Value, json};
fn parse(body: &[u8]) -> Value {
serde_json::from_slice(body).unwrap()
}
#[test]
fn encode_minimal_request_emits_contents_array() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::user("hi")],
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
assert_eq!(
encoded.path,
"/v1beta/models/gemini-2.0-flash:generateContent"
);
let body = parse(&encoded.body);
assert_eq!(body["contents"][0]["role"], "user");
assert_eq!(body["contents"][0]["parts"][0]["text"], "hi");
}
#[test]
fn encode_system_routes_into_system_instruction() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::user("hi")],
system: "Be terse.".into(),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(body["systemInstruction"]["parts"][0]["text"], "Be terse.");
let contents = body["contents"].as_array().unwrap();
for c in contents {
assert_ne!(c["role"], "system");
}
}
#[test]
fn encode_assistant_uses_model_role() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::assistant("hello")],
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(body["contents"][0]["role"], "model");
}
#[test]
fn encode_tool_use_emits_function_call_with_reconstructible_id() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::new(
Role::Assistant,
vec![ContentPart::ToolUse {
id: "double#0".into(),
name: "double".into(),
input: json!({"n": 21}),
provider_echoes: Vec::new(),
}],
)],
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let body = parse(&encoded.body);
let part = &body["contents"][0]["parts"][0];
assert_eq!(part["functionCall"]["name"], "double");
assert_eq!(part["functionCall"]["args"]["n"], 21);
let lossy_id = encoded.warnings.iter().any(|w| match w {
ModelWarning::LossyEncode { field, .. } => field.contains("id"),
_ => false,
});
assert!(
!lossy_id,
"tool_use id is reconstructible on decode; LossyEncode must not fire"
);
}
#[test]
fn encode_tool_result_emits_function_response_with_real_name() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::new(
Role::Tool,
vec![ContentPart::ToolResult {
tool_use_id: "call_1".into(),
name: "double".into(),
content: ToolResultContent::Json(json!({"doubled": 42})),
is_error: false,
cache_control: None,
provider_echoes: Vec::new(),
}],
)],
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let body = parse(&encoded.body);
let part = &body["contents"][0]["parts"][0];
assert_eq!(part["functionResponse"]["name"], "double");
assert_eq!(part["functionResponse"]["response"]["doubled"], 42);
let name_warning = encoded.warnings.iter().any(|w| {
matches!(
w,
entelix_core::ir::ModelWarning::LossyEncode { detail, .. }
if detail.contains("functionResponse needs a name")
)
});
assert!(
!name_warning,
"name placeholder warning should not fire when IR carries the real name"
);
}
#[test]
fn encode_tools_emits_function_declarations() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::user("calc")],
tools: std::sync::Arc::from([ToolSpec::function(
"double",
"doubles n",
json!({"type": "object"}),
)]),
tool_choice: ToolChoice::Required,
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
let decls = &body["tools"][0]["functionDeclarations"];
assert_eq!(decls[0]["name"], "double");
assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
}
#[test]
fn encode_streaming_path_uses_stream_endpoint() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.0-flash".into(),
messages: vec![Message::user("hi")],
..ModelRequest::default()
};
let encoded = codec.encode_streaming(&req).unwrap();
assert!(encoded.streaming);
assert!(encoded.path.contains(":streamGenerateContent"));
assert!(encoded.path.contains("alt=sse"));
}
#[test]
fn decode_text_response() {
let codec = GeminiCodec::new();
let body = json!({
"modelVersion": "gemini-2.0-flash",
"candidates": [{
"content": { "role": "model", "parts": [{ "text": "Hello!" }] },
"finishReason": "STOP"
}],
"usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 2 }
});
let response = codec
.decode(body.to_string().as_bytes(), Vec::new())
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert_eq!(response.usage.input_tokens, 4);
assert_eq!(response.usage.output_tokens, 2);
assert!(matches!(response.content[0], ContentPart::Text { ref text, .. } if text == "Hello!"));
}
#[test]
fn code_execution_toolkind_emits_native_gemini_entry() {
use entelix_core::ir::{ToolKind, ToolSpec};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("compute")],
tools: std::sync::Arc::from([ToolSpec {
name: "code_execution".into(),
description: String::new(),
kind: ToolKind::CodeExecution,
cache_control: None,
}]),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert!(
body["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t.get("code_execution").is_some()),
"Gemini code_execution must emit native tool entry"
);
}
#[test]
fn url_context_ext_appends_native_tool_entry() {
use entelix_core::ir::{GeminiExt, ProviderExtensions};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("read https://example.com")],
provider_extensions: ProviderExtensions::default()
.with_gemini(GeminiExt::default().with_url_context()),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert!(
body["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t.get("url_context").is_some()),
"Gemini url_context ext must append native tool entry"
);
}
#[test]
fn decode_thinking_usage_sums_into_output_tokens() {
let codec = GeminiCodec::new();
let body = json!({
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"content": { "role": "model", "parts": [{ "text": "answer" }] },
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 100,
"thoughtsTokenCount": 400
}
});
let response = codec
.decode(body.to_string().as_bytes(), Vec::new())
.unwrap();
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(
response.usage.output_tokens, 500,
"output_tokens must equal candidatesTokenCount + thoughtsTokenCount"
);
assert_eq!(response.usage.reasoning_tokens, 400);
}
#[test]
fn decode_function_call_reconstructs_tool_use_id_from_name_and_index() {
let codec = GeminiCodec::new();
let body = json!({
"modelVersion": "gemini-2.0-flash",
"candidates": [{
"content": { "role": "model", "parts": [{
"functionCall": { "name": "double", "args": { "n": 21 } }
}]},
"finishReason": "STOP"
}]
});
let response = codec
.decode(body.to_string().as_bytes(), Vec::new())
.unwrap();
if let ContentPart::ToolUse {
id, name, input, ..
} = &response.content[0]
{
assert_eq!(id, "double#0");
assert_eq!(name, "double");
assert_eq!(input["n"], 21);
} else {
panic!("expected tool_use");
}
}
fn sse_chunks(payloads: &[Value]) -> Bytes {
let mut out = String::new();
for payload in payloads {
out.push_str("data: ");
out.push_str(&payload.to_string());
out.push_str("\n\n");
}
Bytes::from(out)
}
fn body_from_bytes(b: Bytes) -> BoxByteStream<'static> {
Box::pin(futures::stream::iter(vec![Ok::<_, entelix_core::Error>(b)]))
}
#[tokio::test]
async fn decode_stream_text_round_trips() {
let codec = GeminiCodec::new();
let bytes = sse_chunks(&[
json!({
"modelVersion": "gemini-2.0-flash",
"candidates": [{ "content": { "role": "model", "parts": [{"text": "Hello, "}] } }]
}),
json!({
"modelVersion": "gemini-2.0-flash",
"candidates": [{
"content": { "role": "model", "parts": [{"text": "world!"}] },
"finishReason": "STOP"
}],
"usageMetadata": { "promptTokenCount": 2, "candidatesTokenCount": 4 }
}),
]);
let mut stream = codec.decode_stream(body_from_bytes(bytes), Vec::new());
let mut aggregator = StreamAggregator::new();
while let Some(item) = stream.next().await {
aggregator.push(item.unwrap()).unwrap();
}
let response = aggregator.finalize().unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert_eq!(response.usage.output_tokens, 4);
if let ContentPart::Text { text, .. } = &response.content[0] {
assert_eq!(text, "Hello, world!");
} else {
panic!("expected text part");
}
}
#[tokio::test]
async fn decode_stream_function_call_round_trips() {
let codec = GeminiCodec::new();
let bytes = sse_chunks(&[json!({
"modelVersion": "gemini-2.0-flash",
"candidates": [{
"content": { "role": "model", "parts": [{
"functionCall": { "name": "double", "args": { "n": 21 } }
}]},
"finishReason": "STOP"
}]
})]);
let mut stream = codec.decode_stream(body_from_bytes(bytes), Vec::new());
let mut deltas: Vec<StreamDelta> = Vec::new();
while let Some(item) = stream.next().await {
deltas.push(item.unwrap());
}
let mut aggregator = StreamAggregator::new();
for d in deltas {
aggregator.push(d).unwrap();
}
let response = aggregator.finalize().unwrap();
if let ContentPart::ToolUse { name, input, .. } = &response.content[0] {
assert_eq!(name, "double");
assert_eq!(input["n"], 21);
} else {
panic!("expected tool_use");
}
}
#[test]
fn gemini_ext_safety_settings_thread_into_top_level() {
use entelix_core::ir::{GeminiExt, ProviderExtensions};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
provider_extensions: ProviderExtensions::default().with_gemini(
GeminiExt::default()
.with_safety_override("HARM_CATEGORY_HATE_SPEECH", "BLOCK_LOW_AND_ABOVE")
.with_safety_override("HARM_CATEGORY_HARASSMENT", "BLOCK_NONE"),
),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(
body["safetySettings"][0]["category"],
"HARM_CATEGORY_HATE_SPEECH"
);
assert_eq!(
body["safetySettings"][0]["threshold"],
"BLOCK_LOW_AND_ABOVE"
);
assert_eq!(
body["safetySettings"][1]["category"],
"HARM_CATEGORY_HARASSMENT"
);
}
#[test]
fn gemini_ext_candidate_count_threads_into_generation_config() {
use entelix_core::ir::{GeminiExt, ProviderExtensions};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
temperature: Some(0.5),
provider_extensions: ProviderExtensions::default()
.with_gemini(GeminiExt::default().with_candidate_count(3)),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(body["generationConfig"]["candidateCount"], 3);
assert_eq!(body["generationConfig"]["temperature"], 0.5);
}
#[test]
fn gemini_ext_candidate_count_creates_generation_config_when_missing() {
use entelix_core::ir::{GeminiExt, ProviderExtensions};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
provider_extensions: ProviderExtensions::default()
.with_gemini(GeminiExt::default().with_candidate_count(2)),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(body["generationConfig"]["candidateCount"], 2);
}
#[test]
fn gemini_codec_warns_on_foreign_vendor_extension() {
use entelix_core::ir::{OpenAiChatExt, ProviderExtensions};
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
provider_extensions: ProviderExtensions::default()
.with_openai_chat(OpenAiChatExt::default().with_cache_key("k")),
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let saw = encoded.warnings.iter().any(|w| {
matches!(
w,
ModelWarning::ProviderExtensionIgnored { vendor } if vendor == "openai_chat"
)
});
assert!(
saw,
"expected ProviderExtensionIgnored openai_chat, got: {:?}",
encoded.warnings
);
}
#[test]
fn gemini_seed_threads_into_generation_config() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
seed: Some(123),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
assert_eq!(body["generationConfig"]["seed"], 123);
}
#[test]
fn gemini_end_user_id_emits_lossy_encode() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
end_user_id: Some("op-1".into()),
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
assert!(encoded.warnings.iter().any(|w| matches!(
w,
ModelWarning::LossyEncode { field, .. } if field == "end_user_id"
)));
}
#[test]
fn top_k_passes_through_natively_on_gemini() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
top_k: Some(40),
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&encoded.body).unwrap();
assert_eq!(body["generationConfig"]["topK"], 40);
assert!(
encoded.warnings.iter().all(|w| !matches!(
w,
ModelWarning::LossyEncode { field, .. } if field == "top_k"
)),
"Gemini native topK must NOT emit LossyEncode"
);
}
fn encode_tool_params(input_schema: serde_json::Value) -> (Value, Vec<ModelWarning>) {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-3.1-pro-preview".into(),
messages: vec![Message::user("calc")],
tools: std::sync::Arc::from([ToolSpec::function("t", "desc", input_schema)]),
tool_choice: ToolChoice::Required,
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let body = parse(&encoded.body);
let params = body["tools"][0]["functionDeclarations"][0]["parameters"].clone();
(params, encoded.warnings)
}
fn lossy_field_match(warnings: &[ModelWarning], needle: &str) -> bool {
warnings.iter().any(|w| {
matches!(
w,
ModelWarning::LossyEncode { field, .. } if field.contains(needle)
)
})
}
#[test]
fn input_schema_passes_scalar_type_unchanged() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "q": { "type": "string" } },
"required": ["q"]
}));
assert_eq!(params["properties"]["q"]["type"], "string");
assert!(params["properties"]["q"].get("nullable").is_none());
assert!(
!lossy_field_match(&warnings, "q.type"),
"scalar `type` must not emit LossyEncode"
);
}
#[test]
fn input_schema_unions_string_null_to_nullable() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "kind": { "type": ["string", "null"] } }
}));
assert_eq!(params["properties"]["kind"]["type"], "string");
assert_eq!(params["properties"]["kind"]["nullable"], true);
assert!(
!lossy_field_match(&warnings, "kind"),
"the lossless nullable form must not emit LossyEncode"
);
}
#[test]
fn input_schema_unions_null_first_order_agnostic() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": { "kind": { "type": ["null", "string"] } }
}));
assert_eq!(params["properties"]["kind"]["type"], "string");
assert_eq!(params["properties"]["kind"]["nullable"], true);
}
#[test]
fn input_schema_null_only_type_warns_and_passes_through() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "void": { "type": ["null"] } }
}));
assert_eq!(params["properties"]["void"]["type"], json!(["null"]));
assert!(params["properties"]["void"].get("nullable").is_none());
assert!(lossy_field_match(&warnings, "void.type"));
}
#[test]
fn input_schema_empty_type_array_warns_and_passes_through() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "weird": { "type": [] } }
}));
assert_eq!(params["properties"]["weird"]["type"], json!([]));
assert!(lossy_field_match(&warnings, "weird.type"));
}
#[test]
fn input_schema_multi_type_union_without_null_coerces_to_first() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "mixed": { "type": ["string", "integer"] } }
}));
assert_eq!(params["properties"]["mixed"]["type"], "string");
assert!(params["properties"]["mixed"].get("nullable").is_none());
assert!(lossy_field_match(&warnings, "mixed.type"));
}
#[test]
fn input_schema_multi_type_union_with_null_coerces_to_nullable_first() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "mixed": { "type": ["string", "integer", "null"] } }
}));
assert_eq!(params["properties"]["mixed"]["type"], "string");
assert_eq!(params["properties"]["mixed"]["nullable"], true);
assert!(lossy_field_match(&warnings, "mixed.type"));
}
#[test]
fn input_schema_non_string_non_array_type_warns_and_passes_through() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": { "bad": { "type": 42 } }
}));
assert_eq!(params["properties"]["bad"]["type"], 42);
assert!(lossy_field_match(&warnings, "bad.type"));
}
#[test]
fn input_schema_collapses_one_of_const_strings_to_enum() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"status": {
"oneOf": [
{ "const": "draft" },
{ "const": "published" }
]
}
}
}));
let status = ¶ms["properties"]["status"];
assert_eq!(status["type"], "string");
assert_eq!(status["enum"], json!(["draft", "published"]));
assert!(status.get("oneOf").is_none());
assert!(status.get("const").is_none());
assert!(
!lossy_field_match(&warnings, "status.oneOf"),
"lossless collapse must not emit LossyEncode"
);
}
#[test]
fn input_schema_collapses_one_of_const_integers_to_enum() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"level": {
"oneOf": [{ "const": 1 }, { "const": 2 }, { "const": 3 }]
}
}
}));
let level = ¶ms["properties"]["level"];
assert_eq!(level["type"], "integer");
assert_eq!(level["enum"], json!([1, 2, 3]));
}
#[test]
fn input_schema_collapses_one_of_const_booleans_to_enum() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"flag": { "oneOf": [{ "const": true }, { "const": false }] }
}
}));
let flag = ¶ms["properties"]["flag"];
assert_eq!(flag["type"], "boolean");
assert_eq!(flag["enum"], json!([true, false]));
}
#[test]
fn input_schema_collapses_one_of_const_mixed_numbers_to_number_type() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"x": { "oneOf": [{ "const": 1 }, { "const": 2.5 }] }
}
}));
let x = ¶ms["properties"]["x"];
assert_eq!(x["type"], "number");
assert_eq!(x["enum"], json!([1, 2.5]));
}
#[test]
fn input_schema_collapses_any_of_const_alternatives() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"status": {
"anyOf": [{ "const": "a" }, { "const": "b" }]
}
}
}));
let status = ¶ms["properties"]["status"];
assert_eq!(status["type"], "string");
assert_eq!(status["enum"], json!(["a", "b"]));
assert!(status.get("anyOf").is_none());
}
#[test]
fn input_schema_collapses_documented_enum_and_folds_descriptions() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"status": {
"oneOf": [
{ "const": "draft", "description": "Not yet published." },
{ "const": "published", "description": "Visible to readers." }
]
}
}
}));
let status = ¶ms["properties"]["status"];
assert_eq!(status["type"], "string");
assert_eq!(status["enum"], json!(["draft", "published"]));
assert!(status.get("oneOf").is_none());
let description = status["description"].as_str().unwrap();
assert!(description.contains("- `draft`: Not yet published."));
assert!(description.contains("- `published`: Visible to readers."));
assert!(
!lossy_field_match(&warnings, "status.oneOf"),
"lossless fold must not emit a decline warning"
);
}
#[test]
fn input_schema_collapses_schemars_documented_enum_with_sibling_type() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"mode": {
"oneOf": [
{
"const": "semantic",
"description": "Semantic similarity search (cosine distance on embeddings).",
"type": "string"
},
{
"const": "keyword",
"description": "Keyword match search.",
"type": "string"
}
]
}
}
}));
let mode = ¶ms["properties"]["mode"];
assert_eq!(mode["type"], "string");
assert_eq!(mode["enum"], json!(["semantic", "keyword"]));
let description = mode["description"].as_str().unwrap();
assert!(description.contains("- `semantic`: Semantic similarity search"));
assert!(description.contains("- `keyword`: Keyword match search."));
assert!(!lossy_field_match(&warnings, "mode"));
}
#[test]
fn input_schema_collapse_appends_to_existing_parent_description() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"status": {
"description": "Publication status of the document.",
"oneOf": [
{ "const": "draft", "description": "Not yet published." },
{ "const": "published", "description": "Visible to readers." }
]
}
}
}));
let status = ¶ms["properties"]["status"];
let description = status["description"].as_str().unwrap();
assert!(description.starts_with("Publication status of the document."));
assert!(description.contains("- `draft`: Not yet published."));
}
#[test]
fn input_schema_collapse_omits_description_block_when_alternatives_carry_none() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"status": { "oneOf": [{ "const": "a" }, { "const": "b" }] }
}
}));
let status = ¶ms["properties"]["status"];
assert_eq!(status["enum"], json!(["a", "b"]));
assert!(status.get("description").is_none());
}
#[test]
fn input_schema_declines_collapse_when_alternative_carries_unknown_key() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"payload": {
"oneOf": [
{
"type": "object",
"properties": { "a": { "type": "string" } },
"required": ["a"]
},
{
"type": "object",
"properties": { "b": { "type": "integer" } },
"required": ["b"]
}
]
}
}
}));
assert!(params["properties"]["payload"]["oneOf"].is_array());
assert!(!lossy_field_match(&warnings, "payload.oneOf"));
}
#[test]
fn input_schema_declines_collapse_when_alternative_type_disagrees_with_const() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"bad": {
"oneOf": [
{ "const": 1, "type": "string" },
{ "const": 2, "type": "string" }
]
}
}
}));
assert!(params["properties"]["bad"]["oneOf"].is_array());
assert!(lossy_field_match(&warnings, "bad.oneOf"));
}
#[test]
fn input_schema_declines_collapse_when_parent_has_structural_keys() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"weird": {
"properties": { "x": { "type": "string" } },
"oneOf": [{ "const": "a" }, { "const": "b" }]
}
}
}));
assert!(params["properties"]["weird"]["oneOf"].is_array());
assert!(params["properties"]["weird"].get("enum").is_none());
}
#[test]
fn input_schema_declines_collapse_when_const_types_differ() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"mixed": { "oneOf": [{ "const": "a" }, { "const": 1 }] }
}
}));
assert!(params["properties"]["mixed"]["oneOf"].is_array());
assert!(lossy_field_match(&warnings, "mixed.oneOf"));
}
#[test]
fn input_schema_declines_collapse_when_const_is_object() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"blob": { "oneOf": [{ "const": {"x": 1} }, { "const": {"x": 2} }] }
}
}));
assert!(params["properties"]["blob"]["oneOf"].is_array());
assert!(lossy_field_match(&warnings, "blob.oneOf"));
}
#[test]
fn input_schema_declines_collapse_when_parent_has_sibling_const_literal() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"x": {
"type": "string",
"const": "x",
"oneOf": [{ "const": "a" }, { "const": "b" }]
}
}
}));
let x = ¶ms["properties"]["x"];
assert_eq!(x["enum"], json!(["x"]));
assert!(x.get("const").is_none());
let alts = x["oneOf"].as_array().unwrap();
assert_eq!(alts.len(), 2);
assert_eq!(alts[0]["enum"], json!(["a"]));
assert_eq!(alts[1]["enum"], json!(["b"]));
assert!(alts[0].get("const").is_none());
assert!(alts[1].get("const").is_none());
}
#[test]
fn input_schema_declines_collapse_when_enum_already_present() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"x": {
"enum": ["a", "b"],
"oneOf": [{ "const": "a" }, { "const": "b" }]
}
}
}));
assert_eq!(params["properties"]["x"]["enum"], json!(["a", "b"]));
assert!(params["properties"]["x"]["oneOf"].is_array());
}
#[test]
fn input_schema_walks_one_of_with_non_const_alternative_without_warning() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"u": { "oneOf": [{ "const": "a" }, { "type": "string" }] }
}
}));
assert!(params["properties"]["u"]["oneOf"].is_array());
assert!(
!lossy_field_match(&warnings, "u.oneOf"),
"non-recognized oneOf shape must not emit a collapse-decline warning"
);
}
#[test]
fn input_schema_recurses_into_items() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": { "type": ["string", "null"] }
}
}
}));
let items = ¶ms["properties"]["tags"]["items"];
assert_eq!(items["type"], "string");
assert_eq!(items["nullable"], true);
}
#[test]
fn input_schema_recurses_into_additional_properties_object() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"additionalProperties": { "type": ["string", "null"] }
}));
assert_eq!(params["additionalProperties"]["type"], "string");
assert_eq!(params["additionalProperties"]["nullable"], true);
}
#[test]
fn input_schema_passes_boolean_additional_properties_through() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": { "x": { "type": "string" } },
"additionalProperties": false
}));
assert_eq!(params["additionalProperties"], false);
}
#[test]
fn input_schema_recurses_into_nested_properties() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"outer": {
"type": "object",
"properties": {
"inner": { "type": ["string", "null"] }
}
}
}
}));
assert_eq!(
params["properties"]["outer"]["properties"]["inner"]["type"],
"string"
);
assert_eq!(
params["properties"]["outer"]["properties"]["inner"]["nullable"],
true
);
}
#[test]
fn input_schema_translation_is_idempotent() {
let codec = GeminiCodec::new();
let pre_translated = json!({
"type": "object",
"properties": { "kind": { "type": "string", "nullable": true } }
});
let req = ModelRequest {
model: "gemini-3.1-pro-preview".into(),
messages: vec![Message::user("hi")],
tools: std::sync::Arc::from([ToolSpec::function("t", "desc", pre_translated)]),
tool_choice: ToolChoice::Required,
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
let params = &body["tools"][0]["functionDeclarations"][0]["parameters"];
assert_eq!(params["properties"]["kind"]["type"], "string");
assert_eq!(params["properties"]["kind"]["nullable"], true);
}
#[test]
fn input_schema_combined_rules_handle_option_of_enum() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"kind": { "type": ["string", "null"] },
"status": {
"oneOf": [{ "const": "draft" }, { "const": "published" }]
}
}
}));
assert_eq!(params["properties"]["kind"]["type"], "string");
assert_eq!(params["properties"]["kind"]["nullable"], true);
assert_eq!(params["properties"]["status"]["type"], "string");
assert_eq!(
params["properties"]["status"]["enum"],
json!(["draft", "published"])
);
}
#[test]
fn input_schema_translates_standalone_string_const_to_enum() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"kind": { "type": "string", "const": "value_map" }
}
}));
let kind = ¶ms["properties"]["kind"];
assert_eq!(kind["type"], "string");
assert_eq!(kind["enum"], json!(["value_map"]));
assert!(kind.get("const").is_none());
assert!(!lossy_field_match(&warnings, "kind"));
}
#[test]
fn input_schema_translates_standalone_integer_const_to_enum() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"version": { "type": "integer", "const": 1 }
}
}));
let version = ¶ms["properties"]["version"];
assert_eq!(version["type"], "integer");
assert_eq!(version["enum"], json!([1]));
}
#[test]
fn input_schema_infers_type_when_const_lacks_sibling_type() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"tag": { "const": "fixed" }
}
}));
let tag = ¶ms["properties"]["tag"];
assert_eq!(tag["type"], "string");
assert_eq!(tag["enum"], json!(["fixed"]));
}
#[test]
fn input_schema_warns_on_const_with_inconsistent_sibling_type() {
let (params, warnings) = encode_tool_params(json!({
"type": "object",
"properties": {
"bad": { "type": "string", "const": 42 }
}
}));
let bad = ¶ms["properties"]["bad"];
assert_eq!(bad["type"], "integer");
assert_eq!(bad["enum"], json!([42]));
assert!(lossy_field_match(&warnings, "bad.type"));
}
#[test]
fn input_schema_preserves_const_sibling_keys_through_translation() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "value_map",
"description": "Maps a source value to a target value."
}
}
}));
let kind = ¶ms["properties"]["kind"];
assert_eq!(kind["type"], "string");
assert_eq!(kind["enum"], json!(["value_map"]));
assert_eq!(
kind["description"],
"Maps a source value to a target value."
);
}
#[test]
fn input_schema_passes_through_const_when_enum_already_present() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"x": {
"type": "string",
"enum": ["a", "b"],
"const": "a"
}
}
}));
let x = ¶ms["properties"]["x"];
assert_eq!(x["enum"], json!(["a", "b"]));
assert_eq!(x["const"], "a");
}
#[test]
fn input_schema_translates_const_inside_internally_tagged_enum_variant() {
let (params, _) = encode_tool_params(json!({
"type": "object",
"properties": {
"mapping": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "value_map" },
"value": { "type": "string" }
},
"required": ["kind", "value"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "lookup" },
"table": { "type": "string" }
},
"required": ["kind", "table"]
}
]
}
}
}));
let mapping = ¶ms["properties"]["mapping"];
let alt0 = &mapping["oneOf"][0];
let alt1 = &mapping["oneOf"][1];
assert_eq!(alt0["properties"]["kind"]["enum"], json!(["value_map"]));
assert!(alt0["properties"]["kind"].get("const").is_none());
assert_eq!(alt1["properties"]["kind"]["enum"], json!(["lookup"]));
assert!(alt1["properties"]["kind"].get("const").is_none());
assert_eq!(alt0["properties"]["value"]["type"], "string");
assert_eq!(alt1["properties"]["table"]["type"], "string");
}
#[test]
fn output_strategy_tool_strips_and_translates_synthetic_decl() {
use entelix_core::ir::{JsonSchemaSpec, OutputStrategy, ResponseFormat};
let codec = GeminiCodec::new();
let raw = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Out",
"type": "object",
"properties": {
"note": { "type": ["string", "null"] }
}
});
let req = ModelRequest {
model: "gemini-3.1-pro-preview".into(),
messages: vec![Message::user("emit")],
response_format: Some(ResponseFormat {
strategy: OutputStrategy::Tool,
json_schema: JsonSchemaSpec {
name: "Out".into(),
schema: raw,
},
strict: true,
}),
..ModelRequest::default()
};
let body = parse(&codec.encode(&req).unwrap().body);
let params = &body["tools"][0]["functionDeclarations"][0]["parameters"];
assert!(params.get("$schema").is_none());
assert!(params.get("title").is_none());
assert_eq!(params["properties"]["note"]["type"], "string");
assert_eq!(params["properties"]["note"]["nullable"], true);
}
#[test]
fn parallel_tool_calls_emits_lossy_encode_on_gemini() {
let codec = GeminiCodec::new();
let req = ModelRequest {
model: "gemini-2.5-pro".into(),
messages: vec![Message::user("hi")],
parallel_tool_calls: Some(true),
..ModelRequest::default()
};
let encoded = codec.encode(&req).unwrap();
let saw = encoded.warnings.iter().any(|w| {
matches!(
w,
ModelWarning::LossyEncode { field, .. } if field == "parallel_tool_calls"
)
});
assert!(saw, "Gemini must emit LossyEncode for parallel_tool_calls");
}