refactor(ai-formats): group formats by provider

Move protocol/request/response format modules under provider-oriented formats modules and update registry, transport, and architecture paths.
This commit is contained in:
fawney19
2026-05-08 15:40:24 +08:00
parent 84a84e3f31
commit 9a84a6ff6c
105 changed files with 1131 additions and 989 deletions

View File

@@ -0,0 +1 @@
pub mod request;

View File

@@ -0,0 +1,34 @@
use serde_json::json;
use serde_json::Value;
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::CanonicalRequest;
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let items = embedding.input.as_string_items()?;
if items.is_empty() || items.iter().any(|value| value.trim().is_empty()) {
return None;
}
let model = mapped_embedding_model(request, ctx.mapped_model_or(request.model.as_str()));
if items.len() == 1 {
return Some(json!({
"model": model,
"content": {
"parts": [{"text": items[0]}]
}
}));
}
Some(json!({
"model": model,
"requests": items.into_iter().map(|text| {
json!({
"model": model,
"content": {
"parts": [{"text": text}]
}
})
}).collect::<Vec<_>>()
}))
}

View File

@@ -0,0 +1 @@
pub mod spec;

View File

@@ -0,0 +1,69 @@
use crate::contracts::{
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
};
#[derive(Debug, Clone, Copy)]
pub struct LocalGeminiFilesSpec {
pub decision_kind: &'static str,
pub report_kind: Option<&'static str>,
pub require_streaming: bool,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
report_kind: Some("gemini_files_delete_mapping"),
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
report_kind: None,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_sync_gemini_files_specs() {
let spec = resolve_sync_spec("gemini_files_upload").expect("spec");
assert_eq!(spec.decision_kind, "gemini_files_upload");
assert_eq!(spec.report_kind, Some("gemini_files_store_mapping"));
assert!(!spec.require_streaming);
}
#[test]
fn resolves_stream_gemini_files_spec() {
let spec = resolve_stream_spec("gemini_files_download").expect("spec");
assert_eq!(spec.decision_kind, "gemini_files_download");
assert_eq!(spec.report_kind, None);
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND};
use crate::formats::shared::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_gemini_chat_sync_spec() {
let spec = resolve_sync_spec("gemini_chat_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_chat_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_gemini_chat_stream_spec() {
let spec = resolve_stream_spec("gemini_chat_stream").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_chat_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND};
use crate::formats::shared::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_gemini_cli_sync_spec() {
let spec = resolve_sync_spec("gemini_cli_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_cli_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_gemini_cli_stream_spec() {
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,16 @@
pub mod chat_spec;
pub mod cli_spec;
pub mod request;
pub mod response;
pub mod spec;
pub mod stream;
use crate::formats::shared::family::LocalStandardSpec;
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat_spec::resolve_sync_spec(plan_kind).or_else(|| cli_spec::resolve_sync_spec(plan_kind))
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat_spec::resolve_stream_spec(plan_kind).or_else(|| cli_spec::resolve_stream_spec(plan_kind))
}

View File

@@ -0,0 +1,689 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
formats::{
context::FormatContext,
openai::shared::{
map_openai_reasoning_effort_to_gemini_budget,
map_thinking_budget_to_openai_reasoning_effort,
},
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
},
protocol::canonical::{
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_openai_extra_body,
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,
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body, ctx.request_path.as_deref().unwrap_or_default())
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}
pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| extract_gemini_model_from_path(request_path))
.unwrap_or_default(),
..CanonicalRequest::default()
};
canonical.instructions = gemini_system_to_canonical_instructions(
request
.get("systemInstruction")
.or_else(|| request.get("system_instruction")),
)?;
let system_text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
canonical.system = Some(system_text);
}
canonical.messages = gemini_contents_to_canonical_messages(request.get("contents"))?;
canonical.generation = gemini_generation_config(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
canonical.thinking = gemini_thinking_to_canonical(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
canonical.response_format = gemini_response_format_to_canonical(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
let (tools, builtin_tools, web_search_options, raw_tools) =
gemini_tools_to_canonical(request.get("tools"))?;
canonical.tools = tools;
canonical.tool_choice = gemini_tool_choice_to_canonical(
request
.get("toolConfig")
.or_else(|| request.get("tool_config")),
);
canonical.extensions = gemini_extensions(
request,
&[
"model",
"systemInstruction",
"system_instruction",
"contents",
"generationConfig",
"generation_config",
"tools",
"toolConfig",
"tool_config",
"safetySettings",
"safety_settings",
"cachedContent",
"cached_content",
"stream",
],
);
if let Some(generation_config) = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object)
{
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
if let Some(thinking_config) =
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
{
gemini_extension.insert("thinking_config".to_string(), thinking_config);
}
if let Some(response_modalities) = gemini_value_by_case(
generation_config,
"responseModalities",
"response_modalities",
)
.cloned()
{
gemini_extension.insert("response_modalities".to_string(), response_modalities);
}
let extra = gemini_generation_config_extra(generation_config);
if !extra.is_empty() {
gemini_extension.insert("generation_config_extra".to_string(), Value::Object(extra));
}
}
if let Some(value) = request
.get("safetySettings")
.or_else(|| request.get("safety_settings"))
.cloned()
{
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("safety_settings".to_string(), value);
}
if let Some(value) = request
.get("cachedContent")
.or_else(|| request.get("cached_content"))
.cloned()
{
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("cached_content".to_string(), value);
}
if let Some(raw_tools) = raw_tools {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_tools".to_string(), raw_tools);
}
if !builtin_tools.is_empty() {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
}
if let Some(tool_config) = request
.get("toolConfig")
.or_else(|| request.get("tool_config"))
.cloned()
{
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);
}
Some(canonical)
}
pub fn to_raw(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
Some(output)
}
fn canonical_to_gemini_request_body(
canonical: &CanonicalRequest,
mapped_model: &str,
_upstream_is_stream: bool,
) -> Option<Value> {
let mut output = Map::new();
if !mapped_model.trim().is_empty() {
output.insert(
"model".to_string(),
Value::String(mapped_model.trim().to_string()),
);
}
output.insert(
"contents".to_string(),
Value::Array(compact_gemini_contents(
canonical_messages_to_gemini_contents(&canonical.messages)?,
)),
);
if let Some(system_instruction) = canonical_system_instruction(canonical) {
output.insert("systemInstruction".to_string(), system_instruction);
}
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical, mapped_model)
{
output.insert("generationConfig".to_string(), generation_config);
}
if let Some(tools) = canonical_tools_to_gemini(canonical) {
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) {
output.insert("toolConfig".to_string(), tool_config);
}
Some(Value::Object(output))
}
fn canonical_system_instruction(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
let text = if text.trim().is_empty() {
canonical.system.as_deref().unwrap_or_default().to_string()
} else {
text
};
(!text.trim().is_empty()).then(|| json!({ "parts": [{ "text": text }] }))
}
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
let mut contents = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
for message in messages {
let role = match message.role {
CanonicalRole::Assistant => "model",
CanonicalRole::System | CanonicalRole::Developer => continue,
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
};
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
if parts.is_empty() {
continue;
}
contents.push(json!({
"role": role,
"parts": parts,
}));
}
Some(contents)
}
fn canonical_blocks_to_gemini_parts(
blocks: &[CanonicalContentBlock],
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> {
let mut parts = Vec::new();
for block in blocks {
if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? {
parts.push(part);
}
}
Some(parts)
}
fn canonical_block_to_gemini_part(
block: &CanonicalContentBlock,
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Option<Value>> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
CanonicalContentBlock::Thinking {
text, signature, ..
} => {
if text.trim().is_empty() {
return Some(None);
}
let mut part = Map::new();
part.insert("text".to_string(), Value::String(text.clone()));
part.insert("thought".to_string(), Value::Bool(true));
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
part.insert(
"thoughtSignature".to_string(),
Value::String(signature.clone()),
);
}
Some(Some(Value::Object(part)))
}
CanonicalContentBlock::Image {
data,
url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("image/png"),
data.as_deref(),
url.as_deref(),
))),
CanonicalContentBlock::File {
data,
file_url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("application/octet-stream"),
data.as_deref(),
file_url.as_deref(),
))),
CanonicalContentBlock::Audio {
data, media_type, ..
} => Some(data.as_ref().map(|data| {
json!({
"inlineData": {
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
"data": data,
}
})
})),
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => {
tool_name_by_id.insert(id.clone(), name.clone());
Some(Some(json!({
"functionCall": {
"id": id,
"name": name,
"args": gemini_function_args(input),
}
})))
}
CanonicalContentBlock::ToolResult {
tool_use_id,
name,
output,
content_text,
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone()
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
.unwrap_or_else(|| tool_use_id.clone()),
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
}
}))),
CanonicalContentBlock::Unknown { .. } => Some(None),
}
}
fn canonical_media_to_gemini_part(
media_type: &str,
data: Option<&str>,
url: Option<&str>,
) -> Value {
if let Some(data) = data.filter(|value| !value.is_empty()) {
return json!({
"inlineData": {
"mimeType": media_type,
"data": data,
}
});
}
json!({
"fileData": {
"mimeType": media_type,
"fileUri": url.unwrap_or_default(),
}
})
}
fn canonical_generation_config_to_gemini(
canonical: &CanonicalRequest,
mapped_model: &str,
) -> Option<Value> {
let mut generation_config = Map::new();
if let Some(value) = canonical.generation.max_tokens {
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
}
insert_f64(
&mut generation_config,
"temperature",
canonical.generation.temperature,
);
insert_f64(&mut generation_config, "topP", canonical.generation.top_p);
if let Some(value) = canonical.generation.top_k {
generation_config.insert("topK".to_string(), Value::from(value));
}
if let Some(value) = canonical.generation.n.filter(|value| *value > 1) {
generation_config.insert("candidateCount".to_string(), Value::from(value));
}
if let Some(value) = canonical.generation.seed {
generation_config.insert("seed".to_string(), Value::from(value));
}
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
generation_config.insert(
"stopSequences".to_string(),
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
);
}
if let Some(response_format) = &canonical.response_format {
apply_response_format_to_gemini_generation_config(&mut generation_config, response_format);
}
if let Some(thinking_config) = canonical.thinking.as_ref().and_then(|thinking| {
thinking
.extensions
.get("gemini")
.and_then(|value| value.get("thinking_config"))
.cloned()
.or_else(|| {
let effort = canonical_openai_reasoning_effort(thinking);
gemini_thinking_config_from_reasoning(mapped_model, effort, thinking.budget_tokens)
})
}) {
generation_config.insert("thinkingConfig".to_string(), thinking_config);
}
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
}
fn gemini_thinking_config_from_reasoning(
mapped_model: &str,
effort: Option<&str>,
budget_tokens: Option<u64>,
) -> Option<Value> {
if gemini_model_uses_thinking_level(mapped_model) {
let level = effort
.and_then(ReasoningEffort::parse)
.or_else(|| {
budget_tokens
.map(map_thinking_budget_to_openai_reasoning_effort)
.and_then(ReasoningEffort::parse)
})
.map(ReasoningEffort::as_gemini_level_value)?;
return Some(json!({
"includeThoughts": true,
"thinkingLevel": level,
}));
}
let budget =
budget_tokens.or_else(|| effort.and_then(map_openai_reasoning_effort_to_gemini_budget))?;
Some(json!({
"includeThoughts": true,
"thinkingBudget": budget,
}))
}
fn apply_response_format_to_gemini_generation_config(
generation_config: &mut Map<String, Value>,
response_format: &CanonicalResponseFormat,
) {
match response_format.format_type.as_str() {
"json_schema" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
if let Some(schema) = response_format
.json_schema
.as_ref()
.and_then(|value| value.get("schema"))
.cloned()
.or_else(|| response_format.json_schema.clone())
{
let mut schema = schema;
clean_gemini_schema(&mut schema);
generation_config.insert("responseSchema".to_string(), schema);
}
}
"json_object" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
}
_ => {}
}
}
fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
let mut declarations = Vec::new();
let mut tools = Vec::new();
let mut google_search = canonical
.extensions
.get("openai")
.and_then(Value::as_object)
.is_some_and(|value| value.contains_key("web_search_options"));
let mut code_execution = false;
let mut url_context = false;
for tool in &canonical.tools {
match normalize_gemini_builtin_tool_name(&tool.name) {
Some("googleSearch") => {
google_search = true;
continue;
}
Some("codeExecution") => {
code_execution = true;
continue;
}
Some("urlContext") => {
url_context = true;
continue;
}
Some(_) => continue,
None => {}
}
if tool
.extensions
.get("openai_responses")
.or_else(|| {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
{
google_search = true;
continue;
}
declarations.push(canonical_tool_to_gemini_declaration(tool));
}
if code_execution {
tools.push(json!({ "codeExecution": {} }));
}
if google_search {
tools.push(json!({ "googleSearch": {} }));
}
if url_context {
tools.push(json!({ "urlContext": {} }));
}
if !declarations.is_empty() {
tools.push(json!({ "functionDeclarations": declarations }));
}
if let Some(builtin_tools) = canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("builtin_tools"))
.and_then(Value::as_array)
{
tools.extend(builtin_tools.iter().cloned());
}
(!tools.is_empty()).then_some(Value::Array(tools))
}
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
if let Some(description) = &tool.description {
declaration.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
declaration.insert(
"parameters".to_string(),
tool.parameters
.clone()
.map(|mut schema| {
clean_gemini_schema(&mut schema);
schema
})
.unwrap_or_else(|| json!({})),
);
Value::Object(declaration)
}
fn canonical_tool_choice_to_gemini(choice: Option<&CanonicalToolChoice>) -> Option<Value> {
let choice = choice?;
let mode = match choice {
CanonicalToolChoice::Auto => "AUTO",
CanonicalToolChoice::None => "NONE",
CanonicalToolChoice::Required | CanonicalToolChoice::Tool { .. } => "ANY",
};
let mut function_calling_config = Map::new();
function_calling_config.insert("mode".to_string(), Value::String(mode.to_string()));
if let CanonicalToolChoice::Tool { name } = choice {
function_calling_config.insert(
"allowedFunctionNames".to_string(),
Value::Array(vec![Value::String(name.clone())]),
);
}
Some(json!({
"functionCallingConfig": Value::Object(function_calling_config),
}))
}
fn gemini_function_args(input: &Value) -> Value {
match input {
Value::Object(_) => input.clone(),
Value::Null => json!({}),
other => json!({ "value": other.clone() }),
}
}
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(value) => json!({ "result": value }),
None => json!({ "result": content_text.unwrap_or_default() }),
}
}
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for content in contents {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parts = content
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if parts.is_empty() {
continue;
}
if let Some(last) = compact.last_mut() {
let last_role = last.get("role").and_then(Value::as_str).unwrap_or_default();
if last_role == role {
if let Some(last_parts) = last
.as_object_mut()
.and_then(|object| object.get_mut("parts"))
.and_then(Value::as_array_mut)
{
last_parts.extend(parts);
continue;
}
}
}
compact.push(json!({
"role": role,
"parts": parts,
}));
}
compact
}
fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
match name
.trim()
.replace(['_', '-', ' '], "")
.to_ascii_lowercase()
.as_str()
{
"googlesearch" | "websearch" | "websearchpreview" => Some("googleSearch"),
"codeexecution" => Some("codeExecution"),
"urlcontext" => Some("urlContext"),
_ => None,
}
}
fn insert_f64(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
if let Some(value) = value.and_then(serde_json::Number::from_f64) {
output.insert(key.to_string(), Value::Number(value));
}
}
fn clean_gemini_schema(value: &mut Value) {
match value {
Value::Object(object) => {
for inner in object.values_mut() {
clean_gemini_schema(inner);
}
if object.get("type").and_then(Value::as_str) == Some("object")
&& !object.contains_key("properties")
{
object.insert("properties".to_string(), Value::Object(Map::new()));
}
}
Value::Array(items) => {
for item in items {
clean_gemini_schema(item);
}
}
_ => {}
}
}

