Skip to content
Merged
102 changes: 81 additions & 21 deletions docs/native-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,56 @@ env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" }

### Configuration file (config.json)

A small JSON file next to `auth.json` (default `<auth dir>/config.json`,
overridable with `OPENAB_CONFIG_PATH`) declares the default model and params, so
a deployment can set them in a file instead of only via env vars. **Secrets never
go here** — credentials stay in the locked `auth.json` store.
A small **valid JSON** file next to `auth.json` declares the default model and
parameters, so a deployment can set them in a file instead of only via
environment variables. The default path is `$HOME/.openab/agent/config.json`
(`/home/agent/.openab/agent/config.json` for the `agent` user). Set
`OPENAB_CONFIG_PATH` to override the whole path. **Secrets never go here** —
credentials stay in the locked `auth.json` store.

```jsonc
For example, an xAI deployment can use:

```json
{
"model": "anthropic/claude-sonnet-4-6", // single provider/model string
"max_tokens": 8192 // optional
"model": "xai/grok-4.5",
"max_tokens": 8192
}
```

Resolution is **env-over-config**: `OPENAB_AGENT_MODEL` / `OPENAB_AGENT_MAX_TOKENS`
override the file, so a pod's injected env stays authoritative over a baked
config. A missing file is fine (empty config); a malformed file is logged and
ignored (the agent then falls back to env / built-in defaults). Unknown keys are
tolerated for forward-compatibility.
The supported fields are `model` (a `provider/model` string) and `max_tokens`.
A missing file is fine (empty config); malformed JSON is logged and ignored, so
the agent falls back to environment variables and built-in defaults. Unknown
keys are tolerated for forward compatibility.

Provider selection and value precedence are separate but related:

1. `OPENAB_AGENT_PROVIDER` explicitly selects `anthropic`, `openai`, `codex`,
`xai`, or `grok`.
2. Otherwise, a provider prefix in `OPENAB_AGENT_MODEL` wins (for example,
`xai/grok-4.5`).
3. Otherwise, a provider prefix in `config.json`'s `model` is used.
4. With no explicit provider, auto-detection remains Anthropic → Codex;
xAI is **not** selected merely because an `xai-oauth` token exists.

`OPENAB_AGENT_MODEL` and `OPENAB_AGENT_MAX_TOKENS` override their config-file
values, so environment variables injected into a pod remain authoritative.
`OPENAB_AGENT_XAI_MODEL` controls the xAI model after xAI has been selected; it
does not by itself enable xAI auto-detection.

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `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_MODEL` | — (required for Anthropic) | Model to use, optionally `provider/`-qualified (for example, `anthropic/claude-opus-4-8` or `xai/grok-4.5`). Anthropic has no hardcoded default and fails loud if unset; xAI falls back to `grok-4.5`. 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 +135,45 @@ 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
```

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` namespace
in `auth.json` and refreshed automatically. The default auth file is
`$HOME/.openab/agent/auth.json`; run `openab-agent auth status` to list each
stored provider, a masked token, and its expiry without printing secrets.

Select xAI explicitly with `OPENAB_AGENT_PROVIDER=xai` (or `grok`) or a
`xai/`- or `grok/`-prefixed model, for example:

```bash
OPENAB_AGENT_PROVIDER=xai openab-agent
# or use model/config selection:
OPENAB_AGENT_MODEL=xai/grok-4.5 openab-agent
```

A config file can make that selection persistent:

```json
{
"model": "xai/grok-4.5",
"max_tokens": 8192
}
```

xAI is not part of auto-detection: an `xai-oauth` entry by itself does not make
the agent choose xAI. `OPENAB_AGENT_XAI_MODEL` controls the model after xAI has
been selected; it does not enable xAI selection on its own. The OAuth token and
refresh token stay in `auth.json`, never in `config.json`.

### Adding an OAuth vendor

Subscription-OAuth providers are declared as a single `OAuthVendor` descriptor
Expand All @@ -128,13 +188,13 @@ Place an `AGENTS.md` file in the working directory (`cwd`). It will be prepended
```
/home/agent/
├── AGENTS.md ← read at session start
── .openab/
└── agent/
── auth.json
── skills/ ← skill directories
└── my-skill/
└── SKILL.md
└── (your project files)
── .openab/
└── agent/
── config.json ← optional model/provider defaults
── auth.json ← OAuth credentials; permissions should remain private
└── skills/ ← skill directories
└── my-skill/
└── SKILL.md
```

## Skills
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