Skip to content
Merged
21 changes: 20 additions & 1 deletion docs/native-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ tolerated for forward-compatibility.
| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id, optionally `provider/`-qualified (e.g. `claude-opus-4-8`, `anthropic/claude-opus-4-8`). No hardcoded default — dateless 4.6+ IDs are fixed canonical IDs that retire each generation, so the agent fails loud if unset rather than pin a model that will eventually 404. Overrides `model` in [config.json](#configuration-file-configjson). |
| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-5.4-mini` | Model to use (must be supported by your ChatGPT plan — see [Supported Models](#supported-models-chatgpt-subscription)) |
| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL |
| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) |
| `OPENAB_AGENT_XAI_MODEL` | `grok-4.5` | xAI model to use (see [xAI credentials](#xai-credentials-supergrok--x-premium)) |
| `OPENAB_AGENT_XAI_BASE_URL` | `https://api.x.ai/v1` | xAI API base URL. Must be an `https://` URL on an `x.ai` host — the OAuth bearer is never sent elsewhere. |
| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`, `xai`, `grok`) |
| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens. Overrides `max_tokens` in config.json. |
| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom Codex OAuth client ID |
| `OPENAB_AGENT_ANTHROPIC_CLIENT_ID` | Claude Code's client | Custom Anthropic OAuth client ID |
| `OPENAB_AGENT_XAI_CLIENT_ID` | grok CLI's client | Custom xAI OAuth client ID |
| `OPENAB_AGENT_MAX_TOOL_LOOPS` | `50` | Max tool-call iterations per prompt before the agent gives up |
| `ANTHROPIC_API_KEY` | — | Anthropic API key. Highest-precedence Anthropic credential (see [Anthropic credentials](#anthropic-credentials)). |
| `CLAUDE_CODE_OAUTH_TOKEN` | — | Pre-provisioned long-lived Claude Pro/Max subscription token (from `claude setup-token`). Fleet route — no interactive login, no `auth.json` write. |
Expand Down Expand Up @@ -114,6 +117,22 @@ Three ways to authenticate Anthropic, resolved in this **precedence** (ADR §5.3
A higher-precedence source's own errors (e.g. a key set but no model) surface
rather than silently falling through to a lower one.

### xAI credentials (SuperGrok / X Premium)

Sign in with a SuperGrok or X Premium subscription via the RFC 8628 device-code
flow — no `XAI_API_KEY` to provision, and headless-friendly (approve on any
device; ideal for pods via `kubectl exec`):

```bash
openab-agent auth xai-device
```

Prints a user code and an `https://auth.x.ai/...` verification link (prefilled
when the server provides one). Tokens are stored under the `xai-oauth` tenant in
`auth.json` and refreshed automatically. Select the provider with
`OPENAB_AGENT_PROVIDER=xai` or a `xai/`-prefixed model (e.g.
`OPENAB_AGENT_MODEL=xai/grok-4.5`); xAI is not part of auto-detection.

### Adding an OAuth vendor

