mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
234da592f5 | ||
|
|
2d138b92fc | ||
|
|
c02f013b17 | ||
|
|
1c6483147e | ||
|
|
7941450a5d | ||
|
|
e6c839ee37 | ||
|
|
97fb9aaf72 | ||
|
|
bc4de246a4 | ||
|
|
2389ab1f5a |
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, open `http://127.0.0.1:8765`, and follow new gateway logs |
|
||||||
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
|
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
|
||||||
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
|
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
|
||||||
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||||
@@ -344,7 +344,7 @@ remain accepted as no-op compatibility aliases.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||||
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
|
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.6; hosted X Search is enabled for models that advertise support |
|
||||||
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||||
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
|
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
|
||||||
|
|||||||
+17
-5
@@ -729,6 +729,11 @@ Then run:
|
|||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The WebUI model selector loads the models available to the signed-in account
|
||||||
|
from Codex's online catalog. Context-window and reasoning-effort metadata come
|
||||||
|
from that response; if discovery is unavailable, nanobot keeps a small built-in
|
||||||
|
fallback instead of emptying the selector.
|
||||||
|
|
||||||
Codex Fast mode can be enabled from the WebUI provider settings, or with:
|
Codex Fast mode can be enabled from the WebUI provider settings, or with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -764,11 +769,14 @@ nanobot provider login xai-grok --set-main
|
|||||||
nanobot agent -m "Hello from Grok."
|
nanobot agent -m "Hello from Grok."
|
||||||
```
|
```
|
||||||
|
|
||||||
The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
|
The default model is `xai-grok/grok-4.6` with a 500,000-token context window.
|
||||||
The provider reads xAI's model catalog and includes the server-hosted `x_search`
|
The provider reads and caches xAI's online model catalog for both WebUI model
|
||||||
tool only when the selected model advertises `supportsBackendSearch`. Models
|
selection and runtime capabilities. Newly available models appear automatically;
|
||||||
without that capability continue normally without hosted X Search. When enabled,
|
when discovery fails, the last successful catalog or built-in fallback remains
|
||||||
searches run inside xAI's Responses API and citations arrive as inline links.
|
available. The server-hosted `x_search` tool is included only when the selected
|
||||||
|
model advertises support. Models without that capability continue normally
|
||||||
|
without hosted X Search. When enabled, searches run inside xAI's Responses API
|
||||||
|
and citations arrive as inline links.
|
||||||
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
|
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
|
||||||
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
|
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
|
||||||
|
|
||||||
@@ -805,6 +813,10 @@ a nanobot update.
|
|||||||
|
|
||||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||||
|
|
||||||
|
After login, the WebUI loads the account-specific Copilot model catalog online.
|
||||||
|
Only models compatible with nanobot's current chat-completions or Responses
|
||||||
|
transport are shown.
|
||||||
|
|
||||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||||
```bash
|
```bash
|
||||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||||
|
|||||||
+15
-3
@@ -572,15 +572,23 @@ For OpenAI Codex:
|
|||||||
nanobot provider login openai-codex --set-main
|
nanobot provider login openai-codex --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The WebUI reads the account's Codex model catalog online, including current
|
||||||
|
context-window and reasoning-effort metadata. A small compatible catalog remains
|
||||||
|
available when the service cannot be reached.
|
||||||
|
|
||||||
For an eligible X Premium / Grok subscription:
|
For an eligible X Premium / Grok subscription:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot provider login xai-grok --set-main
|
nanobot provider login xai-grok --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
|
This selects `xai-grok/grok-4.6`. The WebUI model selector reads xAI's online
|
||||||
exposes the hosted `x_search` tool only when the selected model advertises
|
model catalog, so newly available subscription models appear without a nanobot
|
||||||
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
|
release. Online metadata is cached and enriched with nanobot's curated labels;
|
||||||
|
if xAI is temporarily unavailable, nanobot uses the last successful catalog or
|
||||||
|
a small built-in fallback instead of emptying the selector. The same catalog
|
||||||
|
controls whether the provider exposes the hosted `x_search` tool; models that do
|
||||||
|
not advertise support continue without hosted X Search.
|
||||||
When enabled, Grok can search current X posts and return inline source links
|
When enabled, Grok can search current X posts and return inline source links
|
||||||
without invoking a local nanobot tool. Credentials are stored under the
|
without invoking a local nanobot tool. Credentials are stored under the
|
||||||
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
||||||
@@ -599,6 +607,10 @@ For GitHub Copilot:
|
|||||||
nanobot provider login github-copilot --set-main
|
nanobot provider login github-copilot --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The WebUI reads the models enabled for the signed-in Copilot account. nanobot
|
||||||
|
lists entries that support its current Copilot chat-completions or Responses
|
||||||
|
transport and hides models that it cannot route safely.
|
||||||
|
|
||||||
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
||||||
|
|
||||||
## Provider Resolution
|
## Provider Resolution
|
||||||
|
|||||||
+3
-1
@@ -23,7 +23,9 @@ one is missing, starts or joins the same on-demand gateway used by the native
|
|||||||
TUI, and opens the browser. With a fresh config,
|
TUI, and opens the browser. With a fresh config,
|
||||||
it can open before a model is configured so you can finish setup in **Settings
|
it can open before a model is configured so you can finish setup in **Settings
|
||||||
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
||||||
it is not available from other devices on your LAN.
|
it is not available from other devices on your LAN. While the launcher remains
|
||||||
|
attached, it mirrors new log output from that exact gateway instance in the
|
||||||
|
terminal without replaying older logs.
|
||||||
|
|
||||||
After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
|
After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ _PROVIDER_DISPLAY: dict[str, str] = {
|
|||||||
|
|
||||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||||
"openai_codex": "openai-codex/gpt-5.6-sol",
|
"openai_codex": "openai-codex/gpt-5.6-sol",
|
||||||
"xai_grok": "xai-grok/grok-4.5",
|
"xai_grok": "xai-grok/grok-4.6",
|
||||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,10 @@ def _set_oauth_provider_as_main(
|
|||||||
config.agents.defaults.model_preset = None
|
config.agents.defaults.model_preset = None
|
||||||
config.agents.defaults.provider = provider_name
|
config.agents.defaults.provider = provider_name
|
||||||
config.agents.defaults.model = selected_model
|
config.agents.defaults.model = selected_model
|
||||||
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
if provider_name == "xai_grok" and selected_model in {
|
||||||
|
"xai-grok/grok-4.5",
|
||||||
|
"xai-grok/grok-4.6",
|
||||||
|
}:
|
||||||
config.agents.defaults.context_window_tokens = 500_000
|
config.agents.defaults.context_window_tokens = 500_000
|
||||||
save_config(config, resolved_config_path)
|
save_config(config, resolved_config_path)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Shared WebUI setup, URL, health, and browser helpers."""
|
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, BinaryIO
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
@@ -457,27 +459,104 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
|||||||
console.print("[green]WebUI is attached to the shared gateway.[/green]")
|
console.print("[green]WebUI is attached to the shared gateway.[/green]")
|
||||||
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||||
console.print(
|
console.print(
|
||||||
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]"
|
"[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops "
|
||||||
|
"only when the last local client exits.[/dim]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOG_ANCHOR_BYTES = 64
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _GatewayLogCursor:
|
||||||
|
offset: int = 0
|
||||||
|
identity: tuple[int, int] | None = None
|
||||||
|
anchor: bytes = b""
|
||||||
|
pending: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
|
def _log_anchor(handle: BinaryIO, offset: int) -> bytes:
|
||||||
|
size = min(offset, _LOG_ANCHOR_BYTES)
|
||||||
|
handle.seek(offset - size)
|
||||||
|
return handle.read(size)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_gateway_log_cursor(log_path: Path) -> _GatewayLogCursor:
|
||||||
|
"""Start following at the current end of *log_path*."""
|
||||||
|
try:
|
||||||
|
with log_path.open("rb") as handle:
|
||||||
|
stat = os.fstat(handle.fileno())
|
||||||
|
offset = stat.st_size
|
||||||
|
return _GatewayLogCursor(
|
||||||
|
offset=offset,
|
||||||
|
identity=(stat.st_dev, stat.st_ino),
|
||||||
|
anchor=_log_anchor(handle, offset),
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return _GatewayLogCursor()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_new_gateway_logs(
|
||||||
|
log_path: Path,
|
||||||
|
cursor: _GatewayLogCursor,
|
||||||
|
*,
|
||||||
|
flush: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Read complete gateway log lines appended after *cursor*."""
|
||||||
|
try:
|
||||||
|
with log_path.open("rb") as handle:
|
||||||
|
stat = os.fstat(handle.fileno())
|
||||||
|
identity = (stat.st_dev, stat.st_ino)
|
||||||
|
reset = cursor.identity != identity or stat.st_size < cursor.offset
|
||||||
|
if not reset and cursor.offset:
|
||||||
|
reset = _log_anchor(handle, cursor.offset) != cursor.anchor
|
||||||
|
if reset:
|
||||||
|
cursor.offset = 0
|
||||||
|
cursor.pending = b""
|
||||||
|
|
||||||
|
handle.seek(cursor.offset)
|
||||||
|
chunk = handle.read()
|
||||||
|
cursor.offset = handle.tell()
|
||||||
|
cursor.identity = identity
|
||||||
|
cursor.anchor = _log_anchor(handle, cursor.offset)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parts = (cursor.pending + chunk).split(b"\n")
|
||||||
|
cursor.pending = parts.pop()
|
||||||
|
if flush and cursor.pending:
|
||||||
|
parts.append(cursor.pending)
|
||||||
|
cursor.pending = b""
|
||||||
|
return [part.removesuffix(b"\r").decode("utf-8", errors="replace") for part in parts]
|
||||||
|
|
||||||
|
|
||||||
def _attach_to_background_gateway(
|
def _attach_to_background_gateway(
|
||||||
runtime: "GatewayRuntime",
|
runtime: "GatewayRuntime",
|
||||||
*,
|
*,
|
||||||
poll_hook: Callable[[], None] | None = None,
|
poll_hook: Callable[[], None] | None = None,
|
||||||
sleep: Callable[[float], None] = time.sleep,
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Keep a WebUI launcher attached without taking ownership of the gateway."""
|
"""Keep the launcher attached and mirror this gateway's new log output."""
|
||||||
|
status = runtime.status()
|
||||||
|
log_path = status.log_path
|
||||||
|
cursor = _start_gateway_log_cursor(log_path)
|
||||||
_print_webui_foreground_lifecycle(attached=True)
|
_print_webui_foreground_lifecycle(attached=True)
|
||||||
try:
|
try:
|
||||||
while runtime.status().running:
|
while status.running:
|
||||||
|
for line in _read_new_gateway_logs(log_path, cursor):
|
||||||
|
console.print(line, markup=False, highlight=False)
|
||||||
if poll_hook is not None:
|
if poll_hook is not None:
|
||||||
poll_hook()
|
poll_hook()
|
||||||
sleep(0.5)
|
sleep(0.5)
|
||||||
|
status = runtime.status()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
|
||||||
|
console.print(line, markup=False, highlight=False)
|
||||||
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
|
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
|
||||||
|
console.print(line, markup=False, highlight=False)
|
||||||
console.print("[yellow]Gateway stopped.[/yellow]")
|
console.print("[yellow]Gateway stopped.[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
@@ -17,7 +18,12 @@ from oauth_cli_kit.models import OAuthToken
|
|||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
|
||||||
from nanobot.providers.base import LLMResponse, ProviderCallContext
|
from nanobot.providers.base import LLMResponse, ProviderCallContext
|
||||||
|
from nanobot.providers.oauth_model_catalog import (
|
||||||
|
OAuthModelCatalog,
|
||||||
|
OAuthModelCatalogSnapshot,
|
||||||
|
)
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||||
|
|
||||||
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||||
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||||
@@ -96,7 +102,9 @@ def login_github_copilot(
|
|||||||
|
|
||||||
device_code = str(payload["device_code"])
|
device_code = str(payload["device_code"])
|
||||||
user_code = str(payload["user_code"])
|
user_code = str(payload["user_code"])
|
||||||
verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "")
|
verify_url = str(
|
||||||
|
payload.get("verification_uri") or payload.get("verification_uri_complete") or ""
|
||||||
|
)
|
||||||
verify_complete = str(payload.get("verification_uri_complete") or verify_url)
|
verify_complete = str(payload.get("verification_uri_complete") or verify_url)
|
||||||
interval = max(1, int(payload.get("interval") or 5))
|
interval = max(1, int(payload.get("interval") or 5))
|
||||||
expires_in = int(payload.get("expires_in") or 900)
|
expires_in = int(payload.get("expires_in") or 900)
|
||||||
@@ -180,8 +188,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
*,
|
*,
|
||||||
provider_name: str = "github_copilot",
|
provider_name: str = "github_copilot",
|
||||||
):
|
):
|
||||||
from nanobot.providers.registry import find_by_name
|
|
||||||
|
|
||||||
self._copilot_access_token: str | None = None
|
self._copilot_access_token: str | None = None
|
||||||
self._copilot_expires_at: float = 0.0
|
self._copilot_expires_at: float = 0.0
|
||||||
self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
|
self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
|
||||||
@@ -217,7 +223,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout, follow_redirects=True, trust_env=True
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||||
headers=_copilot_headers(github_token.access),
|
headers=_copilot_headers(github_token.access),
|
||||||
@@ -296,3 +304,174 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
provider_context=provider_context,
|
provider_context=provider_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_github_copilot_model_catalog(
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> OAuthModelCatalogSnapshot:
|
||||||
|
storage = get_storage()
|
||||||
|
token = storage.load()
|
||||||
|
account_key = _catalog_account_key(getattr(token, "account_id", None))
|
||||||
|
cache_key = (
|
||||||
|
f"{storage.get_token_path()}\0{account_key}\0"
|
||||||
|
f"{_resolve('NANOBOT_COPILOT_BASE_URL', DEFAULT_COPILOT_BASE_URL)}\0{proxy or ''}"
|
||||||
|
)
|
||||||
|
return _GITHUB_COPILOT_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_github_copilot_model_catalog() -> None:
|
||||||
|
_GITHUB_COPILOT_MODEL_CATALOG.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_github_copilot_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
github_token = get_storage().load()
|
||||||
|
if not github_token or not github_token.access:
|
||||||
|
raise RuntimeError("GitHub Copilot is not logged in")
|
||||||
|
|
||||||
|
common_headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Editor-Version": EDITOR_VERSION,
|
||||||
|
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
|
||||||
|
}
|
||||||
|
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
|
||||||
|
if proxy:
|
||||||
|
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||||
|
with httpx.Client(**client_kwargs) as client:
|
||||||
|
exchange = client.get(
|
||||||
|
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||||
|
headers={**common_headers, "Authorization": f"token {github_token.access}"},
|
||||||
|
)
|
||||||
|
exchange.raise_for_status()
|
||||||
|
exchange_mapping = _catalog_mapping(exchange.json())
|
||||||
|
copilot_token = exchange_mapping.get("token")
|
||||||
|
if not isinstance(copilot_token, str) or not copilot_token:
|
||||||
|
raise RuntimeError("GitHub Copilot token exchange returned no token")
|
||||||
|
endpoint_base = _catalog_first_text(
|
||||||
|
_catalog_mapping(exchange_mapping.get("endpoints")),
|
||||||
|
"api",
|
||||||
|
)
|
||||||
|
base_url = endpoint_base or _resolve(
|
||||||
|
"NANOBOT_COPILOT_BASE_URL",
|
||||||
|
DEFAULT_COPILOT_BASE_URL,
|
||||||
|
)
|
||||||
|
models_url = (
|
||||||
|
base_url
|
||||||
|
if base_url.rstrip("/").endswith("/models")
|
||||||
|
else f"{base_url.rstrip('/')}/models"
|
||||||
|
)
|
||||||
|
response = client.get(
|
||||||
|
models_url,
|
||||||
|
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _parse_github_copilot_models(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_github_copilot_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
fallback_models = _oauth_fallback_models("github_copilot")
|
||||||
|
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||||
|
models: list[ProviderModelSpec] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in cast(list[object], rows):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
row = cast(dict[str, Any], value)
|
||||||
|
wire_id = _catalog_first_text(row, "id")
|
||||||
|
policy = _catalog_mapping(row.get("policy"))
|
||||||
|
endpoints = row.get("supported_endpoints")
|
||||||
|
if (
|
||||||
|
not wire_id
|
||||||
|
or wire_id in seen
|
||||||
|
or row.get("model_picker_enabled") is not True
|
||||||
|
or policy.get("state") == "disabled"
|
||||||
|
or not _copilot_transport_supported(wire_id, endpoints)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
seen.add(wire_id)
|
||||||
|
capabilities = _catalog_mapping(row.get("capabilities"))
|
||||||
|
supports = _catalog_mapping(capabilities.get("supports"))
|
||||||
|
limits = _catalog_mapping(capabilities.get("limits"))
|
||||||
|
fallback = fallback_by_id.get(wire_id)
|
||||||
|
models.append(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id=f"github-copilot/{wire_id}",
|
||||||
|
label=(
|
||||||
|
_catalog_first_text(row, "name")
|
||||||
|
or (fallback.label if fallback is not None else wire_id)
|
||||||
|
),
|
||||||
|
description=(fallback.description if fallback is not None else ""),
|
||||||
|
owned_by="GitHub Copilot",
|
||||||
|
context_window=(
|
||||||
|
_catalog_positive_int(limits, "max_context_window_tokens")
|
||||||
|
or (fallback.context_window if fallback is not None else None)
|
||||||
|
),
|
||||||
|
reasoning_efforts=_catalog_reasoning_efforts(supports.get("reasoning_effort")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(models)
|
||||||
|
|
||||||
|
|
||||||
|
def _copilot_transport_supported(wire_id: str, endpoints: object) -> bool:
|
||||||
|
if not isinstance(endpoints, list):
|
||||||
|
return True
|
||||||
|
supported = cast(list[object], endpoints)
|
||||||
|
if "/chat/completions" in supported:
|
||||||
|
return True
|
||||||
|
model = wire_id.lower()
|
||||||
|
return "/responses" in supported and any(
|
||||||
|
token in model for token in ("gpt-5", "o1", "o3", "o4")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
assert spec is not None
|
||||||
|
return spec.builtin_models
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_account_key(account_id: object) -> str:
|
||||||
|
value = account_id if isinstance(account_id, str) else ""
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.strip()
|
||||||
|
for item in cast(list[object], value)
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GITHUB_COPILOT_MODEL_CATALOG = OAuthModelCatalog(
|
||||||
|
fallback_models=_oauth_fallback_models("github_copilot"),
|
||||||
|
fetch=_fetch_github_copilot_models,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Shared cache seam for OAuth provider model discovery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec
|
||||||
|
|
||||||
|
CatalogSource = Literal["remote", "cache", "stale", "fallback"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OAuthModelCatalogSnapshot:
|
||||||
|
"""One usable catalog view, including where it came from."""
|
||||||
|
|
||||||
|
models: tuple[ProviderModelSpec, ...]
|
||||||
|
source: CatalogSource
|
||||||
|
fetched_at: float
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
def find(self, model: str) -> ProviderModelSpec | None:
|
||||||
|
wire_id = model.split("/", 1)[-1]
|
||||||
|
return next(
|
||||||
|
(item for item in self.models if item.id.split("/", 1)[-1] == wire_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CacheEntry:
|
||||||
|
snapshot: OAuthModelCatalogSnapshot
|
||||||
|
stored_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthModelCatalog:
|
||||||
|
"""Cache one provider's discovery behind a small failure-tolerant interface."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fallback_models: Sequence[ProviderModelSpec],
|
||||||
|
fetch: Callable[[str | None], Sequence[ProviderModelSpec]],
|
||||||
|
fresh_ttl_s: float = 5 * 60,
|
||||||
|
stale_ttl_s: float = 24 * 60 * 60,
|
||||||
|
failure_ttl_s: float = 30,
|
||||||
|
max_entries: int = 8,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
wall_clock: Callable[[], float] = time.time,
|
||||||
|
) -> None:
|
||||||
|
if fresh_ttl_s < 0 or stale_ttl_s < fresh_ttl_s or failure_ttl_s < 0:
|
||||||
|
raise ValueError("catalog cache TTLs are invalid")
|
||||||
|
if max_entries < 1:
|
||||||
|
raise ValueError("catalog cache must allow at least one entry")
|
||||||
|
self._fallback_models = tuple(fallback_models)
|
||||||
|
self._fetch = fetch
|
||||||
|
self._fresh_ttl_s = fresh_ttl_s
|
||||||
|
self._stale_ttl_s = stale_ttl_s
|
||||||
|
self._failure_ttl_s = failure_ttl_s
|
||||||
|
self._max_entries = max_entries
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._wall_clock = wall_clock
|
||||||
|
self._condition = threading.Condition()
|
||||||
|
self._entries: dict[str, _CacheEntry] = {}
|
||||||
|
self._failures: dict[str, float] = {}
|
||||||
|
self._inflight: set[str] = set()
|
||||||
|
self._generation = 0
|
||||||
|
|
||||||
|
def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot:
|
||||||
|
"""Return a fresh catalog, sharing concurrent work and retaining a fallback."""
|
||||||
|
with self._condition:
|
||||||
|
generation = self._generation
|
||||||
|
cached = self._cached_result(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
while cache_key in self._inflight:
|
||||||
|
self._condition.wait()
|
||||||
|
if generation != self._generation:
|
||||||
|
return self._stale_or_fallback(None, self._monotonic())
|
||||||
|
cached = self._cached_result(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
self._inflight.add(cache_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
models = tuple(self._fetch(proxy))
|
||||||
|
if not models:
|
||||||
|
raise ValueError("provider returned an empty model catalog")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("OAuth model catalog refresh failed: type={}", type(exc).__name__)
|
||||||
|
with self._condition:
|
||||||
|
result = (
|
||||||
|
self._stale_or_fallback(None, self._monotonic())
|
||||||
|
if generation != self._generation
|
||||||
|
else self._failure_result(cache_key)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
now = self._monotonic()
|
||||||
|
result = OAuthModelCatalogSnapshot(
|
||||||
|
models=models,
|
||||||
|
source="remote",
|
||||||
|
fetched_at=self._wall_clock(),
|
||||||
|
)
|
||||||
|
with self._condition:
|
||||||
|
if generation != self._generation:
|
||||||
|
result = self._stale_or_fallback(None, now)
|
||||||
|
else:
|
||||||
|
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
|
||||||
|
self._failures.pop(cache_key, None)
|
||||||
|
finally:
|
||||||
|
with self._condition:
|
||||||
|
self._inflight.discard(cache_key)
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Drop cached work and prevent an older identity refresh from being stored."""
|
||||||
|
with self._condition:
|
||||||
|
self._generation += 1
|
||||||
|
self._entries.clear()
|
||||||
|
self._failures.clear()
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
def _cached_result(self, cache_key: str) -> OAuthModelCatalogSnapshot | None:
|
||||||
|
now = self._monotonic()
|
||||||
|
entry = self._entries.get(cache_key)
|
||||||
|
if entry is not None and now - entry.stored_at < self._fresh_ttl_s:
|
||||||
|
return replace(entry.snapshot, source="cache")
|
||||||
|
failure_until = self._failures.get(cache_key)
|
||||||
|
if failure_until is not None and failure_until <= now:
|
||||||
|
self._failures.pop(cache_key, None)
|
||||||
|
elif failure_until is not None:
|
||||||
|
return self._stale_or_fallback(entry, now)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
|
||||||
|
now = self._monotonic()
|
||||||
|
self._reserve(cache_key)
|
||||||
|
self._failures[cache_key] = now + self._failure_ttl_s
|
||||||
|
return self._stale_or_fallback(self._entries.get(cache_key), now)
|
||||||
|
|
||||||
|
def _stale_or_fallback(
|
||||||
|
self,
|
||||||
|
entry: _CacheEntry | None,
|
||||||
|
now: float,
|
||||||
|
) -> OAuthModelCatalogSnapshot:
|
||||||
|
if entry is not None and now - entry.stored_at < self._stale_ttl_s:
|
||||||
|
return replace(
|
||||||
|
entry.snapshot,
|
||||||
|
source="stale",
|
||||||
|
message="Could not refresh the online model list; showing cached models.",
|
||||||
|
)
|
||||||
|
return OAuthModelCatalogSnapshot(
|
||||||
|
models=self._fallback_models,
|
||||||
|
source="fallback",
|
||||||
|
fetched_at=self._wall_clock(),
|
||||||
|
message="Could not load the online model list; showing built-in fallback models.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _store(self, cache_key: str, entry: _CacheEntry) -> None:
|
||||||
|
self._reserve(cache_key)
|
||||||
|
self._entries[cache_key] = entry
|
||||||
|
|
||||||
|
def _reserve(self, cache_key: str) -> None:
|
||||||
|
known = set(self._entries) | set(self._failures)
|
||||||
|
if cache_key in known or len(known) < self._max_entries:
|
||||||
|
return
|
||||||
|
oldest = min(
|
||||||
|
known,
|
||||||
|
key=lambda key: (
|
||||||
|
self._entries[key].stored_at
|
||||||
|
if key in self._entries
|
||||||
|
else self._failures[key] - self._failure_ttl_s
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._entries.pop(oldest, None)
|
||||||
|
self._failures.pop(oldest, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_model_catalog(
|
||||||
|
provider_name: str,
|
||||||
|
*,
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> OAuthModelCatalogSnapshot:
|
||||||
|
"""Discover models through the owning provider module."""
|
||||||
|
if provider_name == "openai_codex":
|
||||||
|
from nanobot.providers.openai_codex_provider import get_openai_codex_model_catalog
|
||||||
|
|
||||||
|
return get_openai_codex_model_catalog(proxy)
|
||||||
|
if provider_name == "xai_grok":
|
||||||
|
from nanobot.providers.xai_grok_provider import get_xai_grok_model_catalog
|
||||||
|
|
||||||
|
return get_xai_grok_model_catalog(proxy)
|
||||||
|
if provider_name == "github_copilot":
|
||||||
|
from nanobot.providers.github_copilot_provider import get_github_copilot_model_catalog
|
||||||
|
|
||||||
|
return get_github_copilot_model_catalog(proxy)
|
||||||
|
raise ValueError(f"OAuth model discovery is not available for {provider_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_oauth_model_catalog(provider_name: str) -> None:
|
||||||
|
"""Invalidate provider discovery after its OAuth identity changes."""
|
||||||
|
if provider_name == "openai_codex":
|
||||||
|
from nanobot.providers.openai_codex_provider import (
|
||||||
|
invalidate_openai_codex_model_catalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidate_openai_codex_model_catalog()
|
||||||
|
elif provider_name == "xai_grok":
|
||||||
|
from nanobot.providers.xai_grok_provider import invalidate_xai_grok_model_catalog
|
||||||
|
|
||||||
|
invalidate_xai_grok_model_catalog()
|
||||||
|
elif provider_name == "github_copilot":
|
||||||
|
from nanobot.providers.github_copilot_provider import (
|
||||||
|
invalidate_github_copilot_model_catalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidate_github_copilot_model_catalog()
|
||||||
@@ -14,7 +14,10 @@ from typing import Any, cast
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from oauth_cli_kit import get_token as get_codex_token
|
from oauth_cli_kit import get_token as get_codex_token
|
||||||
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||||
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
|
||||||
|
from nanobot import __version__
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
@@ -22,6 +25,10 @@ from nanobot.providers.base import (
|
|||||||
ProviderConversationState,
|
ProviderConversationState,
|
||||||
resolve_stream_idle_timeout_s,
|
resolve_stream_idle_timeout_s,
|
||||||
)
|
)
|
||||||
|
from nanobot.providers.oauth_model_catalog import (
|
||||||
|
OAuthModelCatalog,
|
||||||
|
OAuthModelCatalogSnapshot,
|
||||||
|
)
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
ResponsesStreamCapture,
|
ResponsesStreamCapture,
|
||||||
build_responses_state,
|
build_responses_state,
|
||||||
@@ -35,8 +42,11 @@ from nanobot.providers.openai_responses import (
|
|||||||
responses_state_items,
|
responses_state_items,
|
||||||
responses_state_matches,
|
responses_state_matches,
|
||||||
)
|
)
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||||
|
|
||||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
|
||||||
|
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
|
||||||
DEFAULT_ORIGINATOR = "nanobot"
|
DEFAULT_ORIGINATOR = "nanobot"
|
||||||
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000
|
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000
|
||||||
|
|
||||||
@@ -87,9 +97,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
sanitized_messages = self._sanitize_empty_content(messages)
|
sanitized_messages = self._sanitize_empty_content(messages)
|
||||||
sanitized_state = (
|
sanitized_state = (
|
||||||
provider_context.conversation_state
|
provider_context.conversation_state if provider_context is not None else None
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
if sanitized_state is not None:
|
if sanitized_state is not None:
|
||||||
sanitized_state = sanitized_state.with_pending_messages(
|
sanitized_state = sanitized_state.with_pending_messages(
|
||||||
@@ -168,11 +176,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
compact_threshold = resolve_compact_threshold(
|
compact_threshold = resolve_compact_threshold(
|
||||||
(
|
(provider_context.context_window_tokens if provider_context is not None else None),
|
||||||
provider_context.context_window_tokens
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
max_tokens,
|
max_tokens,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
@@ -236,8 +240,12 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
self,
|
||||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
provider_context: ProviderCallContext | None = None,
|
||||||
@@ -264,8 +272,12 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
self,
|
||||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
@@ -344,11 +356,7 @@ def _without_response_item_ids(
|
|||||||
sanitized_input.append(raw_item)
|
sanitized_input.append(raw_item)
|
||||||
continue
|
continue
|
||||||
item = cast(dict[str, Any], raw_item)
|
item = cast(dict[str, Any], raw_item)
|
||||||
sanitized_input.append({
|
sanitized_input.append({key: value for key, value in item.items() if key != "id"})
|
||||||
key: value
|
|
||||||
for key, value in item.items()
|
|
||||||
if key != "id"
|
|
||||||
})
|
|
||||||
|
|
||||||
body = dict(request_body)
|
body = dict(request_body)
|
||||||
body["input"] = sanitized_input
|
body["input"] = sanitized_input
|
||||||
@@ -444,15 +452,12 @@ async def _request_codex(
|
|||||||
raw = text.decode("utf-8", "ignore")
|
raw = text.decode("utf-8", "ignore")
|
||||||
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
||||||
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
||||||
compaction_unsupported = (
|
compaction_unsupported = response.status_code in {400, 404, 422} and any(
|
||||||
response.status_code in {400, 404, 422}
|
marker in raw.lower()
|
||||||
and any(
|
for marker in (
|
||||||
marker in raw.lower()
|
"context_management",
|
||||||
for marker in (
|
"compact_threshold",
|
||||||
"context_management",
|
"compaction_trigger",
|
||||||
"compact_threshold",
|
|
||||||
"compaction_trigger",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
raise _CodexHTTPError(
|
raise _CodexHTTPError(
|
||||||
@@ -461,7 +466,9 @@ async def _request_codex(
|
|||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
error_type=error_type,
|
error_type=error_type,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
|
should_retry=_should_retry_status(
|
||||||
|
response.status_code, error_type, error_code, raw
|
||||||
|
),
|
||||||
compaction_unsupported=compaction_unsupported,
|
compaction_unsupported=compaction_unsupported,
|
||||||
)
|
)
|
||||||
capture = ResponsesStreamCapture()
|
capture = ResponsesStreamCapture()
|
||||||
@@ -534,7 +541,9 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
default_detail = "HTTP request failed"
|
default_detail = "HTTP request failed"
|
||||||
|
|
||||||
if status_code is not None and should_retry is None:
|
if status_code is not None and should_retry is None:
|
||||||
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
retry_content = (
|
||||||
|
None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||||
|
)
|
||||||
should_retry = _should_retry_status(
|
should_retry = _should_retry_status(
|
||||||
int(status_code),
|
int(status_code),
|
||||||
getattr(exc, "error_type", None),
|
getattr(exc, "error_type", None),
|
||||||
@@ -592,3 +601,139 @@ def _should_retry_status(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
|
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
|
||||||
|
|
||||||
|
|
||||||
|
def get_openai_codex_model_catalog(
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> OAuthModelCatalogSnapshot:
|
||||||
|
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
|
||||||
|
token = storage.load()
|
||||||
|
account_id = getattr(token, "account_id", None)
|
||||||
|
account_key = _catalog_account_key(account_id)
|
||||||
|
cache_key = f"{storage.get_token_path()}\0{account_key}\0{proxy or ''}"
|
||||||
|
return _OPENAI_CODEX_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_openai_codex_model_catalog() -> None:
|
||||||
|
_OPENAI_CODEX_MODEL_CATALOG.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_openai_codex_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
token = get_codex_token(proxy=proxy)
|
||||||
|
account_id = getattr(token, "account_id", None)
|
||||||
|
if not isinstance(account_id, str) or not account_id:
|
||||||
|
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
|
||||||
|
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||||
|
if proxy:
|
||||||
|
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||||
|
with httpx.Client(**client_kwargs) as client:
|
||||||
|
response = client.get(
|
||||||
|
DEFAULT_OPENAI_CODEX_MODELS_URL,
|
||||||
|
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token.access}",
|
||||||
|
"chatgpt-account-id": account_id,
|
||||||
|
"originator": DEFAULT_ORIGINATOR,
|
||||||
|
"User-Agent": f"nanobot/{__version__} (python)",
|
||||||
|
"accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _parse_openai_codex_models(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_openai_codex_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
fallback_models = _oauth_fallback_models("openai_codex")
|
||||||
|
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||||
|
parsed: list[tuple[int, ProviderModelSpec]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in cast(list[object], rows):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
row = cast(dict[str, Any], value)
|
||||||
|
wire_id = _catalog_first_text(row, "slug", "id")
|
||||||
|
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
|
||||||
|
continue
|
||||||
|
seen.add(wire_id)
|
||||||
|
fallback = fallback_by_id.get(wire_id)
|
||||||
|
priority = row.get("priority")
|
||||||
|
parsed.append(
|
||||||
|
(
|
||||||
|
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
|
||||||
|
ProviderModelSpec(
|
||||||
|
id=f"openai-codex/{wire_id}",
|
||||||
|
label=(
|
||||||
|
_catalog_first_text(row, "display_name", "name")
|
||||||
|
or (fallback.label if fallback is not None else wire_id)
|
||||||
|
),
|
||||||
|
description=(
|
||||||
|
_catalog_first_text(row, "description")
|
||||||
|
or (fallback.description if fallback is not None else "")
|
||||||
|
),
|
||||||
|
owned_by="OpenAI Codex",
|
||||||
|
context_window=(
|
||||||
|
_catalog_positive_int(row, "context_window")
|
||||||
|
or (fallback.context_window if fallback is not None else None)
|
||||||
|
),
|
||||||
|
reasoning_efforts=(
|
||||||
|
_catalog_reasoning_efforts(row.get("supported_reasoning_levels"))
|
||||||
|
or (fallback.reasoning_efforts if fallback is not None else ())
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parsed.sort(key=lambda item: item[0])
|
||||||
|
return tuple(model for _, model in parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
assert spec is not None
|
||||||
|
return spec.builtin_models
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_account_key(account_id: object) -> str:
|
||||||
|
value = account_id if isinstance(account_id, str) else ""
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
efforts: list[str] = []
|
||||||
|
for item in cast(list[object], value):
|
||||||
|
if isinstance(item, str):
|
||||||
|
effort = item.strip()
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
|
||||||
|
else:
|
||||||
|
effort = ""
|
||||||
|
if effort and effort not in efforts:
|
||||||
|
efforts.append(effort)
|
||||||
|
return tuple(efforts)
|
||||||
|
|
||||||
|
|
||||||
|
_OPENAI_CODEX_MODEL_CATALOG = OAuthModelCatalog(
|
||||||
|
fallback_models=_oauth_fallback_models("openai_codex"),
|
||||||
|
fetch=_fetch_openai_codex_models,
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ from pydantic.alias_generators import to_snake
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderModelSpec:
|
class ProviderModelSpec:
|
||||||
"""A curated model exposed by providers without a model-list endpoint."""
|
"""Curated model metadata used for fixed catalogs or online fallback."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
label: str = ""
|
label: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
owned_by: str = ""
|
||||||
context_window: int | None = None
|
context_window: int | None = None
|
||||||
|
reasoning_efforts: tuple[str, ...] = ()
|
||||||
|
supports_backend_search: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -42,7 +45,7 @@ class ProviderSpec:
|
|||||||
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
||||||
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
||||||
display_name: str = "" # shown in `nanobot status`
|
display_name: str = "" # shown in `nanobot status`
|
||||||
model_catalog: str = "auto" # WebUI model-list source
|
model_catalog: str = "auto" # WebUI model-list source, including builtin/hybrid
|
||||||
builtin_models: tuple[ProviderModelSpec, ...] = ()
|
builtin_models: tuple[ProviderModelSpec, ...] = ()
|
||||||
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
||||||
|
|
||||||
@@ -407,45 +410,56 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("openai-codex",),
|
keywords=("openai-codex",),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name="OpenAI Codex",
|
display_name="OpenAI Codex",
|
||||||
model_catalog="builtin",
|
model_catalog="hybrid",
|
||||||
builtin_models=(
|
builtin_models=(
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.6-sol",
|
id="openai-codex/gpt-5.6-sol",
|
||||||
label="GPT-5.6-Sol",
|
label="GPT-5.6-Sol",
|
||||||
description="Latest frontier agentic coding model.",
|
description="Latest frontier agentic coding model.",
|
||||||
context_window=372000,
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.6-terra",
|
id="openai-codex/gpt-5.6-terra",
|
||||||
label="GPT-5.6-Terra",
|
label="GPT-5.6-Terra",
|
||||||
description="Balanced agentic coding model for everyday work.",
|
description="Balanced agentic coding model for everyday work.",
|
||||||
context_window=372000,
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.6-luna",
|
id="openai-codex/gpt-5.6-luna",
|
||||||
label="GPT-5.6-Luna",
|
label="GPT-5.6-Luna",
|
||||||
description="Fast and affordable agentic coding model.",
|
description="Fast and affordable agentic coding model.",
|
||||||
context_window=372000,
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.5",
|
id="openai-codex/gpt-5.5",
|
||||||
label="GPT-5.5",
|
label="GPT-5.5",
|
||||||
description="Frontier model for complex coding, research, and real-world work.",
|
description="Frontier model for complex coding, research, and real-world work.",
|
||||||
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.4",
|
id="openai-codex/gpt-5.4",
|
||||||
label="GPT-5.4",
|
label="GPT-5.4",
|
||||||
description="Strong model for everyday coding.",
|
description="Strong model for everyday coding.",
|
||||||
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.4-mini",
|
id="openai-codex/gpt-5.4-mini",
|
||||||
label="GPT-5.4-Mini",
|
label="GPT-5.4-Mini",
|
||||||
description="Small, fast, and cost-efficient model for simpler coding tasks.",
|
description="Small, fast, and cost-efficient model for simpler coding tasks.",
|
||||||
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||||
),
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.3-codex-spark",
|
id="openai-codex/gpt-5.3-codex-spark",
|
||||||
label="GPT-5.3-Codex-Spark",
|
label="GPT-5.3-Codex-Spark",
|
||||||
description="Ultra-fast coding model.",
|
description="Ultra-fast coding model.",
|
||||||
|
context_window=128_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backend="openai_codex",
|
backend="openai_codex",
|
||||||
@@ -459,13 +473,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("xai-grok", "xai_grok"),
|
keywords=("xai-grok", "xai_grok"),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name="xAI Grok",
|
display_name="xAI Grok",
|
||||||
model_catalog="builtin",
|
model_catalog="hybrid",
|
||||||
builtin_models=(
|
builtin_models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.6",
|
||||||
|
label="Grok 4.6",
|
||||||
|
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||||
|
context_window=500_000,
|
||||||
|
),
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="xai-grok/grok-4.5",
|
id="xai-grok/grok-4.5",
|
||||||
label="Grok 4.5",
|
label="Grok 4.5",
|
||||||
description="Grok via xAI subscription; X Search is enabled when supported.",
|
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||||
context_window=500000,
|
context_window=500_000,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backend="xai_grok",
|
backend="xai_grok",
|
||||||
@@ -478,6 +498,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("github_copilot", "copilot"),
|
keywords=("github_copilot", "copilot"),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name="Github Copilot",
|
display_name="Github Copilot",
|
||||||
|
model_catalog="hybrid",
|
||||||
|
builtin_models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="github-copilot/gpt-5.4-mini",
|
||||||
|
label="GPT-5.4 Mini",
|
||||||
|
description="GitHub Copilot Responses model.",
|
||||||
|
),
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="github-copilot/gpt-4.1",
|
||||||
|
label="GPT-4.1",
|
||||||
|
description="GitHub Copilot chat model.",
|
||||||
|
),
|
||||||
|
),
|
||||||
backend="github_copilot",
|
backend="github_copilot",
|
||||||
default_api_base="https://api.githubcopilot.com",
|
default_api_base="https://api.githubcopilot.com",
|
||||||
strip_model_prefix=True,
|
strip_model_prefix=True,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -22,21 +22,24 @@ from nanobot.providers.base import (
|
|||||||
ToolCallRequest,
|
ToolCallRequest,
|
||||||
resolve_stream_idle_timeout_s,
|
resolve_stream_idle_timeout_s,
|
||||||
)
|
)
|
||||||
|
from nanobot.providers.oauth_model_catalog import OAuthModelCatalog, OAuthModelCatalogSnapshot
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
consume_sse_with_reasoning,
|
consume_sse_with_reasoning,
|
||||||
convert_messages,
|
convert_messages,
|
||||||
convert_tools,
|
convert_tools,
|
||||||
)
|
)
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||||
from nanobot.providers.xai_oauth import (
|
from nanobot.providers.xai_oauth import (
|
||||||
XAI_CLIENT_VERSION,
|
XAI_CLIENT_VERSION,
|
||||||
XAIToken,
|
get_xai_oauth_login_status,
|
||||||
|
get_xai_oauth_storage_path,
|
||||||
get_xai_oauth_token,
|
get_xai_oauth_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
|
||||||
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
|
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
|
||||||
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
|
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
|
||||||
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.5"
|
_HOSTED_SEARCH_MAX_TURNS = 5
|
||||||
_MODEL_CAPABILITIES_TTL_S = 5 * 60
|
|
||||||
_MAX_ERROR_BODY_CHARS = 1000
|
_MAX_ERROR_BODY_CHARS = 1000
|
||||||
_SENSITIVE_ERROR_KEYS = {
|
_SENSITIVE_ERROR_KEYS = {
|
||||||
"accesstoken",
|
"accesstoken",
|
||||||
@@ -63,6 +66,10 @@ def _is_named_x_search_tool(value: object) -> bool:
|
|||||||
class XAIGrokProvider(LLMProvider):
|
class XAIGrokProvider(LLMProvider):
|
||||||
"""Call xAI's subscription proxy and expose supported hosted tools."""
|
"""Call xAI's subscription proxy and expose supported hosted tools."""
|
||||||
|
|
||||||
|
# An incomplete hosted-tool stream can already have emitted answer text. Let the
|
||||||
|
# provider close that stream segment before its one bounded recovery attempt.
|
||||||
|
supports_stream_recover_callback = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
default_model: str = DEFAULT_XAI_GROK_MODEL,
|
default_model: str = DEFAULT_XAI_GROK_MODEL,
|
||||||
@@ -75,37 +82,19 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.proxy = proxy or None
|
self.proxy = proxy or None
|
||||||
self._extra_body = dict(extra_body or {})
|
self._extra_body = dict(extra_body or {})
|
||||||
self._model_capabilities: dict[str, bool] | None = None
|
|
||||||
self._model_capabilities_fetched_at = 0.0
|
|
||||||
|
|
||||||
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool:
|
async def _supports_backend_search(self, model: str) -> bool:
|
||||||
now = time.monotonic()
|
catalog = await asyncio.to_thread(
|
||||||
capabilities = self._model_capabilities
|
get_xai_grok_model_catalog,
|
||||||
if (
|
self.proxy,
|
||||||
capabilities is None
|
)
|
||||||
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S
|
if catalog.message:
|
||||||
):
|
logger.warning(
|
||||||
try:
|
"xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
|
||||||
capabilities = await _fetch_xai_model_capabilities(
|
catalog.message,
|
||||||
DEFAULT_XAI_GROK_MODELS_URL,
|
)
|
||||||
_build_model_headers(token),
|
info = catalog.find(model)
|
||||||
proxy=self.proxy,
|
return bool(info and info.supports_backend_search)
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"xAI model capability lookup failed; hosted X Search disabled for model {}: "
|
|
||||||
"type={} error={}",
|
|
||||||
model,
|
|
||||||
type(exc).__name__,
|
|
||||||
str(exc).strip() or "unexpected error",
|
|
||||||
)
|
|
||||||
capabilities = {}
|
|
||||||
self._model_capabilities = capabilities
|
|
||||||
self._model_capabilities_fetched_at = now
|
|
||||||
else:
|
|
||||||
self._model_capabilities = capabilities
|
|
||||||
self._model_capabilities_fetched_at = now
|
|
||||||
return capabilities.get(model, False)
|
|
||||||
|
|
||||||
async def _call_xai(
|
async def _call_xai(
|
||||||
self,
|
self,
|
||||||
@@ -119,6 +108,7 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
wire_model = _strip_model_prefix(model or self.default_model)
|
wire_model = _strip_model_prefix(model or self.default_model)
|
||||||
system_prompt, input_items = convert_messages(messages)
|
system_prompt, input_items = convert_messages(messages)
|
||||||
@@ -128,17 +118,13 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
|
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
|
||||||
configured_tools = self._extra_body.get("tools")
|
configured_tools = self._extra_body.get("tools")
|
||||||
tools_are_explicit = "tools" in self._extra_body
|
tools_are_explicit = "tools" in self._extra_body
|
||||||
configured_hosted_search = (
|
configured_hosted_search = isinstance(configured_tools, list) and any(
|
||||||
isinstance(configured_tools, list)
|
_is_hosted_x_search_tool(tool) for tool in cast(list[object], configured_tools)
|
||||||
and any(
|
|
||||||
_is_hosted_x_search_tool(tool)
|
|
||||||
for tool in cast(list[object], configured_tools)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
supports_backend_search = False
|
supports_backend_search = False
|
||||||
if not tools_are_explicit:
|
if not tools_are_explicit:
|
||||||
stage = "model_capabilities"
|
stage = "model_capabilities"
|
||||||
supports_backend_search = await self._supports_backend_search(token, wire_model)
|
supports_backend_search = await self._supports_backend_search(wire_model)
|
||||||
converted_tools = convert_tools(tools or [])
|
converted_tools = convert_tools(tools or [])
|
||||||
if isinstance(configured_tools, list):
|
if isinstance(configured_tools, list):
|
||||||
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
|
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
|
||||||
@@ -149,6 +135,8 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
if supports_backend_search:
|
if supports_backend_search:
|
||||||
converted_tools.append({"type": "x_search"})
|
converted_tools.append({"type": "x_search"})
|
||||||
|
|
||||||
|
hosted_search_enabled = supports_backend_search or configured_hosted_search
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"model": wire_model,
|
"model": wire_model,
|
||||||
"store": False,
|
"store": False,
|
||||||
@@ -164,51 +152,65 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"reasoning": _build_reasoning_options(reasoning_effort),
|
"reasoning": _build_reasoning_options(reasoning_effort),
|
||||||
}
|
}
|
||||||
|
if hosted_search_enabled:
|
||||||
|
# xAI's global default is intentionally unspecified. Five turns is
|
||||||
|
# their documented balanced setting and prevents a search from
|
||||||
|
# stopping after a single unsuccessful lookup.
|
||||||
|
body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS
|
||||||
if self._extra_body:
|
if self._extra_body:
|
||||||
body.update({
|
body.update(
|
||||||
key: value
|
{key: value for key, value in self._extra_body.items() if key != "tools"}
|
||||||
for key, value in self._extra_body.items()
|
)
|
||||||
if key != "tools"
|
|
||||||
})
|
|
||||||
if tools_are_explicit and not isinstance(configured_tools, list):
|
if tools_are_explicit and not isinstance(configured_tools, list):
|
||||||
body["tools"] = configured_tools
|
body["tools"] = configured_tools
|
||||||
|
|
||||||
headers = _build_headers(token.access, wire_model)
|
headers = _build_headers(token.access, wire_model)
|
||||||
stage = "xai_request"
|
stage = "xai_request"
|
||||||
try:
|
auth_retried = False
|
||||||
result = await _request_xai(
|
hosted_tool_retried = False
|
||||||
DEFAULT_XAI_GROK_URL,
|
retry_usage: LLMUsage | None = None
|
||||||
headers,
|
while True:
|
||||||
body,
|
try:
|
||||||
proxy=self.proxy,
|
result = await _request_xai(
|
||||||
on_content_delta=on_content_delta,
|
DEFAULT_XAI_GROK_URL,
|
||||||
on_thinking_delta=on_thinking_delta,
|
headers,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
body,
|
||||||
)
|
proxy=self.proxy,
|
||||||
except _XAIHTTPError as exc:
|
on_content_delta=on_content_delta,
|
||||||
if exc.status_code != 401:
|
on_thinking_delta=on_thinking_delta,
|
||||||
raise
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
stage = "oauth_refresh"
|
)
|
||||||
token = await asyncio.to_thread(
|
break
|
||||||
get_xai_oauth_token,
|
except _XAIHTTPError as exc:
|
||||||
proxy=self.proxy,
|
if exc.status_code != 401 or auth_retried:
|
||||||
force_refresh=True,
|
raise
|
||||||
)
|
auth_retried = True
|
||||||
self._model_capabilities = None
|
stage = "oauth_refresh"
|
||||||
self._model_capabilities_fetched_at = 0.0
|
token = await asyncio.to_thread(
|
||||||
headers = _build_headers(token.access, wire_model)
|
get_xai_oauth_token,
|
||||||
stage = "xai_request_retry"
|
proxy=self.proxy,
|
||||||
result = await _request_xai(
|
force_refresh=True,
|
||||||
DEFAULT_XAI_GROK_URL,
|
)
|
||||||
headers,
|
headers = _build_headers(token.access, wire_model)
|
||||||
body,
|
stage = "xai_request_after_oauth_refresh"
|
||||||
proxy=self.proxy,
|
except _XAIIncompleteHostedToolError as exc:
|
||||||
on_content_delta=on_content_delta,
|
retry_usage = _combine_usage(retry_usage, exc.usage)
|
||||||
on_thinking_delta=on_thinking_delta,
|
cannot_recover_stream = exc.stream_output_emitted and on_stream_recover is None
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
if hosted_tool_retried or cannot_recover_stream:
|
||||||
)
|
exc.usage = retry_usage
|
||||||
|
raise
|
||||||
|
hosted_tool_retried = True
|
||||||
|
stage = "hosted_tool_recovery"
|
||||||
|
logger.warning(
|
||||||
|
"xAI response ended with unfinished hosted tool(s): {}; retrying once",
|
||||||
|
", ".join(exc.tool_names),
|
||||||
|
)
|
||||||
|
if on_stream_recover is not None:
|
||||||
|
await on_stream_recover()
|
||||||
|
headers = _build_headers(token.access, wire_model)
|
||||||
|
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = result
|
content, tool_calls, finish_reason, usage, reasoning_content = result
|
||||||
|
usage = _combine_usage(retry_usage, usage)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
@@ -257,6 +259,7 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
return await self._call_xai(
|
return await self._call_xai(
|
||||||
messages,
|
messages,
|
||||||
@@ -269,6 +272,7 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
on_content_delta,
|
on_content_delta,
|
||||||
on_thinking_delta,
|
on_thinking_delta,
|
||||||
on_tool_call_delta,
|
on_tool_call_delta,
|
||||||
|
on_stream_recover,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
@@ -288,6 +292,14 @@ def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_usage(left: LLMUsage | None, right: LLMUsage | None) -> LLMUsage | None:
|
||||||
|
if left is None:
|
||||||
|
return right
|
||||||
|
if right is None:
|
||||||
|
return left
|
||||||
|
return left + right
|
||||||
|
|
||||||
|
|
||||||
def _build_headers(token: str, model: str) -> dict[str, str]:
|
def _build_headers(token: str, model: str) -> dict[str, str]:
|
||||||
conversation_id = str(uuid.uuid4())
|
conversation_id = str(uuid.uuid4())
|
||||||
return {
|
return {
|
||||||
@@ -308,44 +320,6 @@ def _build_headers(token: str, model: str) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_model_headers(token: XAIToken) -> dict[str, str]:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {token.access}",
|
|
||||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
||||||
"x-grok-client-version": XAI_CLIENT_VERSION,
|
|
||||||
"x-grok-client-identifier": "nanobot",
|
|
||||||
"x-grok-client-mode": "headless",
|
|
||||||
"User-Agent": f"nanobot/{__version__} (python)",
|
|
||||||
"accept": "application/json",
|
|
||||||
}
|
|
||||||
claims = _decode_access_token_claims(token.access)
|
|
||||||
user_id = claims.get("sub")
|
|
||||||
if claims.get("principal_type") == "Team":
|
|
||||||
user_id = claims.get("principal_id") or user_id
|
|
||||||
if isinstance(user_id, str) and user_id:
|
|
||||||
headers["x-userid"] = user_id
|
|
||||||
email = claims.get("email")
|
|
||||||
if not isinstance(email, str) or "@" not in email:
|
|
||||||
email = token.account_id if token.account_id and "@" in token.account_id else None
|
|
||||||
if email:
|
|
||||||
headers["x-email"] = email
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_access_token_claims(token: str) -> dict[str, Any]:
|
|
||||||
"""Read identity hints from the signed token; the server still authenticates it."""
|
|
||||||
parts = token.split(".")
|
|
||||||
if len(parts) < 2 or not parts[1]:
|
|
||||||
return {}
|
|
||||||
payload = parts[1]
|
|
||||||
try:
|
|
||||||
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
|
|
||||||
claims = json.loads(decoded)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return {}
|
|
||||||
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
class _XAIHTTPError(RuntimeError):
|
class _XAIHTTPError(RuntimeError):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -367,65 +341,25 @@ class _XAIHTTPError(RuntimeError):
|
|||||||
self.response_body = response_body
|
self.response_body = response_body
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_xai_model_capabilities(
|
class _XAIIncompleteHostedToolError(RuntimeError):
|
||||||
url: str,
|
"""A nominally successful xAI stream ended before a hosted tool did."""
|
||||||
headers: dict[str, str],
|
|
||||||
*,
|
|
||||||
proxy: str | None = None,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
|
||||||
if proxy:
|
|
||||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
|
||||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raw = response.content.decode("utf-8", "ignore")
|
|
||||||
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
|
|
||||||
return _parse_xai_model_capabilities(payload)
|
|
||||||
|
|
||||||
|
should_retry = False # _call_xai already performs the one safe recovery attempt.
|
||||||
|
|
||||||
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
|
def __init__(
|
||||||
if isinstance(payload, dict):
|
self,
|
||||||
payload = cast(dict[str, Any], payload)
|
active_tools: list[dict[str, Any]],
|
||||||
rows: object = payload.get("data")
|
*,
|
||||||
if not isinstance(rows, list):
|
usage: LLMUsage | None,
|
||||||
rows = payload.get("models")
|
stream_output_emitted: bool = False,
|
||||||
else:
|
) -> None:
|
||||||
rows = payload
|
names = [str(event.get("name") or "hosted_tool") for event in active_tools]
|
||||||
if not isinstance(rows, list):
|
super().__init__(
|
||||||
return {}
|
"xAI ended the response before its hosted tool completed: " + ", ".join(names)
|
||||||
|
|
||||||
capabilities: dict[str, bool] = {}
|
|
||||||
for row_value in cast(list[object], rows):
|
|
||||||
if not isinstance(row_value, dict):
|
|
||||||
continue
|
|
||||||
row = cast(dict[str, Any], row_value)
|
|
||||||
meta_value = row.get("_meta")
|
|
||||||
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
|
|
||||||
support_value = row.get("supportsBackendSearch")
|
|
||||||
if not isinstance(support_value, bool):
|
|
||||||
support_value = row.get("supports_backend_search")
|
|
||||||
if not isinstance(support_value, bool):
|
|
||||||
support_value = meta.get("supportsBackendSearch")
|
|
||||||
if not isinstance(support_value, bool):
|
|
||||||
support_value = meta.get("supports_backend_search")
|
|
||||||
supports_backend_search = support_value if isinstance(support_value, bool) else False
|
|
||||||
|
|
||||||
identifiers = (
|
|
||||||
row.get("model"),
|
|
||||||
row.get("modelId"),
|
|
||||||
row.get("id"),
|
|
||||||
meta.get("model"),
|
|
||||||
meta.get("modelId"),
|
|
||||||
)
|
)
|
||||||
for identifier in identifiers:
|
self.tool_names = tuple(names)
|
||||||
if isinstance(identifier, str) and identifier.strip():
|
self.usage = usage
|
||||||
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search
|
self.stream_output_emitted = stream_output_emitted
|
||||||
return capabilities
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_xai(
|
async def _request_xai(
|
||||||
@@ -438,10 +372,39 @@ async def _request_xai(
|
|||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
|
||||||
|
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||||
|
stream_output_emitted = False
|
||||||
|
|
||||||
|
async def _forward_content_delta(delta: str) -> None:
|
||||||
|
nonlocal stream_output_emitted
|
||||||
|
if delta:
|
||||||
|
stream_output_emitted = True
|
||||||
|
if on_content_delta is not None:
|
||||||
|
await on_content_delta(delta)
|
||||||
|
|
||||||
|
async def _forward_thinking_delta(delta: str) -> None:
|
||||||
|
nonlocal stream_output_emitted
|
||||||
|
if delta:
|
||||||
|
stream_output_emitted = True
|
||||||
|
if on_thinking_delta is not None:
|
||||||
|
await on_thinking_delta(delta)
|
||||||
|
|
||||||
|
async def _track_and_forward_tool_event(event: dict[str, Any]) -> None:
|
||||||
|
if event.get("kind") == "hosted_tool":
|
||||||
|
call_id = event.get("call_id")
|
||||||
|
if call_id:
|
||||||
|
call_id = str(call_id)
|
||||||
|
if event.get("phase") == "start":
|
||||||
|
active_hosted_tools[call_id] = dict(event)
|
||||||
|
elif event.get("phase") in {"end", "error"}:
|
||||||
|
active_hosted_tools.pop(call_id, None)
|
||||||
|
if on_tool_call_delta is not None:
|
||||||
|
await on_tool_call_delta(event)
|
||||||
|
|
||||||
async def _on_response_event(event: dict[str, Any]) -> None:
|
async def _on_response_event(event: dict[str, Any]) -> None:
|
||||||
hosted_event = _xai_hosted_tool_event(event)
|
hosted_event = _xai_hosted_tool_event(event)
|
||||||
if hosted_event is not None and on_tool_call_delta is not None:
|
if hosted_event is not None:
|
||||||
await on_tool_call_delta(hosted_event)
|
await _track_and_forward_tool_event(hosted_event)
|
||||||
|
|
||||||
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
|
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
|
||||||
if proxy:
|
if proxy:
|
||||||
@@ -452,13 +415,34 @@ async def _request_xai(
|
|||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
raw = content.decode("utf-8", "ignore")
|
raw = content.decode("utf-8", "ignore")
|
||||||
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
||||||
return await consume_sse_with_reasoning(
|
result = await consume_sse_with_reasoning(
|
||||||
response,
|
response,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=(_forward_content_delta if on_content_delta is not None else None),
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
# Always observe tool events so protocol validation also works for
|
||||||
on_reasoning_delta=on_thinking_delta,
|
# non-streaming callers that did not request UI progress callbacks.
|
||||||
on_response_event=_on_response_event if on_tool_call_delta else None,
|
on_tool_call_delta=_track_and_forward_tool_event,
|
||||||
|
on_reasoning_delta=(
|
||||||
|
_forward_thinking_delta if on_thinking_delta is not None else None
|
||||||
|
),
|
||||||
|
on_response_event=_on_response_event,
|
||||||
)
|
)
|
||||||
|
if result[2] != "error" and active_hosted_tools:
|
||||||
|
active = list(active_hosted_tools.values())
|
||||||
|
for event in active:
|
||||||
|
await _track_and_forward_tool_event(
|
||||||
|
{
|
||||||
|
**event,
|
||||||
|
"phase": "error",
|
||||||
|
"result": None,
|
||||||
|
"error": "xAI ended the response before this hosted tool completed.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise _XAIIncompleteHostedToolError(
|
||||||
|
active,
|
||||||
|
usage=result[3],
|
||||||
|
stream_output_emitted=stream_output_emitted,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
@@ -472,19 +456,33 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"phase": "start",
|
"phase": "start",
|
||||||
"call_id": str(call_id),
|
"call_id": str(call_id),
|
||||||
"name": "x_search",
|
"name": "x_search",
|
||||||
"arguments": _xai_hosted_tool_arguments(
|
"arguments": _xai_hosted_tool_arguments(event.get("input", event.get("arguments"))),
|
||||||
event.get("input", event.get("arguments"))
|
|
||||||
),
|
|
||||||
"result": None,
|
"result": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if event_type != "response.output_item.done":
|
if event_type not in {"response.output_item.added", "response.output_item.done"}:
|
||||||
return None
|
return None
|
||||||
item = event.get("item")
|
item = event.get("item")
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
return None
|
return None
|
||||||
item = cast(dict[str, Any], item)
|
item = cast(dict[str, Any], item)
|
||||||
if item.get("type") != "custom_tool_call":
|
item_type = item.get("type")
|
||||||
|
if item_type == "x_search_call":
|
||||||
|
call_id = item.get("id") or item.get("call_id") or event.get("item_id")
|
||||||
|
if not call_id:
|
||||||
|
return None
|
||||||
|
phase = "start" if event_type == "response.output_item.added" else "end"
|
||||||
|
return {
|
||||||
|
"kind": "hosted_tool",
|
||||||
|
"phase": phase,
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": "x_search",
|
||||||
|
"arguments": _xai_hosted_tool_arguments(item.get("action")),
|
||||||
|
"result": (
|
||||||
|
{"status": str(item.get("status") or "completed")} if phase == "end" else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if event_type != "response.output_item.done" or item_type != "custom_tool_call":
|
||||||
return None
|
return None
|
||||||
tool_name = item.get("name")
|
tool_name = item.get("name")
|
||||||
if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
|
if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
|
||||||
@@ -497,9 +495,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"phase": "end",
|
"phase": "end",
|
||||||
"call_id": str(call_id),
|
"call_id": str(call_id),
|
||||||
"name": "x_search",
|
"name": "x_search",
|
||||||
"arguments": _xai_hosted_tool_arguments(
|
"arguments": _xai_hosted_tool_arguments(item.get("input", item.get("arguments"))),
|
||||||
item.get("input", item.get("arguments"))
|
|
||||||
),
|
|
||||||
# Keep the useful search subtype, but do not persist large hosted results
|
# Keep the useful search subtype, but do not persist large hosted results
|
||||||
# in WebUI activity messages. The model answer already carries citations.
|
# in WebUI activity messages. The model answer already carries citations.
|
||||||
"result": {"name": tool_name},
|
"result": {"name": tool_name},
|
||||||
@@ -608,6 +604,8 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
|
|||||||
should_retry = True if should_retry is None else should_retry
|
should_retry = True if should_retry is None else should_retry
|
||||||
elif isinstance(exc, _XAIHTTPError):
|
elif isinstance(exc, _XAIHTTPError):
|
||||||
error_kind = "http"
|
error_kind = "http"
|
||||||
|
elif isinstance(exc, _XAIIncompleteHostedToolError):
|
||||||
|
error_kind = "provider"
|
||||||
if status_code is not None and should_retry is None:
|
if status_code is not None and should_retry is None:
|
||||||
should_retry = _should_retry_status(
|
should_retry = _should_retry_status(
|
||||||
int(status_code),
|
int(status_code),
|
||||||
@@ -617,9 +615,11 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
|
|||||||
)
|
)
|
||||||
message = str(exc).strip() or "unexpected error"
|
message = str(exc).strip() or "unexpected error"
|
||||||
retry_after = getattr(exc, "retry_after", None)
|
retry_after = getattr(exc, "retry_after", None)
|
||||||
|
usage = getattr(exc, "usage", None)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=f"Error calling xAI ({type(exc).__name__}): {message}",
|
content=f"Error calling xAI ({type(exc).__name__}): {message}",
|
||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
|
usage=usage if isinstance(usage, LLMUsage) else None,
|
||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
error_status_code=int(status_code) if status_code is not None else None,
|
error_status_code=int(status_code) if status_code is not None else None,
|
||||||
error_kind=error_kind,
|
error_kind=error_kind,
|
||||||
@@ -647,3 +647,209 @@ def _should_retry_status(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
|
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
|
|
||||||
|
def get_xai_grok_model_catalog(proxy: str | None = None) -> OAuthModelCatalogSnapshot:
|
||||||
|
token = get_xai_oauth_login_status()
|
||||||
|
account_key = _catalog_account_key(getattr(token, "account_id", None))
|
||||||
|
cache_key = f"{get_xai_oauth_storage_path()}\0{account_key}\0{proxy or ''}"
|
||||||
|
return _XAI_GROK_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_xai_grok_model_catalog() -> None:
|
||||||
|
_XAI_GROK_MODEL_CATALOG.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_xai_grok_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
token = get_xai_oauth_token(proxy=proxy)
|
||||||
|
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||||
|
if proxy:
|
||||||
|
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||||
|
with httpx.Client(**client_kwargs) as client:
|
||||||
|
response = client.get(
|
||||||
|
DEFAULT_XAI_GROK_MODELS_URL,
|
||||||
|
headers=_build_xai_model_headers(token.access, token.account_id),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _parse_xai_grok_models(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xai_grok_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload_mapping = cast(dict[str, Any], payload)
|
||||||
|
rows: object = payload_mapping.get("data")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = payload_mapping.get("models")
|
||||||
|
else:
|
||||||
|
rows = payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
fallback_models = _oauth_fallback_models("xai_grok")
|
||||||
|
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||||
|
models: list[ProviderModelSpec] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in cast(list[object], rows):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
row = cast(dict[str, Any], value)
|
||||||
|
meta = _catalog_mapping(row.get("_meta"))
|
||||||
|
raw_id = next(
|
||||||
|
(
|
||||||
|
candidate.strip()
|
||||||
|
for candidate in (
|
||||||
|
row.get("id"),
|
||||||
|
row.get("model"),
|
||||||
|
row.get("modelId"),
|
||||||
|
row.get("name"),
|
||||||
|
meta.get("id"),
|
||||||
|
meta.get("model"),
|
||||||
|
meta.get("modelId"),
|
||||||
|
)
|
||||||
|
if isinstance(candidate, str) and candidate.strip()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if raw_id is None:
|
||||||
|
continue
|
||||||
|
wire_id = raw_id.split("/", 1)[-1]
|
||||||
|
if wire_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(wire_id)
|
||||||
|
fallback = fallback_by_id.get(wire_id)
|
||||||
|
label = _catalog_first_text(row, "display_name", "label", "name") or _catalog_first_text(
|
||||||
|
meta,
|
||||||
|
"display_name",
|
||||||
|
"label",
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
if not label or label == raw_id:
|
||||||
|
label = fallback.label if fallback is not None else wire_id
|
||||||
|
models.append(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id=f"xai-grok/{wire_id}",
|
||||||
|
label=label,
|
||||||
|
description=(
|
||||||
|
_catalog_first_text(row, "description")
|
||||||
|
or _catalog_first_text(meta, "description")
|
||||||
|
or (fallback.description if fallback is not None else "")
|
||||||
|
),
|
||||||
|
owned_by=(
|
||||||
|
_catalog_first_text(row, "owned_by", "owner", "organization")
|
||||||
|
or _catalog_first_text(meta, "owned_by", "owner", "organization")
|
||||||
|
or (fallback.owned_by if fallback is not None else "xAI")
|
||||||
|
),
|
||||||
|
context_window=(
|
||||||
|
_catalog_positive_int(row, "context_window", "context_length")
|
||||||
|
or _catalog_positive_int(meta, "context_window", "context_length")
|
||||||
|
or (fallback.context_window if fallback is not None else None)
|
||||||
|
),
|
||||||
|
reasoning_efforts=_catalog_reasoning_efforts(
|
||||||
|
row.get("reasoning_efforts", meta.get("reasoning_efforts"))
|
||||||
|
),
|
||||||
|
supports_backend_search=_catalog_bool_field(
|
||||||
|
row,
|
||||||
|
"supports_backend_search",
|
||||||
|
"supportsBackendSearch",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(models)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_xai_model_headers(access_token: str, account_id: str | None) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||||
|
"x-grok-client-version": XAI_CLIENT_VERSION,
|
||||||
|
"x-grok-client-identifier": "nanobot",
|
||||||
|
"x-grok-client-mode": "headless",
|
||||||
|
"User-Agent": f"nanobot/{__version__} (python)",
|
||||||
|
"accept": "application/json",
|
||||||
|
}
|
||||||
|
claims = _decode_access_token_claims(access_token)
|
||||||
|
user_id = claims.get("sub")
|
||||||
|
if claims.get("principal_type") == "Team":
|
||||||
|
user_id = claims.get("principal_id") or user_id
|
||||||
|
if isinstance(user_id, str) and user_id:
|
||||||
|
headers["x-userid"] = user_id
|
||||||
|
email = claims.get("email")
|
||||||
|
if not isinstance(email, str) or "@" not in email:
|
||||||
|
email = account_id if account_id and "@" in account_id else None
|
||||||
|
if email:
|
||||||
|
headers["x-email"] = email
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_access_token_claims(token: str) -> dict[str, Any]:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2 or not parts[1]:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
|
||||||
|
claims = json.loads(decoded)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
assert spec is not None
|
||||||
|
return spec.builtin_models
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_account_key(account_id: object) -> str:
|
||||||
|
value = account_id if isinstance(account_id, str) else ""
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_bool_field(row: dict[str, Any], *keys: str) -> bool:
|
||||||
|
for key in keys:
|
||||||
|
value = row.get(key)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
meta = row.get("_meta")
|
||||||
|
return _catalog_bool_field(_catalog_mapping(meta), *keys) if isinstance(meta, dict) else False
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
efforts: list[str] = []
|
||||||
|
for item in cast(list[object], value):
|
||||||
|
if isinstance(item, str):
|
||||||
|
effort = item.strip()
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
|
||||||
|
else:
|
||||||
|
effort = ""
|
||||||
|
if effort and effort not in efforts:
|
||||||
|
efforts.append(effort)
|
||||||
|
return tuple(efforts)
|
||||||
|
|
||||||
|
|
||||||
|
_XAI_GROK_MODEL_CATALOG = OAuthModelCatalog(
|
||||||
|
fallback_models=_oauth_fallback_models("xai_grok"),
|
||||||
|
fetch=_fetch_xai_grok_models,
|
||||||
|
)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ from nanobot.config.loader import resolve_config_env_vars
|
|||||||
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
|
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
|
||||||
from nanobot.providers.image_generation import get_image_gen_provider
|
from nanobot.providers.image_generation import get_image_gen_provider
|
||||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||||
|
from nanobot.providers.oauth_model_catalog import (
|
||||||
|
get_oauth_model_catalog,
|
||||||
|
invalidate_oauth_model_catalog,
|
||||||
|
)
|
||||||
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
||||||
from nanobot.webui.settings_contracts import (
|
from nanobot.webui.settings_contracts import (
|
||||||
QueryParams,
|
QueryParams,
|
||||||
@@ -661,6 +665,30 @@ def provider_models_payload(
|
|||||||
"models": rows,
|
"models": rows,
|
||||||
"model_count": len(rows),
|
"model_count": len(rows),
|
||||||
}
|
}
|
||||||
|
if catalog_kind == "hybrid":
|
||||||
|
proxy = _resolve_env_placeholders(provider_config.proxy)
|
||||||
|
catalog = get_oauth_model_catalog(spec.name, proxy=proxy)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": model.id,
|
||||||
|
"label": model.label or None,
|
||||||
|
"description": model.description or None,
|
||||||
|
"owned_by": model.owned_by or spec.label,
|
||||||
|
"context_window": model.context_window,
|
||||||
|
"reasoning_efforts": list(model.reasoning_efforts),
|
||||||
|
"supports_backend_search": model.supports_backend_search,
|
||||||
|
}
|
||||||
|
for model in catalog.models
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**base_payload,
|
||||||
|
"status": "available",
|
||||||
|
"source": catalog.source,
|
||||||
|
"models": rows,
|
||||||
|
"model_count": len(rows),
|
||||||
|
"message": catalog.message,
|
||||||
|
"fetched_at": catalog.fetched_at,
|
||||||
|
}
|
||||||
|
|
||||||
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
||||||
if spec.name == "openai" and not api_base:
|
if spec.name == "openai" and not api_base:
|
||||||
@@ -1506,6 +1534,7 @@ def login_oauth_provider(
|
|||||||
token = login_github_copilot(print_fn=lambda _message: None)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
@@ -1591,6 +1620,7 @@ def complete_oauth_provider(
|
|||||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||||
if not token.access:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -1629,6 +1659,7 @@ def logout_oauth_provider(
|
|||||||
|
|
||||||
oauth_flows.clear(spec.name)
|
oauth_flows.clear(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||||
@@ -1636,6 +1667,7 @@ def logout_oauth_provider(
|
|||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -799,7 +799,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
|||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "xai_grok"
|
assert saved.agents.defaults.provider == "xai_grok"
|
||||||
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
|
assert saved.agents.defaults.model == "xai-grok/grok-4.6"
|
||||||
assert saved.agents.defaults.context_window_tokens == 500_000
|
assert saved.agents.defaults.context_window_tokens == 500_000
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset is None
|
||||||
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
||||||
@@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
|||||||
assert seen["lease_release_wait_for_stop"] is False
|
assert seen["lease_release_wait_for_stop"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
|
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None:
|
||||||
stopped = False
|
stopped = False
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.touch()
|
||||||
|
|
||||||
class _FakeRuntime:
|
class _FakeRuntime:
|
||||||
def status(self):
|
def status(self):
|
||||||
return SimpleNamespace(running=True)
|
return SimpleNamespace(running=True, log_path=log_path)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
nonlocal stopped
|
nonlocal stopped
|
||||||
@@ -2679,10 +2681,88 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
|
|||||||
assert "WebUI launcher detached" in rendered
|
assert "WebUI launcher detached" in rendered
|
||||||
|
|
||||||
|
|
||||||
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.write_text("historical log\n", encoding="utf-8")
|
||||||
|
polls = 0
|
||||||
|
|
||||||
class _FakeRuntime:
|
class _FakeRuntime:
|
||||||
def status(self):
|
def status(self):
|
||||||
return SimpleNamespace(running=True)
|
return SimpleNamespace(running=True, log_path=log_path)
|
||||||
|
|
||||||
|
def _append_then_interrupt(_seconds: float) -> None:
|
||||||
|
nonlocal polls
|
||||||
|
if polls == 0:
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write("[websocket] live log\n")
|
||||||
|
polls += 1
|
||||||
|
return
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
cli_webui_support._attach_to_background_gateway(
|
||||||
|
_FakeRuntime(),
|
||||||
|
sleep=_append_then_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "[websocket] live log" in output
|
||||||
|
assert "historical log" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.write_text("a much longer historical log line\n", encoding="utf-8")
|
||||||
|
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||||
|
log_path.write_text("fresh log\n", encoding="utf-8")
|
||||||
|
|
||||||
|
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||||
|
|
||||||
|
assert lines == ["fresh log"]
|
||||||
|
assert cursor.offset == log_path.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_new_gateway_logs_detects_fast_rewrite_past_offset(tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.write_text("historical log\n", encoding="utf-8")
|
||||||
|
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||||
|
log_path.write_text("first fresh log\nsecond fresh log\n", encoding="utf-8")
|
||||||
|
|
||||||
|
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||||
|
|
||||||
|
assert lines == ["first fresh log", "second fresh log"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_new_gateway_logs_waits_for_complete_utf8_line(tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.touch()
|
||||||
|
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||||
|
encoded = "模型 ready\n".encode()
|
||||||
|
log_path.write_bytes(encoded[:2])
|
||||||
|
|
||||||
|
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == []
|
||||||
|
|
||||||
|
with log_path.open("ab") as handle:
|
||||||
|
handle.write(encoded[2:])
|
||||||
|
|
||||||
|
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == ["模型 ready"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "missing.log"
|
||||||
|
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||||
|
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||||
|
|
||||||
|
assert lines == []
|
||||||
|
assert cursor.offset == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None:
|
||||||
|
log_path = tmp_path / "gateway.log"
|
||||||
|
log_path.touch()
|
||||||
|
|
||||||
|
class _FakeRuntime:
|
||||||
|
def status(self):
|
||||||
|
return SimpleNamespace(running=True, log_path=log_path)
|
||||||
|
|
||||||
def sidecar_exited() -> None:
|
def sidecar_exited() -> None:
|
||||||
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.providers.oauth_model_catalog import (
|
||||||
|
OAuthModelCatalog,
|
||||||
|
get_oauth_model_catalog,
|
||||||
|
invalidate_oauth_model_catalog,
|
||||||
|
)
|
||||||
|
from nanobot.providers.openai_codex_provider import (
|
||||||
|
DEFAULT_OPENAI_CODEX_MODELS_URL,
|
||||||
|
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
|
||||||
|
)
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec
|
||||||
|
from nanobot.providers.xai_grok_provider import DEFAULT_XAI_GROK_MODELS_URL
|
||||||
|
from nanobot.providers.xai_oauth import XAIToken
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_oauth_catalogs() -> None:
|
||||||
|
for provider in ("openai_codex", "xai_grok", "github_copilot"):
|
||||||
|
invalidate_oauth_model_catalog(provider)
|
||||||
|
yield
|
||||||
|
for provider in ("openai_codex", "xai_grok", "github_copilot"):
|
||||||
|
invalidate_oauth_model_catalog(provider)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_model() -> ProviderModelSpec:
|
||||||
|
return ProviderModelSpec(id="provider/fallback", label="Fallback")
|
||||||
|
|
||||||
|
|
||||||
|
def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original_client = httpx.Client
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
payload = (
|
||||||
|
base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
.rstrip("=")
|
||||||
|
)
|
||||||
|
token = XAIToken(
|
||||||
|
access=f"header.{payload}.signature",
|
||||||
|
refresh="refresh-token",
|
||||||
|
expires=int(time.time() * 1000) + 3_600_000,
|
||||||
|
account_id="user@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["request"] = request
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "grok-4.6",
|
||||||
|
"name": "Grok 4.6",
|
||||||
|
"description": "Latest frontier model",
|
||||||
|
"owned_by": "xAI",
|
||||||
|
"context_window": 500_000,
|
||||||
|
"supports_backend_search": True,
|
||||||
|
"reasoning_efforts": [
|
||||||
|
{"value": "xhigh"},
|
||||||
|
{"value": "high"},
|
||||||
|
{"value": "low"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grok-next",
|
||||||
|
"_meta": {
|
||||||
|
"name": "Grok Next",
|
||||||
|
"context_window": 750_000,
|
||||||
|
"reasoning_efforts": ["high", "low"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_client(**kwargs: object) -> httpx.Client:
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return original_client(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
timeout=kwargs["timeout"],
|
||||||
|
follow_redirects=kwargs["follow_redirects"],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.xai_grok_provider.get_xai_oauth_storage_path",
|
||||||
|
lambda: tmp_path / "auth" / "xai.json",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.xai_grok_provider.get_xai_oauth_login_status",
|
||||||
|
lambda: token,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.xai_grok_provider.get_xai_oauth_token",
|
||||||
|
lambda **_kwargs: token,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.Client", fake_client)
|
||||||
|
|
||||||
|
catalog = get_oauth_model_catalog("xai_grok")
|
||||||
|
|
||||||
|
assert catalog.source == "remote"
|
||||||
|
assert [model.id for model in catalog.models] == [
|
||||||
|
"xai-grok/grok-4.6",
|
||||||
|
"xai-grok/grok-next",
|
||||||
|
]
|
||||||
|
grok = catalog.find("grok-4.6")
|
||||||
|
assert grok is not None
|
||||||
|
assert grok.description == "Latest frontier model"
|
||||||
|
assert grok.context_window == 500_000
|
||||||
|
assert grok.reasoning_efforts == ("xhigh", "high", "low")
|
||||||
|
assert grok.supports_backend_search is True
|
||||||
|
next_model = catalog.find("xai-grok/grok-next")
|
||||||
|
assert next_model is not None
|
||||||
|
assert next_model.label == "Grok Next"
|
||||||
|
assert next_model.context_window == 750_000
|
||||||
|
assert next_model.reasoning_efforts == ("high", "low")
|
||||||
|
|
||||||
|
request = captured["request"]
|
||||||
|
assert isinstance(request, httpx.Request)
|
||||||
|
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
|
||||||
|
assert request.headers["Authorization"] == f"Bearer {token.access}"
|
||||||
|
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
||||||
|
assert request.headers["x-userid"] == "user-42"
|
||||||
|
assert request.headers["x-email"] == "user@example.com"
|
||||||
|
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
|
||||||
|
assert get_oauth_model_catalog("xai_grok").source == "cache"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original_client = httpx.Client
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["request"] = request
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"slug": "gpt-new",
|
||||||
|
"display_name": "GPT New",
|
||||||
|
"description": "New model",
|
||||||
|
"context_window": 300_000,
|
||||||
|
"priority": 2,
|
||||||
|
"visibility": "list",
|
||||||
|
"supported_reasoning_levels": [
|
||||||
|
{"effort": "low"},
|
||||||
|
{"effort": "high"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "gpt-first",
|
||||||
|
"display_name": "GPT First",
|
||||||
|
"priority": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "internal-model",
|
||||||
|
"display_name": "Internal",
|
||||||
|
"visibility": "hide",
|
||||||
|
"priority": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_client(**kwargs: object) -> httpx.Client:
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return original_client(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
timeout=kwargs["timeout"],
|
||||||
|
follow_redirects=kwargs["follow_redirects"],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Storage:
|
||||||
|
def load(self) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(access="secret", account_id="account-42")
|
||||||
|
|
||||||
|
def get_token_path(self) -> Path:
|
||||||
|
return tmp_path / "auth" / "openai-codex.json"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.openai_codex_provider.FileTokenStorage",
|
||||||
|
lambda **_kwargs: Storage(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||||
|
lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.Client", fake_client)
|
||||||
|
|
||||||
|
catalog = get_oauth_model_catalog("openai_codex")
|
||||||
|
|
||||||
|
assert catalog.source == "remote"
|
||||||
|
assert [model.id for model in catalog.models] == [
|
||||||
|
"openai-codex/gpt-first",
|
||||||
|
"openai-codex/gpt-new",
|
||||||
|
]
|
||||||
|
assert catalog.models[1].context_window == 300_000
|
||||||
|
assert catalog.models[1].reasoning_efforts == ("low", "high")
|
||||||
|
request = captured["request"]
|
||||||
|
assert isinstance(request, httpx.Request)
|
||||||
|
assert request.url.copy_with(query=None) == httpx.URL(DEFAULT_OPENAI_CODEX_MODELS_URL)
|
||||||
|
assert request.url.params["client_version"] == OPENAI_CODEX_CATALOG_CLIENT_VERSION
|
||||||
|
assert request.headers["Authorization"] == "Bearer secret"
|
||||||
|
assert request.headers["chatgpt-account-id"] == "account-42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_copilot_catalog_only_lists_compatible_chat_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original_client = httpx.Client
|
||||||
|
captured: list[httpx.Request] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured.append(request)
|
||||||
|
if request.url.path.endswith("/copilot_internal/v2/token"):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"token": "copilot-secret",
|
||||||
|
"endpoints": {"api": "https://api.individual.githubcopilot.com"},
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "claude-sonnet",
|
||||||
|
"name": "Claude Sonnet",
|
||||||
|
"model_picker_enabled": True,
|
||||||
|
"policy": {"state": "enabled"},
|
||||||
|
"supported_endpoints": ["/chat/completions"],
|
||||||
|
"capabilities": {
|
||||||
|
"supports": {"reasoning_effort": ["low", "high"]},
|
||||||
|
"limits": {"max_context_window_tokens": 200_000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5.4-mini",
|
||||||
|
"name": "GPT-5.4 Mini",
|
||||||
|
"model_picker_enabled": True,
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unknown-responses-only",
|
||||||
|
"name": "Unknown Responses only",
|
||||||
|
"model_picker_enabled": True,
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "disabled",
|
||||||
|
"model_picker_enabled": True,
|
||||||
|
"policy": {"state": "disabled"},
|
||||||
|
"supported_endpoints": ["/chat/completions"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_client(**kwargs: object) -> httpx.Client:
|
||||||
|
return original_client(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
timeout=kwargs["timeout"],
|
||||||
|
follow_redirects=kwargs["follow_redirects"],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Storage:
|
||||||
|
def load(self) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(access="github-secret", account_id="octocat")
|
||||||
|
|
||||||
|
def get_token_path(self) -> Path:
|
||||||
|
return tmp_path / "auth" / "github-copilot.json"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.github_copilot_provider.get_storage",
|
||||||
|
lambda: Storage(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.providers.github_copilot_provider.httpx.Client", fake_client)
|
||||||
|
|
||||||
|
catalog = get_oauth_model_catalog("github_copilot")
|
||||||
|
|
||||||
|
assert catalog.source == "remote"
|
||||||
|
assert [model.id for model in catalog.models] == [
|
||||||
|
"github-copilot/claude-sonnet",
|
||||||
|
"github-copilot/gpt-5.4-mini",
|
||||||
|
]
|
||||||
|
assert catalog.models[0].context_window == 200_000
|
||||||
|
assert catalog.models[0].reasoning_efforts == ("low", "high")
|
||||||
|
assert len(captured) == 2
|
||||||
|
assert captured[0].headers["Authorization"] == "token github-secret"
|
||||||
|
assert captured[1].headers["Authorization"] == "Bearer copilot-secret"
|
||||||
|
assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models"
|
||||||
|
assert get_oauth_model_catalog("github_copilot").source == "cache"
|
||||||
|
assert get_oauth_model_catalog(
|
||||||
|
"github_copilot",
|
||||||
|
proxy="http://proxy.example:8080",
|
||||||
|
).source == "remote"
|
||||||
|
assert len(captured) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_single_flights_concurrent_refreshes() -> None:
|
||||||
|
calls = 0
|
||||||
|
calls_lock = threading.Lock()
|
||||||
|
barrier = threading.Barrier(8)
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
nonlocal calls
|
||||||
|
with calls_lock:
|
||||||
|
calls += 1
|
||||||
|
time.sleep(0.05)
|
||||||
|
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
|
||||||
|
|
||||||
|
def get_catalog(_index: int):
|
||||||
|
barrier.wait()
|
||||||
|
return catalog.get(cache_key="shared")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
results = list(pool.map(get_catalog, range(8)))
|
||||||
|
|
||||||
|
assert calls == 1
|
||||||
|
assert {result.models[0].id for result in results} == {"provider/remote"}
|
||||||
|
assert [result.source for result in results].count("remote") == 1
|
||||||
|
assert [result.source for result in results].count("cache") == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
identity = ["old-account"]
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
current = identity[0]
|
||||||
|
if current == "old-account":
|
||||||
|
started.set()
|
||||||
|
assert release.wait(timeout=2)
|
||||||
|
return (ProviderModelSpec(id=f"provider/{current}", label=current),)
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
old_future = pool.submit(catalog.get, cache_key="old-key")
|
||||||
|
assert started.wait(timeout=2)
|
||||||
|
identity[0] = "new-account"
|
||||||
|
catalog.invalidate()
|
||||||
|
new_future = pool.submit(catalog.get, cache_key="new-key")
|
||||||
|
new_result = new_future.result(timeout=2)
|
||||||
|
release.set()
|
||||||
|
old_result = old_future.result(timeout=2)
|
||||||
|
|
||||||
|
assert old_result.source == "fallback"
|
||||||
|
assert new_result.models[0].id == "provider/new-account"
|
||||||
|
|
||||||
|
identity[0] = "old-account"
|
||||||
|
assert catalog.get(cache_key="old-key").models[0].id == "provider/old-account"
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_bounds_failure_only_keys() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
raise httpx.ConnectError("offline")
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(
|
||||||
|
fallback_models=(_fallback_model(),),
|
||||||
|
fetch=fetch,
|
||||||
|
max_entries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("one", "two", "three"):
|
||||||
|
assert catalog.get(cache_key=key).source == "fallback"
|
||||||
|
|
||||||
|
assert calls == 3
|
||||||
|
assert catalog.get(cache_key="one").source == "fallback"
|
||||||
|
assert calls == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
|
||||||
|
now = [0.0]
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls > 1:
|
||||||
|
raise httpx.ConnectError("offline")
|
||||||
|
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(
|
||||||
|
fallback_models=(_fallback_model(),),
|
||||||
|
fetch=fetch,
|
||||||
|
fresh_ttl_s=10,
|
||||||
|
stale_ttl_s=100,
|
||||||
|
failure_ttl_s=30,
|
||||||
|
monotonic=lambda: now[0],
|
||||||
|
wall_clock=lambda: 123.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert catalog.get(cache_key="one").source == "remote"
|
||||||
|
now[0] = 11
|
||||||
|
stale = catalog.get(cache_key="one")
|
||||||
|
assert stale.source == "stale"
|
||||||
|
assert stale.models[0].id == "provider/remote"
|
||||||
|
assert catalog.get(cache_key="one").source == "stale"
|
||||||
|
assert calls == 2
|
||||||
|
|
||||||
|
now[0] = 101
|
||||||
|
fallback = catalog.get(cache_key="one")
|
||||||
|
assert fallback.source == "fallback"
|
||||||
|
assert fallback.models[0].id == "provider/fallback"
|
||||||
|
assert calls == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"failure",
|
||||||
|
[
|
||||||
|
httpx.ConnectError("offline"),
|
||||||
|
ValueError("invalid JSON"),
|
||||||
|
httpx.HTTPStatusError(
|
||||||
|
"unauthorized",
|
||||||
|
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||||
|
response=httpx.Response(401),
|
||||||
|
),
|
||||||
|
httpx.HTTPStatusError(
|
||||||
|
"rate limited",
|
||||||
|
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||||
|
response=httpx.Response(429),
|
||||||
|
),
|
||||||
|
httpx.HTTPStatusError(
|
||||||
|
"upstream failure",
|
||||||
|
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||||
|
response=httpx.Response(503),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
raise failure
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(
|
||||||
|
fallback_models=(_fallback_model(),),
|
||||||
|
fetch=fetch,
|
||||||
|
failure_ttl_s=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = catalog.get(cache_key="one")
|
||||||
|
second = catalog.get(cache_key="one")
|
||||||
|
|
||||||
|
assert first.source == "fallback"
|
||||||
|
assert second.source == "fallback"
|
||||||
|
assert first.models == (_fallback_model(),)
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return () if calls == 1 else (ProviderModelSpec(id="provider/new", label="New"),)
|
||||||
|
|
||||||
|
catalog = OAuthModelCatalog(
|
||||||
|
fallback_models=(_fallback_model(),),
|
||||||
|
fetch=fetch,
|
||||||
|
failure_ttl_s=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert catalog.get(cache_key="one").source == "fallback"
|
||||||
|
catalog.invalidate()
|
||||||
|
refreshed = catalog.get(cache_key="one")
|
||||||
|
assert refreshed.source == "remote"
|
||||||
|
assert refreshed.models[0].id == "provider/new"
|
||||||
|
assert calls == 2
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -12,21 +11,19 @@ import pytest
|
|||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.base import LLMUsage
|
from nanobot.providers.base import LLMUsage
|
||||||
from nanobot.providers.factory import make_provider
|
from nanobot.providers.factory import make_provider
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||||
from nanobot.providers.xai_grok_provider import (
|
from nanobot.providers.xai_grok_provider import (
|
||||||
DEFAULT_XAI_GROK_MODEL,
|
DEFAULT_XAI_GROK_MODEL,
|
||||||
DEFAULT_XAI_GROK_MODELS_URL,
|
|
||||||
XAIGrokProvider,
|
XAIGrokProvider,
|
||||||
_bounded_error_body,
|
_bounded_error_body,
|
||||||
_build_headers,
|
_build_headers,
|
||||||
_build_model_headers,
|
|
||||||
_build_reasoning_options,
|
_build_reasoning_options,
|
||||||
_build_xai_http_error,
|
_build_xai_http_error,
|
||||||
_fetch_xai_model_capabilities,
|
|
||||||
_parse_xai_model_capabilities,
|
|
||||||
_request_xai,
|
_request_xai,
|
||||||
_xai_error_response,
|
_xai_error_response,
|
||||||
_XAIHTTPError,
|
_XAIHTTPError,
|
||||||
|
_XAIIncompleteHostedToolError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,22 +48,41 @@ def _mock_model_capabilities(
|
|||||||
*,
|
*,
|
||||||
supports_backend_search: bool,
|
supports_backend_search: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def fake_fetch(*_args, **_kwargs):
|
def fake_catalog(*_args, **_kwargs):
|
||||||
return {"grok-4.5": supports_backend_search}
|
return OAuthModelCatalogSnapshot(
|
||||||
|
models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.5",
|
||||||
|
label="Grok 4.5",
|
||||||
|
supports_backend_search=supports_backend_search,
|
||||||
|
),
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.6",
|
||||||
|
label="Grok 4.6",
|
||||||
|
supports_backend_search=supports_backend_search,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
source="remote",
|
||||||
|
fetched_at=1,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||||
fake_fetch,
|
fake_catalog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_xai_grok_registry_exposes_curated_x_search_model() -> None:
|
def test_xai_grok_registry_exposes_curated_x_search_models() -> None:
|
||||||
spec = find_by_name("xai_grok")
|
spec = find_by_name("xai_grok")
|
||||||
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
assert spec.is_oauth is True
|
assert spec.is_oauth is True
|
||||||
assert spec.backend == "xai_grok"
|
assert spec.backend == "xai_grok"
|
||||||
assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL
|
assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL
|
||||||
|
assert [model.id for model in spec.builtin_models] == [
|
||||||
|
"xai-grok/grok-4.6",
|
||||||
|
"xai-grok/grok-4.5",
|
||||||
|
]
|
||||||
assert spec.builtin_models[0].context_window == 500000
|
assert spec.builtin_models[0].context_window == 500000
|
||||||
assert "when supported" in spec.builtin_models[0].description
|
assert "when supported" in spec.builtin_models[0].description
|
||||||
|
|
||||||
@@ -117,7 +133,7 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
|||||||
assert response.content == "answer [[1]](https://x.com/example/status/1)"
|
assert response.content == "answer [[1]](https://x.com/example/status/1)"
|
||||||
url, headers, body = calls[0]
|
url, headers, body = calls[0]
|
||||||
assert url == "https://cli-chat-proxy.grok.com/v1/responses"
|
assert url == "https://cli-chat-proxy.grok.com/v1/responses"
|
||||||
assert body["model"] == "grok-4.5"
|
assert body["model"] == "grok-4.6"
|
||||||
assert body["tools"] == [
|
assert body["tools"] == [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -132,12 +148,13 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
|||||||
assert body["stream_tool_calls"] is True
|
assert body["stream_tool_calls"] is True
|
||||||
assert body["reasoning"] == {"summary": "concise", "effort": "high"}
|
assert body["reasoning"] == {"summary": "concise", "effort": "high"}
|
||||||
assert body["store"] is False
|
assert body["store"] is False
|
||||||
|
assert body["max_turns"] == 5
|
||||||
assert headers["Authorization"] == "Bearer subscription-token"
|
assert headers["Authorization"] == "Bearer subscription-token"
|
||||||
assert headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
assert headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
||||||
assert headers["x-authenticateresponse"] == "authenticate-response"
|
assert headers["x-authenticateresponse"] == "authenticate-response"
|
||||||
assert headers["x-grok-client-identifier"] == "nanobot"
|
assert headers["x-grok-client-identifier"] == "nanobot"
|
||||||
assert headers["x-grok-client-mode"] == "headless"
|
assert headers["x-grok-client-mode"] == "headless"
|
||||||
assert headers["x-grok-model-override"] == "grok-4.5"
|
assert headers["x-grok-model-override"] == "grok-4.6"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -147,7 +164,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
|||||||
_mock_token(monkeypatch)
|
_mock_token(monkeypatch)
|
||||||
bodies: list[dict[str, Any]] = []
|
bodies: list[dict[str, Any]] = []
|
||||||
|
|
||||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||||
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
||||||
|
|
||||||
async def fake_request(_url, _headers, body, **_kwargs):
|
async def fake_request(_url, _headers, body, **_kwargs):
|
||||||
@@ -155,7 +172,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
|||||||
return "ok", [], "stop", {}, None
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||||
unexpected_catalog_lookup,
|
unexpected_catalog_lookup,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
@@ -164,10 +181,12 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
|||||||
"allowed_x_handles": ["nanobot_ai"],
|
"allowed_x_handles": ["nanobot_ai"],
|
||||||
"from_date": "2026-01-01",
|
"from_date": "2026-01-01",
|
||||||
}
|
}
|
||||||
provider = XAIGrokProvider(extra_body={
|
provider = XAIGrokProvider(
|
||||||
"parallel_tool_calls": False,
|
extra_body={
|
||||||
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
"parallel_tool_calls": False,
|
||||||
})
|
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
response = await provider.chat(
|
response = await provider.chat(
|
||||||
[{"role": "user", "content": "search"}],
|
[{"role": "user", "content": "search"}],
|
||||||
@@ -210,7 +229,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
|||||||
_mock_token(monkeypatch)
|
_mock_token(monkeypatch)
|
||||||
bodies: list[dict[str, Any]] = []
|
bodies: list[dict[str, Any]] = []
|
||||||
|
|
||||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||||
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
||||||
|
|
||||||
async def fake_request(_url, _headers, body, **_kwargs):
|
async def fake_request(_url, _headers, body, **_kwargs):
|
||||||
@@ -218,7 +237,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
|||||||
return "ok", [], "stop", {}, None
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||||
unexpected_catalog_lookup,
|
unexpected_catalog_lookup,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
@@ -226,23 +245,28 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
|||||||
|
|
||||||
response = await provider.chat(
|
response = await provider.chat(
|
||||||
[{"role": "user", "content": "hello"}],
|
[{"role": "user", "content": "hello"}],
|
||||||
tools=[{
|
tools=[
|
||||||
"type": "function",
|
{
|
||||||
"function": {
|
"type": "function",
|
||||||
"name": "read_file",
|
"function": {
|
||||||
"description": "Read a file",
|
"name": "read_file",
|
||||||
"parameters": {"type": "object"},
|
"description": "Read a file",
|
||||||
},
|
"parameters": {"type": "object"},
|
||||||
}],
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.content == "ok"
|
assert response.content == "ok"
|
||||||
assert bodies[0]["tools"] == [{
|
assert bodies[0]["tools"] == [
|
||||||
"type": "function",
|
{
|
||||||
"name": "read_file",
|
"type": "function",
|
||||||
"description": "Read a file",
|
"name": "read_file",
|
||||||
"parameters": {"type": "object"},
|
"description": "Read a file",
|
||||||
}]
|
"parameters": {"type": "object"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert "max_turns" not in bodies[0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -281,35 +305,8 @@ async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_
|
|||||||
"parameters": {"type": "object"},
|
"parameters": {"type": "object"},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
assert "max_turns" not in bodies[0]
|
||||||
|
assert bodies[0]["instructions"] == ""
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_provider_fails_closed_and_caches_model_catalog_failure(monkeypatch) -> None:
|
|
||||||
_mock_token(monkeypatch)
|
|
||||||
fetch_calls = 0
|
|
||||||
bodies: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def failing_fetch(*_args, **_kwargs):
|
|
||||||
nonlocal fetch_calls
|
|
||||||
fetch_calls += 1
|
|
||||||
raise httpx.ConnectError("catalog unavailable")
|
|
||||||
|
|
||||||
async def fake_request(_url, _headers, body, **_kwargs):
|
|
||||||
bodies.append(body)
|
|
||||||
return "ok", [], "stop", {}, None
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
|
||||||
failing_fetch,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
|
||||||
provider = XAIGrokProvider()
|
|
||||||
|
|
||||||
await provider.chat([{"role": "user", "content": "first"}])
|
|
||||||
await provider.chat([{"role": "user", "content": "second"}])
|
|
||||||
|
|
||||||
assert fetch_calls == 1
|
|
||||||
assert all({"type": "x_search"} not in body["tools"] for body in bodies)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -395,7 +392,10 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
|
|||||||
"providers": {
|
"providers": {
|
||||||
"xaiGrok": {
|
"xaiGrok": {
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"proxy": "http://127.0.0.1:7890",
|
||||||
"extraBody": {"parallel_tool_calls": False},
|
"extraBody": {
|
||||||
|
"parallel_tool_calls": False,
|
||||||
|
"max_turns": 2,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -408,6 +408,7 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
|
|||||||
assert provider.proxy == "http://127.0.0.1:7890"
|
assert provider.proxy == "http://127.0.0.1:7890"
|
||||||
assert response.content == "ok"
|
assert response.content == "ok"
|
||||||
assert bodies[0]["parallel_tool_calls"] is False
|
assert bodies[0]["parallel_tool_calls"] is False
|
||||||
|
assert bodies[0]["max_turns"] == 2
|
||||||
assert {"type": "x_search"} in bodies[0]["tools"]
|
assert {"type": "x_search"} in bodies[0]["tools"]
|
||||||
|
|
||||||
|
|
||||||
@@ -527,75 +528,183 @@ async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatc
|
|||||||
assert "large hosted result" not in json.dumps(tool_events)
|
assert "large hosted result" not in json.dumps(tool_events)
|
||||||
|
|
||||||
|
|
||||||
def test_model_capabilities_follow_upstream_aliases_and_default_to_disabled() -> None:
|
|
||||||
capabilities = _parse_xai_model_capabilities(
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{"id": "grok-4.5", "supportsBackendSearch": False},
|
|
||||||
{
|
|
||||||
"model": "grok-search",
|
|
||||||
"supports_backend_search": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelId": "grok-meta",
|
|
||||||
"_meta": {"supportsBackendSearch": True},
|
|
||||||
},
|
|
||||||
{"id": "grok-unknown"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert capabilities == {
|
|
||||||
"grok-4.5": False,
|
|
||||||
"grok-search": True,
|
|
||||||
"grok-meta": True,
|
|
||||||
"grok-unknown": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_model_capability_request_uses_subscription_headers(monkeypatch) -> None:
|
async def test_raw_response_request_streams_official_x_search_lifecycle(monkeypatch) -> None:
|
||||||
original_client = httpx.AsyncClient
|
original_client = httpx.AsyncClient
|
||||||
captured: dict[str, Any] = {}
|
events = [
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"item": {
|
||||||
|
"type": "x_search_call",
|
||||||
|
"id": "x-search-1",
|
||||||
|
"status": "in_progress",
|
||||||
|
"action": {"query": "nanobot oauth"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"item": {
|
||||||
|
"type": "x_search_call",
|
||||||
|
"id": "x-search-1",
|
||||||
|
"status": "completed",
|
||||||
|
"action": {"query": "nanobot oauth"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {"status": "completed", "usage": {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
captured["request"] = request
|
return httpx.Response(200, content=content, request=request)
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={"data": [{"id": "grok-search", "supportsBackendSearch": True}]},
|
|
||||||
request=request,
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_client(**kwargs) -> httpx.AsyncClient:
|
def fake_client(**kwargs) -> httpx.AsyncClient:
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
return original_client(
|
return original_client(
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
timeout=kwargs["timeout"],
|
timeout=kwargs["timeout"],
|
||||||
follow_redirects=kwargs["follow_redirects"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
|
||||||
payload = base64.urlsafe_b64encode(
|
tool_events: list[dict[str, Any]] = []
|
||||||
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
|
|
||||||
).decode().rstrip("=")
|
|
||||||
access_token = f"header.{payload}.signature"
|
|
||||||
headers = _build_model_headers(_token(access_token))
|
|
||||||
|
|
||||||
capabilities = await _fetch_xai_model_capabilities(
|
await _request_xai(
|
||||||
DEFAULT_XAI_GROK_MODELS_URL,
|
"https://cli-chat-proxy.grok.com/v1/responses",
|
||||||
headers,
|
_build_headers("secret", "grok-4.6"),
|
||||||
|
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
|
||||||
|
on_tool_call_delta=lambda event: _append(tool_events, event),
|
||||||
)
|
)
|
||||||
|
|
||||||
request = captured["request"]
|
assert [(event["phase"], event["name"]) for event in tool_events] == [
|
||||||
assert isinstance(request, httpx.Request)
|
("start", "x_search"),
|
||||||
assert request.method == "GET"
|
("end", "x_search"),
|
||||||
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
|
]
|
||||||
assert request.headers["Authorization"] == f"Bearer {access_token}"
|
assert tool_events[-1]["result"] == {"status": "completed"}
|
||||||
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
|
||||||
assert request.headers["x-userid"] == "user-42"
|
|
||||||
assert request.headers["x-email"] == "user@example.com"
|
@pytest.mark.asyncio
|
||||||
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
|
async def test_raw_response_rejects_unfinished_hosted_tool_and_closes_progress(
|
||||||
assert capabilities == {"grok-search": True}
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
original_client = httpx.AsyncClient
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"type": "response.custom_tool_call_input.done",
|
||||||
|
"item_id": "x-search-1",
|
||||||
|
"input": '{"query":"nanobot oauth"}',
|
||||||
|
},
|
||||||
|
{"type": "response.output_text.delta", "delta": "I will keep searching."},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {
|
||||||
|
"status": "completed",
|
||||||
|
"usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=content, request=request)
|
||||||
|
|
||||||
|
def fake_client(**kwargs) -> httpx.AsyncClient:
|
||||||
|
return original_client(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
timeout=kwargs["timeout"],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
|
||||||
|
tool_events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
with pytest.raises(_XAIIncompleteHostedToolError) as caught:
|
||||||
|
await _request_xai(
|
||||||
|
"https://cli-chat-proxy.grok.com/v1/responses",
|
||||||
|
_build_headers("secret", "grok-4.6"),
|
||||||
|
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
|
||||||
|
on_tool_call_delta=lambda event: _append(tool_events, event),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert caught.value.usage == LLMUsage.reported(input_tokens=8, output_tokens=4)
|
||||||
|
assert [event["phase"] for event in tool_events] == ["start", "error"]
|
||||||
|
assert "before this hosted tool completed" in tool_events[-1]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
_mock_token(monkeypatch)
|
||||||
|
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
|
||||||
|
attempts = 0
|
||||||
|
request_ids: list[str] = []
|
||||||
|
streamed: list[str] = []
|
||||||
|
recovered: list[bool] = []
|
||||||
|
first_usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
|
||||||
|
second_usage = LLMUsage.reported(input_tokens=11, output_tokens=4)
|
||||||
|
|
||||||
|
async def fake_request(_url, headers, body, **kwargs):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
request_ids.append(headers["x-grok-req-id"])
|
||||||
|
assert body["max_turns"] == 5
|
||||||
|
if attempts == 1:
|
||||||
|
await kwargs["on_content_delta"]("I will keep searching.")
|
||||||
|
raise _XAIIncompleteHostedToolError(
|
||||||
|
[{"name": "x_search", "call_id": "search-1"}],
|
||||||
|
usage=first_usage,
|
||||||
|
)
|
||||||
|
await kwargs["on_content_delta"]("Final researched answer.")
|
||||||
|
return "Final researched answer.", [], "stop", second_usage, None
|
||||||
|
|
||||||
|
async def on_recover() -> None:
|
||||||
|
recovered.append(True)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
|
provider = XAIGrokProvider()
|
||||||
|
|
||||||
|
response = await provider.chat_stream_with_retry(
|
||||||
|
[{"role": "user", "content": "Search X"}],
|
||||||
|
on_content_delta=lambda delta: _append(streamed, delta),
|
||||||
|
on_stream_recover=on_recover,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert attempts == 2
|
||||||
|
assert len(set(request_ids)) == 2
|
||||||
|
assert recovered == [True]
|
||||||
|
assert streamed == ["I will keep searching.", "Final researched answer."]
|
||||||
|
assert response.content == "Final researched answer."
|
||||||
|
assert response.usage == first_usage + second_usage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_preserves_usage_when_hosted_tool_recovery_also_fails(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
_mock_token(monkeypatch)
|
||||||
|
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
|
||||||
|
attempts = 0
|
||||||
|
usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
|
||||||
|
|
||||||
|
async def fake_request(*_args, **_kwargs):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
raise _XAIIncompleteHostedToolError(
|
||||||
|
[{"name": "x_search", "call_id": f"search-{attempts}"}],
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
|
provider = XAIGrokProvider()
|
||||||
|
|
||||||
|
response = await provider.chat_stream_with_retry(
|
||||||
|
[{"role": "user", "content": "Search X"}],
|
||||||
|
on_stream_recover=lambda: _append([], True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert attempts == 2
|
||||||
|
assert response.finish_reason == "error"
|
||||||
|
assert response.usage == usage + usage
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
|||||||
from nanobot.llm_usage import get_llm_usage_store
|
from nanobot.llm_usage import get_llm_usage_store
|
||||||
from nanobot.llm_usage.models import LLMCallRecord
|
from nanobot.llm_usage.models import LLMCallRecord
|
||||||
from nanobot.providers.base import LLMUsage
|
from nanobot.providers.base import LLMUsage
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
|
||||||
|
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
@@ -183,11 +184,13 @@ def test_update_api_settings_requires_key_for_network_access(
|
|||||||
with pytest.raises(WebUISettingsError, match="API key"):
|
with pytest.raises(WebUISettingsError, match="API key"):
|
||||||
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
|
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
|
||||||
|
|
||||||
payload = update_api_settings({
|
payload = update_api_settings(
|
||||||
"host": ["0.0.0.0"],
|
{
|
||||||
"port": ["9900"],
|
"host": ["0.0.0.0"],
|
||||||
"api_key": ["secret-token"],
|
"port": ["9900"],
|
||||||
})
|
"api_key": ["secret-token"],
|
||||||
|
}
|
||||||
|
)
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.api.host == "0.0.0.0"
|
assert saved.api.host == "0.0.0.0"
|
||||||
assert saved.api.port == 9900
|
assert saved.api.port == 9900
|
||||||
@@ -346,13 +349,15 @@ def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config.model_validate({
|
config = Config.model_validate(
|
||||||
"providers": {
|
{
|
||||||
DYNAMIC_PROVIDER_NAME: {
|
"providers": {
|
||||||
"apiKey": "sk-test",
|
DYNAMIC_PROVIDER_NAME: {
|
||||||
|
"apiKey": "sk-test",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
@@ -497,9 +502,7 @@ def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config(
|
config = Config(model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")})
|
||||||
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
|
|
||||||
)
|
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
calls: list[tuple[str, str]] = []
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
@@ -890,11 +893,13 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = update_provider_settings({
|
payload = update_provider_settings(
|
||||||
"provider": [provider_name],
|
{
|
||||||
"proxy": [" http://127.0.0.1:7890 "],
|
"provider": [provider_name],
|
||||||
"extraBody": [json.dumps({"tools": []})],
|
"proxy": [" http://127.0.0.1:7890 "],
|
||||||
})
|
"extraBody": [json.dumps({"tools": []})],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
providers = {row["name"]: row for row in payload["providers"]}
|
providers = {row["name"]: row for row in payload["providers"]}
|
||||||
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
||||||
@@ -1099,15 +1104,17 @@ def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypa
|
|||||||
|
|
||||||
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
|
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config.model_validate({
|
config = Config.model_validate(
|
||||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
{
|
||||||
"agents": {
|
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||||
"defaults": {
|
"agents": {
|
||||||
"provider": "opencode_zen",
|
"defaults": {
|
||||||
"model": "opencode/deepseek-v4-pro",
|
"provider": "opencode_zen",
|
||||||
}
|
"model": "opencode/deepseek-v4-pro",
|
||||||
},
|
}
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
@@ -1124,13 +1131,15 @@ def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfi
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config.model_validate({
|
config = Config.model_validate(
|
||||||
"providers": {
|
{
|
||||||
DYNAMIC_PROVIDER_NAME: {
|
"providers": {
|
||||||
"apiKey": "sk-test",
|
DYNAMIC_PROVIDER_NAME: {
|
||||||
|
"apiKey": "sk-test",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
@@ -1466,16 +1475,18 @@ def test_settings_payload_includes_token_usage_summary(
|
|||||||
config = Config()
|
config = Config()
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
get_llm_usage_store().record(LLMCallRecord(
|
get_llm_usage_store().record(
|
||||||
started_at_ms=int(time.time() * 1000),
|
LLMCallRecord(
|
||||||
duration_ms=1,
|
started_at_ms=int(time.time() * 1000),
|
||||||
provider="openai",
|
duration_ms=1,
|
||||||
model="gpt-5",
|
provider="openai",
|
||||||
source="user",
|
model="gpt-5",
|
||||||
stream=False,
|
source="user",
|
||||||
finish_reason="stop",
|
stream=False,
|
||||||
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
|
finish_reason="stop",
|
||||||
))
|
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
payload = settings_payload()
|
payload = settings_payload()
|
||||||
|
|
||||||
@@ -1496,16 +1507,18 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
|
|||||||
config = Config()
|
config = Config()
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
get_llm_usage_store().record(LLMCallRecord(
|
get_llm_usage_store().record(
|
||||||
started_at_ms=int(time.time() * 1000),
|
LLMCallRecord(
|
||||||
duration_ms=1,
|
started_at_ms=int(time.time() * 1000),
|
||||||
provider="openai",
|
duration_ms=1,
|
||||||
model="gpt-5",
|
provider="openai",
|
||||||
source="user",
|
model="gpt-5",
|
||||||
stream=False,
|
source="user",
|
||||||
finish_reason="stop",
|
stream=False,
|
||||||
usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
|
finish_reason="stop",
|
||||||
))
|
usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
payload = settings_usage_payload()
|
payload = settings_usage_payload()
|
||||||
|
|
||||||
@@ -1929,9 +1942,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert exc.value.status == 502
|
assert exc.value.status == 502
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == ("xAI OAuth login failed: Could not reach xAI sign-in: ConnectError.")
|
||||||
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
|
|
||||||
)
|
|
||||||
assert exc.value.__cause__ is failure
|
assert exc.value.__cause__ is failure
|
||||||
|
|
||||||
|
|
||||||
@@ -1995,39 +2006,126 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
|||||||
assert payload["models"][1]["context_window"] == 65536
|
assert payload["models"][1]["context_window"] == 65536
|
||||||
|
|
||||||
|
|
||||||
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
|
def test_provider_models_payload_returns_online_openai_codex_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||||
|
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||||
|
models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="openai-codex/gpt-5.6-sol",
|
||||||
|
label="GPT-5.6-Sol",
|
||||||
|
description="Latest frontier agentic coding model.",
|
||||||
|
owned_by="OpenAI Codex",
|
||||||
|
context_window=272_000,
|
||||||
|
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
source="remote",
|
||||||
|
fetched_at=123,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
payload = provider_models_payload({"provider": ["openai_codex"]})
|
payload = provider_models_payload({"provider": ["openai_codex"]})
|
||||||
|
|
||||||
assert payload["status"] == "available"
|
assert payload["status"] == "available"
|
||||||
assert payload["catalog_kind"] == "builtin"
|
assert payload["catalog_kind"] == "hybrid"
|
||||||
assert payload["model_count"] == 7
|
assert payload["source"] == "remote"
|
||||||
|
assert payload["model_count"] == 1
|
||||||
assert payload["models"][0] == {
|
assert payload["models"][0] == {
|
||||||
"id": "openai-codex/gpt-5.6-sol",
|
"id": "openai-codex/gpt-5.6-sol",
|
||||||
"label": "GPT-5.6-Sol",
|
"label": "GPT-5.6-Sol",
|
||||||
"description": "Latest frontier agentic coding model.",
|
"description": "Latest frontier agentic coding model.",
|
||||||
"owned_by": "OpenAI Codex",
|
"owned_by": "OpenAI Codex",
|
||||||
"context_window": 372000,
|
"context_window": 272000,
|
||||||
|
"reasoning_efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||||
|
"supports_backend_search": False,
|
||||||
}
|
}
|
||||||
assert [model["id"] for model in payload["models"][:3]] == [
|
|
||||||
"openai-codex/gpt-5.6-sol",
|
|
||||||
"openai-codex/gpt-5.6-terra",
|
|
||||||
"openai-codex/gpt-5.6-luna",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_models_payload_returns_xai_grok_model() -> None:
|
def test_provider_models_payload_returns_online_github_copilot_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||||
|
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||||
|
models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="github-copilot/claude-sonnet",
|
||||||
|
label="Claude Sonnet",
|
||||||
|
owned_by="GitHub Copilot",
|
||||||
|
context_window=200_000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
source="remote",
|
||||||
|
fetched_at=123,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = provider_models_payload({"provider": ["github_copilot"]})
|
||||||
|
|
||||||
|
assert payload["status"] == "available"
|
||||||
|
assert payload["catalog_kind"] == "hybrid"
|
||||||
|
assert payload["source"] == "remote"
|
||||||
|
assert payload["models"][0]["id"] == "github-copilot/claude-sonnet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_models_payload_returns_online_xai_grok_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||||
|
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||||
|
models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.6",
|
||||||
|
label="Grok 4.6",
|
||||||
|
description="Latest frontier model",
|
||||||
|
owned_by="xAI",
|
||||||
|
context_window=500_000,
|
||||||
|
reasoning_efforts=("xhigh", "high", "medium", "low"),
|
||||||
|
supports_backend_search=True,
|
||||||
|
),
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.5",
|
||||||
|
label="Grok 4.5",
|
||||||
|
owned_by="xAI",
|
||||||
|
context_window=500_000,
|
||||||
|
reasoning_efforts=("high", "medium", "low"),
|
||||||
|
supports_backend_search=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
source="remote",
|
||||||
|
fetched_at=123,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
payload = provider_models_payload({"provider": ["xai_grok"]})
|
payload = provider_models_payload({"provider": ["xai_grok"]})
|
||||||
|
|
||||||
assert payload["status"] == "available"
|
assert payload["status"] == "available"
|
||||||
assert payload["catalog_kind"] == "builtin"
|
assert payload["catalog_kind"] == "hybrid"
|
||||||
|
assert payload["source"] == "remote"
|
||||||
|
assert payload["fetched_at"] == 123
|
||||||
assert payload["models"] == [
|
assert payload["models"] == [
|
||||||
|
{
|
||||||
|
"id": "xai-grok/grok-4.6",
|
||||||
|
"label": "Grok 4.6",
|
||||||
|
"description": "Latest frontier model",
|
||||||
|
"owned_by": "xAI",
|
||||||
|
"context_window": 500000,
|
||||||
|
"reasoning_efforts": ["xhigh", "high", "medium", "low"],
|
||||||
|
"supports_backend_search": True,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "xai-grok/grok-4.5",
|
"id": "xai-grok/grok-4.5",
|
||||||
"label": "Grok 4.5",
|
"label": "Grok 4.5",
|
||||||
"description": "Grok via xAI subscription; X Search is enabled when supported.",
|
"description": None,
|
||||||
"owned_by": "xAI Grok",
|
"owned_by": "xAI",
|
||||||
"context_window": 500000,
|
"context_window": 500000,
|
||||||
}
|
"reasoning_efforts": ["high", "medium", "low"],
|
||||||
|
"supports_backend_search": True,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -2160,7 +2258,9 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
|
|||||||
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
|
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
|
||||||
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
||||||
assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog"
|
assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog"
|
||||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
|
assert _model_catalog_kind(find_by_name("openai_codex")) == "hybrid"
|
||||||
|
assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid"
|
||||||
|
assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid"
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export function ModelsSettings({
|
|||||||
>
|
>
|
||||||
{saving || creatingSaving
|
{saving || creatingSaving
|
||||||
? tx("settings.actions.saving", "Saving...")
|
? tx("settings.actions.saving", "Saving...")
|
||||||
: tx("settings.actions.savePreset", "Save preset")}
|
: tx("settings.actions.savePreset", "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,13 +204,15 @@ export function ModelIdPicker({
|
|||||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||||
const providerRequiresConfiguration =
|
const providerRequiresConfiguration =
|
||||||
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
||||||
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
const providerHasManagedModels = ["builtin", "hybrid"].includes(
|
||||||
|
providerRow?.model_catalog ?? "",
|
||||||
|
);
|
||||||
const providerUsesManualModelIds =
|
const providerUsesManualModelIds =
|
||||||
!hasStaticModels &&
|
!hasStaticModels &&
|
||||||
hasConcreteProvider &&
|
hasConcreteProvider &&
|
||||||
providerConfigured &&
|
providerConfigured &&
|
||||||
providerRow?.auth_type === "oauth" &&
|
providerRow?.auth_type === "oauth" &&
|
||||||
!providerHasBuiltinModels;
|
!providerHasManagedModels;
|
||||||
const canFetchModels =
|
const canFetchModels =
|
||||||
!hasStaticModels &&
|
!hasStaticModels &&
|
||||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||||
|
|||||||
@@ -483,7 +483,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving",
|
"saving": "Saving",
|
||||||
"saveOrder": "Save order",
|
"saveOrder": "Save order",
|
||||||
"savePreset": "Save preset",
|
"savePreset": "Save",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"saving": "Guardando",
|
"saving": "Guardando",
|
||||||
"saveOrder": "Guardar orden",
|
"saveOrder": "Guardar orden",
|
||||||
"savePreset": "Guardar preajuste",
|
"savePreset": "Guardar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleting": "Eliminando...",
|
"deleting": "Eliminando...",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"saving": "Enregistrement",
|
"saving": "Enregistrement",
|
||||||
"saveOrder": "Enregistrer l’ordre",
|
"saveOrder": "Enregistrer l’ordre",
|
||||||
"savePreset": "Enregistrer le préréglage",
|
"savePreset": "Enregistrer",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleting": "Suppression...",
|
"deleting": "Suppression...",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
"saving": "Menyimpan",
|
"saving": "Menyimpan",
|
||||||
"saveOrder": "Simpan urutan",
|
"saveOrder": "Simpan urutan",
|
||||||
"savePreset": "Simpan prasetel",
|
"savePreset": "Simpan",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"deleting": "Menghapus...",
|
"deleting": "Menghapus...",
|
||||||
"edit": "Ubah",
|
"edit": "Ubah",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中",
|
"saving": "保存中",
|
||||||
"saveOrder": "順序を保存",
|
"saveOrder": "順序を保存",
|
||||||
"savePreset": "プリセットを保存",
|
"savePreset": "保存",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleting": "削除中...",
|
"deleting": "削除中...",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "저장",
|
"save": "저장",
|
||||||
"saving": "저장 중",
|
"saving": "저장 중",
|
||||||
"saveOrder": "순서 저장",
|
"saveOrder": "순서 저장",
|
||||||
"savePreset": "프리셋 저장",
|
"savePreset": "저장",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleting": "삭제 중...",
|
"deleting": "삭제 중...",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
|
|||||||
@@ -483,7 +483,7 @@
|
|||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"saving": "Salvando",
|
"saving": "Salvando",
|
||||||
"saveOrder": "Salvar ordem",
|
"saveOrder": "Salvar ordem",
|
||||||
"savePreset": "Salvar predefinição",
|
"savePreset": "Salvar",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"deleting": "Excluindo...",
|
"deleting": "Excluindo...",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"saving": "Đang lưu",
|
"saving": "Đang lưu",
|
||||||
"saveOrder": "Lưu thứ tự",
|
"saveOrder": "Lưu thứ tự",
|
||||||
"savePreset": "Lưu cấu hình đặt trước",
|
"savePreset": "Lưu",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"deleting": "Đang xóa...",
|
"deleting": "Đang xóa...",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
|
|||||||
@@ -483,7 +483,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "正在保存",
|
"saving": "正在保存",
|
||||||
"saveOrder": "保存顺序",
|
"saveOrder": "保存顺序",
|
||||||
"savePreset": "保存预设",
|
"savePreset": "保存",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleting": "正在删除...",
|
"deleting": "正在删除...",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"saving": "正在儲存",
|
"saving": "正在儲存",
|
||||||
"saveOrder": "儲存順序",
|
"saveOrder": "儲存順序",
|
||||||
"savePreset": "儲存預設",
|
"savePreset": "儲存",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleting": "正在刪除…",
|
"deleting": "正在刪除…",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
|
|||||||
+11
-1
@@ -510,6 +510,8 @@ interface ProviderModelInfo {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
owned_by?: string | null;
|
owned_by?: string | null;
|
||||||
context_window?: number | null;
|
context_window?: number | null;
|
||||||
|
reasoning_efforts?: string[];
|
||||||
|
supports_backend_search?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModelsPayload {
|
export interface ProviderModelsPayload {
|
||||||
@@ -521,7 +523,15 @@ export interface ProviderModelsPayload {
|
|||||||
| "not_configured"
|
| "not_configured"
|
||||||
| "missing_api_base"
|
| "missing_api_base"
|
||||||
| "error";
|
| "error";
|
||||||
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported";
|
catalog_kind:
|
||||||
|
| "builtin"
|
||||||
|
| "hybrid"
|
||||||
|
| "official"
|
||||||
|
| "catalog"
|
||||||
|
| "local"
|
||||||
|
| "custom"
|
||||||
|
| "unsupported";
|
||||||
|
source?: "remote" | "cache" | "stale" | "fallback";
|
||||||
models: ProviderModelInfo[];
|
models: ProviderModelInfo[];
|
||||||
model_count: number;
|
model_count: number;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
|||||||
@@ -2531,7 +2531,7 @@ describe("App layout", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
await user.click(screen.getByRole("button", { name: "Select model" }));
|
await user.click(screen.getByRole("button", { name: "Select model" }));
|
||||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ describe("Settings models", () => {
|
|||||||
fireEvent.change(screen.getByLabelText("Temperature"), {
|
fireEvent.change(screen.getByLabelText("Temperature"), {
|
||||||
target: { value: "0.4" },
|
target: { value: "0.4" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
@@ -173,7 +173,7 @@ describe("Settings models", () => {
|
|||||||
|
|
||||||
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
||||||
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
@@ -196,7 +196,7 @@ describe("Settings models", () => {
|
|||||||
|
|
||||||
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
||||||
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
"A preset with this name already exists.",
|
"A preset with this name already exists.",
|
||||||
@@ -368,7 +368,7 @@ describe("Settings models", () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
|
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
|
||||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
|
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
|
||||||
@@ -604,7 +604,7 @@ describe("Settings models", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
|
||||||
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Complete the preset before saving."),
|
screen.queryByText("Complete the preset before saving."),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -619,7 +619,7 @@ describe("Settings models", () => {
|
|||||||
target: { value: "openai/gpt-4o-mini" },
|
target: { value: "openai/gpt-4o-mini" },
|
||||||
});
|
});
|
||||||
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
||||||
const saveButton = screen.getByRole("button", { name: "Save preset" });
|
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||||
expect(saveButton).toBeEnabled();
|
expect(saveButton).toBeEnabled();
|
||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
@@ -656,7 +656,7 @@ describe("Settings models", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
|
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
|
||||||
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
expect(requestMutationMock).not.toHaveBeenCalled();
|
expect(requestMutationMock).not.toHaveBeenCalled();
|
||||||
expect(nameInput).toHaveAttribute("aria-invalid", "true");
|
expect(nameInput).toHaveAttribute("aria-invalid", "true");
|
||||||
@@ -1295,6 +1295,88 @@ describe("Settings models", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads hybrid online models for configured OAuth providers", async () => {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...base,
|
||||||
|
agent: {
|
||||||
|
...base.agent,
|
||||||
|
model: "xai-grok/grok-4.5",
|
||||||
|
provider: "xai_grok",
|
||||||
|
resolved_provider: "xai_grok",
|
||||||
|
},
|
||||||
|
model_presets: [
|
||||||
|
{
|
||||||
|
...base.model_presets[0],
|
||||||
|
model: "xai-grok/grok-4.5",
|
||||||
|
provider: "xai_grok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "xai_grok",
|
||||||
|
label: "xAI Grok",
|
||||||
|
configured: true,
|
||||||
|
auth_type: "oauth",
|
||||||
|
api_key_required: false,
|
||||||
|
api_key_hint: null,
|
||||||
|
api_base: null,
|
||||||
|
default_api_base: "https://cli-chat-proxy.grok.com/v1",
|
||||||
|
model_catalog: "hybrid",
|
||||||
|
oauth_account: "acct-test",
|
||||||
|
oauth_expires_at: null,
|
||||||
|
oauth_login_supported: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings/provider-models?provider=xai_grok") {
|
||||||
|
return jsonResponse({
|
||||||
|
provider: "xai_grok",
|
||||||
|
label: "xAI Grok",
|
||||||
|
status: "available",
|
||||||
|
catalog_kind: "hybrid",
|
||||||
|
source: "remote",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "xai-grok/grok-4.6",
|
||||||
|
label: "Grok 4.6",
|
||||||
|
description: "Latest frontier model",
|
||||||
|
owned_by: "xAI",
|
||||||
|
context_window: 500_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "xai-grok/grok-4.5",
|
||||||
|
label: "Grok 4.5",
|
||||||
|
owned_by: "xAI",
|
||||||
|
context_window: 500_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model_count: 2,
|
||||||
|
fetched_at: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
|
||||||
|
await togglePresetEditor();
|
||||||
|
const modelButtons = await screen.findAllByRole("button", {
|
||||||
|
name: /xai-grok\/grok-4\.5/i,
|
||||||
|
});
|
||||||
|
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Grok 4.6")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Latest frontier model/)).toBeInTheDocument();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider-models?provider=xai_grok",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
|
it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -1417,7 +1499,7 @@ describe("Settings models", () => {
|
|||||||
fireEvent.change(screen.getByLabelText("Reasoning effort"), {
|
fireEvent.change(screen.getByLabelText("Reasoning effort"), {
|
||||||
target: { value: "provider-native-mode" },
|
target: { value: "provider-native-mode" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user