chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
@@ -0,0 +1,615 @@
use super::*;
use chrono::Utc;
use jcode_provider_antigravity::{
FetchAvailableModelsResponse, parse_fetch_available_models_response,
};
use jcode_provider_core::Provider;
use tokio_stream::StreamExt;
#[test]
fn parse_fetch_available_models_response_discovers_metadata_and_priority_order() {
let response: FetchAvailableModelsResponse = serde_json::from_value(serde_json::json!({
"defaultAgentModelId": "gemini-3.1-pro-high",
"commandModelIds": ["gemini-3-flash"],
"models": {
"claude-opus-4-6-thinking": {
"displayName": "Claude Opus 4.6 (Thinking)",
"quotaInfo": { "remainingFraction": 1, "resetTime": "2026-04-24T20:53:26Z" },
"recommended": true,
"modelProvider": "MODEL_PROVIDER_ANTHROPIC"
},
"gemini-3.1-pro-high": {
"displayName": "Gemini 3.1 Pro (High)",
"quotaInfo": { "remainingFraction": 0.25 }
},
"gemini-3-flash": {
"displayName": "Gemini 3 Flash",
"quotaInfo": { "remainingFraction": 0, "resetTime": "2026-04-24T21:53:26Z" }
},
"gpt-oss-120b-medium": {}
}
}))
.expect("parse response");
let parsed = parse_fetch_available_models_response(&response);
assert_eq!(
parsed.default_model_id.as_deref(),
Some("gemini-3.1-pro-high")
);
let parsed = parsed.models;
assert_eq!(parsed[0].id, "claude-opus-4-6-thinking");
assert_eq!(parsed[1].id, "gemini-3.1-pro-high");
assert_eq!(parsed[2].id, "gpt-oss-120b-medium");
assert_eq!(
parsed[0].display_name.as_deref(),
Some("Claude Opus 4.6 (Thinking)")
);
assert_eq!(parsed[1].remaining_fraction_milli, Some(250));
let flash = parsed
.iter()
.find(|model| model.id == "gemini-3-flash")
.expect("gemini flash model");
assert!(!flash.available);
assert_eq!(flash.remaining_fraction_milli, Some(0));
}
#[test]
fn client_metadata_uses_backend_accepted_platform() {
assert_eq!(metadata_platform(), "PLATFORM_UNSPECIFIED");
assert!(client_metadata_header().contains("\"platform\":\"PLATFORM_UNSPECIFIED\""));
}
#[test]
fn available_models_display_includes_dynamic_cache_and_current_override() {
let provider = AntigravityProvider::new();
*provider.fetched_catalog.write().expect("catalog lock") = vec![
CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
},
CatalogModel {
id: "gemini-3-pro-high".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: false,
available: true,
remaining_fraction_milli: Some(1000),
},
];
provider
.set_model("custom-antigravity-model")
.expect("set custom model");
let models = provider.available_models_display();
assert!(models.contains(&"claude-opus-4-6-thinking".to_string()));
assert!(models.contains(&"gemini-3-pro-high".to_string()));
assert!(models.contains(&"custom-antigravity-model".to_string()));
}
#[test]
fn available_models_display_seeds_from_persisted_catalog() {
let _guard = jcode_base::storage::lock_test_env();
let temp = tempfile::TempDir::new().expect("temp dir");
let previous = std::env::var_os("JCODE_HOME");
jcode_base::env::set_var("JCODE_HOME", temp.path());
let path = jcode_base::provider::antigravity::persisted_catalog_path().expect("catalog path");
jcode_base::storage::write_json(
&path,
&PersistedCatalog {
models: vec![CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: Some("Claude Opus 4.6 (Thinking)".to_string()),
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
}],
fetched_at_rfc3339: Utc::now().to_rfc3339(),
default_model_id: Some("gemini-3-flash".to_string()),
},
)
.expect("write persisted catalog");
let provider = AntigravityProvider::new();
assert!(
provider
.available_models_display()
.contains(&"claude-opus-4-6-thinking".to_string())
);
assert_eq!(
provider.backend_default_model().as_deref(),
Some("gemini-3-flash")
);
if let Some(previous) = previous {
jcode_base::env::set_var("JCODE_HOME", previous);
} else {
jcode_base::env::remove_var("JCODE_HOME");
}
}
#[test]
fn catalog_detail_mentions_quota_and_reset() {
let detail = catalog_model_detail(&CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: Some("Claude Opus 4.6 (Thinking)".to_string()),
reset_time: Some("2026-04-24T20:53:26Z".to_string()),
tag_title: Some("New".to_string()),
model_provider: Some("MODEL_PROVIDER_ANTHROPIC".to_string()),
max_tokens: Some(250_000),
max_output_tokens: Some(64_000),
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
});
assert!(detail.contains("recommended"));
assert!(detail.contains("quota 100.0%"));
assert!(detail.contains("resets 2026-04-24T20:53:26Z"));
}
#[test]
fn catalog_stale_handles_invalid_timestamp() {
assert!(catalog_is_stale("not-a-time"));
}
#[test]
fn resolve_model_for_request_maps_default_alias_to_real_model() {
let provider = AntigravityProvider::new();
// With no backend default and no catalog, the alias resolves to the
// hardcoded fallback rather than the literal "default" (which 404s).
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = Vec::new();
assert_eq!(
provider.resolve_model_for_request("default"),
DEFAULT_FALLBACK_MODEL
);
assert_eq!(
provider.resolve_model_for_request(" "),
DEFAULT_FALLBACK_MODEL
);
// A backend-advertised default takes precedence over the fallback.
*provider
.backend_default_model
.write()
.expect("default lock") = Some("gemini-3.5-flash-low".to_string());
assert_eq!(
provider.resolve_model_for_request("default"),
"gemini-3.5-flash-low"
);
// Explicit model ids are always passed through untouched.
assert_eq!(
provider.resolve_model_for_request("claude-sonnet-4-6"),
"claude-sonnet-4-6"
);
// ...except the catalog-advertised-but-unserviceable `gemini-3.1-pro-high`,
// which is remapped to the equivalent working "Gemini 3.1 Pro (High)" id.
assert_eq!(
provider.resolve_model_for_request("gemini-3.1-pro-high"),
"gemini-pro-agent"
);
// Its sibling `-low` works as-is and must not be remapped.
assert_eq!(
provider.resolve_model_for_request("gemini-3.1-pro-low"),
"gemini-3.1-pro-low"
);
}
#[test]
fn remap_unsupported_model_only_touches_broken_pro_high() {
assert_eq!(
remap_unsupported_model("gemini-3.1-pro-high"),
"gemini-pro-agent"
);
for model in [
"gemini-3.1-pro-low",
"gemini-pro-agent",
"gemini-3-flash",
"claude-sonnet-4-6",
"gpt-oss-120b-medium",
"default",
] {
assert_eq!(remap_unsupported_model(model), model);
}
}
#[test]
fn resolve_model_for_request_default_prefers_gemini_catalog_model() {
let provider = AntigravityProvider::new();
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = vec![
CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
},
CatalogModel {
id: "gemini-3-flash".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: false,
available: true,
remaining_fraction_milli: Some(1000),
},
];
// Even though Claude is listed first (and recommended), the default alias
// resolves to the Gemini model, which works reliably with tool use on the
// Cloud Code backend. Claude on this backend rejects jcode's tool schemas.
assert_eq!(
provider.resolve_model_for_request("default"),
"gemini-3-flash"
);
}
#[test]
fn resolve_model_for_request_default_falls_back_to_any_catalog_model_without_gemini() {
let provider = AntigravityProvider::new();
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = vec![CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
}];
// With no Gemini model available, fall back to the first available catalog
// model rather than the hardcoded default.
assert_eq!(
provider.resolve_model_for_request("default"),
"claude-opus-4-6-thinking"
);
}
#[tokio::test]
async fn complete_uses_native_https_transport_not_cli_subprocess() {
let provider = AntigravityProvider::new();
let mut stream = provider
.complete(&[], &[], "say hello", None)
.await
.expect("create stream");
let first_event = stream
.next()
.await
.expect("first event")
.expect("connection event");
match first_event {
StreamEvent::ConnectionType { connection } => {
assert_eq!(connection, "https");
assert_ne!(connection, "cli subprocess");
}
other => panic!("expected connection type, got {other:?}"),
}
}
#[test]
fn model_is_claude_detects_anthropic_models_only() {
assert!(model_is_claude("claude-sonnet-4-6"));
assert!(model_is_claude("claude-opus-4-6-thinking"));
assert!(model_is_claude("CLAUDE-SONNET"));
assert!(!model_is_claude("gemini-3-flash"));
assert!(!model_is_claude("gpt-oss-120b-medium"));
assert!(!model_is_claude("default"));
}
#[test]
fn flatten_schema_combiners_collapses_anyof_to_first_branch() {
// Mirrors the real `bg` tool's `status_filter` schema that the Antigravity
// Claude backend rejects.
let schema = serde_json::json!({
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
],
"description": "Status filter string or array."
});
let flattened = flatten_schema_combiners(&schema);
assert!(flattened.get("anyOf").is_none(), "anyOf must be removed");
assert_eq!(flattened["type"], serde_json::json!("string"));
// Sibling metadata is preserved onto the chosen branch.
assert_eq!(
flattened["description"],
serde_json::json!("Status filter string or array.")
);
}
#[test]
fn flatten_schema_combiners_recurses_into_nested_properties() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"oneOf": [
{ "type": "string" },
{ "type": "array" }
]
},
"name": { "type": "string" }
}
});
let flattened = flatten_schema_combiners(&schema);
assert_eq!(
flattened["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
assert!(
flattened["properties"]["status_filter"]
.get("oneOf")
.is_none()
);
// Untouched branches are preserved verbatim.
assert_eq!(
flattened["properties"]["name"]["type"],
serde_json::json!("string")
);
}
#[test]
fn flatten_schema_combiners_collapses_allof_inside_array_items() {
let schema = serde_json::json!({
"type": "array",
"items": {
"allOf": [
{ "type": "object", "properties": { "tool": { "type": "string" } } }
]
}
});
let flattened = flatten_schema_combiners(&schema);
assert!(flattened["items"].get("allOf").is_none());
assert_eq!(flattened["items"]["type"], serde_json::json!("object"));
}
#[test]
fn flatten_schema_combiners_leaves_combiner_free_schema_unchanged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"task_ids": { "type": "array", "items": { "type": "string" } },
"intent": { "type": "string" }
},
"required": ["intent"]
});
assert_eq!(flatten_schema_combiners(&schema), schema);
}
#[test]
fn model_is_gemini_detects_gemini_models_only() {
assert!(model_is_gemini("gemini-3-flash"));
assert!(model_is_gemini("gemini-2.5-pro"));
assert!(model_is_gemini("GEMINI-3-FLASH"));
assert!(!model_is_gemini("claude-sonnet-4-6"));
assert!(!model_is_gemini("gpt-oss-120b-medium"));
assert!(!model_is_gemini("default"));
}
#[test]
fn strip_numeric_schema_bounds_drops_array_and_string_and_object_bounds() {
// Mirrors the real `batch` tool schema that the gpt-oss backend rejects
// because it re-encodes the integer bound as the string "10".
let schema = serde_json::json!({
"type": "object",
"properties": {
"tool_calls": {
"type": "array",
"items": { "type": "object" },
"minItems": 1,
"maxItems": 10
},
"name": { "type": "string", "minLength": 1, "maxLength": 64 }
},
"minProperties": 1,
"maxProperties": 5
});
let stripped = strip_numeric_schema_bounds(&schema);
let tool_calls = &stripped["properties"]["tool_calls"];
assert!(tool_calls.get("minItems").is_none());
assert!(tool_calls.get("maxItems").is_none());
// Structural keys are preserved.
assert_eq!(tool_calls["type"], serde_json::json!("array"));
assert_eq!(tool_calls["items"]["type"], serde_json::json!("object"));
let name = &stripped["properties"]["name"];
assert!(name.get("minLength").is_none());
assert!(name.get("maxLength").is_none());
assert_eq!(name["type"], serde_json::json!("string"));
assert!(stripped.get("minProperties").is_none());
assert!(stripped.get("maxProperties").is_none());
}
#[test]
fn antigravity_compatible_schema_passes_gemini_through_unchanged() {
// Gemini is the native backend path; it accepts everything jcode emits, so
// the schema must be byte-identical (combiners and numeric bounds intact).
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
assert_eq!(
antigravity_compatible_schema(&schema, "gemini-3-flash"),
schema,
"Gemini path must not rewrite the schema"
);
}
#[test]
fn antigravity_compatible_schema_flattens_combiners_for_claude_but_keeps_bounds() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
let out = antigravity_compatible_schema(&schema, "claude-sonnet-4-6");
// Combiner collapsed (Anthropic strictness)...
assert!(out["properties"]["status_filter"].get("anyOf").is_none());
assert_eq!(
out["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
// ...but numeric bounds are retained for Claude (it accepts them).
assert_eq!(
out["properties"]["tool_calls"]["maxItems"],
serde_json::json!(10)
);
}
#[test]
fn antigravity_compatible_schema_strips_bounds_and_combiners_for_gpt_oss() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
let out = antigravity_compatible_schema(&schema, "gpt-oss-120b-medium");
// Combiner collapsed AND numeric bounds dropped (OpenAI-compatible bridge).
assert!(out["properties"]["status_filter"].get("anyOf").is_none());
assert_eq!(
out["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
assert!(out["properties"]["tool_calls"].get("minItems").is_none());
assert!(out["properties"]["tool_calls"].get("maxItems").is_none());
assert_eq!(
out["properties"]["tool_calls"]["type"],
serde_json::json!("array")
);
}
#[test]
fn is_retryable_empty_turn_detects_malformed_function_call() {
// Empty content + MALFORMED_FUNCTION_CALL is the transient Gemini-3 failure we
// retry transparently.
let response: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {},
"finishReason": "MALFORMED_FUNCTION_CALL",
"finishMessage": "Malformed function call: print(default_api.read(...))"
}]
}
}))
.expect("decode malformed response");
assert!(is_retryable_empty_turn(&response));
}
#[test]
fn is_retryable_empty_turn_ignores_normal_and_productive_turns() {
// A normal STOP turn with text is never retried.
let with_text: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {"parts": [{"text": "hello"}]},
"finishReason": "STOP"
}]
}
}))
.expect("decode text response");
assert!(!is_retryable_empty_turn(&with_text));
// A turn with a function call is productive even with no text.
let with_call: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {"parts": [{"functionCall": {"name": "read", "args": {}}}]},
"finishReason": "STOP"
}]
}
}))
.expect("decode function call response");
assert!(!is_retryable_empty_turn(&with_call));
// An empty STOP turn (legitimately empty answer) is not retried in a loop.
let empty_stop: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{ "content": {}, "finishReason": "STOP" }]
}
}))
.expect("decode empty stop response");
assert!(!is_retryable_empty_turn(&empty_stop));
}
@@ -0,0 +1,748 @@
//! Antigravity provider runtime (Google Cloud Code backend multiplexing
//! Gemini/Claude/gpt-oss upstreams), moved out of `jcode-base` so provider
//! edits compile only this crate plus a binary relink instead of rebuilding
//! the base -> app-core -> tui spine. The binary's composition root registers
//! [`AntigravityProvider`] with `jcode_base::provider::external` at startup.
use anyhow::{Context, Result};
use async_trait::async_trait;
use jcode_base::auth::antigravity as antigravity_auth;
use jcode_message_types::{ConnectionPhase, Message, StreamEvent, ToolDefinition};
use jcode_provider_antigravity::{
AVAILABLE_MODELS, CatalogModel, CatalogSnapshot, DEFAULT_FALLBACK_MODEL,
GENERATE_CONTENT_API_URL, PersistedCatalog, X_GOOG_API_CLIENT, antigravity_compatible_schema,
antigravity_user_agent, catalog_is_stale, catalog_model_detail, client_metadata_header,
is_retryable_empty_turn, merge_antigravity_model_ids, remap_unsupported_model,
};
#[cfg(test)]
use jcode_provider_antigravity::{
flatten_schema_combiners, metadata_platform, model_is_claude, model_is_gemini,
strip_numeric_schema_bounds,
};
use jcode_provider_core::{EventStream, Provider};
use jcode_provider_gemini::{
CodeAssistGenerateRequest, CodeAssistGenerateResponse, GeminiFunctionCallingConfig,
GeminiToolConfig, VertexGenerateContentRequest,
};
use serde_json::{Value, json};
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
const DEFAULT_MODEL: &str = "default";
pub struct AntigravityProvider {
client: reqwest::Client,
model: Arc<RwLock<String>>,
fetched_catalog: Arc<RwLock<Vec<CatalogModel>>>,
/// Backend-advertised default agent model id (from `fetchAvailableModels`).
/// Used to resolve the `"default"` alias to a real model for inference.
backend_default_model: Arc<RwLock<Option<String>>>,
}
impl Clone for AntigravityProvider {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
model: self.model.clone(),
fetched_catalog: self.fetched_catalog.clone(),
backend_default_model: self.backend_default_model.clone(),
}
}
}
impl AntigravityProvider {
fn load_persisted_catalog() -> Option<PersistedCatalog> {
jcode_base::provider::antigravity::load_persisted_catalog()
}
fn persist_catalog(snapshot: &CatalogSnapshot) {
jcode_base::provider::antigravity::persist_catalog(snapshot);
}
fn seed_cached_catalog(&self) {
if let Some(catalog) = Self::load_persisted_catalog() {
if catalog_is_stale(&catalog.fetched_at_rfc3339) {
jcode_base::logging::info(
"Loaded stale persisted Antigravity model catalog; a refresh will update it on next prefetch",
);
}
if let Some(default_model_id) = catalog.default_model_id.clone() {
*self
.backend_default_model
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(default_model_id);
}
if let Ok(mut models) = self.fetched_catalog.write() {
*models = catalog.models;
}
}
}
pub fn new() -> Self {
let model =
std::env::var("JCODE_ANTIGRAVITY_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
let provider = Self {
client: jcode_provider_core::shared_http_client(),
model: Arc::new(RwLock::new(model)),
fetched_catalog: Arc::new(RwLock::new(Vec::new())),
backend_default_model: Arc::new(RwLock::new(None)),
};
provider.seed_cached_catalog();
provider
}
fn fetched_catalog(&self) -> Vec<CatalogModel> {
self.fetched_catalog
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
fn backend_default_model(&self) -> Option<String> {
self.backend_default_model
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
/// Resolve the live OAuth credential the runtime would use, for the
/// provider-doctor's native Antigravity driver.
///
/// Antigravity authenticates exclusively via the Google OAuth tokens minted
/// by `jcode login --provider antigravity`; there is no API-key path. This
/// loads (and refreshes if needed) those tokens through the exact same code
/// path inference uses, returning only the resolved Google account email so
/// the doctor can confirm the credential without ever surfacing the token
/// itself.
pub async fn resolve_account_for_doctor(&self) -> Result<String> {
let tokens = antigravity_auth::load_or_refresh_tokens().await?;
if tokens.access_token.trim().is_empty() {
anyhow::bail!("resolved an empty Antigravity access token");
}
match antigravity_auth::fetch_email(&tokens.access_token).await {
Ok(email) if !email.trim().is_empty() => Ok(email),
_ => Ok(String::new()),
}
}
/// Fetch the live Antigravity model catalog using the resolved credential.
///
/// Mirrors [`Provider::prefetch_models`] but returns the available model ids
/// to the caller (rather than only persisting them) so the doctor can assert
/// the live `fetchAvailableModels` endpoint works and that the model under
/// test is in the live catalog. The warm catalog is persisted exactly like
/// the runtime's own prefetch so the rest of the process benefits.
pub async fn fetch_live_model_ids_for_doctor(&self) -> Result<Vec<String>> {
let snapshot = self.fetch_available_models().await?;
if snapshot.models.is_empty() {
anyhow::bail!("Antigravity model catalog returned no models");
}
Self::persist_catalog(&snapshot);
if let Some(default_model_id) = snapshot.default_model_id.clone() {
*self
.backend_default_model
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(default_model_id);
}
let model_ids: Vec<String> = snapshot
.models
.iter()
.filter(|model| model.available)
.map(|model| model.id.clone())
.collect();
*self
.fetched_catalog
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = snapshot.models;
if model_ids.is_empty() {
anyhow::bail!("Antigravity model catalog returned no available models");
}
Ok(model_ids)
}
/// Live catalog snapshot for the `/usage` report: per-model quota
/// (`remaining_fraction_milli`) and reset times. Persists the warm catalog
/// like the runtime prefetch so the rest of the process benefits.
pub async fn fetch_catalog_snapshot_for_usage(&self) -> Result<CatalogSnapshot> {
let snapshot = self.fetch_available_models().await?;
if snapshot.models.is_empty() {
anyhow::bail!("Antigravity model catalog returned no models");
}
Self::persist_catalog(&snapshot);
Ok(snapshot)
}
/// Resolve a requested model id into a real backend model id. The literal
/// alias `"default"` (and the empty string) is rejected by the
/// `generateContent` endpoint with HTTP 404, so it is mapped to the
/// backend-advertised default, then to a known-good catalog model, and
/// finally to a hardcoded fallback.
///
/// Note: this only resolves the `"default"` alias / empty input. An
/// explicit model id from the user is honoured verbatim, except for ids
/// the backend advertises but cannot actually service, which are remapped
/// to an equivalent working id via [`remap_unsupported_model`].
fn resolve_model_for_request(&self, model: &str) -> String {
let trimmed = model.trim();
if !trimmed.is_empty() && trimmed != DEFAULT_MODEL {
return remap_unsupported_model(trimmed).to_string();
}
if let Some(backend_default) = self
.backend_default_model()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty() && id != DEFAULT_MODEL)
{
return backend_default;
}
// No backend-advertised default: pick a usable catalog model. Prefer a
// Gemini model, which works reliably with tool use on this backend.
// Claude models on the Cloud Code backend currently reject jcode's tool
// schemas (they require JSON Schema draft 2020-12), so they are a poor
// automatic default even when listed first in the catalog.
let catalog = self.fetched_catalog();
if let Some(gemini_model) = catalog
.iter()
.find(|model| {
model.available
&& model.id.trim() != DEFAULT_MODEL
&& model.id.starts_with("gemini-")
})
.map(|model| model.id.clone())
{
return gemini_model;
}
if let Some(catalog_model) = catalog
.iter()
.find(|model| model.available && model.id.trim() != DEFAULT_MODEL)
.map(|model| model.id.clone())
{
return catalog_model;
}
DEFAULT_FALLBACK_MODEL.to_string()
}
async fn fetch_available_models(&self) -> Result<CatalogSnapshot> {
jcode_base::provider::antigravity::fetch_catalog_snapshot(&self.client).await
}
async fn generate_content(
&self,
model: &str,
messages: &[Message],
tools: &[ToolDefinition],
system: &str,
resume_session_id: Option<&str>,
force_function_call: bool,
) -> Result<CodeAssistGenerateResponse> {
let mut tokens = antigravity_auth::load_or_refresh_tokens().await?;
let project = match tokens
.project_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
Some(project_id) => project_id.to_string(),
None => {
let project_id = antigravity_auth::fetch_project_id(&tokens.access_token).await?;
tokens.project_id = Some(project_id.clone());
let _ = antigravity_auth::save_tokens(&tokens);
project_id
}
};
let resolved_model = self.resolve_model_for_request(model);
let tools_is_empty = tools.is_empty();
let mut tools = jcode_provider_gemini::build_tools(tools);
// Normalize each tool's JSON schema for the specific Antigravity backend
// path the resolved model uses. The Cloud Code backend forwards each
// model family to a different upstream (Gemini-native, Gemini->Anthropic,
// or an OpenAI-compatible bridge), and each upstream rejects a different
// construct. Gemini-native accepts everything jcode emits, so Gemini
// models pass through unchanged. See `antigravity_compatible_schema`.
if let Some(tools) = tools.as_mut() {
for tool in tools.iter_mut() {
for decl in tool.function_declarations.iter_mut() {
decl.parameters =
antigravity_compatible_schema(&decl.parameters, &resolved_model);
}
}
}
let request = CodeAssistGenerateRequest {
model: resolved_model,
project,
user_prompt_id: Uuid::new_v4().to_string(),
request: VertexGenerateContentRequest {
contents: jcode_provider_gemini::build_contents(messages),
system_instruction: jcode_provider_gemini::build_system_instruction_with_tool_guard(
system,
!tools_is_empty,
),
tools,
tool_config: if tools_is_empty {
None
} else {
// On a transparent retry after a MALFORMED_FUNCTION_CALL, force
// function-calling mode `ANY` so the model must emit a real
// functionCall instead of the Python-style pseudo-code that
// triggered the malformed turn (the proven recovery for this
// failure mode). Normal turns use `AUTO`.
Some(GeminiToolConfig {
function_calling_config: GeminiFunctionCallingConfig {
mode: if force_function_call { "ANY" } else { "AUTO" },
},
})
},
session_id: resume_session_id
.filter(|value| !value.trim().is_empty())
.map(str::to_string),
},
};
let contents_value = serde_json::to_value(&request.request.contents).unwrap_or(Value::Null);
let content_items = contents_value.as_array().cloned().unwrap_or_default();
let system_value = request
.request
.system_instruction
.as_ref()
.and_then(|system| serde_json::to_value(system).ok());
let tools_value = request
.request
.tools
.as_ref()
.and_then(|tools| serde_json::to_value(tools).ok());
let payload = json!({
"model": &request.model,
"contents": contents_value,
"system_instruction": system_value.as_ref(),
"tools": tools_value.as_ref(),
"tool_config": &request.request.tool_config,
});
jcode_provider_core::fingerprint::log_provider_canonical_input(
"antigravity",
model,
"gemini_generate_content",
&payload,
&content_items,
system_value.as_ref(),
tools_value.as_ref(),
request.request.tools.as_ref().map(|tools| tools.len()),
&[
(
"session_id_present",
request.request.session_id.is_some().to_string(),
),
("project_present", (!request.project.is_empty()).to_string()),
],
);
let response = self
.client
.post(GENERATE_CONTENT_API_URL)
.bearer_auth(&tokens.access_token)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::USER_AGENT, antigravity_user_agent())
.header("x-goog-api-client", X_GOOG_API_CLIENT)
.header(
"x-goog-request-params",
format!("project={}", request.project),
)
.header("x-goog-client-metadata", client_metadata_header())
.json(&request)
.send()
.await
.context("Failed to send Antigravity generateContent request")?;
if !response.status().is_success() {
let status = response.status();
let body = jcode_base::util::http_error_body(response, "HTTP error").await;
anyhow::bail!(
"Antigravity generateContent failed (HTTP {}): {}",
status,
body.trim()
);
}
response
.json()
.await
.context("Failed to decode Antigravity generateContent response")
}
}
impl Default for AntigravityProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Provider for AntigravityProvider {
async fn complete(
&self,
messages: &[Message],
_tools: &[ToolDefinition],
system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
let model = self
.model
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
let messages = messages.to_vec();
let tools = _tools.to_vec();
let system = system.to_string();
let resume_session_id = _resume_session_id.map(str::to_string);
let provider = self.clone();
let (tx, rx) = mpsc::channel::<Result<jcode_message_types::StreamEvent>>(100);
tokio::spawn(async move {
let _ = tx
.send(Ok(StreamEvent::ConnectionType {
connection: "https".to_string(),
}))
.await;
let _ = tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: ConnectionPhase::Authenticating,
}))
.await;
let _ = tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: ConnectionPhase::WaitingForResponse,
}))
.await;
let response = match provider
.generate_content(
&model,
&messages,
&tools,
&system,
resume_session_id.as_deref(),
false,
)
.await
{
Ok(response) => response,
Err(err) => {
let _ = tx.send(Err(err)).await;
return;
}
};
// Gemini-3 thinking models intermittently return an empty
// `MALFORMED_FUNCTION_CALL` turn (pseudo-code instead of a clean
// functionCall). It is transient, so transparently re-request a few
// times before surfacing it; this turns a frequent hard failure into a
// near-always-successful turn without the agent loop seeing the blip.
// The retries force function-calling mode `ANY` so the model must emit
// a real functionCall rather than the pseudo-code that failed.
let mut response = response;
let mut malformed_retries = 0u8;
const MAX_MALFORMED_RETRIES: u8 = 2;
while is_retryable_empty_turn(&response) && malformed_retries < MAX_MALFORMED_RETRIES {
malformed_retries += 1;
match provider
.generate_content(
&model,
&messages,
&tools,
&system,
resume_session_id.as_deref(),
true,
)
.await
{
Ok(retried) => response = retried,
Err(err) => {
let _ = tx.send(Err(err)).await;
return;
}
}
}
let _ = tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: ConnectionPhase::Streaming,
}))
.await;
if let Some(usage) = response
.response
.as_ref()
.and_then(|r| r.usage_metadata.as_ref())
{
let _ = tx
.send(Ok(StreamEvent::TokenUsage {
input_tokens: usage.prompt_token_count,
output_tokens: usage.candidates_token_count,
cache_read_input_tokens: usage.cached_content_token_count,
cache_creation_input_tokens: None,
}))
.await;
}
let Some(candidate) = response
.response
.and_then(|r| r.candidates)
.and_then(|mut c| c.drain(..).next())
else {
let _ = tx
.send(Err(anyhow::anyhow!(
"Antigravity returned no candidates for generateContent"
)))
.await;
return;
};
// Track whether this candidate produced any usable output (text or a
// tool call). Gemini-3 thinking models intermittently emit Python-style
// pseudo-code instead of a clean functionCall and finish with
// `MALFORMED_FUNCTION_CALL` (or a bare `OTHER`) and empty content. If we
// silently end the turn the agent loop looks like it stalled with no
// answer, so we surface an actionable error below instead.
let mut produced_output = false;
if let Some(content) = candidate.content {
// Gemini 3 attaches a `thoughtSignature` to function-call parts
// (and occasionally to a standalone preceding part). Emit tool
// calls through the standard ToolUseStart/End path so jcode
// drives the multi-turn loop, and replay the signature via a
// dedicated ToolUseSignature event so it can be persisted on the
// ToolUse block and resent on later turns (required by the
// Cloud Code backend, which rejects function calls missing it).
let mut pending_signature: Option<String> = None;
for part in content.parts {
let part_signature = part
.thought_signature
.as_ref()
.filter(|sig| !sig.is_empty())
.cloned();
if let Some(text) = part.text.filter(|text| !text.is_empty()) {
produced_output = true;
let _ = tx.send(Ok(StreamEvent::TextDelta(text))).await;
}
if let Some(function_call) = part.function_call {
produced_output = true;
let signature = part_signature.clone().or_else(|| pending_signature.take());
let raw_call_id = function_call
.id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let call_id = jcode_message_types::sanitize_tool_id(&raw_call_id);
let _ = tx
.send(Ok(StreamEvent::ToolUseStart {
id: call_id,
name: function_call.name,
}))
.await;
let _ = tx
.send(Ok(StreamEvent::ToolInputDelta(
function_call.args.to_string(),
)))
.await;
let _ = tx.send(Ok(StreamEvent::ToolUseEnd)).await;
if let Some(signature) = signature {
let _ = tx.send(Ok(StreamEvent::ToolUseSignature(signature))).await;
}
} else if let Some(signature) = part_signature {
// Standalone signature part; remember it for the next
// function call in this candidate.
pending_signature = Some(signature);
}
}
// A thought signature that was never consumed by a following
// function call (e.g. a pure-text reasoning turn) is still an
// opaque reasoning signal. Surface it as a ThinkingSignatureDelta
// rather than dropping it, so reasoning-aware consumers (and the
// provider-doctor reasoning probe) can see the model reasoned.
if let Some(signature) = pending_signature.take() {
let _ = tx
.send(Ok(StreamEvent::ThinkingSignatureDelta(signature)))
.await;
}
}
// An abnormal finish (typically Gemini-3's intermittent
// `MALFORMED_FUNCTION_CALL`, where the model writes pseudo-code rather
// than a valid functionCall) that yielded no text and no tool call is a
// dead turn: surface it as a retryable error instead of a silent empty
// `MessageEnd` that looks like the agent gave up. `STOP`/`MAX_TOKENS`
// are normal terminal reasons and are left to flow through as usual.
if !produced_output {
let abnormal = candidate
.finish_reason
.as_deref()
.map(|reason| {
!matches!(
reason.to_ascii_uppercase().as_str(),
"STOP" | "MAX_TOKENS" | "FINISH_REASON_UNSPECIFIED" | ""
)
})
.unwrap_or(false);
if abnormal {
let reason = candidate.finish_reason.as_deref().unwrap_or("unknown");
let detail = candidate
.finish_message
.as_deref()
.filter(|msg| !msg.trim().is_empty())
.map(|msg| format!(": {}", jcode_base::util::truncate_str(msg.trim(), 300)))
.unwrap_or_default();
let _ = tx
.send(Err(anyhow::anyhow!(
"Antigravity returned no usable output (finish_reason={reason}){detail}"
)))
.await;
return;
}
}
let _ = tx
.send(Ok(StreamEvent::MessageEnd {
stop_reason: candidate.finish_reason.clone(),
}))
.await;
});
Ok(Box::pin(ReceiverStream::new(rx)))
}
fn name(&self) -> &'static str {
"antigravity"
}
fn model(&self) -> String {
self.model
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
fn set_model(&self, model: &str) -> Result<()> {
let trimmed = model.trim();
if trimmed.is_empty() {
anyhow::bail!("Antigravity model cannot be empty");
}
*self
.model
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = trimmed.to_string();
Ok(())
}
fn available_models(&self) -> Vec<&'static str> {
AVAILABLE_MODELS.to_vec()
}
fn available_models_display(&self) -> Vec<String> {
let catalog = self.fetched_catalog();
merge_antigravity_model_ids(
catalog
.into_iter()
.map(|model| model.id)
.chain(std::iter::once(self.model())),
)
}
fn available_models_for_switching(&self) -> Vec<String> {
self.available_models_display()
}
fn model_routes(&self) -> Vec<jcode_provider_core::ModelRoute> {
let catalog = self.fetched_catalog();
if !catalog.is_empty() {
return catalog
.into_iter()
.map(|model| jcode_provider_core::ModelRoute {
model: model.id.clone(),
provider: "Antigravity".to_string(),
api_method: "https".to_string(),
available: model.available,
detail: catalog_model_detail(&model),
cheapness: None,
})
.collect();
}
self.available_models_display()
.into_iter()
.map(|model| jcode_provider_core::ModelRoute {
model,
provider: "Antigravity".to_string(),
api_method: "https".to_string(),
available: true,
detail: "fallback catalog".to_string(),
cheapness: None,
})
.collect()
}
fn on_auth_changed(&self) {
let provider = self.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
if provider.prefetch_models().await.is_ok() {
jcode_base::bus::Bus::global().publish_models_updated();
}
});
}
}
async fn prefetch_models(&self) -> Result<()> {
match self.fetch_available_models().await {
Ok(snapshot) => {
if !snapshot.models.is_empty() {
jcode_base::logging::info(&format!(
"Discovered Antigravity models: {}{}",
snapshot
.models
.iter()
.map(|model| model.id.as_str())
.collect::<Vec<_>>()
.join(", "),
snapshot
.default_model_id
.as_deref()
.map(|id| format!(" (default: {id})"))
.unwrap_or_default()
));
Self::persist_catalog(&snapshot);
if let Some(default_model_id) = snapshot.default_model_id.clone() {
*self
.backend_default_model
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
Some(default_model_id);
}
*self
.fetched_catalog
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = snapshot.models;
}
}
Err(err) => {
jcode_base::logging::warn(&format!(
"Antigravity model catalog refresh failed; keeping fallback list: {}",
err
));
}
}
Ok(())
}
fn supports_compaction(&self) -> bool {
false
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(Self {
client: self.client.clone(),
model: Arc::new(RwLock::new(self.model())),
fetched_catalog: self.fetched_catalog.clone(),
backend_default_model: self.backend_default_model.clone(),
})
}
}
#[cfg(test)]
#[path = "antigravity_tests.rs"]
mod antigravity_tests;