Subscription-OAuth providers are declared as a single `OAuthVendor` descriptor
Expand Down
175 changes: 171 additions & 4 deletions openab-agent/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,16 @@ impl AcpServer {
Err(e) => return self.error_response(id, -32000, &e),
}
}
"xai" | "grok" => {
let res = match model_override {
Some(m) => crate::llm::XaiProvider::from_auth_store_with_model(m),
None => crate::llm::XaiProvider::from_auth_store(),
};
match res {
Ok(p) => (Box::new(p), "xai"),
Err(e) => return self.error_response(id, -32000, &e),
}
}
_ => {
// Auto-detect: Anthropic (API key or OAuth) first, then codex.
let anthropic_res = match model_override {
Expand Down Expand Up @@ -462,6 +472,9 @@ impl AcpServer {
if crate::auth::load_tokens().is_ok() {
models.extend(Self::static_openai_models());
}
if crate::auth::load_tokens_for(crate::auth::XAI_NAMESPACE).is_ok() {
models.extend(Self::static_xai_models());
}
if models.is_empty() {
models.push(ModelOption::new(
"none",
Expand Down Expand Up @@ -505,6 +518,16 @@ impl AcpServer {
]
}

fn static_xai_models() -> Vec<ModelOption> {
// Static list matching Pi's trimmed xAI built-in models (earendil-works/pi
// #6734); grok-4.5 is the default the provider falls back to.
vec![
ModelOption::new("grok-4.5", "Grok 4.5", "xai"),
ModelOption::new("grok-4.3", "Grok 4.3", "xai"),
ModelOption::new("grok-build-0.1", "Grok Build 0.1", "xai"),
]
}

async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec<String> {
let session_id = params
.get("sessionId")
Expand Down Expand Up @@ -594,15 +617,23 @@ impl AcpServer {

// Rebuild the current session's provider so the switch takes effect immediately
if !session_id.is_empty() && self.sessions.contains_key(session_id) {
// Preserve the session's auth mode: an OAuth-forced session must not
// silently fall back to ANTHROPIC_API_KEY (which `auto_*` prefers).
let session_is_oauth = self.sessions[session_id].provider_is_oauth();
// Preserve the session's auth mode per provider: an Anthropic
// OAuth session must not silently fall back to ANTHROPIC_API_KEY
// (which `auto_*` prefers). The preference is *sticky* on the
// session (round-3 F2): an Anthropic-OAuth → xAI → Anthropic
// round trip still returns to OAuth, while a session that never
// chose Anthropic OAuth (e.g. created on xAI) rebuilds via
// `auto_with_model`, honoring a configured API key (round-2 F2).
let session_prefers_anthropic_oauth =
self.sessions[session_id].prefers_anthropic_oauth();
let new_provider: Result<Box<dyn crate::llm::LlmProvider>, String> = match provider_name
{
"anthropic" if session_is_oauth => {
"anthropic" if session_prefers_anthropic_oauth => {
AnthropicProvider::from_oauth_auto_with_model(value).map(|p| Box::new(p) as _)
}
"anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _),
"xai" => crate::llm::XaiProvider::from_auth_store_with_model(value)
.map(|p| Box::new(p) as _),
_ => crate::llm::OpenAiProvider::from_auth_store_with_model(value)
.map(|p| Box::new(p) as _),
};
Expand Down Expand Up @@ -987,6 +1018,142 @@ mod tests {
);
}

/// Minimal always-OAuth xAI-flavored provider so the F2 regression test
/// doesn't need a stored xai-oauth tenant on disk.
struct FakeXaiOauthProvider;
impl crate::llm::LlmProvider for FakeXaiOauthProvider {
fn model(&self) -> &str {
"grok-4.5"
}
fn is_oauth(&self) -> bool {
true
}
fn provider_name(&self) -> &str {
"xai"
}
fn chat<'a>(
&'a self,
_system: &'a str,
_messages: &'a [crate::llm::Message],
_tools: &'a [crate::llm::ToolDef],
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<Vec<crate::llm::LlmEvent>>>
+ Send
+ 'a,
>,
> {
Box::pin(async { Ok(vec![]) })
}
}

/// Anthropic-OAuth-flavored stub for the round-trip test below.
struct FakeAnthropicOauthProvider;
impl crate::llm::LlmProvider for FakeAnthropicOauthProvider {
fn model(&self) -> &str {
"claude-opus-4-20250514"
}
fn is_oauth(&self) -> bool {
true
}
fn provider_name(&self) -> &str {
"anthropic"
}
fn chat<'a>(
&'a self,
_system: &'a str,
_messages: &'a [crate::llm::Message],
_tools: &'a [crate::llm::ToolDef],
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<Vec<crate::llm::LlmEvent>>>
+ Send
+ 'a,
>,
> {
Box::pin(async { Ok(vec![]) })
}
}

#[test]
fn test_anthropic_oauth_round_trip_via_xai_keeps_oauth_mode() {
// Review round-3 F2: Anthropic OAuth → xAI → Anthropic (with
// ANTHROPIC_API_KEY set) must return to the OAuth path, not silently
// switch to the API key. With no anthropic-oauth tenant on disk the
// OAuth rebuild fails loudly — an error here proves the OAuth path was
// taken; a silent success would mean the API key hijacked the session
// (the regression this test pins).
let _guard = ENV_LOCK.lock().unwrap();
let mut server = AcpServer::new();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };

// Session explicitly created on Anthropic OAuth, then moved to xAI.
let mut agent = Agent::new_boxed(
Box::new(FakeAnthropicOauthProvider),
"/tmp".to_string(),
None,
);
agent.swap_provider(Box::new(FakeXaiOauthProvider));
assert!(agent.prefers_anthropic_oauth(), "sticky policy lost");
server.sessions.insert("rt-session".to_string(), agent);
server.model_options = vec![ModelOption::new(
"claude-opus-4-20250514",
"Claude Opus 4",
"anthropic",
)];

let resp_str = server.handle_set_config_option(
22,
&json!({
"configId": "model",
"value": "claude-opus-4-20250514",
"sessionId": "rt-session",
}),
);
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
let resp: Value = serde_json::from_str(&resp_str).unwrap();
assert!(
resp["error"].is_object(),
"round trip must take the OAuth path (fails without a tenant), \
not silently rebuild on ANTHROPIC_API_KEY: {resp}"
);
}

#[test]
fn test_switch_from_xai_oauth_to_anthropic_uses_api_key() {
// Review F2: OAuth-mode preservation must be provider-specific. A
// session on xAI OAuth switching to an Anthropic model must rebuild
// via `auto_with_model` (honoring ANTHROPIC_API_KEY) — NOT
// `from_oauth_auto_with_model`, which requires an Anthropic OAuth
// tenant and bypasses a configured API key.
let _guard = ENV_LOCK.lock().unwrap();
let mut server = AcpServer::new();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };

let agent = Agent::new_boxed(Box::new(FakeXaiOauthProvider), "/tmp".to_string(), None);
server.sessions.insert("xai-session".to_string(), agent);
server.model_options = vec![ModelOption::new(
"claude-opus-4-20250514",
"Claude Opus 4",
"anthropic",
)];

let resp_str = server.handle_set_config_option(
21,
&json!({
"configId": "model",
"value": "claude-opus-4-20250514",
"sessionId": "xai-session",
}),
);
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
let resp: Value = serde_json::from_str(&resp_str).unwrap();
assert!(
resp["error"].is_null(),
"xAI OAuth → Anthropic switch must use the API key (F2): {resp}"
);
}

#[tokio::test]
async fn test_session_load_returns_config_options() {
let _guard = ENV_LOCK.lock().unwrap();
Expand Down
Loading
Loading