Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Read the [configuration docs here](/_docs/config/index.mdx).
I tried crabcode specifically for these providers:

- [x] **openai** (both API key and OAuth, thank you OpenAI for supporting OSS harnesses!)
- [x] **Google Antigravity / Gemini** (browser OAuth with a pasted callback URL)
- [x] **xAI / Grok** (API key and SuperGrok/X Premium OAuth, thank you xAI for openly supporting OSS harnesses: based on [OpenClaw](https://x.ai/news/grok-openclaw), [OpenCode](https://x.ai/news/grok-opencode), [KiloCode](https://x.ai/news/grok-kilocode), [Hermes](https://x.ai/news/grok-hermes))
- [x] **opencode-zen** and **opencode-go**
- [x] **nano-gpt**
Expand All @@ -143,9 +144,32 @@ I tried crabcode specifically for these providers:

> I might work harder to support these in the future.

- Gemini - It's OAuth + also very unsure. So currently no.
- Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry.

### Google Antigravity sign-in

Before starting Crabcode, provide an authorized OAuth application's client
credentials through `CRABCODE_ANTIGRAVITY_CLIENT_ID` and
`CRABCODE_ANTIGRAVITY_CLIENT_SECRET`. Both are required at runtime for browser
sign-in and token refresh. There are no bundled client credentials or build-time
fallbacks. Keep these values in your local environment or secret manager, not
in committed configuration or command-line arguments. The application's
registered redirect URI must be `http://localhost:51121/oauth-callback`.

The project maintainer must arrange an OAuth client authorized for the required
Google scopes and Antigravity service. An arbitrary Google Cloud OAuth client
does not guarantee access. Existing refresh tokens are tied to their original
client; reconnect if you change clients.

Run `/connect`, choose **Google Antigravity**, then **Gemini via Antigravity (browser)**.
Open the sign-in link, authorize your account, and paste the full
`http://localhost:51121/oauth-callback?...` URL into the callback input. The
localhost page may fail to load; copy the address bar URL anyway. Press Enter
to submit, or Esc to cancel. Credentials are saved and refreshed automatically.

This is an unofficial integration. Google account eligibility, service terms,
and account restrictions apply. The API-key option remains available.

## Performance

Like any benchmark, please take this with a grain of salt. I have a cherry-picked benchmark for 1 purpose only: "Is crabcode at least as reliable as codex/opencode?". It's a useful feedback loop for my Crabcode trying to improve itself. We're honestly only chasing for at least: best parity if not better perf while having massively better TUI UX based on my personal preferences. Here is my most recent run (Jul 13, 2026):
Expand Down
71 changes: 66 additions & 5 deletions src/acp/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,41 @@ pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
bail!("provider login requires an interactive terminal; run crabcode acp --login in a terminal");
}

fn open_url(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
.map_err(Into::into)
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
.map_err(Into::into)
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map(|_| ())
.map_err(Into::into)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
Ok(())
}
}
eprintln!("Connect a provider (no chat will be started).");
eprintln!(
"Provider examples: openai, anthropic, xai, ollama; custom provider IDs are also accepted."
"Provider examples: openai, anthropic, xai, google (Google Antigravity), ollama; custom provider IDs are also accepted."
);
let provider = prompt("Provider ID (empty to cancel): ")?;
validate_provider_id(&provider)?;
Expand Down Expand Up @@ -61,10 +93,27 @@ pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
let method = if matches!(provider.as_str(), "openai" | "xai") {
eprintln!("1: browser OAuth 2: headless/device OAuth 3: API key");
prompt("Login method (empty to cancel): ")?
} else if provider == "google" {
eprintln!("1: Google Antigravity browser OAuth 3: API key");
prompt("Login method (empty to cancel): ")?
} else {
"3".into()
};
let credentials = match (provider.as_str(), method.as_str()) {
("google", "1") => {
let authorization = crate::auth::antigravity_oauth::BrowserAuthorization::new()?;
eprintln!("This unofficial third-party integration uses Antigravity. Google account eligibility, service terms, and account restrictions apply.\nOpen this URL in a browser:\n{}", authorization.authorization_url());
let _ = open_url(authorization.authorization_url());
eprintln!("The localhost page may be unavailable; copy its complete URL from the address bar anyway.");
let callback =
read_hidden("Complete callback URL (hidden; Esc to cancel): ", 16 * 1024)?;
oauth_config(
authorization
.complete(&callback)
.await
.map_err(|_| anyhow::anyhow!("OAuth login failed; retry provider login"))?,
)
}
("openai", "1") => oauth_config(
crate::auth::openai_oauth::authorize_browser()
.await
Expand Down Expand Up @@ -137,6 +186,7 @@ fn oauth_config(credentials: crate::auth::OAuthCredentials) -> AuthConfig {
expires: credentials.expires,
account_id: credentials.account_id,
enterprise_url: credentials.enterprise_url,
project_id: credentials.project_id,
}
}

Expand All @@ -161,9 +211,13 @@ impl Drop for RawMode {
}

fn read_api_key() -> Result<String> {
eprint!("API key (hidden; Esc to cancel): ");
read_hidden("API key (hidden; Esc to cancel): ", 16 * 1024)
}

fn read_hidden(label: &str, max_bytes: usize) -> Result<String> {
eprint!("{label}");
io::stderr().flush()?;
enable_raw_mode().context("failed to hide API key input")?;
enable_raw_mode().context("failed to hide credential input")?;
let guard = RawMode;
let mut value = String::new();
loop {
Expand All @@ -182,15 +236,20 @@ fn read_api_key() -> Result<String> {
KeyCode::Backspace => {
value.pop();
}
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => value.push(ch),
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if value.len() + ch.len_utf8() > max_bytes {
bail!("credential input is too long");
}
value.push(ch);
}
_ => {}
}
}
drop(guard);
eprintln!();
let value = value.trim().to_owned();
if value.is_empty() {
bail!("API key cannot be empty");
bail!("credential input cannot be empty");
}
Ok(value)
}
Expand Down Expand Up @@ -223,6 +282,7 @@ mod tests {
expires: 0,
account_id: None,
enterprise_url: None,
project_id: None,
});
assert!(usable_credentials(&auth));
assert!(!usable_credentials(&AuthConfig::OAuth {
Expand All @@ -231,6 +291,7 @@ mod tests {
expires: 0,
account_id: None,
enterprise_url: None,
project_id: None,
}));
}
}
3 changes: 3 additions & 0 deletions src/acp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,9 @@ impl AcpService {
&live_usage,
)?;
}
crate::llm::ChunkMessage::ProviderReplay(item) => {
crate::llm::antigravity::persist_replay(&mut assistant, item);
}
crate::llm::ChunkMessage::Reasoning(text) => {
assistant.append_reasoning(&text);
send_text(connection, session_id, &message_id, text, true)?;
Expand Down
3 changes: 3 additions & 0 deletions src/agent/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct OpenAIRequestOptions {
/// Sticky prompt-cache routing key (Responses / chat-completions).
/// Typically the crabcode session id.
pub prompt_cache_key: Option<String>,
/// Google Antigravity OAuth project routing identifier.
pub antigravity_project: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -100,6 +102,7 @@ pub enum ProviderKind {
OpenAI,
OpenAICompatible,
Anthropic,
Antigravity,
}

static LLM_SESSION: OnceLock<RwLock<Option<LlmSessionConfig>>> = OnceLock::new();
Expand Down
63 changes: 62 additions & 1 deletion src/agent/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ pub async fn run_subagent(
use futures::StreamExt;
use std::collections::HashMap;

let mut session = resolve_subagent_session(&agent, parent_session, sender.as_ref()).await?;
let mut session = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err("Subagent cancelled".to_string()),
session = resolve_subagent_session(&agent, parent_session, sender.as_ref()) => session?,
};
// Child cache key on purpose: subagents have a different system prompt and tool set,
// so parent-prefix reuse would miss (and risk sticky-routing pollution). See
// SessionAffinity::child_session docs.
Expand Down Expand Up @@ -443,6 +447,40 @@ async fn start_subagent_stream(
);

match session.provider_kind {
ProviderKind::Antigravity => {
let access = session
.api_key
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Missing Google Antigravity OAuth access token".to_string())?;
let project = session
.openai_options
.antigravity_project
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Missing Google Antigravity OAuth project".to_string())?;
let provider = crate::llm::antigravity::Antigravity::new(
session.base_url.clone(),
session.model.clone(),
access,
project,
)
.with_stored_credentials();
stream_with_tools_options(
provider,
messages,
tools,
max_steps,
None,
headers,
cancel_token,
StreamWithToolsOptions {
prune_tool_outputs: session.prune_tool_outputs,
},
)
.await
.map_err(|e| format!("Stream error: {}", e))
}
ProviderKind::OpenAICompatible => {
let mut builder = OpenAICompatible::builder()
.base_url(&session.base_url)
Expand Down Expand Up @@ -1088,6 +1126,29 @@ mod tests {
);
}

#[test]
fn inherited_parent_session_preserves_antigravity_routing() {
let registry = crate::agent::definition::AgentRegistry::default();
let agent = registry.task_target("explore").unwrap();
let mut parent = test_session(None);
parent.provider_kind = crate::agent::config::ProviderKind::Antigravity;
parent.openai_options.antigravity_project = Some("google-project".into());
parent.base_url = crate::auth::antigravity_oauth::ANTIGRAVITY_ENDPOINT.into();
let session = tokio_test::block_on(resolve_subagent_session(&agent, parent, None)).unwrap();
assert_eq!(
session.provider_kind,
crate::agent::config::ProviderKind::Antigravity
);
assert_eq!(
session.openai_options.antigravity_project.as_deref(),
Some("google-project")
);
assert_eq!(
session.base_url,
crate::auth::antigravity_oauth::ANTIGRAVITY_ENDPOINT
);
}

fn test_session(
reasoning_effort: Option<crate::model::reasoning::ReasoningEffort>,
) -> crate::agent::config::LlmSessionConfig {
Expand Down
Loading
Loading