mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-17 17:46:12 +03:00
feat(providers): add xAI Grok OAuth support
This commit is contained in:
@@ -12,6 +12,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_compat_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.xai_oauth_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.bedrock_provider", raising=False)
|
||||
|
||||
@@ -21,6 +22,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
assert "nanobot.providers.openai_compat_provider" not in sys.modules
|
||||
assert "nanobot.providers.openai_codex_provider" not in sys.modules
|
||||
assert "nanobot.providers.github_copilot_provider" not in sys.modules
|
||||
assert "nanobot.providers.xai_oauth_provider" not in sys.modules
|
||||
assert "nanobot.providers.azure_openai_provider" not in sys.modules
|
||||
assert "nanobot.providers.bedrock_provider" not in sys.modules
|
||||
assert providers.__all__ == [
|
||||
@@ -30,6 +32,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
"OpenAICompatProvider",
|
||||
"OpenAICodexProvider",
|
||||
"GitHubCopilotProvider",
|
||||
"XaiOAuthProvider",
|
||||
"AzureOpenAIProvider",
|
||||
"BedrockProvider",
|
||||
]
|
||||
@@ -50,3 +53,9 @@ def test_openai_codex_supports_progress_deltas() -> None:
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
assert OpenAICodexProvider.supports_progress_deltas is True
|
||||
|
||||
|
||||
def test_xai_oauth_supports_progress_deltas() -> None:
|
||||
from nanobot.providers.xai_oauth_provider import XaiOAuthProvider
|
||||
|
||||
assert XaiOAuthProvider.supports_progress_deltas is True
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.providers.xai_oauth_provider as auth
|
||||
|
||||
|
||||
def test_build_xai_authorization_url_includes_pkce_and_grok_scope() -> None:
|
||||
endpoints = auth.XaiOAuthEndpoints(
|
||||
authorization_endpoint="https://auth.x.ai/authorize",
|
||||
token_endpoint="https://auth.x.ai/oauth/token",
|
||||
)
|
||||
|
||||
url = auth.build_xai_authorization_url(
|
||||
endpoints,
|
||||
verifier="verifier",
|
||||
state="state",
|
||||
nonce="nonce",
|
||||
)
|
||||
|
||||
parsed = urlparse(url)
|
||||
params = parse_qs(parsed.query)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.hostname == "auth.x.ai"
|
||||
assert params["client_id"] == [auth.DEFAULT_XAI_CLIENT_ID]
|
||||
assert params["code_challenge"] == [auth.pkce_challenge("verifier")]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
assert params["scope"] == [auth.DEFAULT_XAI_SCOPE]
|
||||
assert params["nonce"] == ["nonce"]
|
||||
assert params["plan"] == ["generic"]
|
||||
assert params["referrer"] == ["nanobot"]
|
||||
|
||||
|
||||
def test_parse_callback_value_accepts_fallback_shapes() -> None:
|
||||
assert auth._parse_callback_value("https://localhost/callback?code=abc&state=state") == ("abc", "state")
|
||||
assert auth._parse_callback_value("?code=abc&state=state") == ("abc", "state")
|
||||
assert auth._parse_callback_value("code=abc&state=state") == ("abc", "state")
|
||||
assert auth._parse_callback_value("fallback-code") == ("fallback-code", None)
|
||||
|
||||
|
||||
def test_file_storage_fallback_is_private_and_round_trips(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(auth, "_keyring_set", lambda _tokens: False)
|
||||
monkeypatch.setattr(auth, "_keyring_get", lambda: None)
|
||||
|
||||
saved = auth.save_xai_oauth_credential(
|
||||
auth.XaiOAuthCredential(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
expires_at=123.0,
|
||||
account_id="acct",
|
||||
)
|
||||
)
|
||||
|
||||
path = auth.get_xai_oauth_metadata_path()
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert saved.storage == "file"
|
||||
assert payload["storage"] == "file"
|
||||
assert payload["tokens"]["access_token"] == "access"
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
|
||||
loaded = auth.load_xai_oauth_credential()
|
||||
assert loaded is not None
|
||||
assert loaded.access_token == "access"
|
||||
assert loaded.refresh_token == "refresh"
|
||||
assert loaded.account_id == "acct"
|
||||
assert loaded.storage == "file"
|
||||
|
||||
|
||||
def test_keyring_storage_keeps_tokens_out_of_metadata(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
|
||||
secret: dict[str, object] = {}
|
||||
|
||||
def fake_set(tokens: dict[str, object]) -> bool:
|
||||
secret.update(tokens)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth, "_keyring_set", fake_set)
|
||||
monkeypatch.setattr(auth, "_keyring_get", lambda: dict(secret))
|
||||
|
||||
auth.save_xai_oauth_credential(
|
||||
auth.XaiOAuthCredential(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
expires_at=123.0,
|
||||
account_id="acct",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(auth.get_xai_oauth_metadata_path().read_text(encoding="utf-8"))
|
||||
assert payload["storage"] == "keyring"
|
||||
assert "tokens" not in payload
|
||||
assert auth.load_xai_oauth_credential().access_token == "access"
|
||||
|
||||
|
||||
def test_exchange_xai_oauth_code_sends_required_code_challenge(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return {"access_token": "access", "refresh_token": "refresh", "expires_in": 3600}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
pass
|
||||
|
||||
def post(self, url: str, headers: dict[str, str], data: dict[str, str]) -> FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["data"] = data
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(auth.httpx, "Client", FakeClient)
|
||||
endpoints = auth.XaiOAuthEndpoints(
|
||||
authorization_endpoint="https://auth.x.ai/authorize",
|
||||
token_endpoint="https://auth.x.ai/oauth/token",
|
||||
)
|
||||
|
||||
credential = auth.exchange_xai_oauth_code("code", verifier="verifier", endpoints=endpoints)
|
||||
|
||||
assert credential.access_token == "access"
|
||||
assert captured["url"] == "https://auth.x.ai/oauth/token"
|
||||
data = captured["data"]
|
||||
assert data["code_verifier"] == "verifier"
|
||||
assert data["code_challenge"] == auth.pkce_challenge("verifier")
|
||||
assert data["code_challenge_method"] == "S256"
|
||||
|
||||
|
||||
def test_rejects_non_xai_discovery_endpoints() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
auth._validate_xai_endpoint("https://example.com/oauth/token", "token_endpoint")
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from nanobot.config.schema import XaiOAuthXSearchConfig
|
||||
import nanobot.providers.xai_oauth_provider as xai_oauth_provider
|
||||
from nanobot.providers.xai_oauth_provider import (
|
||||
XaiOAuthCredential,
|
||||
XaiOAuthProvider,
|
||||
_build_xai_responses_body,
|
||||
_strip_model_prefix,
|
||||
)
|
||||
|
||||
|
||||
def test_xai_oauth_strip_prefix_supports_aliases() -> None:
|
||||
assert _strip_model_prefix("xai-oauth/grok-4.3") == "grok-4.3"
|
||||
assert _strip_model_prefix("xai_oauth/grok-4.3") == "grok-4.3"
|
||||
assert _strip_model_prefix("grok-oauth/grok-4.3") == "grok-4.3"
|
||||
assert _strip_model_prefix("grok-4.3") == "grok-4.3"
|
||||
|
||||
|
||||
def test_build_xai_responses_body_keeps_system_prompt_in_input() -> None:
|
||||
body = _build_xai_responses_body(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are nanobot."},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ping",
|
||||
"description": "Ping",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
],
|
||||
model="xai-oauth/grok-4.3",
|
||||
max_tokens=32,
|
||||
temperature=0.2,
|
||||
reasoning_effort="high",
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["model"] == "grok-4.3"
|
||||
assert "instructions" not in body
|
||||
assert body["input"][0] == {
|
||||
"role": "system",
|
||||
"content": [{"type": "input_text", "text": "You are nanobot."}],
|
||||
}
|
||||
assert body["input"][1]["role"] == "user"
|
||||
assert body["max_output_tokens"] == 32
|
||||
assert body["temperature"] == 0.2
|
||||
assert body["reasoning"] == {"effort": "high"}
|
||||
assert body["tools"][0]["name"] == "ping"
|
||||
|
||||
|
||||
def test_build_xai_responses_body_attaches_hosted_x_search_by_default() -> None:
|
||||
body = _build_xai_responses_body(
|
||||
messages=[{"role": "user", "content": "what is happening on X?"}],
|
||||
tools=None,
|
||||
model="xai-oauth/grok-4.3",
|
||||
max_tokens=32,
|
||||
temperature=0.2,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
hosted_x_search=XaiOAuthXSearchConfig(),
|
||||
)
|
||||
|
||||
assert body["tools"] == [{"type": "x_search"}]
|
||||
|
||||
|
||||
def test_build_xai_responses_body_can_customize_hosted_x_search() -> None:
|
||||
body = _build_xai_responses_body(
|
||||
messages=[{"role": "user", "content": "what is happening on X?"}],
|
||||
tools=None,
|
||||
model="xai-oauth/grok-4.3",
|
||||
max_tokens=32,
|
||||
temperature=0.2,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
hosted_x_search=XaiOAuthXSearchConfig(
|
||||
allowed_x_handles=["@xai", " nanobot "],
|
||||
enable_image_understanding=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert body["tools"] == [
|
||||
{
|
||||
"type": "x_search",
|
||||
"allowed_x_handles": ["xai", "nanobot"],
|
||||
"enable_image_understanding": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_build_xai_responses_body_omits_disabled_hosted_x_search() -> None:
|
||||
body = _build_xai_responses_body(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="xai-oauth/grok-4.3",
|
||||
max_tokens=32,
|
||||
temperature=0.2,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
hosted_x_search=XaiOAuthXSearchConfig(enable=False),
|
||||
)
|
||||
|
||||
assert "tools" not in body
|
||||
|
||||
|
||||
def test_xai_oauth_provider_refreshes_once_on_401(monkeypatch) -> None:
|
||||
async def run() -> None:
|
||||
response = await provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert response.finish_reason == "stop"
|
||||
assert calls == [("resolve", False), ("resolve", True)]
|
||||
|
||||
provider = XaiOAuthProvider(default_model="xai-oauth/grok-4.3")
|
||||
credentials = [
|
||||
XaiOAuthCredential(access_token="expired"),
|
||||
XaiOAuthCredential(access_token="fresh"),
|
||||
]
|
||||
calls: list[tuple[str, bool]] = []
|
||||
|
||||
def fake_resolve(*, force_refresh: bool = False) -> XaiOAuthCredential:
|
||||
calls.append(("resolve", force_refresh))
|
||||
return credentials.pop(0)
|
||||
|
||||
async def fake_request(credential, body, on_content_delta=None, on_tool_call_delta=None):
|
||||
from nanobot.providers.xai_oauth_provider import _XaiHTTPError
|
||||
|
||||
if credential.access_token == "expired":
|
||||
raise _XaiHTTPError("expired", status_code=401)
|
||||
return "ok", [], "stop"
|
||||
|
||||
monkeypatch.setattr(xai_oauth_provider, "resolve_xai_oauth_credential", fake_resolve)
|
||||
monkeypatch.setattr(xai_oauth_provider, "_request_xai", fake_request)
|
||||
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user