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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
</p>

- 🤖 Control Android and iOS devices with natural language commands
- 🔀 Use OpenAI, Anthropic, Gemini, Ollama, DeepSeek, OpenRouter, and OpenAI-compatible models
- 🔀 Use OpenAI, Anthropic, Gemini, xAI, Ollama, DeepSeek, OpenRouter, and OpenAI-compatible models
- 🧠 Run direct tasks or enable reasoning mode for complex multi-step automation
- 💻 Automate from the CLI, a terminal UI, Docker, or Python code
- 🐍 Extend agents with custom tools, structured output, app cards, and credentials
Expand Down Expand Up @@ -113,7 +113,7 @@ You should see confirmation that the Portal is installed and accessible.
mobilerun configure
```

The wizard walks you through choosing a provider, auth method, and model. You can also use provider environment variables such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `MINIMAX_API_KEY`.
The wizard walks you through choosing a provider, auth method, and model. You can also use provider environment variables such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `XAI_API_KEY`, or `MINIMAX_API_KEY`.

### 4. Run your first command

Expand Down
29 changes: 29 additions & 0 deletions docs/guides/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ mobilerun run "Create shopping list" \
--provider OpenAI \
--model gpt-4o

# xAI Grok
export XAI_API_KEY=your-key
mobilerun run "Open Settings" \
--provider XAI \
--model grok-4.5

# Anthropic Claude
export ANTHROPIC_API_KEY=your-key
mobilerun run "Reply to latest email" \
Expand Down Expand Up @@ -154,13 +160,35 @@ mobilerun run "Enable 2FA" \
|----------|---------|---------------------|
| GoogleGenAI | Included by default | `GOOGLE_API_KEY` |
| OpenAI | Included by default | `OPENAI_API_KEY` |
| XAI | Included by default | `XAI_API_KEY` |
| OpenAILike | Included by default | Varies by provider |
| OpenRouter | Included by default | `OPENROUTER_API_KEY` |
| Ollama | Included by default | None (local) |
| Anthropic | `uv pip install 'mobilerun[anthropic]'` | `ANTHROPIC_API_KEY` |
| DeepSeek | Included by default | `DEEPSEEK_API_KEY` |
| MiniMax | Included by default | `MINIMAX_API_KEY` |

### xAI API key and OAuth

Configure XAI with an API key or OAuth:

```bash
# API key
mobilerun configure \
--provider XAI \
--auth-mode api_key \
--model grok-4.5

# OAuth through provider options
mobilerun configure --provider XAI --auth-mode oauth --model grok-4.5

# OAuth shortcut
mobilerun configure xai

# Device-code login for SSH/headless hosts
mobilerun configure xai --device-code --no-browser
```

### MiniMax endpoints and credentials

Run `mobilerun configure`, choose MiniMax, and then select the API region where
Expand Down Expand Up @@ -551,6 +579,7 @@ mobilerun ping --tcp
| `GOOGLE_API_KEY` | Google Gemini API key | None |
| `OPENAI_API_KEY` | OpenAI API key | None |
| `ANTHROPIC_API_KEY` | Anthropic API key | None |
| `XAI_API_KEY` | xAI API key for Grok | None |
| `DEEPSEEK_API_KEY` | DeepSeek API key | None |
| `MINIMAX_API_KEY` | MiniMax API key | None |
| `MOBILERUN_CLOUD_API_KEY` | Mobilerun Cloud API key for `devices --cloud` and cloud device actions | None |
Expand Down
5 changes: 4 additions & 1 deletion docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export OPENAI_API_KEY=your-api-key-here

# For Anthropic Claude
export ANTHROPIC_API_KEY=your-api-key-here

# For xAI
export XAI_API_KEY=your-api-key-here
```

