use anyhow::Result; use jcode_message_types::{ContentBlock, Message, Role, ToolCall, ToolDefinition}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashSet; pub const DEFAULT_MODEL: &str = "gemini-2.5-pro"; pub const AVAILABLE_MODELS: &[&str] = &[ "gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", ]; pub const FALLBACK_MODELS: &[&str] = &[ "gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-2.5-flash", "gemini-2.0-flash", ]; pub const CODE_ASSIST_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com"; pub const CODE_ASSIST_API_VERSION: &str = "v1internal"; /// Official Gemini Developer API (Google AI Studio) endpoint. Used when an API /// key is configured instead of OAuth Code Assist credentials. pub const GEMINI_API_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; pub const GEMINI_API_VERSION: &str = "v1beta"; pub const USER_TIER_FREE: &str = "free-tier"; pub const USER_TIER_LEGACY: &str = "legacy-tier"; #[derive(Debug, Clone)] pub struct GeminiRuntimeState { pub project_id: String, pub session_id: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClientMetadata { pub ide_type: &'static str, pub platform: &'static str, pub plugin_type: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub duet_project: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LoadCodeAssistRequest { #[serde(skip_serializing_if = "Option::is_none")] pub cloudaicompanion_project: Option, pub metadata: ClientMetadata, #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option<&'static str>, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LoadCodeAssistResponse { #[serde(default)] pub current_tier: Option, #[serde(default)] pub allowed_tiers: Option>, #[serde(default)] pub ineligible_tiers: Option>, #[serde(default)] pub cloudaicompanion_project: Option, #[serde(default)] pub paid_tier: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GeminiUserTier { #[serde(default)] pub id: Option, #[serde(default)] pub name: Option, #[serde(default)] pub is_default: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IneligibleTier { #[serde(default)] pub reason_code: Option, #[serde(default)] pub reason_message: Option, #[serde(default)] pub validation_url: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OnboardUserRequest { #[serde(skip_serializing_if = "Option::is_none")] pub tier_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cloudaicompanion_project: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LongRunningOperationResponse { #[serde(default)] pub name: Option, #[serde(default)] pub done: Option, #[serde(default)] pub response: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnboardUserResponse { #[serde(default)] pub cloudaicompanion_project: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ProjectRef { #[serde(default)] pub id: Option, } #[derive(Debug, Clone, Serialize)] pub struct CodeAssistGenerateRequest { pub model: String, pub project: String, pub user_prompt_id: String, pub request: VertexGenerateContentRequest, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct VertexGenerateContentRequest { pub contents: Vec, #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")] pub system_instruction: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(rename = "toolConfig", skip_serializing_if = "Option::is_none")] pub tool_config: Option, #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")] pub session_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiContent { // Requests always set `role` (see `build_contents`), but `generateContent` // responses occasionally omit it on a candidate's `content` (observed on // Antigravity/Cloud Code Gemini-3 turns). The response-side value is never // read, so default it rather than failing the whole decode with // "missing field `role`". #[serde(default)] pub role: String, #[serde(default)] pub parts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct GeminiPart { #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inline_data: Option, #[serde(skip_serializing_if = "Option::is_none")] pub function_call: Option, #[serde(skip_serializing_if = "Option::is_none")] pub function_response: Option, /// Gemini 3 thought signature for this part. Must be replayed verbatim on /// the `functionCall` part in later turns or the Cloud Code backend rejects /// the request. #[serde(skip_serializing_if = "Option::is_none")] pub thought_signature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InlineData { pub mime_type: String, pub data: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiFunctionCall { pub name: String, #[serde(default)] pub args: Value, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiFunctionResponse { pub name: String, pub response: Value, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, } pub fn build_system_instruction(system: &str) -> Option { let trimmed = system.trim(); if trimmed.is_empty() { None } else { Some(GeminiContent { role: "user".to_string(), parts: vec![GeminiPart { text: Some(trimmed.to_string()), ..Default::default() }], }) } } /// Prevention guidance appended to the Gemini system prompt when tools are /// advertised. Gemini-3 "thinking" models intermittently emit Python-style /// pseudo-code (e.g. `print(default_api.read(...))`) instead of a clean /// `functionCall`, which the backend rejects with `MALFORMED_FUNCTION_CALL` and /// empty content. Explicitly forbidding code/namespaces measurably reduces that /// failure mode at no latency cost (see the Gemini function-calling guidance and /// field reports of this exact behavior). const GEMINI_FUNCTION_CALL_GUARD: &str = "\n\n## Function calling\n\ - When you call a tool, emit a native function call, not code. Never write \ Python (or any language) that calls the tool, and never wrap a call in \ print(...) or a code block.\n\ - Use the function name exactly as defined. Do not prepend `default_api.` \ or any other namespace to the function name."; /// Build the Gemini `system_instruction`, appending [`GEMINI_FUNCTION_CALL_GUARD`] /// when tools are advertised so the model is steered away from the /// `MALFORMED_FUNCTION_CALL` pseudo-code failure mode. pub fn build_system_instruction_with_tool_guard( system: &str, has_tools: bool, ) -> Option { if !has_tools { return build_system_instruction(system); } let mut combined = system.trim().to_string(); combined.push_str(GEMINI_FUNCTION_CALL_GUARD); build_system_instruction(&combined) } pub fn build_contents(messages: &[Message]) -> Vec { // Gemini-3 attaches an opaque `thoughtSignature` to function-call parts, and // the Cloud Code / Antigravity backend rejects an assistant turn whose // function calls are ALL unsigned with `Function call is missing a // thought_signature in functionCall parts` (HTTP 400, issue #339). This // happens because: // * a parallel multi-call turn only signs its FIRST call (siblings persist // unsigned), and // * locally synthesized tool calls (batch sub-calls, manual tool use, // auto-poke continuations, recovery) and pre-signature/imported sessions // carry no signature at all. // // Live-verified backend rule: a turn is accepted as long as *at least one* // of its function calls carries a (valid) signature; a fully-unsigned turn // 400s. All calls in a session share the same opaque reasoning channel and // the backend accepts a previously-emitted signature replayed on later // calls, so we carry the most recent real signature forward across the whole // conversation onto any function call that lacks one. This keeps multi-call // turns and synthesized/imported histories replayable instead of hard-failing. let mut last_signature: Option = None; messages .iter() .filter_map(|message| { let role = match message.role { Role::User => "user", Role::Assistant => "model", }; let mut parts = Vec::new(); for block in &message.content { match block { ContentBlock::Text { text, .. } => { parts.push(GeminiPart { text: Some(text.clone()), ..Default::default() }); } ContentBlock::Reasoning { .. } | ContentBlock::ReasoningTrace { .. } | ContentBlock::AnthropicThinking { .. } | ContentBlock::OpenAIReasoning { .. } => {} ContentBlock::ToolUse { id, name, input, thought_signature, } => { let own_signature = thought_signature .as_ref() .filter(|sig| !sig.is_empty()) .cloned(); if own_signature.is_some() { last_signature = own_signature.clone(); } let signature = own_signature.or_else(|| last_signature.clone()); parts.push(GeminiPart { function_call: Some(GeminiFunctionCall { name: name.clone(), args: ToolCall::input_as_object(input), id: Some(id.clone()), }), thought_signature: signature, ..Default::default() }); } ContentBlock::ToolResult { tool_use_id, content, is_error, } => { parts.push(GeminiPart { function_response: Some(GeminiFunctionResponse { name: tool_name_from_tool_result(tool_use_id, messages), response: if is_error.unwrap_or(false) { json!({ "error": content }) } else { json!({ "content": content }) }, id: Some(tool_use_id.clone()), }), ..Default::default() }); } ContentBlock::Image { media_type, data } => { parts.push(GeminiPart { inline_data: Some(InlineData { mime_type: media_type.clone(), data: data.clone(), }), ..Default::default() }); } ContentBlock::OpenAICompaction { .. } => {} } } if parts.is_empty() { None } else { Some(GeminiContent { role: role.to_string(), parts, }) } }) .collect() } fn tool_name_from_tool_result(tool_use_id: &str, messages: &[Message]) -> String { for message in messages.iter().rev() { for block in &message.content { if let ContentBlock::ToolUse { id, name, .. } = block && id == tool_use_id { return name.clone(); } } } "tool".to_string() } pub fn build_tools(tools: &[ToolDefinition]) -> Option> { if tools.is_empty() { return None; } Some(vec![GeminiTool { function_declarations: tools .iter() .map(|tool| GeminiFunctionDeclaration { name: tool.name.clone(), // Prompt-visible. Approximate token cost for this field: // tool.description_token_estimate(). description: tool.description.clone(), parameters: gemini_compatible_schema(&tool.input_schema), }) .collect(), }]) } /// JSON Schema keywords the Gemini Code Assist `generateContent` endpoint /// rejects outright (HTTP 400 "Unknown name ... Cannot find field"). Gemini /// accepts only an OpenAPI 3.0 subset for `function_declarations.parameters`, /// so these draft-style keywords must be stripped before sending. const GEMINI_UNSUPPORTED_SCHEMA_KEYS: &[&str] = &[ "additionalProperties", "$schema", "$id", "$ref", "$defs", "definitions", "$comment", ]; fn gemini_compatible_schema(schema: &Value) -> Value { match schema { Value::Object(map) => { let mut out = serde_json::Map::new(); for (key, value) in map { // Drop draft-JSON-Schema keywords the Gemini API does not model; // leaving them in fails the whole request with HTTP 400. if GEMINI_UNSUPPORTED_SCHEMA_KEYS.contains(&key.as_str()) { continue; } if key == "const" { out.insert( "enum".to_string(), Value::Array(vec![gemini_compatible_schema(value)]), ); } else { out.insert(key.clone(), gemini_compatible_schema(value)); } } Value::Object(out) } Value::Array(items) => Value::Array(items.iter().map(gemini_compatible_schema).collect()), _ => schema.clone(), } } #[derive(Debug, Clone, Serialize)] pub struct GeminiTool { #[serde(rename = "functionDeclarations")] pub function_declarations: Vec, } #[derive(Debug, Clone, Serialize)] pub struct GeminiFunctionDeclaration { pub name: String, pub description: String, pub parameters: Value, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GeminiToolConfig { pub function_calling_config: GeminiFunctionCallingConfig, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GeminiFunctionCallingConfig { pub mode: &'static str, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CodeAssistGenerateResponse { #[serde(default)] pub trace_id: Option, #[serde(default)] pub response: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VertexGenerateContentResponse { #[serde(default)] pub candidates: Option>, #[serde(default)] pub prompt_feedback: Option, #[serde(default)] pub usage_metadata: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GeminiCandidate { #[serde(default)] pub content: Option, #[serde(default)] pub finish_reason: Option, #[serde(default)] pub finish_message: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GeminiPromptFeedback { #[serde(default)] pub block_reason: Option, #[serde(default)] pub block_reason_message: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GeminiUsageMetadata { #[serde(default)] pub prompt_token_count: Option, #[serde(default)] pub candidates_token_count: Option, #[serde(default)] pub cached_content_token_count: Option, } pub fn gemini_fallback_models(current_model: &str) -> Vec<&'static str> { FALLBACK_MODELS .iter() .copied() .filter(|candidate| !candidate.eq_ignore_ascii_case(current_model)) .collect() } pub fn google_cloud_project_from_env() -> Option { std::env::var("GOOGLE_CLOUD_PROJECT") .ok() .or_else(|| std::env::var("GOOGLE_CLOUD_PROJECT_ID").ok()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } pub fn load_code_assist_request( project_id: Option, metadata: ClientMetadata, ) -> LoadCodeAssistRequest { LoadCodeAssistRequest { cloudaicompanion_project: project_id, metadata, mode: None, } } pub fn merge_gemini_model_lists(models: Vec) -> Vec { let mut seen = HashSet::new(); let mut preferred = Vec::new(); for known in AVAILABLE_MODELS { if models.iter().any(|model| model == known) && seen.insert((*known).to_string()) { preferred.push((*known).to_string()); } } let mut extras: Vec = models .into_iter() .map(|model| model.trim().to_string()) .filter(|model| is_gemini_model_id(model) && seen.insert(model.clone())) .collect(); extras.sort(); preferred.extend(extras); preferred } pub fn extract_gemini_model_ids(value: &Value) -> Vec { let mut found = HashSet::new(); collect_gemini_model_ids(value, &mut found); merge_gemini_model_lists(found.into_iter().collect()) } fn collect_gemini_model_ids(value: &Value, found: &mut HashSet) { match value { Value::String(raw) => { let trimmed = raw.trim(); if is_gemini_model_id(trimmed) { found.insert(trimmed.to_string()); } } Value::Array(items) => { for item in items { collect_gemini_model_ids(item, found); } } Value::Object(map) => { for item in map.values() { collect_gemini_model_ids(item, found); } } Value::Null | Value::Bool(_) | Value::Number(_) => {} } } pub fn is_gemini_model_id(value: &str) -> bool { let trimmed = value.trim(); !trimmed.is_empty() && trimmed.starts_with("gemini-") && trimmed .bytes() .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_')) } pub fn client_metadata(project_id: Option) -> ClientMetadata { ClientMetadata { ide_type: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", plugin_type: "GEMINI", duet_project: project_id, } } pub fn validate_load_code_assist_response(res: &LoadCodeAssistResponse) -> Result<()> { if res.current_tier.is_none() && let Some(validation) = res.ineligible_tiers.as_ref().and_then(|tiers| { tiers.iter().find(|tier| { tier.reason_code.as_deref() == Some("VALIDATION_REQUIRED") && tier.validation_url.is_some() }) }) { let description = validation .reason_message .clone() .unwrap_or_else(|| "Account validation required".to_string()); let url = validation.validation_url.clone().unwrap_or_default(); anyhow::bail!("{description}. Complete account validation: {url}"); } Ok(()) } pub fn ineligible_or_project_error(res: &LoadCodeAssistResponse) -> anyhow::Error { if let Some(reasons) = res .ineligible_tiers .as_ref() .filter(|tiers| !tiers.is_empty()) { let joined = reasons .iter() .filter_map(|tier| tier.reason_message.as_deref()) .collect::>() .join(", "); return anyhow::anyhow!(joined); } anyhow::anyhow!( "This Google account requires setting GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID. See Gemini Code Assist Workspace auth docs." ) } pub fn choose_onboard_tier(res: &LoadCodeAssistResponse) -> GeminiUserTier { if let Some(default_tier) = res.allowed_tiers.as_ref().and_then(|tiers| { tiers .iter() .find(|tier| tier.is_default.unwrap_or(false)) .cloned() }) { return default_tier; } GeminiUserTier { id: Some(USER_TIER_LEGACY.to_string()), name: Some(String::new()), is_default: None, } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn fallback_models_skip_current_model() { assert_eq!( gemini_fallback_models("gemini-2.5-flash"), vec![ "gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-2.0-flash", ] ); } #[test] fn extract_gemini_model_ids_discovers_nested_models() { let response = json!({ "routing": { "manual": { "models": [ {"id": "gemini-3-pro-preview"}, {"name": "gemini-3.1-pro-preview"} ] }, "auto": ["gemini-3-flash-preview", "not-a-model"] } }); assert_eq!( extract_gemini_model_ids(&response), vec![ "gemini-3.1-pro-preview".to_string(), "gemini-3-pro-preview".to_string(), "gemini-3-flash-preview".to_string(), ] ); } #[test] fn candidate_content_decodes_without_role() { // Antigravity/Cloud Code Gemini-3 responses occasionally omit `role` on // a candidate's `content` (and sometimes `parts` entirely). The whole // generateContent decode used to fail with "missing field `role`", // which aborted the turn; assert the response now decodes and the // function call survives. let raw = json!({ "response": { "candidates": [{ "content": { "parts": [{ "functionCall": {"name": "read", "args": {"file_path": "/tmp/x"}}, "thoughtSignature": "SIG_XYZ" }] }, "finishReason": "STOP" }] } }) .to_string(); let decoded: CodeAssistGenerateResponse = serde_json::from_str(&raw).expect("decode response with role-less content"); let candidates = decoded.response.unwrap().candidates.unwrap(); let part = &candidates[0].content.as_ref().unwrap().parts[0]; assert_eq!(part.function_call.as_ref().unwrap().name, "read"); assert_eq!(part.thought_signature.as_deref(), Some("SIG_XYZ")); } #[test] fn candidate_content_decodes_without_parts() { // A bare `content: {}` (no `role`, no `parts`) must not abort the decode. let raw = json!({ "response": { "candidates": [{ "content": {}, "finishReason": "STOP" }] } }) .to_string(); let decoded: CodeAssistGenerateResponse = serde_json::from_str(&raw).expect("decode response with empty content"); let content = decoded.response.unwrap().candidates.unwrap()[0] .content .clone() .unwrap(); assert!(content.role.is_empty()); assert!(content.parts.is_empty()); } }