diff --git a/CHANGELOG.md b/CHANGELOG.md index b9915c95d3..84d5c326d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Antigravity is retired to a non-runnable legacy tombstone: it is no longer + selectable from the provider picker, `--provider`, `/provider`, + `CODEWHALE_PROVIDER`, `config set provider`, or route ids; the private + Antigravity OAuth-token import, `AGY_ADC_AUTH`, `ANTIGRAVITY_API_KEY`, and + the cloud-code transport are removed; the catalog export and public docs + omit it (`scripts/check-provider-registry.py` fails if it reappears). + Existing `[providers.antigravity]` / `[providers.agy]` tables still parse + so `codewhale auth clear --provider antigravity` can forget only + Codewhale-owned state (config table, fallback entries, top-level selection, + Codewhale's own secret slot, and the sibling `config.toml.bak`); it never + reads, revokes, or alters a Google or Antigravity session. Gemini routes + through the supported `google` provider with `GEMINI_API_KEY`. - Provider-native web search now applies domain constraints before accepting an attempt, discards generated answers when returned citations violate those constraints, and preserves the caller's configured/local timeout as an diff --git a/config.example.toml b/config.example.toml index 284b6c1b77..4ffcd4aeab 100644 --- a/config.example.toml +++ b/config.example.toml @@ -814,6 +814,16 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # base_url = "https://api.mistral.ai/v1" # model = "mistral-code-latest" # or mistral-medium-latest, mistral-small-latest, mistral-large-latest +# Google Gemini — Google AI Studio (https://aistudio.google.com/apikey) +# OpenAI-compatible Chat Completions route on the official Gemini endpoint; +# this is the supported Gemini path (see docs/PROVIDERS.md). +# Provider aliases: google, gemini, google-gemini, ai-studio +# Env var aliases: GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_BASE_URL, GOOGLE_BASE_URL +[providers.google] +# api_key = "YOUR_GEMINI_API_KEY" +# base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" +# model = "gemini-3.1-pro-preview" + # ───────────────────────────────────────────────────────────────────────────────── # Alibaba Cloud Model Studio — Token Plan # (https://bailian.console.aliyun.com/) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9294c93783..0a097f2cb2 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -35,8 +35,18 @@ use codewhale_telemetry::{ TelemetryDecision, TurnWall, }; +fn is_antigravity_legacy_selector(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "antigravity" | "agy" + ) +} + /// Catalog-backed `--provider` parser. Replaces the closed 47-arm `ProviderArg` enum. fn parse_catalog_route(value: &str) -> std::result::Result { + if is_antigravity_legacy_selector(value) { + return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string()); + } parse_route_kind(value).ok_or_else(|| { format!( "unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)" @@ -45,7 +55,17 @@ fn parse_catalog_route(value: &str) -> std::result::Result } fn builtin_provider_arg(value: &str) -> Option { - parse_route_kind(value) + parse_route_kind(value).filter(|provider| *provider != ProviderKind::Antigravity) +} + +/// The legacy tombstone is accepted only by the local Codewhale-state clear +/// command. Every selectable/auth-consuming parser continues through +/// [`parse_catalog_route`], which rejects it. +fn parse_auth_clear_provider(value: &str) -> std::result::Result { + if is_antigravity_legacy_selector(value) { + return Ok(ProviderKind::Antigravity); + } + parse_catalog_route(value) } fn parse_provider_identifier(value: &str) -> std::result::Result { @@ -451,6 +471,9 @@ fn top_level_provider_override( let Some(provider) = provider else { return Ok(None); }; + if is_antigravity_legacy_selector(provider) { + bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } if let Some(provider) = builtin_provider_arg(provider) { return Ok(Some(provider)); } @@ -1503,7 +1526,7 @@ enum AuthCommand { }, /// Delete a provider's key from config and secret-store storage. Clear { - #[arg(long, value_parser = parse_catalog_route)] + #[arg(long, value_parser = parse_auth_clear_provider)] provider: ProviderKind, }, /// List all known providers with their runtime-effective auth state, @@ -2488,6 +2511,9 @@ fn clear_auth_provider( secrets: &Secrets, provider: ProviderKind, ) -> Result<()> { + if provider == ProviderKind::Antigravity { + return clear_legacy_antigravity_config(store, secrets); + } let slot = provider_slot(provider); let original_config = store.config.clone(); clear_provider_api_key_from_config(store, provider); @@ -2510,6 +2536,72 @@ fn clear_auth_provider( Ok(()) } +/// Remove only Codewhale-owned state for the retired Antigravity route. +/// +/// This deliberately operates on the already-loaded Codewhale config and its +/// own secret slot. It never resolves an external credential path, reads an +/// environment credential, or invokes a Google/Antigravity logout or revoke +/// flow. +fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> { + let provider = ProviderKind::Antigravity; + let slot = provider_slot(provider); + let original_config = store.config.clone(); + let prior_secret = secrets.get(slot).map_err(|error| { + anyhow!( + "could not snapshot the Codewhale-owned legacy {slot} secret slot before clearing it: {error}; config was not changed" + ) + })?; + + store.config.providers.antigravity = Default::default(); + store + .config + .fallback_providers + .retain(|fallback| *fallback != provider); + if store.config.provider == provider { + store.config.provider = ProviderKind::default(); + store.config.selected_provider_id = None; + } + + if let Err(error) = secrets.delete(slot) { + store.config = original_config; + return Err(anyhow!( + "could not clear the Codewhale-owned legacy {slot} secret slot: {error}; config was not changed" + )); + } + + if let Err(error) = store.save() { + store.config = original_config; + if let Some(previous) = prior_secret { + let current = secrets.get(slot).map_err(|rollback| { + anyhow!( + "{error}; additionally could not verify rollback of the Codewhale-owned legacy {slot} secret slot: {rollback}" + ) + })?; + match current { + None => secrets.set(slot, &previous).map_err(|rollback| { + anyhow!( + "{error}; additionally failed to restore the Codewhale-owned legacy {slot} secret slot: {rollback}" + ) + })?, + Some(current) if current == previous => {} + Some(_) => { + return Err(anyhow!( + "{error}; additionally the Codewhale-owned legacy {slot} secret slot changed concurrently and was not overwritten during rollback" + )); + } + } + } + return Err(error); + } + + codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?; + codewhale_config::scrub_legacy_antigravity_from_config_backup(store.path())?; + println!( + "cleared Codewhale-owned legacy Antigravity config, consent, selection, fallback entries, and secret-store slot; Google and Antigravity sessions were not read, revoked, or changed. For Gemini, configure provider google and set GEMINI_API_KEY" + ); + Ok(()) +} + fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) { store.config.providers.for_provider_mut(provider).api_key = None; if provider == ProviderKind::Deepseek { @@ -2593,10 +2685,6 @@ fn external_credential_target( codewhale_config::ExternalCredentialSource::DshCli, codewhale_config::default_dsh_credentials_path(), ), - ProviderKind::Antigravity => ( - codewhale_config::ExternalCredentialSource::AgyCli, - codewhale_config::default_agy_credentials_path(), - ), ProviderKind::Moonshot => bail!( "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported." ), @@ -6301,9 +6389,73 @@ verbosity = "project-imported" } #[test] - fn antigravity_provider_aliases_parse_as_builtin() { + fn antigravity_provider_aliases_are_clear_only_and_never_raw_custom() { for alias in ["antigravity", "agy"] { - assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::Antigravity)); + assert_eq!(builtin_provider_arg(alias), None, "{alias}"); + assert_eq!( + parse_auth_clear_provider(alias), + Ok(ProviderKind::Antigravity), + "{alias}" + ); + let error = parse_catalog_route(alias).expect_err("legacy route is not selectable"); + assert!(error.contains("non-runnable legacy provider"), "{error}"); + assert!(error.contains("--provider antigravity"), "{error}"); + assert!(error.contains("google"), "{error}"); + assert!(error.contains("GEMINI_API_KEY"), "{error}"); + + let clear = parse_ok(&["codewhale", "auth", "clear", "--provider", alias]); + assert!(matches!( + clear.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Clear { + provider: ProviderKind::Antigravity, + } + })) + )); + + for argv in [ + vec!["codewhale", "auth", "set", "--provider", alias], + vec!["codewhale", "auth", "get", "--provider", alias], + vec!["codewhale", "auth", "print-api-key", "--provider", alias], + vec!["codewhale", "auth", "status", "--provider", alias], + vec!["codewhale", "auth", "external-revoke", "--provider", alias], + vec![ + "codewhale", + "auth", + "external-consent", + "--provider", + alias, + "--mode", + "read-only", + "--yes", + ], + vec!["codewhale", "model", "list", "--provider", alias], + vec!["codewhale", "model", "resolve", "--provider", alias], + ] { + let error = Cli::try_parse_from(argv) + .expect_err("legacy Antigravity route must be rejected outside auth clear"); + assert_eq!(error.kind(), ErrorKind::ValueValidation); + assert!( + error.to_string().contains("non-runnable legacy provider"), + "{error}" + ); + } + + for command in [ + Commands::Exec(TuiPassthroughArgs { + args: vec!["Reply OK".into()], + }), + Commands::Fleet(TuiPassthroughArgs { + args: vec!["status".into()], + }), + ] { + let error = top_level_provider_override(Some(alias), Some(&command)) + .expect_err("legacy alias must not fall through as a raw custom provider"); + assert!( + error.to_string().contains("non-runnable legacy provider"), + "{error}" + ); + } } } @@ -7398,6 +7550,201 @@ verbosity = "project-imported" let _ = std::fs::remove_file(path); } + #[test] + fn antigravity_clear_removes_only_codewhale_owned_legacy_state() { + use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let dir = tempfile::TempDir::new().expect("isolated legacy fixture"); + let config_path = dir.path().join("config.toml"); + let external_session_path = dir.path().join("external-antigravity-session.db"); + let external_session = b"external session bytes must remain unchanged"; + std::fs::write(&external_session_path, external_session) + .expect("write external session trap"); + + let mut store = ConfigStore::load(Some(config_path.clone())).expect("load empty config"); + store.config.provider = ProviderKind::Antigravity; + store.config.fallback_providers = vec![ProviderKind::Antigravity, ProviderKind::Google]; + { + let legacy = &mut store.config.providers.antigravity; + legacy.api_key = Some("legacy-codewhale-fixture-key".to_string()); + legacy.base_url = Some("https://legacy.invalid/v1".to_string()); + legacy.model = Some("legacy-fixture-model".to_string()); + legacy.context_window = Some(1234); + legacy.mode = Some("legacy-fixture-mode".to_string()); + legacy.wire = Some("legacy-fixture-wire".to_string()); + legacy.auth_mode = Some("oauth".to_string()); + legacy.insecure_skip_tls_verify = Some(true); + legacy + .http_headers + .insert("X-Legacy-Fixture".to_string(), "fixture".to_string()); + legacy.path_suffix = Some("legacy-fixture-path".to_string()); + legacy.external_credentials = + Some(codewhale_config::ExternalCredentialConsentToml::read_only( + ProviderKind::Antigravity, + codewhale_config::ExternalCredentialSource::AgyCli, + external_session_path.clone(), + )); + legacy.extras.insert( + "legacy_fixture_extra".to_string(), + toml::Value::String("remove-me".to_string()), + ); + } + store.config.providers.google.api_key = Some("google-fixture-key".to_string()); + store.config.providers.google.base_url = Some("https://google.example/v1".to_string()); + store.config.providers.google.model = Some("google-fixture-model".to_string()); + store.save().expect("save legacy fixture"); + + // Released configs accepted the short `[providers.agy]` table alias. + // Exercise that on-disk spelling as well as the clear command's alias. + let canonical = std::fs::read_to_string(&config_path).expect("read canonical fixture"); + let alias = canonical.replace("[providers.antigravity", "[providers.agy"); + std::fs::write(&config_path, alias).expect("write legacy alias fixture"); + let mut store = ConfigStore::load(Some(config_path.clone())).expect("reload alias fixture"); + + let inner = Arc::new(InMemoryKeyringStore::new()); + inner + .set("antigravity", "legacy-codewhale-secret-slot") + .expect("seed Codewhale-owned legacy secret slot"); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Clear { + provider: ProviderKind::Antigravity, + }, + &secrets, + ) + .expect("legacy clear should succeed"); + + assert_eq!(store.config.provider, ProviderKind::default()); + assert_eq!(store.config.fallback_providers, vec![ProviderKind::Google]); + assert!(store.config.providers.antigravity.is_empty()); + assert_eq!(inner.get("antigravity").unwrap(), None); + assert_eq!( + store.config.providers.google.api_key.as_deref(), + Some("google-fixture-key") + ); + assert_eq!( + store.config.providers.google.base_url.as_deref(), + Some("https://google.example/v1") + ); + assert_eq!( + store.config.providers.google.model.as_deref(), + Some("google-fixture-model") + ); + assert_eq!( + std::fs::read(&external_session_path).expect("external session trap still exists"), + external_session + ); + + let raw = std::fs::read_to_string(&config_path).expect("read cleared config"); + assert!(!raw.contains("[providers.antigravity"), "{raw}"); + assert!(!raw.contains("[providers.agy"), "{raw}"); + assert!(!raw.contains("legacy_fixture_extra"), "{raw}"); + assert!(raw.contains("[providers.google]"), "{raw}"); + + let backup_path = config_path.with_file_name(format!( + "{}.bak", + config_path + .file_name() + .expect("config fixture has a file name") + .to_string_lossy() + )); + let backup = std::fs::read_to_string(backup_path).expect("read cleared config backup"); + assert!(!backup.contains("[providers.antigravity"), "{backup}"); + assert!(!backup.contains("[providers.agy"), "{backup}"); + assert!(!backup.contains("legacy_fixture_extra"), "{backup}"); + assert!( + !backup.contains(&external_session_path.to_string_lossy().to_string()), + "{backup}" + ); + assert!( + backup.contains("base_url = \"https://google.example/v1\""), + "{backup}" + ); + assert!( + backup.contains("model = \"google-fixture-model\""), + "{backup}" + ); + + let reloaded = ConfigStore::load(Some(config_path)).expect("reload cleared config"); + assert_eq!(reloaded.config.provider, ProviderKind::default()); + assert!(reloaded.config.providers.antigravity.is_empty()); + assert_eq!( + reloaded.config.providers.google.api_key.as_deref(), + Some("google-fixture-key") + ); + } + + #[test] + fn antigravity_clear_restores_codewhale_secret_when_config_write_fails() { + use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let dir = tempfile::TempDir::new().expect("isolated rollback fixture"); + let config_path = dir.path().join("config.toml"); + let external_session_path = dir.path().join("external-session.db"); + let external_session = b"external session rollback trap"; + std::fs::write(&external_session_path, external_session) + .expect("write external session trap"); + let mut store = ConfigStore::load(Some(config_path.clone())).expect("load absent config"); + store.config.provider = ProviderKind::Antigravity; + store.config.fallback_providers = vec![ProviderKind::Antigravity]; + store.config.providers.antigravity.api_key = Some("legacy-config-fixture".to_string()); + store.config.providers.antigravity.external_credentials = + Some(codewhale_config::ExternalCredentialConsentToml::read_only( + ProviderKind::Antigravity, + codewhale_config::ExternalCredentialSource::AgyCli, + external_session_path.clone(), + )); + std::fs::create_dir(&config_path).expect("make config target unwritable as a file"); + + let inner = Arc::new(InMemoryKeyringStore::new()); + inner + .set("antigravity", "legacy-secret-fixture") + .expect("seed Codewhale-owned legacy slot"); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Clear { + provider: ProviderKind::Antigravity, + }, + &secrets, + ) + .expect_err("config failure must fail the clear transaction"); + + assert_eq!(store.config.provider, ProviderKind::Antigravity); + assert_eq!( + store.config.fallback_providers, + vec![ProviderKind::Antigravity] + ); + assert_eq!( + store.config.providers.antigravity.api_key.as_deref(), + Some("legacy-config-fixture") + ); + assert!( + store + .config + .providers + .antigravity + .external_credentials + .is_some() + ); + assert_eq!( + inner + .get("antigravity") + .expect("read restored slot") + .as_deref(), + Some("legacy-secret-fixture") + ); + assert_eq!( + std::fs::read(external_session_path).expect("external session trap still exists"), + external_session + ); + } + #[test] fn auth_status_scoped_probe_and_list_all_provider_keyrings() { use codewhale_secrets::{KeyringStore, SecretsError}; @@ -8980,7 +9327,7 @@ verbosity = "project-imported" .collect(); // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface. assert_eq!(registry_kinds.len(), 47); - assert_eq!(ProviderKind::ALL.len(), 42); + assert_eq!(ProviderKind::ALL.len(), 41); for kind in ProviderKind::ALL { assert!( registry_kinds.contains(&kind), diff --git a/crates/config/src/external_credentials.rs b/crates/config/src/external_credentials.rs index 4811a36a1b..1c270b2d1c 100644 --- a/crates/config/src/external_credentials.rs +++ b/crates/config/src/external_credentials.rs @@ -177,7 +177,8 @@ pub enum ExternalCredentialSource { GrokCli, /// Official DeepSeek Harness (`dsh`) `$DSH_HOME/.credentials.yaml`. DshCli, - /// Official Antigravity CLI (`agy`) `state.vscdb` OAuth token. + /// Legacy tombstone retained only so old Codewhale consent records can + /// deserialize and be cleared. No runtime may resolve or read this source. AgyCli, } @@ -197,46 +198,6 @@ pub fn default_dsh_credentials_path() -> PathBuf { home.join(".credentials.yaml") } -/// Default Antigravity credential store, resolved without probing: the -/// official `agy` CLI persists its OAuth token in the Antigravity app's -/// VSCode-style `state.vscdb` under the user profile. Consent is pinned to -/// this exact path; an ambient move is reported, never followed. -#[must_use] -pub fn default_agy_credentials_path() -> PathBuf { - let base = match std::env::var_os("ANTIGRAVITY_STATE_DIR") { - Some(value) if !value.is_empty() => PathBuf::from(value), - _ => agy_profile_base(), - }; - base.join("User").join("globalStorage").join("state.vscdb") -} - -#[cfg(target_os = "macos")] -fn agy_profile_base() -> PathBuf { - codewhale_paths::user_home() - .unwrap_or_else(|| PathBuf::from(".")) - .join("Library/Application Support/Antigravity") -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn agy_profile_base() -> PathBuf { - match std::env::var_os("XDG_CONFIG_HOME") { - Some(value) if !value.is_empty() => PathBuf::from(value), - _ => codewhale_paths::user_home() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".config"), - } - .join("Antigravity") -} - -#[cfg(windows)] -fn agy_profile_base() -> PathBuf { - match std::env::var_os("APPDATA") { - Some(value) if !value.is_empty() => PathBuf::from(value), - _ => codewhale_paths::user_home().unwrap_or_else(|| PathBuf::from(".")), - } - .join("Antigravity") -} - impl ExternalCredentialSource { #[must_use] pub const fn as_str(self) -> &'static str { @@ -257,7 +218,7 @@ impl ExternalCredentialSource { Self::KimiCodeCli => "Kimi Code CLI", Self::GrokCli => "Grok CLI", Self::DshCli => "DeepSeek Harness", - Self::AgyCli => "Antigravity CLI", + Self::AgyCli => "retired Antigravity consent", } } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 096a23d5fc..b2cba10bc8 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -78,9 +78,8 @@ pub use codewhale_secrets::Secrets; pub use external_credentials::{ EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS, ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml, - ExternalCredentialReadGrant, ExternalCredentialSource, default_agy_credentials_path, - default_dsh_credentials_path, external_credential_consent_status, quote_os_path, - resolve_external_credential_path, + ExternalCredentialReadGrant, ExternalCredentialSource, default_dsh_credentials_path, + external_credential_consent_status, quote_os_path, resolve_external_credential_path, }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; @@ -90,6 +89,7 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; pub const CONFIG_FILE_NAME: &str = "config.toml"; pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml"; +pub const LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE: &str = "Antigravity is a retired, non-runnable legacy provider. Clear Codewhale-owned legacy state with `codewhale auth clear --provider antigravity`; this does not alter Google or Antigravity sessions. For Gemini use provider `google` with `GEMINI_API_KEY`."; /// Secret-store routing metadata; never credential material. pub const API_KEYRING_SENTINEL: &str = "__KEYRING__"; @@ -442,8 +442,8 @@ pub struct ProvidersToml { alias = "gemini" )] pub google: ProviderConfigToml, - /// Google Antigravity (`agy`) — consent-gated credential import only; - /// sends fail closed until the cloud-code wire protocol exists. + /// Retired Antigravity configuration. This table exists only so old + /// Codewhale-owned state can deserialize and be cleared safely. #[serde( default, skip_serializing_if = "ProviderConfigToml::is_empty", @@ -645,6 +645,7 @@ impl ProvidersToml { && ProviderKind::all() .iter() .all(|provider| self.for_provider(*provider).is_empty()) + && self.antigravity.is_empty() } #[must_use] @@ -1022,6 +1023,9 @@ fn set_provider_config_value( field: ProviderConfigField, value: &str, ) -> Result<()> { + if provider == ProviderKind::Antigravity { + bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } match field { ProviderConfigField::ApiKey => { let value = value.to_string(); @@ -2961,6 +2965,11 @@ impl ConfigToml { } pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> { + if parse_custom_provider_config_key(key).is_some_and(|(provider_id, _)| { + ProviderKind::parse_config_identity(provider_id) == Some(ProviderKind::Antigravity) + }) { + bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } if let Some((provider, field)) = parse_provider_config_key(key) { return set_provider_config_value(self, provider, field, value); } @@ -2971,6 +2980,9 @@ impl ConfigToml { match key { "provider" => { if let Some(provider) = ProviderKind::parse_config_identity(value) { + if provider == ProviderKind::Antigravity { + bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } self.provider = provider; self.selected_provider_id = None; } else { @@ -5464,6 +5476,73 @@ pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> { Ok(()) } +/// Remove only retired Antigravity state from Codewhale's one-time config +/// backup. This never resolves, reads, writes, or revokes any external Google +/// or Antigravity session; it edits only the checked sibling `.bak` file owned +/// by Codewhale. +pub fn scrub_legacy_antigravity_from_config_backup(path: &Path) -> Result<()> { + let backup = checked_config_backup_path(path)?; + if !backup.exists() { + return Ok(()); + } + + let raw = read_checked_toml_file(&backup, "config backup")?; + let scrubbed = config_toml_without_legacy_antigravity(&raw).with_context(|| { + format!( + "failed to clear retired provider state from config backup {}", + backup.display() + ) + })?; + if scrubbed != raw { + persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| { + format!( + "failed to write retired-provider-free config backup {}", + backup.display() + ) + })?; + } + Ok(()) +} + +fn config_toml_without_legacy_antigravity(raw: &str) -> Result { + let mut document = raw.parse::().map_err(|_| { + anyhow::anyhow!( + "failed to parse config TOML while clearing retired provider state; file contents were omitted" + ) + })?; + let root = document.as_table_mut(); + + if root + .get("provider") + .and_then(toml_edit::Item::as_str) + .is_some_and(is_legacy_antigravity_name) + { + root.remove("provider"); + } + if let Some(fallbacks) = root + .get_mut("fallback_providers") + .and_then(toml_edit::Item::as_array_mut) + { + fallbacks.retain(|value| !value.as_str().is_some_and(is_legacy_antigravity_name)); + if fallbacks.is_empty() { + root.remove("fallback_providers"); + } + } + if let Some(providers) = root + .get_mut("providers") + .and_then(toml_edit::Item::as_table_like_mut) + { + providers.remove("antigravity"); + providers.remove("agy"); + } + + Ok(document.to_string()) +} + +fn is_legacy_antigravity_name(value: &str) -> bool { + value.eq_ignore_ascii_case("antigravity") || value.eq_ignore_ascii_case("agy") +} + fn write_one_time_config_backup(path: &Path) -> Result<()> { let backup = checked_config_backup_path(path)?; if backup.exists() { @@ -6909,8 +6988,6 @@ struct EnvRuntimeOverrides { mistral_model: Option, google_base_url: Option, google_model: Option, - antigravity_base_url: Option, - antigravity_model: Option, telecomjs_base_url: Option, telecomjs_model: Option, edenai_base_url: Option, @@ -7229,12 +7306,6 @@ impl EnvRuntimeOverrides { xai_model: std::env::var("XAI_MODEL") .ok() .filter(|v| !v.trim().is_empty()), - antigravity_base_url: std::env::var("ANTIGRAVITY_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()), - antigravity_model: std::env::var("ANTIGRAVITY_MODEL") - .ok() - .filter(|v| !v.trim().is_empty()), google_base_url: std::env::var("GOOGLE_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()) @@ -7348,7 +7419,7 @@ impl EnvRuntimeOverrides { ProviderKind::Xai => self.xai_base_url.clone(), ProviderKind::Mistral => self.mistral_base_url.clone(), ProviderKind::Google => self.google_base_url.clone(), - ProviderKind::Antigravity => self.antigravity_base_url.clone(), + ProviderKind::Antigravity => None, ProviderKind::Telecomjs => self.telecomjs_base_url.clone(), ProviderKind::Edenai => self.edenai_base_url.clone(), ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => { @@ -7395,7 +7466,7 @@ impl EnvRuntimeOverrides { ProviderKind::Xai => self.xai_model.clone(), ProviderKind::Mistral => self.mistral_model.clone(), ProviderKind::Google => self.google_model.clone(), - ProviderKind::Antigravity => self.antigravity_model.clone(), + ProviderKind::Antigravity => None, ProviderKind::Telecomjs => self.telecomjs_model.clone(), ProviderKind::Edenai => self.edenai_model.clone(), ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => { diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 6502619727..0678634623 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -448,10 +448,10 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp { guidance: "Sign in to Alibaba Cloud Model Studio (Bailian console), create or copy an API key, and select the plan endpoint matching your subscription (Token Plan or Coding Plan).", }, ProviderKind::Antigravity => CredentialHelp { - acquisition: OAuth, + acquisition: Configuration, credential_url: None, - docs_url: Some("https://antigravity.google/docs/cli/reference"), - guidance: "Sign in with the official agy CLI (1.1.13). Codewhale can read that login's token read-only from the exact pinned state.vscdb after `codewhale auth external-consent`; it never writes or refreshes it. An ANTIGRAVITY_API_KEY or AGY_ADC_AUTH in the process wins over the file.", + docs_url: None, + guidance: "Legacy configuration only; this route is disabled. Run `codewhale auth clear --provider antigravity` to clear only Codewhale-owned legacy state, then use provider `google` with `GEMINI_API_KEY` for Gemini.", }, ProviderKind::Google => CredentialHelp { acquisition: ApiKey, @@ -1043,10 +1043,10 @@ provider!( Antigravity, Antigravity, "antigravity", - "Google Antigravity", + "Antigravity (legacy, disabled)", DEFAULT_ANTIGRAVITY_BASE_URL, DEFAULT_ANTIGRAVITY_MODEL, - ["ANTIGRAVITY_API_KEY"], + [], "antigravity", aliases: ["agy"] ); @@ -1755,11 +1755,11 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [ &CUSTOM, ]; -/// Return all built-in provider metadata entries in `ProviderKind::ALL` order. +/// Return all built-in and legacy provider metadata entries. /// -/// This insertion order is the stable order used for internal parsing and -/// default selection. It is intentionally NOT the order user-facing UI should -/// render; for browsing/picker surfaces use [`providers_sorted_for_display`]. +/// The full registry retains legacy entries needed to read old configuration. +/// It is intentionally NOT a user-facing provider list; for browsing/picker +/// surfaces use [`providers_sorted_for_display`]. #[must_use] pub fn all_providers() -> &'static [&'static dyn Provider] { &PROVIDER_REGISTRY @@ -1773,16 +1773,22 @@ pub fn all_providers() -> &'static [&'static dyn Provider] { /// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The /// ordering policy intentionally differs from internal parsing/default order: /// -/// - [`all_providers`] / [`ProviderKind::ALL`] — stable order for internal -/// matching, parsing, and default selection. Do not reorder. +/// - [`all_providers`] — full compatibility registry for internal identity +/// matching, including legacy entries. +/// - [`ProviderKind::ALL`] — stable selectable catalog order. Do not reorder. /// - [`providers_sorted_for_display`] — neutral alphabetical order for UI -/// browsing. DeepSeek stays present and searchable but is not hard-coded -/// first; a caller may still highlight/pin the active provider separately. +/// browsing, with legacy tombstones omitted. DeepSeek stays present and +/// searchable but is not hard-coded first; a caller may still highlight/pin +/// the active provider separately. /// /// Returns an owned `Vec` because the sorted order is computed, not static. #[must_use] pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> { - let mut providers = all_providers().to_vec(); + let mut providers: Vec<_> = all_providers() + .iter() + .copied() + .filter(|provider| provider.kind() != ProviderKind::Antigravity) + .collect(); providers.sort_by(|a, b| { a.display_name() .to_ascii_lowercase() @@ -2153,6 +2159,26 @@ mod tests { ); } + #[test] + fn antigravity_registry_entry_is_a_non_runnable_legacy_tombstone() { + let legacy = provider_for_kind(ProviderKind::Antigravity); + assert_eq!(legacy.id(), "antigravity"); + assert!(legacy.env_vars().is_empty()); + assert!(legacy.default_base_url().ends_with(".invalid")); + assert_eq!(legacy.default_model(), "legacy-antigravity-disabled"); + + let help = legacy.credential_help(); + assert_eq!(help.acquisition, CredentialAcquisition::Configuration); + assert_eq!(help.credential_url, None); + assert_eq!(help.docs_url, None); + assert!( + help.guidance + .contains("codewhale auth clear --provider antigravity") + ); + assert!(help.guidance.contains("provider `google`")); + assert!(help.guidance.contains("GEMINI_API_KEY")); + } + #[test] fn live_verified_console_replacements_do_not_regress_to_404_links() { let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help(); @@ -2214,7 +2240,7 @@ mod tests { #[test] fn display_order_differs_from_internal_all_order() { // The whole point of the helper is that UI ordering is NOT the - // internal ProviderKind::ALL / all_providers() insertion order. + // internal compatibility-registry insertion order. let display_ids: Vec<&str> = providers_sorted_for_display() .iter() .map(|p| p.id()) @@ -2228,12 +2254,25 @@ mod tests { #[test] fn display_order_is_complete_and_unique() { - // No provider is dropped or duplicated by the sort. + // Every selectable provider is retained exactly once; legacy + // configuration tombstones stay in the internal registry only. let display = providers_sorted_for_display(); assert_eq!( display.len(), - all_providers().len(), - "display order must include every built-in provider" + all_providers().len() - 1, + "display order must include every selectable built-in provider" + ); + assert!( + all_providers() + .iter() + .any(|provider| provider.kind() == ProviderKind::Antigravity), + "legacy config identity must remain in the internal registry" + ); + assert!( + display + .iter() + .all(|provider| provider.kind() != ProviderKind::Antigravity), + "legacy Antigravity tombstone must not appear in provider pickers" ); let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect(); ids.sort_unstable(); diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 2164b0a147..60e3170290 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -215,8 +215,10 @@ pub const DEFAULT_GOOGLE_BASE_URL: &str = /// Default Gemini model for the Google provider (preview flagship, 2026-08). pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview"; -/// Antigravity cloud-code internal endpoint (credential plane only; the -/// wire protocol is not implemented and sends fail closed). -pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal"; -/// Placeholder model id; never sent — the route fails closed before transport. -pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "gemini-3-pro-preview"; +/// Non-network endpoint for the legacy Antigravity configuration tombstone. +/// +/// `.invalid` is reserved and cannot resolve. Existing configuration remains +/// readable solely so Codewhale can guide the user to clear it safely. +pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://legacy-antigravity.invalid"; +/// Non-runnable model marker for the legacy Antigravity tombstone. +pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "legacy-antigravity-disabled"; diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 123cbd47eb..b86177807a 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -191,9 +191,12 @@ pub enum ProviderKind { alias = "alibaba-coding-plan-anthropic" )] ModelstudioCodingPlanAnthropic, - /// Google Antigravity (`agy` CLI) — consent-gated read-only credential - /// import only; the cloud-code wire protocol is not implemented and - /// requests fail closed with an actionable message. + /// Legacy Antigravity configuration identity. + /// + /// Kept only so existing configuration can be read and cleared. It is not + /// a selectable or runnable provider; Gemini users should use [`Google`]. + /// + /// [`Google`]: Self::Google #[serde(alias = "agy")] Antigravity, /// Google — Gemini OpenAI-compatible endpoint. Its own backend, not an @@ -232,7 +235,7 @@ impl ProviderKind { /// stay on the enum for serde and `provider_for_kind`, but they are not /// first-class catalog rows. Plan is `mode` / base_url; dialect is /// `wire = openai|anthropic` on the primary provider config. - pub const ALL: [Self; 42] = [ + pub const ALL: [Self; 41] = [ Self::Deepseek, Self::NvidiaNim, Self::Openai, @@ -272,7 +275,6 @@ impl ProviderKind { Self::Telecomjs, Self::ModelstudioTokenPlan, Self::Google, - Self::Antigravity, Self::Edenai, Self::Custom, ]; @@ -299,13 +301,11 @@ impl ProviderKind { #[must_use] pub fn parse(value: &str) -> Option { let trimmed = value.trim(); - provider::all_providers() - .iter() - .find(|p| { - trimmed.eq_ignore_ascii_case(p.id()) - || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a)) - }) - .map(|p| p.kind()) + Self::all().iter().copied().find(|kind| { + let p = kind.provider(); + trimmed.eq_ignore_ascii_case(p.id()) + || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a)) + }) } /// Parse a provider identifier for **config-table identity** — the kind @@ -326,6 +326,12 @@ impl ProviderKind { /// alias collapse; everything else falls back to [`parse`](Self::parse). /// Wire-endpoint selection is unaffected: it keys off the resolved kind's /// `wire` config, not this parse. + /// + /// Retired tombstone kinds (absent from [`ALL`](Self::ALL), so never + /// returned by [`parse`](Self::parse)) still resolve here through their + /// registry aliases (`agy` -> `Antigravity`), so every selection surface + /// can name the tombstone and refuse it instead of minting a custom + /// `[providers.agy]` table that serde would fold back onto the legacy one. #[must_use] pub fn parse_config_identity(value: &str) -> Option { let trimmed = value.trim(); @@ -337,6 +343,18 @@ impl ProviderKind { }) .map(|p| p.kind()) .or_else(|| Self::parse(trimmed)) + .or_else(|| Self::parse_retired_alias(trimmed)) + } + + /// Alias lookup restricted to registry entries that are *not* in the + /// selectable catalog. Catalog aliases are handled by [`parse`](Self::parse) + /// and always take precedence. + fn parse_retired_alias(trimmed: &str) -> Option { + provider::all_providers() + .iter() + .filter(|p| !Self::all().contains(&p.kind())) + .find(|p| p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))) + .map(|p| p.kind()) } #[must_use] diff --git a/crates/config/src/route/descriptor.rs b/crates/config/src/route/descriptor.rs index 08764425eb..b0e4faa587 100644 --- a/crates/config/src/route/descriptor.rs +++ b/crates/config/src/route/descriptor.rs @@ -142,7 +142,7 @@ pub enum TransportKind { ModelAware, /// ChatGPT Codex OAuth route. Codex, - /// Google Antigravity consent-gated OAuth. + /// Retired Antigravity identity retained for legacy config inspection. Antigravity, /// Local runtime (Ollama / vLLM / SGLang). LocalRuntime, @@ -200,6 +200,9 @@ pub fn family_for(kind: ProviderKind) -> &'static str { /// Auth methods declared for a provider kind. OAuth is a type, not an adapter. #[must_use] pub fn auth_methods_for(kind: ProviderKind) -> &'static [AuthMethod] { + if kind == ProviderKind::Antigravity { + return &[]; + } match kind.provider().credential_help().acquisition { CredentialAcquisition::ApiKey => &[AuthMethod::API_KEY], CredentialAcquisition::ApiKeyOrOAuth => &[AuthMethod::API_KEY, AuthMethod::OAUTH], diff --git a/crates/config/src/route/export.rs b/crates/config/src/route/export.rs index ca0bbf83f0..c1cd0e6d69 100644 --- a/crates/config/src/route/export.rs +++ b/crates/config/src/route/export.rs @@ -76,6 +76,7 @@ impl ProvidersExport { let offerings = bundled_catalog_offerings(); let mut routes: Vec = provider::all_providers() .iter() + .filter(|entry| entry.kind() != ProviderKind::Antigravity) .map(|entry| { let descriptor = ProviderDescriptor::for_kind(entry.kind()); route_export(&descriptor, &offerings) @@ -152,17 +153,13 @@ pub fn parse_route_kind(value: &str) -> Option { if trimmed.is_empty() { return None; } - if let Some(kind) = exact_config_identity(trimmed) { - return Some(kind); - } let folded = trimmed.replace('_', "-"); - if let Some(kind) = exact_config_identity(&folded) { - return Some(kind); - } - if let Some(kind) = clap_compat_alias(trimmed) { - return Some(kind); - } - ProviderKind::parse(trimmed).or_else(|| ProviderKind::parse(&folded.to_ascii_lowercase())) + exact_config_identity(trimmed) + .or_else(|| exact_config_identity(&folded)) + .or_else(|| clap_compat_alias(trimmed)) + .or_else(|| ProviderKind::parse(trimmed)) + .or_else(|| ProviderKind::parse(&folded.to_ascii_lowercase())) + .filter(|kind| *kind != ProviderKind::Antigravity) } /// Exact id / config-table key only. Does not collapse dialect aliases onto @@ -181,7 +178,6 @@ fn exact_config_identity(value: &str) -> Option { fn clap_compat_alias(value: &str) -> Option { let key = value.replace('_', "-").to_ascii_lowercase(); Some(match key.as_str() { - "agy" => ProviderKind::Antigravity, "opencodego" | "opencode-go" => ProviderKind::OpencodeGo, "ollama-cloud" => ProviderKind::OllamaCloud, "mini-max-anthropic" => ProviderKind::MinimaxAnthropic, @@ -213,9 +209,10 @@ mod tests { let ids = export.route_ids(); let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect(); assert_eq!(unique.len(), ids.len(), "route ids must be unique"); - assert_eq!(ids.len(), provider::all_providers().len()); + assert_eq!(ids.len(), provider::all_providers().len() - 1); assert!(ids.contains(&"deepseek")); assert!(ids.contains(&"custom")); + assert!(!ids.contains(&"antigravity")); } #[test] @@ -248,6 +245,16 @@ mod tests { parse_route_kind("mini-max-anthropic"), Some(ProviderKind::MinimaxAnthropic) ); + assert_eq!(parse_route_kind("antigravity"), None); + assert_eq!(parse_route_kind("agy"), None); + assert_eq!( + ProviderKind::parse_config_identity("antigravity"), + Some(ProviderKind::Antigravity) + ); + assert_eq!( + ProviderKind::parse_config_identity("agy"), + Some(ProviderKind::Antigravity) + ); assert_eq!(parse_route_kind(""), None); assert_eq!(parse_route_kind("not-a-provider"), None); } diff --git a/crates/config/src/route/golden_route_ids.txt b/crates/config/src/route/golden_route_ids.txt index 563569af26..6df1938e0b 100644 --- a/crates/config/src/route/golden_route_ids.txt +++ b/crates/config/src/route/golden_route_ids.txt @@ -2,7 +2,6 @@ # Source: ProviderDescriptor registry via ProvidersExport::from_registry. # New ids may be appended (sorted); never recycle a retired spelling. anthropic -antigravity arcee atlascloud custom diff --git a/crates/config/src/route/providers-export.golden.json b/crates/config/src/route/providers-export.golden.json index 3769fb7950..35255a1de4 100644 --- a/crates/config/src/route/providers-export.golden.json +++ b/crates/config/src/route/providers-export.golden.json @@ -41,24 +41,6 @@ ], "transport": "anthropic-messages" }, - { - "id": "antigravity", - "family": "antigravity", - "label": "Google Antigravity", - "endpoint": "https://cloudcode-pa.googleapis.com/v1internal", - "wire": "chat-completions", - "defaultModel": "gemini-3-pro-preview", - "envVars": [ - "ANTIGRAVITY_API_KEY" - ], - "auth": [ - { - "kind": "oauth", - "label": "OAuth" - } - ], - "transport": "antigravity" - }, { "id": "arcee", "family": "arcee", diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 1a5143386d..3bb9d5b5cb 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -148,6 +148,11 @@ impl RouteResolver { // 1. Provider scope from explicit choice only; default otherwise. // The provider is NEVER inferred from a model prefix. let provider_kind = req.explicit_provider.unwrap_or_default(); + if provider_kind == ProviderKind::Antigravity { + return Err(RouteError::InvalidProvider( + crate::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string(), + )); + } let descriptor = ProviderDescriptor::for_kind(provider_kind); let provider_id = descriptor.id(); let default_offering = self.default_offering(&provider_id); diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index e251ee7bdc..f72c570af1 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1219,6 +1219,22 @@ fn resolver_empty_string_selector_is_empty_model_error() { assert!(matches!(out, Err(RouteError::EmptyModel))); } +#[test] +fn resolver_rejects_retired_antigravity_before_minting_a_route() { + let error = RouteResolver::new() + .resolve(&req(Some(ProviderKind::Antigravity), Some("gemini-3-pro"))) + .expect_err("the legacy tombstone must never produce an executable route"); + let rendered = error.to_string(); + assert!(matches!(error, RouteError::InvalidProvider(_))); + assert!(rendered.contains("non-runnable"), "{rendered}"); + assert!( + rendered.contains("auth clear --provider antigravity"), + "{rendered}" + ); + assert!(rendered.contains("google"), "{rendered}"); + assert!(rendered.contains("GEMINI_API_KEY"), "{rendered}"); +} + #[test] fn resolver_empty_saved_provider_model_is_empty_model_error() { // An empty selector from the saved-model fallback must be rejected too, not diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 234e02da5f..30943b572b 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -5031,7 +5031,7 @@ fn provider_metadata_registry_covers_every_provider_kind_once() { // Full registry keeps legacy dialect/plan kinds for provider_for_kind. assert_eq!(providers.len(), 47); // Catalog surface is one identity per vendor (no dual-wire / plan rows). - assert_eq!(ProviderKind::ALL.len(), 42); + assert_eq!(ProviderKind::ALL.len(), 41); assert!(ProviderKind::ALL.len() < providers.len()); let mut ids = std::collections::BTreeSet::new(); @@ -5727,6 +5727,82 @@ fn parse_config_identity_preserves_legacy_table_kinds() { assert_eq!(ProviderKind::parse_config_identity("nope"), None); } +#[test] +fn antigravity_legacy_config_loads_but_new_selection_and_fields_are_rejected() { + assert_eq!(ProviderKind::parse("antigravity"), None); + assert_eq!(ProviderKind::parse("agy"), None); + assert_eq!( + ProviderKind::parse_config_identity("antigravity"), + Some(ProviderKind::Antigravity) + ); + assert_eq!( + ProviderKind::parse_config_identity("agy"), + Some(ProviderKind::Antigravity) + ); + assert_eq!( + ProviderKind::parse_config_identity(" AGY "), + Some(ProviderKind::Antigravity) + ); + // The tombstone is absent from every selectable surface. + assert!(!ProviderKind::all().contains(&ProviderKind::Antigravity)); + assert!(!ProviderKind::names_hint().contains("antigravity")); + assert!(crate::route::descriptor::auth_methods_for(ProviderKind::Antigravity).is_empty()); + assert!(ProviderKind::Antigravity.provider().env_vars().is_empty()); + + let loaded: ConfigToml = toml::from_str( + r#" +provider = "antigravity" +[providers.antigravity] +base_url = "https://old.example.invalid" +model = "legacy-model" +"#, + ) + .expect("legacy tombstone config remains readable for clearing"); + assert_eq!(loaded.provider, ProviderKind::Antigravity); + assert_eq!( + loaded.providers.antigravity.model.as_deref(), + Some("legacy-model") + ); + + // The historical `agy` table spelling folds onto the same tombstone slot + // instead of surviving as an anonymous custom table. + let loaded_alias: ConfigToml = toml::from_str( + r#" +provider = "agy" +[providers.agy] +api_key = "legacy-literal" +"#, + ) + .expect("legacy agy spelling remains readable for clearing"); + assert_eq!(loaded_alias.provider, ProviderKind::Antigravity); + assert_eq!( + loaded_alias.providers.antigravity.api_key.as_deref(), + Some("legacy-literal") + ); + assert!(loaded_alias.providers.extras.is_empty()); + + for (key, value) in [ + ("provider", "antigravity"), + ("provider", "agy"), + ("providers.antigravity.model", "gemini-3-pro"), + ("providers.agy.base_url", "https://example.invalid"), + ] { + let mut config = ConfigToml::default(); + let error = config + .set_value(key, value) + .expect_err("the tombstone must not accept new runnable configuration") + .to_string(); + assert!(error.contains("non-runnable"), "{key}: {error}"); + assert!(error.contains("GEMINI_API_KEY"), "{key}: {error}"); + assert!(error.contains("provider `google`"), "{key}: {error}"); + // Refusal is total: no tombstone field is written and no custom + // `[providers.agy]` table is minted as a side door. + assert!(config.providers.antigravity.is_empty(), "{key}"); + assert!(config.providers.extras.is_empty(), "{key}"); + assert_eq!(config.provider, ProviderKind::default(), "{key}"); + } +} + #[test] fn legacy_dual_wire_toml_table_supplies_credentials_and_endpoint() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index ecfd1b3f7e..f37bf73868 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -142,6 +142,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Antigravity is retired to a non-runnable legacy tombstone: it is no longer + selectable from the provider picker, `--provider`, `/provider`, + `CODEWHALE_PROVIDER`, `config set provider`, or route ids; the private + Antigravity OAuth-token import, `AGY_ADC_AUTH`, `ANTIGRAVITY_API_KEY`, and + the cloud-code transport are removed; the catalog export and public docs + omit it (`scripts/check-provider-registry.py` fails if it reappears). + Existing `[providers.antigravity]` / `[providers.agy]` tables still parse + so `codewhale auth clear --provider antigravity` can forget only + Codewhale-owned state (config table, fallback entries, top-level selection, + Codewhale's own secret slot, and the sibling `config.toml.bak`); it never + reads, revokes, or alters a Google or Antigravity session. Gemini routes + through the supported `google` provider with `GEMINI_API_KEY`. - Provider-native web search now applies domain constraints before accepting an attempt, discards generated answers when returned citations violate those constraints, and preserves the caller's configured/local timeout as an diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index ad828780e7..b4952903d8 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "per torn · heurística local · sense petició al router", "ModelPickerAutoLastRoute": "última {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} via {source} · Ctrl+O: detalls de la ruta", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code no pot enviar aquest torn de manera segura perquè aquesta connexió encara no admet instruccions del sistema. No s’ha enviat res; tria un altre proveïdor.", "HelpTitle": "Ajuda", "HelpFilterPlaceholder": "Escriu per filtrar", "HelpFilterPrefix": "Filtre: ", diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 1c3f9e4633..4fdf19b084 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "pro Zug · lokale Heuristik · keine Router-Anfrage", "ModelPickerAutoLastRoute": "zuletzt {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} via {source} · Ctrl+O: Routendetails", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code kann diesen Turn nicht sicher senden, da diese Verbindung noch keine Systemanweisungen unterstützt. Nichts wurde gesendet; wähle einen anderen Anbieter.", "HelpTitle": "Hilfe", "HelpFilterPlaceholder": "Zum Filtern tippen", "HelpFilterPrefix": "Filter: ", diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index afb5fbcdfb..a79440f824 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "per turn · local heuristic · no router request", "ModelPickerAutoLastRoute": "last {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} via {source} · Ctrl+O: route details", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code cannot safely send this turn because this wire does not support system instructions yet. Nothing was sent; choose another provider.", "HelpTitle": "Help", "HelpFilterPlaceholder": "Type to filter", "HelpFilterPrefix": "Filter: ", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index bf08ce794f..18c6531f9a 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "por turno · heurística local · sin solicitud al enrutador", "ModelPickerAutoLastRoute": "última {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} mediante {source} · Ctrl+O: detalles de la ruta", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code no puede enviar este turno de forma segura porque esta conexión aún no admite instrucciones del sistema. No se envió nada; elige otro proveedor.", "HelpTitle": "Ayuda", "HelpFilterPlaceholder": "Escribe para filtrar", "HelpFilterPrefix": "Filtro: ", diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 485db040d2..d9a40cd3df 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "par tour · heuristique locale · pas de requête au routeur", "ModelPickerAutoLastRoute": "dernière {provider} · {model}", "AutoRouteSelectedToast": "Auto : {provider} / {model} via {source} · Ctrl+O : détails de la route", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code ne peut pas envoyer ce tour en toute sécurité, car cette connexion ne prend pas encore en charge les instructions système. Rien n’a été envoyé ; choisissez un autre fournisseur.", "HelpTitle": "Aide", "HelpFilterPlaceholder": "Taper pour filtrer", "HelpFilterPrefix": "Filtre : ", diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index be6877c2b1..b925644127 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "प्रति टर्न · लोकल ह्यूरिस्टिक · कोई राउटर अनुरोध नहीं", "ModelPickerAutoLastRoute": "अंतिम {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} ({source} द्वारा) · Ctrl+O: रूट विवरण", - "CloudCodeSystemPromptUnsupported": "यह कनेक्शन अभी सिस्टम निर्देश समर्थित नहीं करता, इसलिए Antigravity cloud-code इस टर्न को सुरक्षित रूप से नहीं भेज सकता। कुछ नहीं भेजा गया; कोई अन्य प्रदाता चुनें।", "HelpTitle": "मदद", "HelpFilterPlaceholder": "फ़िल्टर करने के लिए टाइप करें", "HelpFilterPrefix": "फ़िल्टर: ", diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index ddad8fecbb..ffe867f701 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "per giliran · heuristik lokal · tanpa permintaan router", "ModelPickerAutoLastRoute": "terakhir {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} via {source} · Ctrl+O: detail rute", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code tidak dapat mengirim giliran ini dengan aman karena koneksi ini belum mendukung instruksi sistem. Tidak ada yang dikirim; pilih penyedia lain.", "HelpTitle": "Bantuan", "HelpFilterPlaceholder": "Ketik untuk menyaring", "HelpFilterPrefix": "Saring: ", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 04656f725b..dbe2df8541 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "ターンごと · ローカル判定 · ルーター送信なし", "ModelPickerAutoLastRoute": "前回 {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model}({source})· Ctrl+O: ルート詳細", - "CloudCodeSystemPromptUnsupported": "この接続はシステム指示にまだ対応していないため、Antigravity cloud-code はこのターンを安全に送信できません。何も送信されていません。別のプロバイダーを選んでください。", "HelpTitle": "ヘルプ", "HelpFilterPlaceholder": "入力して絞り込み", "HelpFilterPrefix": "絞り込み: ", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 04da63ad2c..837463c5ea 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "턴별 · 로컬 휴리스틱 · 라우터 요청 없음", "ModelPickerAutoLastRoute": "최근 {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} ({source}) · Ctrl+O: 경로 세부 정보", - "CloudCodeSystemPromptUnsupported": "이 연결은 아직 시스템 지시를 지원하지 않으므로 Antigravity cloud-code가 이 턴을 안전하게 보낼 수 없습니다. 아무것도 보내지 않았습니다. 다른 공급자를 선택하세요.", "HelpTitle": "도움말", "HelpFilterPlaceholder": "입력하여 필터링", "HelpFilterPrefix": "필터: ", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 5bebcafb1c..7f32703b26 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "por turno · heurística local · sem solicitação ao roteador", "ModelPickerAutoLastRoute": "última {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} via {source} · Ctrl+O: detalhes da rota", - "CloudCodeSystemPromptUnsupported": "O Antigravity cloud-code não pode enviar este turno com segurança porque esta conexão ainda não aceita instruções de sistema. Nada foi enviado; escolha outro provedor.", "HelpTitle": "Ajuda", "HelpFilterPlaceholder": "Digite para filtrar", "HelpFilterPrefix": "Filtro: ", diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index f9b7101696..31b639673a 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "на ход · локальная эвристика · без запроса к маршрутизатору", "ModelPickerAutoLastRoute": "последний {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} через {source} · Ctrl+O: детали маршрута", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code не может безопасно отправить этот ход: это подключение пока не поддерживает системные инструкции. Ничего не отправлено; выберите другого провайдера.", "HelpTitle": "Справка", "HelpFilterPlaceholder": "Введите для фильтра", "HelpFilterPrefix": "Фильтр: ", diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index b77760614d..9a3cc901c5 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -320,7 +320,6 @@ "ModelPickerAutoLocalHint": "за крок · локальна евристика · без запиту до маршрутизатора", "ModelPickerAutoLastRoute": "останній {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} через {source} · Ctrl+O: деталі маршруту", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code не може безпечно надіслати цей хід: це з’єднання ще не підтримує системні інструкції. Нічого не надіслано; виберіть іншого провайдера.", "HelpTitle": "Довідка", "HelpFilterPlaceholder": "Вводьте для фільтра", "HelpFilterPrefix": "Фільтр: ", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index f552281a9d..59110404e2 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "mỗi lượt · heuristic cục bộ · không gửi yêu cầu định tuyến", "ModelPickerAutoLastRoute": "lần trước {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model} qua {source} · Ctrl+O: chi tiết tuyến", - "CloudCodeSystemPromptUnsupported": "Antigravity cloud-code không thể gửi lượt này an toàn vì kết nối này chưa hỗ trợ chỉ dẫn hệ thống. Chưa gửi gì; hãy chọn nhà cung cấp khác.", "HelpTitle": "Trợ giúp", "HelpFilterPlaceholder": "Nhập để lọc", "HelpFilterPrefix": "Bộ lọc: ", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 23972f4e8b..84abc82482 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -323,7 +323,6 @@ "ModelPickerAutoLocalHint": "每轮 · 本地启发式 · 不发送路由请求", "ModelPickerAutoLastRoute": "上次 {provider} · {model}", "AutoRouteSelectedToast": "Auto: {provider} / {model}({source})· Ctrl+O:路由详情", - "CloudCodeSystemPromptUnsupported": "此连接尚不支持系统指令,因此 Antigravity cloud-code 无法安全发送本轮。未发送任何内容;请选择其他提供商。", "HelpTitle": "帮助", "HelpFilterPlaceholder": "输入以筛选", "HelpFilterPrefix": "筛选: ", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 85b9fa6499..34b9235e56 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -163,7 +163,6 @@ "ApprovalTruncationHint": " … 已截斷 · 按 {details} 查看完整內容", "AutoReviewQuestionSkipped": "Auto-Review 已略過使用者問題並自主繼續", "AutoRouteSelectedToast": "Auto: {provider} / {model}({source})· Ctrl+O:路由詳情", - "CloudCodeSystemPromptUnsupported": "此連線尚不支援系統指令,因此 Antigravity cloud-code 無法安全傳送本輪。未傳送任何內容;請選擇其他供應商。", "BehavioralTipBackgroundReceipt": "回執在 Work 面板中 — 按 {key} 開啟檢查器", "BehavioralTipClearedInput": "已清空 · 按 {chord} 還原", "BehavioralTipMcpValidation": "{command} 會啟動伺服器並顯示原因", diff --git a/crates/tui/src/agy_credentials.rs b/crates/tui/src/agy_credentials.rs deleted file mode 100644 index 790f14f1f6..0000000000 --- a/crates/tui/src/agy_credentials.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Read-only Antigravity (`agy`) credential import. -//! -//! Official `agy` (1.1.13) persists its OAuth token inside the Antigravity -//! app's VSCode-style SQLite store `state.vscdb`, table `ItemTable`, key -//! `antigravityUnifiedStateSync.oauthToken`. Codewhale may read that one -//! value from that exact file only after `codewhale auth external-consent`. -//! The database is opened read-only and never written, refreshed, or -//! copied into the process environment. Codewhale-owned -//! `ANTIGRAVITY_API_KEY` and the process's own `AGY_ADC_AUTH` always win -//! over the external file. - -use std::io::{Read as _, Seek, SeekFrom}; -use std::path::Path; - -use anyhow::{Context, Result, bail}; -use codewhale_config::{ExternalCredentialReadGrant, ExternalCredentialSource}; - -/// ItemTable key holding the `agy` OAuth token. -pub const AGY_OAUTH_TOKEN_KEY: &str = "antigravityUnifiedStateSync.oauthToken"; - -/// Upper bound on the credential store size we are willing to open. -const AGY_STATE_DB_LIMIT: u64 = 64 * 1024 * 1024; - -/// Credential resolution order for the Antigravity route. -/// -/// 1. Codewhale-owned `ANTIGRAVITY_API_KEY` (config table or environment) -/// 2. The process's own `AGY_ADC_AUTH` (what `agy` itself calls ADC auth) -/// 3. The consented external `state.vscdb` — read-only, never refreshed -#[must_use] -pub fn antigravity_credential_precedence( - owned_api_key: Option<&str>, - process_env: &std::collections::HashMap, - grant: Option<&ExternalCredentialReadGrant>, -) -> AntigravityCredential { - if let Some(key) = owned_api_key.filter(|key| !key.trim().is_empty()) { - return AntigravityCredential::OwnedKey(key.trim().to_string()); - } - if let Some(adc) = process_env - .get("AGY_ADC_AUTH") - .filter(|value| !value.trim().is_empty()) - { - return AntigravityCredential::ProcessEnv(adc.trim().to_string()); - } - let Some(grant) = grant else { - return AntigravityCredential::None; - }; - match antigravity_oauth_token_from_grant(grant) { - Ok(Some(token)) => AntigravityCredential::ExternalFile(token), - Ok(None) => AntigravityCredential::None, - Err(error) => AntigravityCredential::Error(error.to_string()), - } -} - -/// Where the Antigravity credential came from, for display and receipts. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AntigravityCredential { - OwnedKey(String), - ProcessEnv(String), - ExternalFile(String), - None, - Error(String), -} - -impl AntigravityCredential { - #[must_use] - pub fn source_label(&self) -> &'static str { - match self { - Self::OwnedKey(_) => "ANTIGRAVITY_API_KEY", - Self::ProcessEnv(_) => "AGY_ADC_AUTH", - Self::ExternalFile(_) => "agy state.vscdb (read-only)", - Self::None | Self::Error(_) => "none", - } - } -} - -/// Extract the `agy` OAuth token from a granted `state.vscdb`. -pub(crate) fn antigravity_oauth_token_from_grant( - grant: &ExternalCredentialReadGrant, -) -> Result> { - if grant.source() != ExternalCredentialSource::AgyCli { - bail!( - "Antigravity import requires an agy_cli grant, not {}", - grant.source().as_str() - ); - } - let path = grant.path(); - // Secure-open the exact granted path first: regular file only, no - // symlink/reparse-point leaf, size-capped before any SQLite parsing. - let mut file = crate::external_credentials::open_external_regular_file(path)?; - let mut header = [0u8; 16]; - let read = file.read(&mut header).with_context(|| { - format!( - "reading SQLite header of {}", - codewhale_config::quote_os_path(path) - ) - })?; - if read < 16 || header[..15] != *b"SQLite format 3" { - bail!( - "external agy credential file {} is not a SQLite database", - codewhale_config::quote_os_path(path) - ); - } - file.seek(SeekFrom::Start(0)).ok(); - let metadata = file - .metadata() - .with_context(|| format!("statting {}", codewhale_config::quote_os_path(path)))?; - if metadata.len() > AGY_STATE_DB_LIMIT { - bail!( - "external agy credential store {} exceeds the {} byte safety limit", - codewhale_config::quote_os_path(path), - AGY_STATE_DB_LIMIT - ); - } - // Pin the file identity: SQLite reopens the path by name, so hold the - // secure handle open across the query and prove the inode did not move. - let pinned = file_identity(&file); - drop(file); - let value = query_oauth_token(path)?; - let reopened = std::fs::File::open(path) - .ok() - .and_then(|recheck| file_identity_of(&recheck)); - if pinned != reopened { - bail!( - "external agy credential store {} changed while being read", - codewhale_config::quote_os_path(path) - ); - } - parse_agy_oauth_token_value(value) -} - -#[cfg(unix)] -fn file_identity(file: &std::fs::File) -> Option<(u64, u64)> { - use std::os::unix::fs::MetadataExt as _; - file.metadata().ok().map(|m| (m.dev(), m.ino())) -} - -#[cfg(unix)] -fn file_identity_of(file: &std::fs::File) -> Option<(u64, u64)> { - file_identity(file) -} - -#[cfg(windows)] -fn file_identity(file: &std::fs::File) -> Option<(u64, u64)> { - use std::os::windows::io::AsRawHandle as _; - use std::os::windows::raw::HANDLE; - // Windows: BY_HANDLE_FILE_INFORMATION via winapi is out of scope here; - // the secure-open layer already rejects reparse points, and the file is - // held by SQLite for the duration of the read. Identity recheck is a - // Unix hardening bonus. - let _ = (file, file.as_raw_handle() as HANDLE); - None -} - -#[cfg(windows)] -fn file_identity_of(file: &std::fs::File) -> Option<(u64, u64)> { - let _ = file; - None -} - -#[cfg(not(any(unix, windows)))] -fn file_identity(_file: &std::fs::File) -> Option<(u64, u64)> { - None -} - -#[cfg(not(any(unix, windows)))] -fn file_identity_of(_file: &std::fs::File) -> Option<(u64, u64)> { - None -} - -/// Open the granted path read-only through the shared secure boundary. -fn query_oauth_token(path: &Path) -> Result> { - let connection = rusqlite::Connection::open_with_flags( - path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .with_context(|| { - format!( - "opening {} read-only", - codewhale_config::quote_os_path(path) - ) - })?; - let value: Option = connection - .query_row( - "SELECT value FROM ItemTable WHERE key = ?1", - [AGY_OAUTH_TOKEN_KEY], - |row| row.get(0), - ) - .map(Some) - .or_else(|error| match error { - rusqlite::Error::QueryReturnedNoRows => Ok(None), - other => Err(other), - }) - .with_context(|| { - format!( - "querying {} for {AGY_OAUTH_TOKEN_KEY}", - codewhale_config::quote_os_path(path) - ) - })?; - Ok(value) -} - -/// The stored value is opaque to Codewhale. Accept only shapes observed in -/// the official store — a bare token string or a JSON object with a token -/// member — and never synthesize or trim secrets beyond whitespace. -pub(crate) fn parse_agy_oauth_token_value(value: Option) -> Result> { - let Some(raw) = value else { - return Ok(None); - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(None); - } - if trimmed.starts_with('{') { - let parsed: serde_json::Value = serde_json::from_str(trimmed) - .with_context(|| "agy OAuth token value is malformed JSON")?; - for member in ["access_token", "accessToken", "token"] { - if let Some(token) = parsed.get(member).and_then(|v| v.as_str()) { - if token.trim().is_empty() { - bail!("agy OAuth token member `{member}` is empty"); - } - return Ok(Some(token.to_string())); - } - } - bail!("agy OAuth token JSON carries no access token member"); - } - Ok(Some(trimmed.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use codewhale_config::ExternalCredentialReadGrant; - use std::collections::HashMap; - - fn grant_for(path: &Path) -> ExternalCredentialReadGrant { - codewhale_config::ExternalCredentialConsentToml::read_only( - codewhale_config::ProviderKind::Antigravity, - ExternalCredentialSource::AgyCli, - path.to_path_buf(), - ) - .read_grant( - codewhale_config::ProviderKind::Antigravity, - ExternalCredentialSource::AgyCli, - path, - ) - .expect("test grant") - } - - fn fixture_db(token_value: Option<&str>) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - // Canonicalize: production open_secure_regular_file uses O_NOFOLLOW on - // every path component, so macOS TempDir paths under `/var` (symlink - // to `private/var`) would fail the secure open for an unrelated reason. - // Resolving the fixture root keeps the suite focused on credential - // parsing while preserving the production no-follow boundary. - let root = dir.path().canonicalize().expect("canonical temp root"); - let path = root.join("state.vscdb"); - let connection = rusqlite::Connection::open(&path).expect("create fixture database"); - connection - .execute_batch( - "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value BLOB);", - ) - .unwrap(); - if let Some(value) = token_value { - connection - .execute( - "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", - rusqlite::params![AGY_OAUTH_TOKEN_KEY, value], - ) - .unwrap(); - } - connection - .execute( - "INSERT INTO ItemTable (key, value) VALUES ('antigravityAuthStatus', 'signedIn')", - [], - ) - .unwrap(); - drop(connection); - (dir, path) - } - - #[test] - fn extracts_token_from_fixture_state_db() { - let (_dir, path) = fixture_db(Some("ya29.test-token")); - let grant = grant_for(&path); - assert_eq!( - antigravity_oauth_token_from_grant(&grant).unwrap(), - Some("ya29.test-token".to_string()) - ); - } - - #[test] - fn extracts_access_token_member_from_json_value() { - let (_dir, path) = fixture_db(Some( - r#"{"access_token":"ya29.json","scope":"cloud-platform"}"#, - )); - let grant = grant_for(&path); - assert_eq!( - antigravity_oauth_token_from_grant(&grant).unwrap(), - Some("ya29.json".to_string()) - ); - } - - #[test] - fn missing_token_row_is_absent_not_an_error() { - let (_dir, path) = fixture_db(None); - let grant = grant_for(&path); - assert_eq!(antigravity_oauth_token_from_grant(&grant).unwrap(), None); - } - - #[test] - fn json_without_token_member_fails_closed() { - assert!(parse_agy_oauth_token_value(Some(r#"{"scope":"x"}"#.into())).is_err()); - } - - #[test] - fn empty_token_fails_or_is_absent() { - assert_eq!( - parse_agy_oauth_token_value(Some(" ".into())).unwrap(), - None - ); - assert!(parse_agy_oauth_token_value(Some(r#"{"access_token":""}"#.to_string())).is_err()); - } - - #[test] - fn wrong_grant_source_is_rejected() { - let (_dir, path) = fixture_db(Some("token")); - let grant = codewhale_config::ExternalCredentialConsentToml::read_only( - codewhale_config::ProviderKind::Antigravity, - ExternalCredentialSource::DshCli, - path.clone(), - ) - .read_grant( - codewhale_config::ProviderKind::Antigravity, - ExternalCredentialSource::DshCli, - &path, - ) - .expect("test grant"); - assert!(antigravity_oauth_token_from_grant(&grant).is_err()); - } - - #[test] - fn precedence_owned_key_beats_env_beats_file() { - let mut env = HashMap::new(); - env.insert( - "AGY_ADC_AUTH".to_string(), - "adc-process-credential".to_string(), - ); - let (_dir, path) = fixture_db(Some("file-token")); - let grant = grant_for(&path); - - assert_eq!( - antigravity_credential_precedence(Some("owned-key"), &env, Some(&grant)), - AntigravityCredential::OwnedKey("owned-key".to_string()) - ); - assert_eq!( - antigravity_credential_precedence(None, &env, Some(&grant)), - AntigravityCredential::ProcessEnv("adc-process-credential".to_string()) - ); - assert_eq!( - antigravity_credential_precedence(None, &HashMap::new(), Some(&grant)), - AntigravityCredential::ExternalFile("file-token".to_string()) - ); - assert_eq!( - antigravity_credential_precedence(None, &HashMap::new(), None), - AntigravityCredential::None - ); - } -} diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 22959ce55a..d28807cd52 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1184,6 +1184,9 @@ impl DeepSeekClient { config: &Config, ) -> Result { let api_provider = config.api_provider(); + if api_provider == ApiProvider::Antigravity { + bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } let provider_identity = config.provider_identity_for(api_provider); let billing_surface = crate::route_billing::billing_surface_for_dispatch( Some(config), @@ -1955,11 +1958,7 @@ impl DeepSeekClient { // adapter is what stops an unrepresentable role from being discovered // as an opaque provider 400 (Anthropic) or from vanishing silently // (the OpenAI-shaped dialects) depending on which adapter ran. - let outbound_dialect = if self.api_provider == crate::config::ApiProvider::Antigravity { - WireDialect::GoogleCloudCode - } else { - WireDialect::from_wire_format(self.wire_format) - }; + let outbound_dialect = WireDialect::from_wire_format(self.wire_format); role_placement::reject_unsupported_roles(&request.messages, outbound_dialect)?; let clamp_output_cap = |mut request: MessageRequest, route_limits: Option| { let route_cap = @@ -1975,21 +1974,6 @@ impl DeepSeekClient { } request }; - if self.api_provider == crate::config::ApiProvider::Antigravity { - let request = - clamp_output_cap(self.prepare_model_bound_request(request), self.route_limits); - let body = cloud_code::build_generate_content_body(&request)?; - let url = cloud_code::stream_generate_content_url(&self.base_url); - return Ok(PreparedOutboundRequest::new( - WireDialect::GoogleCloudCode, - self.endpoint_identity(url, RouteShape::CloudCode), - request.model.clone(), - body, - request.reasoning_effort.clone(), - None, - CallerStreamMode::from_stream_flag(stream), - )); - } let (request, request_route_limits) = self.bind_request_to_protocol(self.prepare_model_bound_request(request))?; let mut request = clamp_output_cap(request, request_route_limits); @@ -2001,7 +1985,6 @@ impl DeepSeekClient { } } let requested_effort = request.reasoning_effort.clone(); - // Same value computed for the seam above; Antigravity already returned. let dialect = outbound_dialect; // `stream` is the caller's entry point, not a wire fact: each dialect // decides for itself what the body's `stream` field says. @@ -2317,7 +2300,7 @@ impl DeepSeekClient { let response = match prepared.dialect { WireDialect::OpenAiResponses => self.handle_responses_message(&prepared).await?, WireDialect::AnthropicMessages => self.handle_anthropic_message(&prepared).await?, - WireDialect::ChatCompletions | WireDialect::GoogleCloudCode => unreachable!(), + WireDialect::ChatCompletions => unreachable!(), }; return translation_text_from_response(&response); } @@ -2992,9 +2975,6 @@ impl DeepSeekClient { WireDialect::OpenAiResponses => isolated.handle_responses_message(&prepared).await, WireDialect::AnthropicMessages => isolated.handle_anthropic_message(&prepared).await, WireDialect::ChatCompletions => isolated.create_message_chat(&prepared, false).await, - WireDialect::GoogleCloudCode => anyhow::bail!( - "Antigravity cloud-code is stream-only; blocking create_message is not implemented" - ), } } } @@ -3072,9 +3052,6 @@ impl LlmClient for DeepSeekClient { WireDialect::OpenAiResponses => self.handle_responses_message(&prepared).await, WireDialect::AnthropicMessages => self.handle_anthropic_message(&prepared).await, WireDialect::ChatCompletions => self.create_message_chat(&prepared, cacheable).await, - WireDialect::GoogleCloudCode => anyhow::bail!( - "Antigravity cloud-code is stream-only; blocking create_message is not implemented" - ), } } @@ -3085,15 +3062,6 @@ impl LlmClient for DeepSeekClient { let inference = self.acquire_remote_control_inference_permit().await; let permit = self.acquire_provider_request_permit().await; let prepared = self.prepare_outbound_request(request, true)?; - if self.api_provider == crate::config::ApiProvider::Antigravity { - let stream = Self::hold_provider_request_permit_for_stream( - self.handle_cloud_code_stream(&prepared).await?, - permit, - ); - return Ok(Self::hold_remote_control_inference_permit_for_stream( - stream, inference, - )); - } let projection_warning = (!prepared.omitted_tool_names.is_empty()).then(|| { let omitted_tool_count = prepared.omitted_tool_names.len(); ( @@ -3108,9 +3076,6 @@ impl LlmClient for DeepSeekClient { WireDialect::OpenAiResponses => self.handle_responses_stream(&prepared).await?, WireDialect::AnthropicMessages => self.handle_anthropic_stream(&prepared).await?, WireDialect::ChatCompletions => self.handle_chat_completion_stream(prepared).await?, - WireDialect::GoogleCloudCode => { - unreachable!("Antigravity streams before dialect match") - } }; let stream = match projection_warning { Some((provider, omitted_tool_names, omitted_tool_count)) => { @@ -3977,7 +3942,6 @@ impl DeepSeekClient { mod anthropic; mod chat; -pub(crate) mod cloud_code; mod deepseek_effort; #[cfg(test)] mod ds4_tests; diff --git a/crates/tui/src/client/cloud_code.rs b/crates/tui/src/client/cloud_code.rs deleted file mode 100644 index e3ce7b26ed..0000000000 --- a/crates/tui/src/client/cloud_code.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! Google Antigravity / `agy` cloud-code wire (`/v1internal`). -//! -//! This is not OpenAI-compat. The official `agy` CLI speaks -//! `POST {base}:streamGenerateContent?alt=sse` with a GenerateContent JSON -//! body. Anything we have not seen on the wire fails closed. - -use anyhow::{Context, Result, bail}; -use futures_util::StreamExt; -use serde_json::{Value, json}; - -use crate::llm_client::StreamEventBox; -use crate::models::{ - ContentBlock, ContentBlockStart, Delta, MessageRequest, MessageResponse, StreamEvent, - SystemPrompt, -}; - -use super::PreparedOutboundRequest; -use super::prepared::WireDialect; -use super::role_placement::{RolePlacement, role_placement}; -use super::stream_entry; - -/// Model id advertised only after a live cloud-code turn succeeds. -#[cfg(test)] -pub const GEMINI_37_FLASH: &str = "gemini-3.7-flash"; - -/// Semantic request-shape failures that must remain typed until the host -/// chooses localized user-facing prose. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub(crate) enum CloudCodeRequestError { - #[error("cloud-code request would omit non-empty system instructions")] - SystemPromptUnsupported, -} - -/// Build the cloud-code streaming URL from the configured `/v1internal` base. -#[must_use] -pub fn stream_generate_content_url(base_url: &str) -> String { - let base = base_url.trim_end_matches('/'); - format!("{base}:streamGenerateContent?alt=sse") -} - -/// Minimum GenerateContent JSON body. Tools, images, and unknown roles fail -/// closed — those shapes are unproven on this wire. -pub fn build_generate_content_body(request: &MessageRequest) -> Result { - let has_system_text = match request.system.as_ref() { - Some(SystemPrompt::Text(text)) => !text.trim().is_empty(), - Some(SystemPrompt::Blocks(blocks)) => { - blocks.iter().any(|block| !block.text.trim().is_empty()) - } - None => false, - }; - if has_system_text { - return Err(CloudCodeRequestError::SystemPromptUnsupported.into()); - } - if request - .tools - .as_ref() - .is_some_and(|tools| !tools.is_empty()) - { - bail!( - "Antigravity cloud-code tools are not implemented yet; send a text-only turn or use the google provider" - ); - } - let mut contents = Vec::new(); - for message in &request.messages { - // Fail closed, as this wire always has: the shared placement table - // says only user and assistant output are representable here, and - // anything else is refused rather than guessed at or dropped. - let role = match role_placement(&message.role, WireDialect::GoogleCloudCode) { - RolePlacement::User => "user", - RolePlacement::Assistant => "model", - // Listed rather than caught by `_` so a new placement forces a - // decision here instead of silently becoming a hard error. - RolePlacement::InterruptedAssistant - | RolePlacement::System - | RolePlacement::Developer - | RolePlacement::Omitted - | RolePlacement::Rejected => bail!( - "Antigravity cloud-code does not accept role {:?}", - message.role.as_str() - ), - }; - let mut parts = Vec::new(); - for block in &message.content { - match block { - ContentBlock::Text { text, .. } if !text.trim().is_empty() => { - parts.push(json!({ "text": text })); - } - ContentBlock::Text { .. } => {} - _ => bail!( - "Antigravity cloud-code accepts text parts only; non-text content fails closed" - ), - } - } - if !parts.is_empty() { - contents.push(json!({ "role": role, "parts": parts })); - } - } - if contents.is_empty() { - bail!("Antigravity cloud-code request has no text contents"); - } - let model = request.model.trim(); - if model.is_empty() { - bail!("Antigravity cloud-code request is missing a model id"); - } - Ok(json!({ - "model": model, - "userAgent": "codewhale", - "request": { - "contents": contents, - } - })) -} - -/// Pull visible text out of a cloud-code SSE JSON object. Unknown shapes -/// return `None` so the caller can fail closed instead of guessing. -pub fn extract_cloud_code_text(value: &Value) -> Option { - if let Some(text) = value.pointer("/response/candidates/0/content/parts/0/text") { - return text.as_str().filter(|s| !s.is_empty()).map(str::to_string); - } - if let Some(text) = value.pointer("/candidates/0/content/parts/0/text") { - return text.as_str().filter(|s| !s.is_empty()).map(str::to_string); - } - if let Some(text) = value.get("text").and_then(Value::as_str) { - return (!text.is_empty()).then(|| text.to_string()); - } - None -} - -impl super::DeepSeekClient { - pub(super) async fn handle_cloud_code_stream( - &self, - prepared: &PreparedOutboundRequest, - ) -> Result { - let url = prepared.endpoint.url.clone(); - let body = prepared.body.clone(); - let open_req = stream_entry::StreamOpenRequest::new( - stream_entry::stream_open_timeout(), - self.stream_idle_timeout, - ); - let opened = stream_entry::open_sse_response(&open_req, |policy| { - let url = url.clone(); - let body = body.clone(); - async move { - self.wait_for_rate_limit().await; - let client = stream_entry::client_for_policy( - &self.http_client, - self.http1_fallback_client(), - policy, - ); - client - .post(&url) - .header("Accept", "text/event-stream") - .json(&body) - .send() - .await - .context("Antigravity cloud-code request failed") - } - }) - .await; - let response = match opened { - Ok(response) => response, - Err(err) => { - self.mark_request_failure(&format!("cloud-code stream open: {err}")) - .await; - return Err(err); - } - }; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - let redacted = crate::llm_client::sanitize_http_error_body( - Some("antigravity"), - status.as_u16(), - &body, - ); - bail!("Antigravity cloud-code HTTP {status}: {redacted}"); - } - - let stream_idle_timeout = self.stream_idle_timeout; - let byte_stream = response.bytes_stream(); - let stream = async_stream::stream! { - let mut buffer: Vec = Vec::new(); - let stream_start = std::time::Instant::now(); - let mut last_chunk_at = std::time::Instant::now(); - let mut bytes_received: usize = 0; - let mut started = false; - tokio::pin!(byte_stream); - - loop { - let chunk = match tokio::time::timeout(stream_idle_timeout, byte_stream.next()).await { - Ok(Some(Ok(chunk))) => chunk, - Ok(Some(Err(e))) => { - yield Err(anyhow::anyhow!("Stream read error: {e}")); - return; - } - Ok(None) => break, - Err(_) => { - yield Err(anyhow::anyhow!(stream_entry::idle_timeout_message( - stream_idle_timeout, - bytes_received, - stream_start.elapsed(), - last_chunk_at.elapsed(), - ))); - return; - } - }; - bytes_received += chunk.len(); - last_chunk_at = std::time::Instant::now(); - buffer.extend_from_slice(&chunk); - - loop { - let line = match super::take_sse_line(&mut buffer) { - Ok(Some(line)) => line, - Ok(None) => break, - Err(err) => { - yield Err(anyhow::anyhow!("{err}")); - return; - } - }; - if line.is_empty() || line.starts_with(':') { - continue; - } - let Some(data) = super::extract_sse_data_value(&line) else { - continue; - }; - if data == "[DONE]" { - break; - } - let value: Value = match serde_json::from_str(data) { - Ok(value) => value, - Err(err) => { - yield Err(anyhow::anyhow!( - "Antigravity cloud-code SSE is not JSON: {err}" - )); - return; - } - }; - if let Some(error) = value.get("error") { - yield Ok(StreamEvent::Error { - error: error.clone(), - }); - return; - } - let Some(text) = extract_cloud_code_text(&value) else { - if value.get("response").is_some() || value.get("candidates").is_some() { - continue; - } - yield Err(anyhow::anyhow!( - "Antigravity cloud-code SSE shape is unproven; failing closed" - )); - return; - }; - if !started { - started = true; - yield Ok(StreamEvent::MessageStart { - message: MessageResponse { - id: "agy".to_string(), - r#type: "message".to_string(), - role: "assistant".to_string(), - content: Vec::new(), - model: String::new(), - stop_reason: None, - stop_sequence: None, - container: None, - usage: crate::models::Usage::default(), - }, - }); - yield Ok(StreamEvent::ContentBlockStart { - index: 0, - content_block: ContentBlockStart::Text { - text: String::new(), - }, - }); - } - yield Ok(StreamEvent::ContentBlockDelta { - index: 0, - delta: Delta::TextDelta { text }, - }); - } - } - if started { - yield Ok(StreamEvent::ContentBlockStop { index: 0 }); - yield Ok(StreamEvent::MessageStop); - } else { - yield Err(anyhow::anyhow!( - "Antigravity cloud-code stream ended without a text part" - )); - } - }; - Ok(Box::pin(stream)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::Role; - use crate::models::{Message, MessageRequest, SystemBlock, SystemPrompt}; - - fn text_request(model: &str, prompt: &str) -> MessageRequest { - MessageRequest { - model: model.to_string(), - messages: vec![Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: prompt.to_string(), - cache_control: None, - }], - }], - max_tokens: 32, - system: None, - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: None, - stream: Some(true), - temperature: None, - top_p: None, - } - } - - #[test] - fn stream_url_uses_v1internal_colon_rpc() { - assert_eq!( - stream_generate_content_url("https://cloudcode-pa.googleapis.com/v1internal"), - "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn generate_content_body_is_text_only() { - let body = build_generate_content_body(&text_request(GEMINI_37_FLASH, "ping")).unwrap(); - assert_eq!(body["model"], GEMINI_37_FLASH); - assert_eq!(body["request"]["contents"][0]["parts"][0]["text"], "ping"); - } - - #[tokio::test] - #[ignore = "live Antigravity cloud-code; run with --ignored"] - async fn live_gemini_37_flash_one_turn() { - let mut config = crate::config::Config::load(None, None).expect("load config"); - config.provider = Some("antigravity".to_string()); - config.default_text_model = Some(GEMINI_37_FLASH.to_string()); - eprintln!( - "agy live creds: ANTIGRAVITY_API_KEY={} AGY_ADC_AUTH={}", - if std::env::var("ANTIGRAVITY_API_KEY").is_ok_and(|v| !v.trim().is_empty()) { - "set" - } else { - "unset" - }, - if std::env::var("AGY_ADC_AUTH").is_ok_and(|v| !v.trim().is_empty()) { - "set" - } else { - "unset" - } - ); - let client = match crate::client::DeepSeekClient::new(&config) { - Ok(client) => client, - Err(err) => { - panic!("antigravity client did not resolve a sendable credential: {err}"); - } - }; - let request = text_request(GEMINI_37_FLASH, "Reply with the single word pong."); - let mut stream = crate::llm_client::LlmClient::create_message_stream(&client, request) - .await - .expect("cloud-code stream opened"); - let mut text = String::new(); - while let Some(event) = futures_util::StreamExt::next(&mut stream).await { - match event.expect("stream event") { - StreamEvent::ContentBlockDelta { - delta: Delta::TextDelta { text: chunk }, - .. - } => text.push_str(&chunk), - StreamEvent::Error { error } => { - panic!("cloud-code error object (redacted shape): {error}"); - } - StreamEvent::MessageStop => break, - _ => {} - } - } - assert!( - !text.trim().is_empty(), - "live Gemini 3.7 Flash turn returned no text" - ); - eprintln!( - "agy live turn ok: {} chars, first word {:?}", - text.chars().count(), - text.split_whitespace().next() - ); - } - - #[test] - fn generate_content_body_rejects_tools() { - let mut request = text_request(GEMINI_37_FLASH, "ping"); - request.tools = Some(vec![crate::models::Tool { - tool_type: None, - name: "read".to_string(), - description: "read".to_string(), - input_schema: json!({"type": "object"}), - allowed_callers: None, - defer_loading: None, - input_examples: None, - strict: None, - cache_control: None, - }]); - assert!(build_generate_content_body(&request).is_err()); - } - - #[test] - fn generate_content_body_rejects_text_system_prompt_instead_of_dropping_it() { - let mut request = text_request(GEMINI_37_FLASH, "ping"); - request.system = Some(SystemPrompt::Text("Keep this instruction".to_string())); - - let error = build_generate_content_body(&request).unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(CloudCodeRequestError::SystemPromptUnsupported) - )); - } - - #[test] - fn generate_content_body_rejects_block_system_prompt_instead_of_dropping_it() { - let mut request = text_request(GEMINI_37_FLASH, "ping"); - request.system = Some(SystemPrompt::Blocks(vec![SystemBlock { - block_type: "text".to_string(), - text: "Keep this structured instruction".to_string(), - cache_control: None, - }])); - - let error = build_generate_content_body(&request).unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(CloudCodeRequestError::SystemPromptUnsupported) - )); - } - - #[test] - fn generate_content_body_accepts_semantically_empty_system_prompt() { - let mut request = text_request(GEMINI_37_FLASH, "ping"); - request.system = Some(SystemPrompt::Blocks(vec![SystemBlock { - block_type: "text".to_string(), - text: " \n\t".to_string(), - cache_control: None, - }])); - - assert!(build_generate_content_body(&request).is_ok()); - } -} diff --git a/crates/tui/src/client/prepared.rs b/crates/tui/src/client/prepared.rs index e47f8b5fb1..2bee4dfe23 100644 --- a/crates/tui/src/client/prepared.rs +++ b/crates/tui/src/client/prepared.rs @@ -44,8 +44,6 @@ pub(crate) enum WireDialect { AnthropicMessages, /// OpenAI-style `POST /responses`. OpenAiResponses, - /// Google Antigravity / `agy` cloud-code (`POST /v1internal:streamGenerateContent`). - GoogleCloudCode, } impl WireDialect { @@ -63,7 +61,6 @@ impl WireDialect { Self::ChatCompletions => "chat-completions", Self::AnthropicMessages => "anthropic-messages", Self::OpenAiResponses => "openai-responses", - Self::GoogleCloudCode => "google-cloud-code", } } } @@ -92,8 +89,6 @@ pub(crate) enum RouteShape { OpencodeZen, /// A user-configured custom/compatible endpoint on a standard dialect. CustomCompatible, - /// Google Antigravity / `agy` `/v1internal:streamGenerateContent`. - CloudCode, } impl RouteShape { @@ -106,7 +101,6 @@ impl RouteShape { Self::CodexResponses => "codex-responses", Self::OpencodeZen => "opencode-zen", Self::CustomCompatible => "custom-compatible", - Self::CloudCode => "cloud-code", } } } @@ -169,7 +163,6 @@ impl ReasoningReceipt { ], WireDialect::AnthropicMessages => &["thinking", "output_config"], WireDialect::OpenAiResponses => &["reasoning", "include"], - WireDialect::GoogleCloudCode => &[], } } @@ -545,7 +538,6 @@ impl<'a> WireBodyView<'a> { WireDialect::ChatCompletions => (None, "messages"), WireDialect::AnthropicMessages => (Some("system"), "messages"), WireDialect::OpenAiResponses => (Some("instructions"), "input"), - WireDialect::GoogleCloudCode => (None, "request"), }; // The system region is accumulated as canonical text so it can be @@ -638,7 +630,6 @@ fn is_tool_result_item(dialect: WireDialect, item: &Value) -> bool { WireDialect::OpenAiResponses => { item.get("type").and_then(Value::as_str) == Some("function_call_output") } - WireDialect::GoogleCloudCode => false, } } @@ -662,7 +653,6 @@ fn count_attachments(dialect: WireDialect, item: &Value) -> (usize, usize) { WireDialect::OpenAiResponses => { matches!(part_type, Some("input_image" | "input_file")) } - WireDialect::GoogleCloudCode => false, }; if !is_attachment { continue; @@ -1436,25 +1426,6 @@ mod dialect_seam_tests { assert_eq!(carried["role"], "user"); } - /// Same seam, same rejection, on the wire that has always failed closed. - #[test] - fn seam_refuses_the_interrupted_sentinel_on_cloud_code() { - let client = client("antigravity", |providers| { - providers.antigravity = configured("agy-test", None, "gemini-3-pro"); - }); - let mut request = request("gemini-3-pro"); - request.system = None; - request.tools = None; - request - .messages - .push(message(Role::InterruptedAssistant, "half a thought")); - - let error = client - .prepare_outbound_request(request, true) - .expect_err("cloud-code has never accepted the interrupted sentinel"); - assert!(error.to_string().contains("google-cloud-code"), "{error}"); - } - /// The dialects that have always dropped an unrepresentable role keep /// dropping it. Turning that into a hard failure would break live /// sessions; the point of the seam is to make the choice explicit, not to diff --git a/crates/tui/src/client/role_placement.rs b/crates/tui/src/client/role_placement.rs index d53fc0faee..54257f5df9 100644 --- a/crates/tui/src/client/role_placement.rs +++ b/crates/tui/src/client/role_placement.rs @@ -1,8 +1,8 @@ //! The one table that answers "where does a message with this role go on this //! wire, and when is the pair not representable at all?" //! -//! Before this module existed the question was answered four times, once per -//! adapter, and the four answers disagreed — not by design, by drift: +//! Before this module existed the question was answered once per adapter, and +//! the answers disagreed — not by design, by drift: //! //! * Chat Completions matched `user`/`assistant`/`system` and let anything //! else fall off the end of an `if`/`else if` chain, silently. @@ -11,7 +11,6 @@ //! * Anthropic Messages forwarded `message.role` **verbatim**, so a `system` //! message earned an opaque provider-side 400 that named neither the role //! nor the message. -//! * Google cloud-code was the only one that failed closed. //! //! Now each adapter asks [`role_placement`] which channel to render into, and //! [`reject_unsupported_roles`] runs at the outbound seam @@ -33,8 +32,8 @@ use crate::models::{Message, Role}; /// Which channel of a wire body a message renders into. /// /// Adapters own the structural rendering for their own dialect — Chat's -/// `tool_calls` array, Responses' `function_call_output` items, Anthropic's -/// content blocks, cloud-code's `parts`. This enum only names the channel, so +/// `tool_calls` array, Responses' `function_call_output` items, and Anthropic's +/// content blocks. This enum only names the channel, so /// that the *choice* of channel is made in exactly one place. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RolePlacement { @@ -76,14 +75,10 @@ pub(crate) fn role_placement(role: &Role, dialect: WireDialect) -> RolePlacement // Every dialect carries user input. (Role::User, _) => RolePlacement::User, - // Every dialect carries assistant output. cloud-code calls the - // channel "model"; that is the adapter's own name for it. + // Every dialect carries assistant output. (Role::Assistant, _) => RolePlacement::Assistant, - // Interrupted assistant text replays as assistant output with a - // marker, except on cloud-code, which has never accepted it and - // keeps failing closed rather than guessing. - (Role::InterruptedAssistant, WireDialect::GoogleCloudCode) => RolePlacement::Rejected, + // Interrupted assistant text replays as assistant output with a marker. (Role::InterruptedAssistant, _) => RolePlacement::InterruptedAssistant, // Chat Completions and Responses both accept load-bearing system and @@ -96,24 +91,20 @@ pub(crate) fn role_placement(role: &Role, dialect: WireDialect) -> RolePlacement RolePlacement::System } (Role::System, WireDialect::AnthropicMessages) => RolePlacement::User, - (Role::System, WireDialect::GoogleCloudCode) => RolePlacement::Rejected, (Role::Developer, WireDialect::ChatCompletions | WireDialect::OpenAiResponses) => { RolePlacement::Developer } (Role::Developer, WireDialect::AnthropicMessages) => RolePlacement::User, - (Role::Developer, WireDialect::GoogleCloudCode) => RolePlacement::Rejected, // A role this build does not know, e.g. from a transcript written by // a newer build. The OpenAI-shaped dialects already dropped these; - // Anthropic sent them verbatim for the provider to reject, and - // cloud-code bailed. + // Anthropic sent them verbatim for the provider to reject. (Role::Unrecognized(_), WireDialect::ChatCompletions | WireDialect::OpenAiResponses) => { RolePlacement::Omitted } // CHANGED: was a verbatim pass-through ending in a provider 400. (Role::Unrecognized(_), WireDialect::AnthropicMessages) => RolePlacement::Rejected, - (Role::Unrecognized(_), WireDialect::GoogleCloudCode) => RolePlacement::Rejected, } } @@ -158,11 +149,10 @@ mod tests { use super::{RolePlacement, WireDialect, reject_unsupported_roles, role_placement}; use crate::models::{ContentBlock, Message, Role}; - const DIALECTS: [WireDialect; 4] = [ + const DIALECTS: [WireDialect; 3] = [ WireDialect::ChatCompletions, WireDialect::AnthropicMessages, WireDialect::OpenAiResponses, - WireDialect::GoogleCloudCode, ]; fn message(role: Role) -> Message { @@ -187,7 +177,7 @@ mod tests { } #[test] - fn interrupted_assistant_replays_everywhere_except_cloud_code() { + fn interrupted_assistant_replays_on_every_supported_dialect() { for dialect in [ WireDialect::ChatCompletions, WireDialect::AnthropicMessages, @@ -197,10 +187,6 @@ mod tests { assert_eq!(placement, RolePlacement::InterruptedAssistant); assert!(placement.is_assistant_channel()); } - assert_eq!( - role_placement(&Role::InterruptedAssistant, WireDialect::GoogleCloudCode), - RolePlacement::Rejected, - ); } #[test] @@ -217,11 +203,6 @@ mod tests { role_placement(&Role::System, WireDialect::AnthropicMessages), RolePlacement::User ); - assert_eq!( - role_placement(&Role::System, WireDialect::GoogleCloudCode), - RolePlacement::Rejected - ); - assert_eq!( role_placement(&Role::Developer, WireDialect::ChatCompletions), RolePlacement::Developer @@ -234,10 +215,6 @@ mod tests { role_placement(&Role::Developer, WireDialect::AnthropicMessages), RolePlacement::User ); - assert_eq!( - role_placement(&Role::Developer, WireDialect::GoogleCloudCode), - RolePlacement::Rejected - ); } #[test] @@ -268,15 +245,6 @@ mod tests { .expect("Anthropic projects positioned system history onto the user channel"); } - #[test] - fn seam_rejects_the_interrupted_sentinel_on_cloud_code() { - let messages = vec![message(Role::InterruptedAssistant)]; - let err = reject_unsupported_roles(&messages, WireDialect::GoogleCloudCode) - .expect_err("cloud-code has never accepted the interrupted sentinel"); - assert_eq!(err.role, "assistant_interrupted"); - assert_eq!(err.dialect, "google-cloud-code"); - } - #[test] fn seam_accepts_what_each_dialect_can_carry() { let plain = vec![message(Role::User), message(Role::Assistant)]; @@ -316,7 +284,7 @@ mod tests { mod adapter_agreement_tests { use serde_json::{Value, json}; - use super::super::{anthropic, chat, cloud_code, responses}; + use super::super::{anthropic, chat, responses}; use crate::config::ApiProvider; use crate::models::{ ContentBlock, INTERRUPTED_ASSISTANT_CONTEXT_PREFIX, Message, MessageRequest, Role, @@ -464,37 +432,4 @@ mod adapter_agreement_tests { format!("{INTERRUPTED_ASSISTANT_CONTEXT_PREFIX}half an answer"), ); } - - #[test] - fn cloud_code_still_fails_closed_on_everything_it_cannot_represent() { - for role in [ - Role::System, - Role::InterruptedAssistant, - Role::Developer, - Role::Unrecognized("future_role".to_string()), - ] { - let error = cloud_code::build_generate_content_body(&request(vec![ - message(Role::User, "ask"), - message(role.clone(), "body"), - ])) - .expect_err("cloud-code fails closed on unrepresentable roles"); - assert!( - error.to_string().contains("does not accept role"), - "{role}: {error}" - ); - } - } - - #[test] - fn cloud_code_names_the_assistant_channel_model() { - let body = cloud_code::build_generate_content_body(&request(vec![ - message(Role::User, "ask"), - message(Role::Assistant, "answer"), - ])) - .expect("cloud-code carries user and assistant turns"); - let contents = body["request"]["contents"] - .as_array() - .expect("contents array"); - assert_eq!(roles(contents), vec!["user", "model"]); - } } diff --git a/crates/tui/src/commands/groups/core/provider.rs b/crates/tui/src/commands/groups/core/provider.rs index f429f4cd22..912357840b 100644 --- a/crates/tui/src/commands/groups/core/provider.rs +++ b/crates/tui/src/commands/groups/core/provider.rs @@ -67,6 +67,12 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { }; } + if crate::config::is_legacy_antigravity_identity(name) { + return CommandResult::error( + codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string(), + ); + } + let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( "Unknown provider '{name}'. Expected: {}.", @@ -116,6 +122,9 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { } pub(in crate::commands) fn provider_setup_action_for_name(raw: &str) -> Result { + if crate::config::is_legacy_antigravity_identity(raw) { + return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string()); + } if raw.eq_ignore_ascii_case("ds4") || raw.eq_ignore_ascii_case("dwarfstar") { return Ok(AppAction::OpenDs4Setup); } @@ -263,6 +272,25 @@ mod tests { assert_eq!(result.action, Some(AppAction::OpenProviderPicker)); } + #[test] + fn retired_antigravity_selectors_return_the_tombstone_without_an_action() { + let _guard = lock_test_env(); + for identity in ["antigravity", "agy", "AGY"] { + let mut app = create_test_app(); + let result = provider(&mut app, Some(identity)); + assert!(result.is_error, "{identity}"); + assert_eq!(result.action, None, "{identity}"); + let message = result.message.expect("tombstone message"); + assert!(message.contains("non-runnable"), "{identity}: {message}"); + assert!(message.contains("GEMINI_API_KEY"), "{identity}: {message}"); + assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek); + + let setup = provider_setup_action_for_name(identity) + .expect_err("setup must not open for the tombstone"); + assert!(setup.contains("provider `google`"), "{identity}: {setup}"); + } + } + #[test] fn setup_subcommand_opens_provider_setup_catalog() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 851fe5c52c..7a633325b0 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -101,10 +101,8 @@ pub enum ApiProvider { /// backend, not an OpenAI alias: thought signatures on tool calls are /// captured and replayed per Google's contract. Google, - /// Google Antigravity (`agy`). Consent-gated read-only import of the - /// official CLI's login, then a text-only cloud-code stream - /// (`/v1internal:streamGenerateContent`). Tools and non-text parts - /// fail closed. + /// Retired Antigravity identity retained only to deserialize and clear + /// legacy Codewhale configuration. It is never selectable or runnable. Antigravity, /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. Telecomjs, @@ -149,6 +147,11 @@ pub(crate) struct ProviderIdentity { pub(crate) migrated_legacy_ollama_cloud_route: bool, } +pub(crate) fn is_legacy_antigravity_identity(value: &str) -> bool { + codewhale_config::ProviderKind::parse_config_identity(value) + == Some(codewhale_config::ProviderKind::Antigravity) +} + impl ProviderIdentity { #[must_use] pub(crate) fn persisted_id(&self) -> Option<&str> { @@ -174,6 +177,9 @@ impl ApiProvider { #[must_use] pub fn parse(value: &str) -> Option { let trimmed = value.trim(); + if is_legacy_antigravity_identity(trimmed) { + return None; + } // ApiProvider-specific: "deepseek-cn" is a legacy variant here, // while ProviderKind treats it as a Deepseek alias. if trimmed.eq_ignore_ascii_case("deepseek-cn") @@ -1617,8 +1623,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati "gemini-2.5-pro", "gemini-2.5-flash", ], - // The cloud-code wire protocol is not implemented; no model is - // advertised for the credential-import-only route. + // Legacy tombstone only; never advertise a runnable model. ApiProvider::Antigravity => Vec::new(), ApiProvider::Edenai => vec![DEFAULT_EDENAI_MODEL], // Custom endpoints expose no built-in completion names; the user @@ -4784,6 +4789,13 @@ impl Config { /// Validate that critical config fields are present. pub fn validate(&self) -> Result<()> { + if self + .provider + .as_deref() + .is_some_and(is_legacy_antigravity_identity) + { + anyhow::bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } if let Some(provider) = self.provider.as_deref() && ApiProvider::parse(provider).is_none() && self @@ -4926,6 +4938,18 @@ impl Config { { return ApiProvider::Custom; } + // The retired Antigravity selection is not selectable (`parse` rejects + // it) but it must still resolve to its own tombstone identity here: + // falling through to the base-URL sniff would silently run a legacy + // `provider = "antigravity"` config as DeepSeek and bypass every + // fail-closed tombstone branch in the client and credential resolver. + if self + .provider + .as_deref() + .is_some_and(is_legacy_antigravity_identity) + { + return ApiProvider::Antigravity; + } if let Some(provider) = self.provider.as_deref().and_then(ApiProvider::parse) { if provider == ApiProvider::Ollama && self.selects_legacy_ollama_cloud_route() { return ApiProvider::OllamaCloud; @@ -6550,6 +6574,9 @@ impl Config { fn deepseek_api_key_with_secret_store_mode(&self, read_only: bool) -> Result { let provider = self.api_provider(); + if provider == ApiProvider::Antigravity { + anyhow::bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } let auth_mode = self.auth_mode_for_provider(provider); if auth_mode_disables_api_key(auth_mode.as_deref()) { return Ok(String::new()); @@ -6748,41 +6775,6 @@ impl Config { } } - // Official Antigravity (`agy`) login. `ANTIGRAVITY_API_KEY` config - // and env slots were already checked above; here the process's own - // `AGY_ADC_AUTH` wins over the consented `state.vscdb`, which is - // imported read-only from the one pinned path. The token is then - // used on the cloud-code stream; it is never logged. - if provider == ApiProvider::Antigravity && !custom_endpoint { - let grant = self - .external_credential_read_grant( - provider, - codewhale_config::ExternalCredentialSource::AgyCli, - &codewhale_config::default_agy_credentials_path(), - ) - .ok(); - let process_env: std::collections::HashMap = std::env::vars().collect(); - match crate::agy_credentials::antigravity_credential_precedence( - None, - &process_env, - grant.as_ref(), - ) { - crate::agy_credentials::AntigravityCredential::ProcessEnv(token) => { - return Ok(token); - } - crate::agy_credentials::AntigravityCredential::ExternalFile(token) => { - return Ok(token); - } - other => { - tracing::debug!( - target: "config", - source = other.source_label(), - "antigravity credential plane did not yield a sendable token" - ); - } - } - } - if !auth_mode_requires_api_key(auth_mode.as_deref()) && (provider_route_is_keyless_self_hosted(provider, &self.deepseek_base_url()) || base_url_uses_local_host(&self.deepseek_base_url())) @@ -7915,7 +7907,7 @@ fn provider_env_base_url_override(provider: ApiProvider) -> Option { ApiProvider::Xai => &["XAI_BASE_URL"], ApiProvider::Mistral => &["MISTRAL_BASE_URL"], ApiProvider::Google => &["GOOGLE_BASE_URL", "GEMINI_BASE_URL"], - ApiProvider::Antigravity => &["ANTIGRAVITY_BASE_URL"], + ApiProvider::Antigravity => &[], ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"], ApiProvider::Edenai => &["EDENAI_BASE_URL"], ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic => { @@ -8277,13 +8269,7 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo .google .base_url = Some(value); } - ApiProvider::Antigravity => { - config - .providers - .get_or_insert_with(ProvidersConfig::default) - .antigravity - .base_url = Some(value); - } + ApiProvider::Antigravity => {} ApiProvider::Telecomjs => { config .providers diff --git a/crates/tui/src/config/credential_resolve.rs b/crates/tui/src/config/credential_resolve.rs index fde44d67b0..d269f512ca 100644 --- a/crates/tui/src/config/credential_resolve.rs +++ b/crates/tui/src/config/credential_resolve.rs @@ -20,9 +20,9 @@ //! 2. An explicit `--api-key` on the active, non-OAuth provider. //! 3. `[providers.] api_key_env` — a credential the route *names*. //! 4. An ambient provider environment variable (official endpoints only). -//! 5. Provider-owned login state: an explicitly consented external CLI -//! credential file (Codex, DeepSeek Harness, Antigravity) or CodeWhale's own -//! xAI OAuth storage. +//! 5. Provider-owned login state: an explicitly consented supported external +//! CLI credential file (Codex or DeepSeek Harness) or CodeWhale's own xAI +//! OAuth storage. //! 6. A keyless self-hosted / loopback route. //! 7. `[providers.] api_key` in the config file. //! 8. CodeWhale's durable secret store. @@ -161,33 +161,6 @@ pub(crate) fn resolve_credential_source_with( flow: "xAI".to_string(), }); } - if provider == ApiProvider::Antigravity && !config.provider_uses_custom_endpoint(provider) { - let path = codewhale_config::default_agy_credentials_path(); - if config - .external_credential_read_grant( - provider, - codewhale_config::ExternalCredentialSource::AgyCli, - &path, - ) - .is_ok_and(|grant| { - crate::agy_credentials::antigravity_oauth_token_from_grant(&grant) - .ok() - .flatten() - .is_some() - }) - { - return CredentialResolution::found(CredentialSource::ExternalGrant { - cli: "Antigravity CLI".to_string(), - path: path.display().to_string(), - }); - } - probed.push(external_grant_probe( - "Antigravity CLI", - &path, - "codewhale auth external-consent --provider antigravity --mode read-only", - ctx, - )); - } if matches!( provider, ApiProvider::Deepseek | ApiProvider::DeepseekAnthropic diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index d583573694..118b02c527 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -246,8 +246,8 @@ pub const XAI_GROK_4_20_0309_NON_REASONING_MODEL: &str = "grok-4.20-0309-non-rea pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; pub const DEFAULT_MISTRAL_MODEL: &str = "mistral-code-latest"; pub const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1"; -pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "gemini-3-pro-preview"; -pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal"; +pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "legacy-antigravity-disabled"; +pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://legacy-antigravity.invalid"; pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview"; pub const DEFAULT_GOOGLE_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai/"; diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 523dd7768c..0a461dabd6 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -112,6 +112,110 @@ fn api_provider_metadata_helpers_follow_config_provider_metadata() { ); } +#[test] +fn retired_antigravity_is_not_selectable_but_has_an_actionable_tombstone() { + for identity in ["antigravity", "agy"] { + assert_eq!(ApiProvider::parse(identity), None, "{identity}"); + } + assert!(!ApiProvider::catalog().contains(&ApiProvider::Antigravity)); + assert!(!ApiProvider::sorted_for_display().contains(&ApiProvider::Antigravity)); + + for identity in ["antigravity", "agy"] { + assert!(is_legacy_antigravity_identity(identity), "{identity}"); + let config = Config { + provider: Some(identity.to_string()), + ..Config::default() + }; + let error = config + .validate() + .expect_err("a persisted legacy selection must fail before runtime setup") + .to_string(); + assert!(error.contains("non-runnable"), "{identity}: {error}"); + assert!( + error.contains("auth clear --provider antigravity"), + "{identity}: {error}" + ); + assert!(error.contains("provider `google`"), "{identity}: {error}"); + assert!(error.contains("GEMINI_API_KEY"), "{identity}: {error}"); + } +} + +#[test] +fn retired_antigravity_env_selection_is_refused_with_the_tombstone() { + let _guard = lock_test_env(); + let _deepseek_provider = EnvVarGuard::remove("DEEPSEEK_PROVIDER"); + for identity in ["antigravity", "agy"] { + let _provider = EnvVarGuard::set("CODEWHALE_PROVIDER", identity); + let mut config = Config::default(); + apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime); + let error = config + .validate() + .expect_err("CODEWHALE_PROVIDER must not select the tombstone") + .to_string(); + assert!(error.contains("non-runnable"), "{identity}: {error}"); + assert!(error.contains("GEMINI_API_KEY"), "{identity}: {error}"); + } +} + +#[test] +fn retired_antigravity_credentials_are_never_read_and_no_client_is_built() { + let _guard = lock_test_env(); + // The retired private credential plane: neither variable is consulted. + let _api_key = EnvVarGuard::set("ANTIGRAVITY_API_KEY", "must-never-be-read"); + let _adc = EnvVarGuard::set("AGY_ADC_AUTH", "must-never-be-read"); + assert!(ApiProvider::Antigravity.env_vars().is_empty()); + assert!( + ApiProvider::Antigravity.kind().is_some(), + "tombstone keeps a kind so it can deserialize" + ); + + // A persisted legacy selection resolves to its own tombstone identity + // instead of falling through to the DeepSeek default, so every + // fail-closed branch keyed on `api_provider()` is actually reachable. + for identity in ["antigravity", "agy"] { + let config = Config { + provider: Some(identity.to_string()), + ..Config::default() + }; + assert_eq!( + config.api_provider(), + ApiProvider::Antigravity, + "{identity}" + ); + } + + let mut config = Config { + provider: Some("antigravity".to_string()), + ..Config::default() + }; + let providers = config.providers.get_or_insert_with(Default::default); + providers.antigravity.api_key = Some("legacy-literal-left-behind".to_string()); + + // A leftover key in the legacy table is not a credential: readiness + // reports the tombstone as legacy, so `/model` never lists it. + assert_eq!( + crate::provider_readiness::credential_state_for_provider(&config, ApiProvider::Antigravity), + crate::provider_readiness::CredentialState::Legacy + ); + let inventory = crate::model_inventory::ModelInventory::from_config(&config); + assert!( + inventory + .candidates + .iter() + .all(|candidate| candidate.provider != ApiProvider::Antigravity), + "the tombstone must not surface as a model candidate" + ); + + // No transport can be constructed for the tombstone; the refusal happens + // before any network or credential I/O. + let error = crate::client::DeepSeekClient::new(&config) + .err() + .expect("constructing a client for the tombstone must fail") + .to_string(); + assert!(error.contains("non-runnable"), "{error}"); + assert!(error.contains("GEMINI_API_KEY"), "{error}"); +} + #[test] fn every_api_provider_variant_resolves_base_url_without_panicking() { // Guard against the historical `.expect("ApiProvider variant missing diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 0bea822d27..6e10723f36 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -39,16 +39,6 @@ const REPRESENTATIVE_PROJECT_AUTHORITY_BODY: &str = concat!( "- Record exact measurements and distinguish source proof from installed proof.\n", ); -#[test] -fn cloud_code_system_prompt_rejection_is_localized_from_its_semantic_error() { - let error = anyhow::Error::new( - crate::client::cloud_code::CloudCodeRequestError::SystemPromptUnsupported, - ); - let message = initial_stream_error_user_message("es-419", &error); - assert!(message.contains("No se envió nada"), "{message}"); - assert!(!message.contains("omit non-empty system"), "{message}"); -} - #[test] fn preview_request_error_preserves_non_semantic_context_chain() { let error = anyhow::Error::msg("root cause").context("request preparation failed"); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 2e3faf1021..9fcd06a635 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -50,31 +50,18 @@ struct StreamOutcome { stream_error: Option, } -fn localized_request_preparation_error(locale_tag: &str, error: &anyhow::Error) -> Option { - if matches!( - error.downcast_ref::(), - Some(crate::client::cloud_code::CloudCodeRequestError::SystemPromptUnsupported) - ) { - return Some( - crate::localization::tr( - crate::localization::resolve_locale(locale_tag), - crate::localization::MessageId::CloudCodeSystemPromptUnsupported, - ) - .into_owned(), - ); - } - None -} - -pub(super) fn initial_stream_error_user_message(locale_tag: &str, error: &anyhow::Error) -> String { - localized_request_preparation_error(locale_tag, error).unwrap_or_else(|| error.to_string()) +pub(super) fn initial_stream_error_user_message( + _locale_tag: &str, + error: &anyhow::Error, +) -> String { + error.to_string() } pub(super) fn preview_request_error_user_message( - locale_tag: &str, + _locale_tag: &str, error: &anyhow::Error, ) -> String { - localized_request_preparation_error(locale_tag, error).unwrap_or_else(|| format!("{error:#}")) + format!("{error:#}") } fn approval_intent_summary(text: &str) -> Option { diff --git a/crates/tui/src/external_credentials.rs b/crates/tui/src/external_credentials.rs index 9aaec31a97..07e6be4c12 100644 --- a/crates/tui/src/external_credentials.rs +++ b/crates/tui/src/external_credentials.rs @@ -94,18 +94,6 @@ pub(crate) fn read_to_string(grant: &ExternalCredentialReadGrant) -> Result Result { - open_secure_regular_file(path, false).with_context(|| { - format!( - "securely opening external credential file {}", - codewhale_config::quote_os_path(path) - ) - }) -} - /// Read one Codewhale-owned credential file through the same no-follow, /// bounded I/O boundary used for external grants. On Unix the opened handle /// must belong to the effective user and have no group/other permission bits. diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a99d88ad65..a444a13b39 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -21,7 +21,6 @@ use rust_i18n::i18n; i18n!("locales", fallback = ["en"]); mod acp_server; -mod agy_credentials; mod approval_log; mod artifacts; mod audit; @@ -859,6 +858,9 @@ fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Resu if provider_arg.is_empty() { return Ok(()); } + if crate::config::is_legacy_antigravity_identity(provider_arg) { + bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE); + } if config .providers .as_ref() @@ -4290,6 +4292,17 @@ fn doctor_should_probe_api( probes.should_probe_api(local) } +/// Providers whose credential *presence* `codewhale doctor` reports. +/// +/// The retired Antigravity identity is a non-runnable tombstone kept only so +/// legacy tables deserialize and clear; doctor never advertises it as a slot. +fn doctor_api_key_providers() -> impl Iterator { + crate::config::ApiProvider::all() + .iter() + .copied() + .filter(|provider| *provider != crate::config::ApiProvider::Antigravity) +} + /// Doctor must never turn credential inspection into a refresh/write path. /// OAuth connectivity is exercised by an ordinary user request instead; /// doctor limits itself to non-mutating readiness inspection. @@ -4461,7 +4474,7 @@ async fn run_doctor( // Per-provider state: env + config file only (no values printed). // Keep doctor/status prompt-free and credential-value-free even for // unsigned rebuilt binaries. - for provider in crate::config::ApiProvider::all().iter().copied() { + for provider in doctor_api_key_providers() { let slot = provider.as_str(); let provider_config = config.provider_config_for(provider); let config_declared = provider_config.is_some_and(|entry| { diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 0863644361..aa1c8fdace 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -335,7 +335,6 @@ pub enum MessageId { ModelPickerAutoLocalHint, ModelPickerAutoLastRoute, AutoRouteSelectedToast, - CloudCodeSystemPromptUnsupported, HelpTitle, HelpSubtitle, HelpFilterPlaceholder, @@ -2320,7 +2319,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ModelPickerAutoLocalHint, MessageId::ModelPickerAutoLastRoute, MessageId::AutoRouteSelectedToast, - MessageId::CloudCodeSystemPromptUnsupported, MessageId::HelpTitle, MessageId::HelpSubtitle, MessageId::HelpFilterPlaceholder, @@ -5058,7 +5056,6 @@ mod tests { MessageId::ProviderCustomFormBaseUrl, MessageId::ProviderCustomFormModel, MessageId::ConfigHintProviderUrl, - MessageId::CloudCodeSystemPromptUnsupported, MessageId::SessionsOpenedHistory, MessageId::SessionsTimeJustNow, ]; diff --git a/crates/tui/src/main/tests.rs b/crates/tui/src/main/tests.rs index 10fcb76fc5..06a0313315 100644 --- a/crates/tui/src/main/tests.rs +++ b/crates/tui/src/main/tests.rs @@ -3,6 +3,18 @@ use clap::Parser; use std::fs; use tempfile::TempDir; +#[test] +fn doctor_api_key_rows_never_name_the_retired_antigravity_slot() { + let rows: Vec = doctor_api_key_providers().collect(); + assert!(!rows.contains(&crate::config::ApiProvider::Antigravity)); + assert!(rows.contains(&crate::config::ApiProvider::Deepseek)); + assert!(rows.contains(&crate::config::ApiProvider::Google)); + assert!( + rows.iter() + .all(|provider| provider.as_str() != "antigravity") + ); +} + #[test] fn offline_doctor_loads_never_materialize_secret_environment_overrides() { let _guard = crate::test_support::lock_test_env(); diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index b1e3e1512d..315069ee2f 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -179,7 +179,12 @@ pub(crate) fn credential_state_for_provider( CredentialState::MissingKey }; } - if provider.kind().is_none() { + // The retired Antigravity identity keeps a `ProviderKind` only so legacy + // `[providers.antigravity]` tables deserialize and can be cleared. A + // leftover `api_key` in that table must never read as `Saved`: the route + // is a non-runnable tombstone, so `/model`, setup, and readiness treat it + // as legacy regardless of what the table contains. + if provider == ApiProvider::Antigravity || provider.kind().is_none() { return CredentialState::Legacy; } if provider == ApiProvider::Custom { @@ -296,21 +301,6 @@ pub(crate) fn credential_state_for_provider( return CredentialState::MissingKey; } - if provider == ApiProvider::Antigravity { - if crate::config::has_api_key_for(config, provider) { - return CredentialState::Saved; - } - if provider != config.api_provider() - && config.external_credential_read_consent_configured( - provider, - codewhale_config::ExternalCredentialSource::AgyCli, - ) - { - return CredentialState::ExternalConsent; - } - return CredentialState::MissingKey; - } - if crate::config::has_api_key_for(config, provider) { CredentialState::Saved } else if matches!( diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index a49ceffa30..98dfc27b65 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -649,6 +649,9 @@ pub(crate) fn resolve_runtime_route_for_identity( identity: &ProviderIdentity, model_selector: Option<&str>, ) -> Result { + if identity.provider == ApiProvider::Antigravity { + return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string()); + } let identity = config.resolve_persisted_provider_identity( Some(identity.provider.as_str()), identity.persisted_id(), diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1e10ba1d9e..d521f81e14 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -423,7 +423,7 @@ Anthropic providers, set `provider = ""` or pass `ollama`, `ollama-cloud`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`, -`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`, `antigravity`, +`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`, `edenai`, and `custom` (a user-defined OpenAI-compatible endpoint via `[providers.]`). For the provider-by-provider registry, including wire protocol, auth variables, @@ -1790,7 +1790,7 @@ reasoning contract, and all four membership ids omit generic sampling fields. ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `antigravity`, `edenai`, or `custom`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go Chat Completions route at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route. +- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go Chat Completions route at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route. - `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model is resolved through the curated Zen catalog: GPT uses Responses, Claude/Qwen use Anthropic Messages, and the documented DeepSeek/MiniMax/GLM/Kimi/Grok/free rows use Chat Completions. Gemini and unknown models fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog). - `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 5c9484a806..10f8eda5a8 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -42,7 +42,7 @@ Sources to keep in sync: ## Provider Selection -The canonical provider IDs are the 42 entries of `ProviderKind::ALL` +The canonical selectable provider IDs are the entries of `ProviderKind::ALL` (`crates/config/src/provider_kind.rs`), in that order: `deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, @@ -51,7 +51,7 @@ The canonical provider IDs are the 42 entries of `ProviderKind::ALL` `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, -`google`, `antigravity`, `edenai`, and `custom`. +`google`, `edenai`, and `custom`. `deepseek-anthropic` is *not* on this list — it is a wire dialect of `deepseek`, reached with `wire = "anthropic"`, not a separate route to select. @@ -88,6 +88,16 @@ key-scoped and remains isolated from every other provider's live snapshot. Fresh shared config writes to `~/.codewhale/config.toml`. Existing `~/.deepseek/config.toml` files are still read for compatibility. +### Legacy Antigravity tombstone + +Antigravity is not a Codewhale provider and cannot be selected or run. Existing +legacy Antigravity provider state is recognized only as a non-runnable migration +tombstone. Run `codewhale auth clear --provider antigravity` to forget only +Codewhale-owned legacy configuration and consent metadata. This does not sign +out of, revoke, read, or otherwise alter any official Google or Antigravity +session. For Gemini, select the supported `google` provider and supply +`GEMINI_API_KEY`. + ### Wire Protocol Compatibility Provider selection is explicit. A model string prefix such as @@ -144,7 +154,6 @@ the listed provider env vars. | `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` | | `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` | | `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | -| `antigravity` | `[providers.antigravity]` | none — requests fail closed; credential import only | `ANTIGRAVITY_API_KEY` (key plane); `AGY_ADC_AUTH` (process env) | | `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` | | `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | | `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | @@ -499,7 +508,6 @@ configuration path instead of guessing a vendor page. | `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. | | `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) | | `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. | -| `antigravity` | Sign in with the official `agy` CLI (1.1.13). Codewhale can read that login's OAuth token read-only from the exact pinned `state.vscdb` after `codewhale auth external-consent`; it never writes or refreshes the file. An `ANTIGRAVITY_API_KEY` or the process's `AGY_ADC_AUTH` wins over the file. The cloud-code wire protocol is not implemented: requests fail closed with an actionable message — use `google` for Gemini models. | | `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) | | `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. | | `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. | @@ -579,7 +587,6 @@ overlay and lets DSH resolve its own keys. | `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = "https://api.moonshot.cn/v1"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. | -| `antigravity` | `[providers.antigravity]` | `ANTIGRAVITY_API_KEY` | `ANTIGRAVITY_BASE_URL`; default `https://cloudcode-pa.googleapis.com/v1internal` | none advertised — requests fail closed until the cloud-code wire protocol exists | Antigravity (`agy` 1.1.13) credential plane: consent-gated read-only import of the official CLI's `state.vscdb` OAuth token (`antigravityUnifiedStateSync.oauthToken`), pinned to the exact per-OS app-profile path. The store is opened read-only through the secure no-follow boundary with an inode recheck; Codewhale never writes, refreshes, or re-authenticates. Precedence: `ANTIGRAVITY_API_KEY` > process `AGY_ADC_AUTH` > consented file. Not an embed of any other harness. No live calls made in this environment. | | `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. | | `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = "GLM-5.3-Flash"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. | | `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. | diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 0373b866f4..97ec2a4c65 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -30,9 +30,20 @@ TUI_CONFIG_MODELS_RS = ROOT / "crates" / "tui" / "src" / "config" / "models.rs" AGENT_RS = ROOT / "crates" / "agent" / "src" / "lib.rs" PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" +CONFIGURATION_MD = ROOT / "docs" / "CONFIGURATION.md" +WEB_FACTS_LIB = ROOT / "web" / "scripts" / "facts-lib.mjs" +WEB_FACTS_DRIFT = ROOT / "web" / "lib" / "facts-drift.ts" +WEB_FACTS_GENERATED = ROOT / "web" / "lib" / "facts.generated.ts" +README_MD = ROOT / "README.md" +CONFIG_EXAMPLE_TOML = ROOT / "config.example.toml" +TUI_PROVIDER_READINESS_RS = ROOT / "crates" / "tui" / "src" / "provider_readiness.rs" +TUI_LIB_RS = ROOT / "crates" / "tui" / "src" / "lib.rs" API_PROVIDER_ONLY_IDS = {"deepseek-cn"} +LEGACY_PROVIDER_TOMBSTONE_IDS = {"antigravity"} +LEGACY_PROVIDER_TOMBSTONE_TABLES = {"antigravity"} +LEGACY_PROVIDER_SELECTION_IDS = {"antigravity", "agy"} # `custom` is the dynamic OpenAI-compatible meta-provider (#1519): a single # catch-all `[providers.custom]` table that backs arbitrary user-defined @@ -172,6 +183,24 @@ def provider_kind_ids(config_rs: str) -> dict[str, str]: return ids +def provider_kind_catalog_ids( + provider_kind_rs: str, variant_to_id: dict[str, str] +) -> set[str]: + catalog = re.search( + r"pub const ALL:\s*\[Self;\s*\d+\]\s*=\s*\[(.*?)\];", + provider_kind_rs, + flags=re.DOTALL, + ) + if catalog is None: + raise ValueError("crates/config/src/provider_kind.rs: missing ProviderKind::ALL") + variants = set(re.findall(r"Self::(\w+)", catalog.group(1))) + catalog_variant_to_id = {**variant_to_id, "Custom": "custom"} + missing = variants - set(catalog_variant_to_id) + if missing: + raise ValueError(f"ProviderKind::ALL uses unknown variants: {sorted(missing)}") + return {catalog_variant_to_id[variant] for variant in variants} + + def api_provider_ids(tui_config_rs: str) -> dict[str, str]: # ApiProvider ids derive from ProviderKind ids (via delegation to .kind().as_str()) # plus the legacy "deepseek-cn" variant that exists only in ApiProvider. @@ -207,6 +236,268 @@ def shipped_provider_tables(providers_md: str) -> set[str]: return set(re.findall(r"\|\s*`\[providers\.([a-z0-9_]+)\]`\s*\|", table)) +def documented_selectable_provider_ids(providers_md: str) -> set[str]: + marker = require_index(providers_md, "in that order:", "docs/PROVIDERS.md") + start = require_index(providers_md, "\n\n", "provider selection list", marker) + 2 + end = require_index(providers_md, "\n\n", "provider selection list", start) + return set(re.findall(r"`([^`]+)`", providers_md[start:end])) + + +def report_provider_kind_selector_contract(provider_kind_rs: str) -> list[str]: + start = require_index( + provider_kind_rs, + "pub fn parse(value: &str) -> Option", + "ProviderKind::parse", + ) + end = require_index( + provider_kind_rs, "pub fn parse_config_identity", "ProviderKind::parse", start + ) + selector = provider_kind_rs[start:end] + if "Self::ALL" not in selector and "Self::all()" not in selector: + return [ + "ProviderKind::parse must gate registry aliases through the selectable " + "ProviderKind::ALL catalog" + ] + return [] + + +def report_tui_catalog_contract(tui_config_rs: str) -> list[str]: + start = require_index( + tui_config_rs, "pub fn catalog() -> &'static [Self]", "ApiProvider::catalog" + ) + end = require_index( + tui_config_rs, "pub fn catalog_identity", "ApiProvider::catalog", start + ) + catalog = tui_config_rs[start:end] + errors: list[str] = [] + if ( + "codewhale_config::ProviderKind::ALL" not in catalog + or "Antigravity" in catalog + ): + errors.append( + "ApiProvider::catalog must derive from ProviderKind::ALL without " + "legacy Antigravity" + ) + + impl_start = require_index(tui_config_rs, "impl ApiProvider", "ApiProvider impl") + parse_start = require_index( + tui_config_rs, + "pub fn parse(value: &str) -> Option", + "ApiProvider::parse", + impl_start, + ) + parse_end = require_index( + tui_config_rs, "pub fn as_str", "ApiProvider::parse", parse_start + ) + selector = tui_config_rs[parse_start:parse_end] + if ( + "is_legacy_antigravity_identity(trimmed)" not in selector + or "return None" not in selector + ): + errors.append( + "ApiProvider::parse must reject both retired Antigravity config identities" + ) + return errors + + +def report_tombstone_runtime_contract( + provider_kind_rs: str, tui_provider_readiness_rs: str, tui_lib_rs: str +) -> list[str]: + """The tombstone must resolve under every legacy spelling and never read + as a credentialed or advertised slot on a running-product surface.""" + + errors: list[str] = [] + start = require_index( + provider_kind_rs, + "pub fn parse_config_identity(value: &str) -> Option", + "ProviderKind::parse_config_identity", + ) + end = require_index( + provider_kind_rs, "pub fn is_siliconflow", "ProviderKind::parse_config_identity", start + ) + config_identity = provider_kind_rs[start:end] + if "parse_retired_alias" not in config_identity: + errors.append( + "ProviderKind::parse_config_identity must resolve retired registry aliases " + "(`agy`) so every selection surface can name the tombstone" + ) + + if ( + "provider == ApiProvider::Antigravity || provider.kind().is_none()" + not in tui_provider_readiness_rs + ): + errors.append( + "provider_readiness::credential_state_for_provider must classify " + "ApiProvider::Antigravity as CredentialState::Legacy" + ) + + if "for provider in doctor_api_key_providers()" not in tui_lib_rs or ( + "*provider != crate::config::ApiProvider::Antigravity" not in tui_lib_rs + ): + errors.append( + "`codewhale doctor` API Keys rows must iterate doctor_api_key_providers() " + "with the retired Antigravity slot filtered out" + ) + return errors + + +def report_antigravity_public_contract( + providers_md: str, + configuration_md: str, + web_facts_lib: str, + web_facts_drift: str, + web_facts_generated: str, + readme_md: str, + config_example_toml: str, +) -> list[str]: + """Keep the retired provider as one safe, non-runnable docs tombstone.""" + + errors: list[str] = [] + heading = "### Legacy Antigravity tombstone" + heading_count = providers_md.count(heading) + if heading_count != 1: + errors.append( + "docs/PROVIDERS.md must contain exactly one legacy Antigravity tombstone " + f"heading (found {heading_count})" + ) + tombstone = "" + outside_tombstone = providers_md + else: + start = providers_md.index(heading) + next_heading = re.search(r"\n#{1,3} ", providers_md[start + len(heading) :]) + end = ( + len(providers_md) + if next_heading is None + else start + len(heading) + next_heading.start() + ) + tombstone = providers_md[start:end] + outside_tombstone = providers_md[:start] + providers_md[end:] + + normalized_tombstone = " ".join(tombstone.split()) + required_tombstone_copy = [ + "not a Codewhale provider", + "cannot be selected or run", + "non-runnable migration tombstone", + "`codewhale auth clear --provider antigravity`", + "Codewhale-owned legacy configuration and consent metadata", + "does not sign out of, revoke, read, or otherwise alter any official Google or Antigravity session", + "supported `google` provider", + "`GEMINI_API_KEY`", + ] + missing_tombstone_copy = [ + required + for required in required_tombstone_copy + if required not in normalized_tombstone + ] + if missing_tombstone_copy: + errors.append( + "legacy Antigravity tombstone is missing required safety or migration copy " + f"({len(missing_tombstone_copy)} checks failed)" + ) + clear_command = "`codewhale auth clear --provider antigravity`" + legacy_provider_forms = [ + match.lower() + for match in re.findall( + r"--provider\s+(antigravity|agy)\b", providers_md, flags=re.IGNORECASE + ) + ] + if providers_md.count(clear_command) != 1 or legacy_provider_forms != [ + "antigravity" + ]: + errors.append( + "docs/PROVIDERS.md must contain the Codewhale-owned Antigravity " + "clear command as its only --provider antigravity/agy form" + ) + setup_guidance = re.search( + r"\bagy\b|\boauth\b|\blog(?:in|\s+in)\b|\bsign\s+in\b|" + r"\bimport\b|\bexternal-consent\b|/provider\s+(?:antigravity|agy)\b|" + r"CODEWHALE_PROVIDER\s*=\s*(?:antigravity|agy)\b", + tombstone, + flags=re.IGNORECASE, + ) + if setup_guidance: + errors.append( + "legacy Antigravity tombstone contains login, OAuth import, consent, " + "or provider-selection guidance" + ) + + if re.search(r"\b(?:antigravity|agy)\b", outside_tombstone, flags=re.IGNORECASE): + errors.append( + "docs/PROVIDERS.md mentions Antigravity/agy outside its legacy tombstone" + ) + if re.search(r"\b(?:antigravity|agy)\b", configuration_md, flags=re.IGNORECASE): + errors.append("docs/CONFIGURATION.md advertises retired Antigravity state") + if re.search(r"\b(?:antigravity|agy)\b", readme_md, flags=re.IGNORECASE): + errors.append("README.md advertises retired Antigravity state") + if re.search(r"\b(?:antigravity|agy)\b", config_example_toml, flags=re.IGNORECASE): + errors.append("config.example.toml advertises retired Antigravity state") + if "[providers.google]" not in config_example_toml or not re.search( + r"GEMINI_API_KEY", config_example_toml + ): + errors.append( + "config.example.toml must document the supported `google` Gemini route " + "with GEMINI_API_KEY" + ) + + forbidden_markers = { + "Antigravity API-key environment guidance": "ANTIGRAVITY_API_KEY", + "Antigravity ADC environment guidance": "AGY_ADC_AUTH", + "Antigravity base-URL environment guidance": "ANTIGRAVITY_BASE_URL", + "Antigravity model environment guidance": "ANTIGRAVITY_MODEL", + "private cloud-code endpoint guidance": "cloudcode-pa", + "private cloud-code protocol guidance": "cloud-code", + "official CLI credential-store guidance": "state.vscdb", + "official CLI OAuth-state guidance": "antigravityUnifiedStateSync", + "runnable legacy provider selection": 'provider = "antigravity"', + "runnable legacy provider table": "[providers.antigravity]", + } + public_sources = { + "docs/PROVIDERS.md": providers_md, + "docs/CONFIGURATION.md": configuration_md, + "web/scripts/facts-lib.mjs": web_facts_lib, + "web/lib/facts-drift.ts": web_facts_drift, + "web/lib/facts.generated.ts": web_facts_generated, + "README.md": readme_md, + "config.example.toml": config_example_toml, + } + for context, source in public_sources.items(): + for description, marker in forbidden_markers.items(): + if marker.lower() in source.lower(): + errors.append(f"{context} contains forbidden {description}") + + for context, source, exclusion_name, exclusion_filter in [ + ( + "web/scripts/facts-lib.mjs", + web_facts_lib, + "EXCLUDED_PROVIDERS", + ".filter((v) => !EXCLUDED_PROVIDERS.has(v))", + ), + ( + "web/lib/facts-drift.ts", + web_facts_drift, + "EXCLUDED", + ".filter((v) => !EXCLUDED.has(v))", + ), + ]: + exclusion_decl = re.search( + rf"const\s+{exclusion_name}\s*=\s*new Set\(\[[^\]]*\"Antigravity\"", + source, + ) + if exclusion_decl is None or exclusion_filter not in source: + errors.append(f"{context} does not explicitly exclude legacy Antigravity") + if re.search(r"^\s*Antigravity\s*:", source, flags=re.MULTILINE): + errors.append(f"{context} maps legacy Antigravity to public provider facts") + if re.search(r"\bagy\b", source, flags=re.IGNORECASE): + errors.append(f"{context} exposes the legacy agy alias") + + if re.search( + r"\b(?:antigravity|agy)\b", web_facts_generated, flags=re.IGNORECASE + ): + errors.append("web/lib/facts.generated.ts exposes legacy Antigravity/agy") + + return errors + + def static_registry_provider_rows(providers_md: str) -> set[str]: table = markdown_section(providers_md, "## Static Model Registry") return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE)) @@ -229,7 +520,9 @@ def default_strings(tui_config_rs: str) -> set[str]: r'const\s+(DEFAULT_[A-Z0-9_]+(?:MODEL|BASE_URL)):\s*&str\s*=\s*"([^"]+)"', sources, ): - if name == "DEFAULT_DEEPSEEKCN_BASE_URL": + if name == "DEFAULT_DEEPSEEKCN_BASE_URL" or name.startswith( + "DEFAULT_ANTIGRAVITY_" + ): continue defaults.add(value) if not defaults: @@ -368,26 +661,66 @@ def provider_table_name(provider_id: str) -> str: def main() -> int: try: config_rs = read(CONFIG_RS) + provider_kind_rs = read(PROVIDER_KIND_RS) tui_config_rs = read(TUI_CONFIG_RS) agent_rs = read(AGENT_RS) providers_md = read(PROVIDERS_MD) + configuration_md = read(CONFIGURATION_MD) + web_facts_lib = read(WEB_FACTS_LIB) + web_facts_drift = read(WEB_FACTS_DRIFT) + web_facts_generated = read(WEB_FACTS_GENERATED) + readme_md = read(README_MD) + config_example_toml = read(CONFIG_EXAMPLE_TOML) + tui_provider_readiness_rs = read(TUI_PROVIDER_READINESS_RS) + tui_lib_rs = read(TUI_LIB_RS) variant_to_id = provider_kind_ids(config_rs) canonical_ids = set(variant_to_id.values()) + selectable_provider_ids = provider_kind_catalog_ids( + provider_kind_rs, variant_to_id + ) live_api_provider_ids = set(api_provider_ids(tui_config_rs).values()) - expected_tables = {provider_table_name(provider_id) for provider_id in canonical_ids} + public_provider_ids = canonical_ids - LEGACY_PROVIDER_TOMBSTONE_IDS + expected_tables = { + provider_table_name(provider_id) for provider_id in public_provider_ids + } + runtime_tables = expected_tables | LEGACY_PROVIDER_TOMBSTONE_TABLES errors: list[str] = [] errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids) + errors += report_provider_kind_selector_contract(provider_kind_rs) + errors += report_tui_catalog_contract(tui_config_rs) + errors += report_tombstone_runtime_contract( + provider_kind_rs, tui_provider_readiness_rs, tui_lib_rs + ) + errors += report_set( + "legacy provider identities in ProviderKind::ALL", + set(), + selectable_provider_ids & LEGACY_PROVIDER_SELECTION_IDS, + ) + errors += report_set( + "documented selectable provider IDs", + selectable_provider_ids, + documented_selectable_provider_ids(providers_md), + ) errors += report_huggingface_coverage(config_rs, tui_config_rs, providers_md) + errors += report_antigravity_public_contract( + providers_md, + configuration_md, + web_facts_lib, + web_facts_drift, + web_facts_generated, + readme_md, + config_example_toml, + ) errors += report_set( "shipped provider rows", - canonical_ids, + public_provider_ids, shipped_provider_rows(providers_md), ) errors += report_set( "provider TOML tables", - expected_tables, + runtime_tables, provider_tables(config_rs) - META_PROVIDER_TABLES, ) errors += report_set( diff --git a/web/lib/facts-drift.ts b/web/lib/facts-drift.ts index c5662ba97d..175f817e2f 100644 --- a/web/lib/facts-drift.ts +++ b/web/lib/facts-drift.ts @@ -153,8 +153,8 @@ function deriveProvidersFromConfig(cfg: string): ProviderFact[] { // Log loudly on unmapped variants so a new provider can never be silently // dropped from the drift-derived facts again. DeepseekCN (#1104), the // dynamic Custom meta-provider (#1519, user-defined endpoints), and - // Antigravity (off the website 44 until the cloud-code wire is a - // first-class advertised outbound route) are the deliberate exclusions. + // Antigravity (a non-runnable legacy config tombstone, never a website + // provider) are the deliberate exclusions. const EXCLUDED = new Set(["DeepseekCN", "Custom", "Antigravity"]); const unmapped = variants.filter((v) => !EXCLUDED.has(v) && !labelMap[v]); if (unmapped.length > 0) { @@ -163,7 +163,10 @@ function deriveProvidersFromConfig(cfg: string): ProviderFact[] { "Add them to labelMap here AND PROVIDER_LABEL_MAP in web/scripts/facts-lib.mjs (or to EXCLUDED if intentionally hidden).", ); } - return variants.map((v) => labelMap[v]).filter(Boolean); + return variants + .filter((v) => !EXCLUDED.has(v)) + .map((v) => labelMap[v]) + .filter(Boolean); } function deriveDefaultModel(cfg: string): string | null { diff --git a/web/scripts/facts-lib.mjs b/web/scripts/facts-lib.mjs index 67afc930a0..1217dcdb18 100644 --- a/web/scripts/facts-lib.mjs +++ b/web/scripts/facts-lib.mjs @@ -61,8 +61,8 @@ export function deriveSandboxBackendsFromSource(source) { * * Excluded variants: DeepseekCN (not wired through shared ProviderKind, * #1104), Custom (dynamic meta-provider, #1519), and Antigravity - * (kept off the website 44 until the cloud-code wire is a first-class - * advertised outbound route). + * (a non-runnable legacy config tombstone, permanently excluded from public + * provider facts). */ const PROVIDER_LABEL_MAP = { Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, @@ -115,8 +115,8 @@ const PROVIDER_LABEL_MAP = { // DeepseekCN: not wired through shared ProviderKind (#1104). // Custom: the dynamic OpenAI-compatible meta-provider (#1519) — a runtime // catch-all for user-defined endpoints, not a website-listable provider. -// Antigravity: credential-import + text-only cloud-code wire. Kept off the -// website 44-count until it is a first-class advertised outbound route. +// Antigravity is a non-runnable legacy config tombstone, never a website +// provider. const EXCLUDED_PROVIDERS = new Set(["DeepseekCN", "Custom", "Antigravity"]); function providerEnumVariants() { @@ -151,7 +151,10 @@ export function deriveProviders() { // The generator stays lenient and returns what it can map; the hard gate // lives in check-facts.mjs via unmappedProviderVariants() (#3772). } - return variants.map((v) => PROVIDER_LABEL_MAP[v]).filter(Boolean); + return variants + .filter((v) => !EXCLUDED_PROVIDERS.has(v)) + .map((v) => PROVIDER_LABEL_MAP[v]) + .filter(Boolean); } export function deriveDefaultModel() {