### Run Your First Command via CLI
Expand All @@ -125,7 +128,7 @@ mobilerun run "Find a contact named John and send him an email" --reasoning
```

**Common CLI flags:**
- `--provider` - LLM provider (GoogleGenAI, OpenAI, Anthropic, etc.)
- `--provider` - LLM provider (GoogleGenAI, OpenAI, XAI, Anthropic, etc.)
- `--model` - Model name (gemini-3.5-flash-lite, gpt-5.5, etc.)
- `--vision` - Enable screenshot processing
- `--reasoning` - Enable multi-agent planning mode
Expand Down
3 changes: 2 additions & 1 deletion docs/sdk/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ mobilerun run "Task" --config /path/to/config.yaml
- `--config PATH` - Custom config file
- `--device SERIAL` - Device serial/IP
- `--agent NAME` - External agent to use. Not yet supported — reserved for future use.
- `--provider PROVIDER` - LLM provider (OpenAI, Ollama, Anthropic, GoogleGenAI, DeepSeek, MiniMax)
- `--provider PROVIDER` - LLM provider (OpenAI, XAI, Ollama, Anthropic, GoogleGenAI, DeepSeek, MiniMax)
- `--model MODEL` - LLM model name
- `--temperature FLOAT` - LLM temperature
- `--steps INT` - Max steps
Expand All @@ -635,6 +635,7 @@ Set API keys via environment variables:
export GOOGLE_API_KEY=your-key
export OPENAI_API_KEY=your-key
export ANTHROPIC_API_KEY=your-key
export XAI_API_KEY=your-key
export DEEPSEEK_API_KEY=your-key
export MINIMAX_API_KEY=your-key
export MOBILERUN_CONFIG=/path/to/config.yaml # Custom config path
Expand Down
69 changes: 69 additions & 0 deletions mobilerun/agent/providers/grok.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Shared Grok/xAI model and transport metadata."""

from __future__ import annotations

from collections.abc import Mapping, MutableMapping
from typing import Any

GROK_DEFAULT_MODEL = "grok-4.5"
GROK_MODELS = (GROK_DEFAULT_MODEL,)
GROK_MODEL_ALIASES = {
"grok-4.5-latest": GROK_DEFAULT_MODEL,
"grok-build-latest": GROK_DEFAULT_MODEL,
}

XAI_API_BASE = "https://api.x.ai/v1"
GROK_CONTEXT_WINDOW = 500_000

# Grok accepts temperature and top_p on the Responses API, but these legacy
# Chat Completions controls are rejected. Filter after all constructor and
# per-call kwargs are merged so an override cannot accidentally restore them.
GROK_UNSUPPORTED_SAMPLING_PARAMS = frozenset(
{"presence_penalty", "frequency_penalty", "stop"}
)


def normalize_grok_model_id(model: object) -> str:
"""Normalize public xAI/Grok aliases to Mobilerun's canonical model id."""

model_id = str(model or "").strip()
if model_id.startswith("xai/"):
model_id = model_id.removeprefix("xai/")
return GROK_MODEL_ALIASES.get(model_id, model_id)


def sanitize_grok_responses_kwargs(
payload: MutableMapping[str, Any],
*,
omit_sampler_fields: bool = False,
omit_tool_choice: bool = False,
) -> MutableMapping[str, Any]:
"""Apply Grok's Responses API parameter contract to a final payload."""

filtered_params = set(GROK_UNSUPPORTED_SAMPLING_PARAMS)
if omit_sampler_fields:
filtered_params.update(("temperature", "top_p"))
if omit_tool_choice:
filtered_params.add("tool_choice")

for param in filtered_params:
payload.pop(param, None)
payload["store"] = False
payload.pop("reasoning", None)

# The OpenAI SDK merges ``extra_body`` after its normal typed parameters,
# so an unsanitized value here could otherwise restore storage, reasoning,
# an unsupported sampler, or even a caller-selected model. Preserve other
# extension fields while removing every value Mobilerun pins or rejects.
extra_body = payload.get("extra_body")
if extra_body is not None:
if not isinstance(extra_body, Mapping):
payload.pop("extra_body", None)
else:
sanitized_extra_body = dict(extra_body)
for param in filtered_params:
sanitized_extra_body.pop(param, None)
for param in ("model", "store", "reasoning"):
sanitized_extra_body.pop(param, None)
payload["extra_body"] = sanitized_extra_body
return payload
33 changes: 33 additions & 0 deletions mobilerun/agent/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
ANTHROPIC_OAUTH_DEFAULT_MODEL,
ANTHROPIC_OAUTH_MODELS,
)
from mobilerun.agent.providers.grok import (
GROK_DEFAULT_MODEL,
GROK_MODELS,
XAI_API_BASE,
normalize_grok_model_id,
)
from mobilerun.agent.providers.minimax import MINIMAX_GLOBAL_BASE_URL
from mobilerun.agent.providers.types import (
ProviderFamilySpec,
Expand All @@ -14,6 +20,7 @@
from mobilerun.config_manager.credential_paths import (
ANTHROPIC_OAUTH_CREDENTIAL_PATH,
GEMINI_OAUTH_CREDENTIAL_PATH,
GROK_OAUTH_CREDENTIAL_PATH,
OPENAI_OAUTH_CREDENTIAL_PATH,
)

Expand All @@ -22,6 +29,7 @@
VARIANT_ENV_KEY_SLOT: dict[str, str] = {
"GoogleGenAI": "google",
"OpenAIResponses": "openai",
"XAI": "xai",
"Anthropic": "anthropic",
"ZAI": "zai",
"ZAI_Coding": "zai",
Expand Down Expand Up @@ -133,6 +141,29 @@
),
),
),
ProviderFamilySpec(
id="xai",
display_name="XAI",
variants=(
ProviderVariantSpec(
id="XAI",
runtime_provider_name="XAI",
auth_mode="api_key",
default_model=GROK_DEFAULT_MODEL,
models=GROK_MODELS,
requires_api_key=True,
base_url=XAI_API_BASE,
),
ProviderVariantSpec(
id="xai_oauth",
runtime_provider_name="xai_oauth",
auth_mode="oauth",
default_model=GROK_DEFAULT_MODEL,
models=GROK_MODELS,
credential_path=str(GROK_OAUTH_CREDENTIAL_PATH),
),
),
),
ProviderFamilySpec(
id="ollama",
display_name="Ollama",
Expand Down Expand Up @@ -285,6 +316,8 @@ def normalize_model_id_for_variant(

if family_id == "openai":
candidate = OPENAI_MODEL_ALIASES.get(candidate, candidate)
elif family_id == "xai":
candidate = normalize_grok_model_id(candidate)

if candidate in allowed_model_ids:
return candidate
Expand Down
7 changes: 5 additions & 2 deletions mobilerun/agent/providers/setup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
# which allocates the full KV cache (256K-context models -> ~19 GB) and
# spills to CPU on typical machines. -1 restores model-max for big GPUs.
"Ollama": {"context_window": 32768},
"XAI": {"context_window": 500_000},
}

HIDDEN_ROLE_FALLBACKS: tuple[str, ...] = ("app_opener", "structured_output")
Expand Down Expand Up @@ -176,10 +177,12 @@ def create_profile_for_variant(
api_key_source=selection.api_key_source,
base_url=base_url,
api_base=(
base_url if runtime_provider_name in {"OpenAILike", "MiniMax"} else None
base_url
if runtime_provider_name in {"OpenAILike", "MiniMax", "XAI"}
else None
),
credential_path=selection.credential_path or variant.credential_path,
kwargs=kwargs if env_slot is None else {},
kwargs=kwargs if env_slot is None or variant.id == "XAI" else {},
)


Expand Down
30 changes: 25 additions & 5 deletions mobilerun/agent/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"MobilerunAnthropic": "Anthropic",
"MobilerunOpenAIResponses": "OpenAIResponses",
"openai_responses_llm": "OpenAIResponses",
"GrokOAuth": "OpenAIResponses",
"xai_oauth": "OpenAIResponses",
"Ollama_llm": "Ollama",
}

Expand Down Expand Up @@ -57,14 +59,22 @@ def _usage_field(usage: Any, *names: str) -> int:
return 0


def _response_field(response: Any, name: str) -> Any:
"""Read a response field from either an SDK object or decoded JSON."""

if isinstance(response, dict):
return response.get(name)
return getattr(response, name, None)


def _normalize_provider_name(provider: str) -> str:
return PROVIDER_ALIASES.get(provider, provider)


def get_usage_from_response(provider: str, chat_rsp: ChatResponse) -> UsageResult:
provider = _normalize_provider_name(provider)
rsp = chat_rsp.raw
if not rsp:
if not rsp and provider not in ("OpenAIResponses", "OpenAIOAuth"):
raise ValueError("No raw response in chat response")

if provider in {
Expand Down Expand Up @@ -100,15 +110,25 @@ def get_usage_from_response(provider: str, chat_rsp: ChatResponse) -> UsageResul
requests=1,
)
elif provider in ("OpenAIResponses", "OpenAIOAuth"):
usage = getattr(rsp, "usage", None)
usage = _response_field(rsp, "usage")
if usage is None:
# Streaming Responses end with a ``response.completed`` event. Its
# accounting belongs to the nested final Response rather than the
# event itself.
usage = _response_field(_response_field(rsp, "response"), "usage")
if usage is None:
# LlamaIndex also copies completed-stream usage into the final
# ChatResponse's additional kwargs. Keep this fallback for custom
# or normalized event representations that omit the raw response.
usage = _response_field(chat_rsp.additional_kwargs, "usage")
if usage is None:
return UsageResult(
request_tokens=0, response_tokens=0, total_tokens=0, requests=1
)
return UsageResult(
request_tokens=getattr(usage, "input_tokens", 0) or 0,
response_tokens=getattr(usage, "output_tokens", 0) or 0,
total_tokens=getattr(usage, "total_tokens", 0) or 0,
request_tokens=_usage_field(usage, "input_tokens"),
response_tokens=_usage_field(usage, "output_tokens"),
total_tokens=_usage_field(usage, "total_tokens"),
requests=1,
)
elif provider in {"Anthropic", "Anthropic_LLM", "AnthropicOAuthLLM"}:
Expand Down
Loading
Loading