View File

@@ -0,0 +1,355 @@
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_extension_object_mut, gemini_extensions, gemini_part_to_canonical_block,
gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
CanonicalUsage,
},
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
to_raw(response, &ctx.report_context_value())
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") {
return None;
}
let candidates = body.get("candidates")?.as_array()?;
let mut outputs = Vec::new();
for (fallback_index, candidate) in candidates.iter().enumerate() {
let candidate_object = candidate.as_object()?;
let parts = candidate_object
.get("content")
.and_then(Value::as_object)
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let content = parts
.iter()
.enumerate()
.filter_map(|(index, part)| gemini_part_to_canonical_block(part, index))
.collect::<Vec<_>>();
let mut stop_reason = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
.and_then(Value::as_str)
.and_then(gemini_stop_reason_to_canonical);
if content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }))
&& stop_reason
.as_ref()
.is_none_or(|reason| matches!(reason, CanonicalStopReason::EndTurn))
{
stop_reason = Some(CanonicalStopReason::ToolUse);
}
outputs.push(CanonicalResponseOutput {
index: candidate_object
.get("index")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(fallback_index),
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: Default::default(),
});
}
let content = outputs
.first()
.map(|output| output.content.clone())
.unwrap_or_default();
let stop_reason = outputs
.first()
.and_then(|output| output.stop_reason.clone());
let mut canonical = CanonicalResponse {
id: body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("gemini-local-finalize")
.to_string(),
model: body
.get("modelVersion")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs,
content,
stop_reason,
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
extensions: gemini_extensions(
body,
&[
"responseId",
"_v1internal_response_id",
"modelVersion",
"candidates",
"usageMetadata",
],
),
};
if let Some(candidates) = body.get("candidates").cloned() {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_candidates".to_string(), candidates);
}
Some(canonical)
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
let mut response = canonical_to_gemini_response(canonical, report_context)?;
if let Some(object) = response.as_object_mut() {
if let Some(gemini) = canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
{
for (key, value) in gemini {
if key == "raw_candidates" || object.contains_key(key) {
continue;
}
object.insert(key.clone(), value.clone());
}
}
}
Some(response)
}
fn canonical_to_gemini_response(
canonical: &CanonicalResponse,
report_context: &Value,
) -> Option<Value> {
let outputs = if canonical.outputs.is_empty() {
vec![CanonicalResponseOutput {
index: 0,
role: crate::protocol::canonical::CanonicalRole::Assistant,
content: canonical.content.clone(),
stop_reason: canonical.stop_reason.clone(),
extensions: Default::default(),
}]
} else {
canonical.outputs.clone()
};
let mut candidates = Vec::new();
for output in outputs {
let parts = canonical_blocks_to_gemini_parts(&output.content)?;
candidates.push(json!({
"index": output.index,
"content": {
"role": "model",
"parts": parts,
},
"finishReason": canonical_stop_reason_to_gemini(
output.stop_reason.as_ref().or(canonical.stop_reason.as_ref())
),
}));
}
let mut response = Map::new();
response.insert(
"responseId".to_string(),
Value::String(if canonical.id.trim().is_empty() {
"resp-local-finalize".to_string()
} else {
canonical.id.clone()
}),
);
response.insert(
"modelVersion".to_string(),
Value::String(
if canonical.model.trim().is_empty() || canonical.model == "unknown" {
report_context
.get("mapped_model")
.and_then(Value::as_str)
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown")
.to_string()
} else {
canonical.model.clone()
},
),
);
response.insert("candidates".to_string(), Value::Array(candidates));
if let Some(usage) = &canonical.usage {
response.insert(
"usageMetadata".to_string(),
canonical_usage_to_gemini_usage_metadata(usage),
);
}
Some(Value::Object(response))
}
fn canonical_blocks_to_gemini_parts(blocks: &[CanonicalContentBlock]) -> Option<Vec<Value>> {
let mut parts = Vec::new();
for block in blocks {
if let Some(part) = canonical_block_to_gemini_part(block)? {
parts.push(part);
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
Some(parts)
}
fn canonical_block_to_gemini_part(block: &CanonicalContentBlock) -> Option<Option<Value>> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
CanonicalContentBlock::Thinking {
text, signature, ..
} => {
if text.trim().is_empty() {
return Some(None);
}
let mut part = Map::new();
part.insert("text".to_string(), Value::String(text.clone()));
part.insert("thought".to_string(), Value::Bool(true));
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
part.insert(
"thoughtSignature".to_string(),
Value::String(signature.clone()),
);
}
Some(Some(Value::Object(part)))
}
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => Some(Some(json!({
"functionCall": {
"id": id,
"name": name,
"args": gemini_function_args(input),
}
}))),
CanonicalContentBlock::ToolResult {
tool_use_id,
name,
output,
content_text,
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone().unwrap_or_else(|| tool_use_id.clone()),
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
}
}))),
CanonicalContentBlock::Image {
data,
url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("image/png"),
data.as_deref(),
url.as_deref(),
))),
CanonicalContentBlock::File {
data,
file_url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("application/octet-stream"),
data.as_deref(),
file_url.as_deref(),
))),
CanonicalContentBlock::Audio {
data, media_type, ..
} => Some(data.as_ref().map(|data| {
json!({
"inlineData": {
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
"data": data,
}
})
})),
CanonicalContentBlock::Unknown { .. } => Some(None),
}
}
fn canonical_media_to_gemini_part(
media_type: &str,
data: Option<&str>,
url: Option<&str>,
) -> Value {
if let Some(data) = data.filter(|value| !value.is_empty()) {
return json!({
"inlineData": {
"mimeType": media_type,
"data": data,
}
});
}
json!({
"fileData": {
"mimeType": media_type,
"fileUri": url.unwrap_or_default(),
}
})
}
fn gemini_function_args(input: &Value) -> Value {
match input {
Value::Object(_) => input.clone(),
Value::Null => json!({}),
other => json!({ "value": other.clone() }),
}
}
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(Value::Object(object)) => Value::Object(object.clone()),
Some(value) => json!({ "result": value }),
None => json!({ "result": content_text.unwrap_or_default() }),
}
}
fn canonical_stop_reason_to_gemini(reason: Option<&CanonicalStopReason>) -> Value {
Value::String(
match reason {
Some(CanonicalStopReason::MaxTokens) => "MAX_TOKENS",
Some(CanonicalStopReason::ContentFiltered) | Some(CanonicalStopReason::Refusal) => {
"SAFETY"
}
Some(CanonicalStopReason::Unknown) => "OTHER",
_ => "STOP",
}
.to_string(),
)
}
fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
let mut out = Map::new();
out.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
out.insert(
"candidatesTokenCount".to_string(),
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
);
out.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
);
if usage.reasoning_tokens > 0 {
out.insert(
"thoughtsTokenCount".to_string(),
Value::from(usage.reasoning_tokens),
);
}
Value::Object(out)
}

View File

@@ -0,0 +1 @@
pub use super::{chat_spec, cli_spec, resolve_stream_spec, resolve_sync_spec};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
pub mod embedding;
pub mod files;
pub mod generate_content;
pub mod video;

View File

@@ -0,0 +1 @@
pub mod spec;

View File

@@ -0,0 +1,27 @@
use crate::contracts::GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND;
use crate::formats::shared::video::{LocalVideoCreateFamily, LocalVideoCreateSpec};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
match plan_kind {
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
api_format: "gemini:video",
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
report_kind: "gemini_video_create_sync_finalize",
family: LocalVideoCreateFamily::Gemini,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_sync_spec, LocalVideoCreateFamily};
#[test]
fn resolves_gemini_video_create_spec() {
let spec = resolve_sync_spec("gemini_video_create_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:video");
assert_eq!(spec.family, LocalVideoCreateFamily::Gemini);
assert_eq!(spec.report_kind, "gemini_video_create_sync_finalize");
}
}