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
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:
@@ -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;
|
||||
Reference in New Issue
Block a user