feat(providers): support provider-scoped proxy config
This commit is contained in:
+25
-5
@@ -240,6 +240,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
@@ -632,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
|
||||
|
||||
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
|
||||
|
||||
**3. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -656,7 +674,9 @@ nanobot provider login openai-codex
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat:**
|
||||
Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
|
||||
|
||||
**4. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
|
||||
@@ -675,7 +695,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
<details>
|
||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` 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.
|
||||
|
||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||
```bash
|
||||
|
||||
@@ -61,9 +61,12 @@ These fields answer different questions:
|
||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
||||
|
||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
|
||||
## Common Provider Patterns
|
||||
|
||||
### OpenRouter Gateway
|
||||
@@ -422,6 +425,32 @@ nanobot provider login github-copilot
|
||||
|
||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
||||
|
||||
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
||||
|
||||
+10
-1
@@ -1812,14 +1812,23 @@ def _login_openai_codex() -> None:
|
||||
try:
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
proxy = None
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
console.print(f"[red]{e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda s: console.print(s),
|
||||
prompt_fn=lambda s: typer.prompt(s),
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
console.print("[red]✗ Authentication failed[/red]")
|
||||
|
||||
@@ -80,6 +80,10 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
if config.providers.openai_codex.proxy is not None:
|
||||
data.setdefault("providers", {})["openaiCodex"] = {
|
||||
"proxy": config.providers.openai_codex.proxy,
|
||||
}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -179,6 +179,7 @@ class ProviderConfig(Base):
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
|
||||
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
|
||||
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
||||
|
||||
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
||||
|
||||
@@ -58,6 +58,11 @@ def _make_provider_core(
|
||||
if spec and spec.is_transcription_only:
|
||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
|
||||
raise ValueError(
|
||||
f"providers.{provider_name}.proxy is only supported for "
|
||||
"OpenAI-compatible providers and OpenAI Codex."
|
||||
)
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_base:
|
||||
@@ -79,7 +84,10 @@ def _make_provider_core(
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
provider = OpenAICodexProvider(
|
||||
default_model=model,
|
||||
proxy=getattr(p, "proxy", None) if p else None,
|
||||
)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
@@ -124,6 +132,7 @@ def _make_provider_core(
|
||||
extra_body=p.extra_body if p else None,
|
||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||
extra_query=p.extra_query if p else None,
|
||||
proxy=p.proxy if p else None,
|
||||
)
|
||||
|
||||
provider.generation = resolved.to_generation_settings()
|
||||
@@ -218,6 +227,7 @@ def provider_signature(
|
||||
fallback.temperature,
|
||||
fallback.reasoning_effort,
|
||||
fallback.context_window_tokens,
|
||||
getattr(fp, "proxy", None) if fp else None,
|
||||
)
|
||||
|
||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||
@@ -237,6 +247,7 @@ def provider_signature(
|
||||
resolved.temperature,
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
getattr(p, "proxy", None) if p else None,
|
||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
|
||||
|
||||
supports_progress_deltas = True
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
def __init__(
|
||||
self,
|
||||
default_model: str = "openai-codex/gpt-5.1-codex",
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
self.proxy = proxy or None
|
||||
|
||||
async def _call_codex(
|
||||
self,
|
||||
@@ -52,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
|
||||
model = model or self.default_model
|
||||
system_prompt, input_items = convert_messages(messages)
|
||||
|
||||
token = await asyncio.to_thread(get_codex_token)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(model),
|
||||
"store": False,
|
||||
@@ -74,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
|
||||
body["tools"] = convert_tools(tools)
|
||||
|
||||
try:
|
||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
try:
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -87,6 +93,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -199,12 +206,17 @@ async def _request_codex(
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
proxy: str | None = None,
|
||||
on_content_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,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
client_kwargs["trust_env"] = False
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
|
||||
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
api_type: str = "auto",
|
||||
extra_query: dict[str, str] | None = None,
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._extra_body = extra_body or {}
|
||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||
self._extra_query = extra_query or {}
|
||||
self._proxy = proxy or None
|
||||
|
||||
if api_key and spec and spec.env_key:
|
||||
self._setup_env(api_key, api_base)
|
||||
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
timeout_s = _openai_compat_timeout_s()
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
if self._is_local:
|
||||
if self._proxy:
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=timeout_s,
|
||||
proxy=self._proxy,
|
||||
trust_env=False,
|
||||
follow_redirects=True,
|
||||
)
|
||||
elif self._is_local:
|
||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||
# HTTP connections before the client-side keepalive expires. When
|
||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||
|
||||
@@ -22,7 +22,7 @@ from nanobot.audio.transcription_registry import (
|
||||
resolve_transcription_provider,
|
||||
transcription_provider_names,
|
||||
)
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
@@ -1166,14 +1166,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except ImportError:
|
||||
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
messages: list[str] = []
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda message: messages.append(str(message)),
|
||||
prompt_fn=lambda _prompt: "",
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ dependencies = [
|
||||
"websocket-client>=1.9.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"ddgs>=9.5.5,<10.0.0",
|
||||
"oauth-cli-kit>=0.1.3,<1.0.0",
|
||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||
"loguru>=0.7.3,<1.0.0",
|
||||
"readability-lxml>=0.8.4,<1.0.0",
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
|
||||
+105
-2
@@ -5,6 +5,7 @@ import shutil
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -19,7 +20,7 @@ from nanobot.cron.service import CronJobSkippedError
|
||||
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||
from nanobot.providers.factory import ProviderSnapshot, make_provider
|
||||
from nanobot.providers.factory import ProviderSnapshot, make_provider, provider_signature
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.metadata import (
|
||||
@@ -434,6 +435,60 @@ def test_provider_login_rejects_unknown_provider():
|
||||
assert "Unknown OAuth provider" in result.stdout
|
||||
|
||||
|
||||
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.load_config",
|
||||
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
||||
)
|
||||
|
||||
import oauth_cli_kit
|
||||
|
||||
def fake_get_token(**_kwargs):
|
||||
raise RuntimeError("no-token")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
||||
captured["proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
||||
|
||||
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["proxy"] == proxy
|
||||
|
||||
|
||||
def test_provider_login_openai_codex_resolves_proxy_env_ref(monkeypatch):
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.load_config",
|
||||
lambda: Config.model_validate(
|
||||
{"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_FOR_TEST}"}}}
|
||||
),
|
||||
)
|
||||
|
||||
import oauth_cli_kit
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_get_token(*, proxy=None):
|
||||
captured["proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
|
||||
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["proxy"] == proxy
|
||||
|
||||
|
||||
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "ollama/llama3.2"
|
||||
@@ -685,6 +740,54 @@ def test_make_provider_uses_github_copilot_backend():
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
||||
def config_with_proxy(proxy: str) -> Config:
|
||||
return Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai-codex",
|
||||
"model": "openai-codex/gpt-5.5",
|
||||
}
|
||||
},
|
||||
"providers": {"openaiCodex": {"proxy": proxy}},
|
||||
}
|
||||
)
|
||||
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config = config_with_proxy(proxy)
|
||||
|
||||
provider = make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "OpenAICodexProvider"
|
||||
assert provider.proxy == proxy
|
||||
assert provider_signature(config) != provider_signature(
|
||||
config_with_proxy("http://127.0.0.1:23459")
|
||||
)
|
||||
|
||||
|
||||
def test_provider_proxy_rejects_unsupported_backend():
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "anthropic",
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"apiKey": "sk-test",
|
||||
"proxy": "http://127.0.0.1:23458",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"providers\.anthropic\.proxy"):
|
||||
make_provider(config)
|
||||
|
||||
|
||||
def test_github_copilot_provider_strips_prefixed_model_name():
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
@@ -752,7 +855,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||
"x-session-affinity": "sticky-session",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from nanobot.config.loader import (
|
||||
resolve_config_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
@@ -127,6 +128,31 @@ class TestResolveConfig:
|
||||
assert "githubCopilot" not in saved["providers"]
|
||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||
|
||||
def test_save_preserves_openai_codex_proxy_config(self, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"providers": {
|
||||
"openaiCodex": {
|
||||
"apiKey": "codex-secret",
|
||||
"proxy": proxy,
|
||||
},
|
||||
"groq": {"apiKey": "groq-secret"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
save_config(config, config_path)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["providers"]["openaiCodex"] == {"proxy": proxy}
|
||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||
|
||||
reloaded = load_config(config_path)
|
||||
assert reloaded.providers.openai_codex.proxy == proxy
|
||||
assert reloaded.providers.openai_codex.api_key is None
|
||||
|
||||
def test_preserves_excluded_fields_when_no_env_refs(self, tmp_path):
|
||||
"""Regression: fields with ``exclude=True`` (e.g. ProviderConfig.openai_codex)
|
||||
must survive ``resolve_config_env_vars`` when the config has no
|
||||
|
||||
@@ -21,9 +21,12 @@ from nanobot.providers.openai_codex_provider import (
|
||||
|
||||
|
||||
def _mock_codex_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_token(**_kwargs):
|
||||
return SimpleNamespace(account_id="acct", access="token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||
lambda: SimpleNamespace(account_id="acct", access="token"),
|
||||
fake_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +80,12 @@ async def test_codex_request_non_200_populates_http_metadata(monkeypatch) -> Non
|
||||
request=request,
|
||||
)
|
||||
|
||||
def fake_client(*, timeout: int, verify: bool) -> httpx.AsyncClient:
|
||||
def fake_client(
|
||||
*,
|
||||
timeout: int,
|
||||
verify: bool,
|
||||
**_kwargs: object,
|
||||
) -> httpx.AsyncClient:
|
||||
assert timeout == 90
|
||||
assert verify is True
|
||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
||||
@@ -106,7 +114,12 @@ async def test_codex_request_honors_stream_idle_timeout_env(monkeypatch) -> None
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
def fake_client(*, timeout: int, verify: bool) -> httpx.AsyncClient:
|
||||
def fake_client(
|
||||
*,
|
||||
timeout: int,
|
||||
verify: bool,
|
||||
**_kwargs: object,
|
||||
) -> httpx.AsyncClient:
|
||||
seen["timeout"] = timeout
|
||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
||||
|
||||
@@ -117,6 +130,39 @@ async def test_codex_request_honors_stream_idle_timeout_env(monkeypatch) -> None
|
||||
assert seen["timeout"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_request_uses_configured_proxy(monkeypatch) -> None:
|
||||
original_client = httpx.AsyncClient
|
||||
seen: dict[str, object] = {}
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
def fake_client(
|
||||
*,
|
||||
timeout: int,
|
||||
verify: bool,
|
||||
proxy: str | None = None,
|
||||
trust_env: bool = True,
|
||||
) -> httpx.AsyncClient:
|
||||
seen["proxy"] = proxy
|
||||
seen["trust_env"] = trust_env
|
||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.AsyncClient", fake_client)
|
||||
|
||||
await _request_codex(
|
||||
"https://codex.example/responses",
|
||||
{},
|
||||
{"input": []},
|
||||
verify=True,
|
||||
proxy=proxy,
|
||||
)
|
||||
|
||||
assert seen == {"proxy": proxy, "trust_env": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatch) -> None:
|
||||
bodies: list[dict] = []
|
||||
@@ -128,11 +174,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
|
||||
headers,
|
||||
body,
|
||||
verify,
|
||||
proxy=None,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
_ = proxy, on_thinking_delta, on_tool_call_delta
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
@@ -186,6 +233,40 @@ async def test_codex_timeout_error_is_typed_and_retryable(monkeypatch) -> None:
|
||||
assert response.error_should_retry is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_provider_passes_proxy_to_oauth_and_response_request(monkeypatch) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_token(*, proxy=None):
|
||||
seen["token_proxy"] = proxy
|
||||
return SimpleNamespace(account_id="acct", access="token")
|
||||
|
||||
async def fake_request(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
verify,
|
||||
proxy=None,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = url, headers, body, verify, on_content_delta, on_thinking_delta, on_tool_call_delta
|
||||
seen["request_proxy"] = proxy
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider.get_codex_token", fake_token)
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||
|
||||
provider = OpenAICodexProvider(proxy=proxy)
|
||||
response = await provider.chat([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert seen["token_proxy"] == proxy
|
||||
assert seen["request_proxy"] == proxy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_timeout_error_writes_diagnostic_log(monkeypatch) -> None:
|
||||
log_capture = _capture_codex_warnings(monkeypatch)
|
||||
@@ -409,9 +490,12 @@ def test_codex_reasoning_options_request_summary_without_forcing_effort() -> Non
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||
def fake_token(**_kwargs):
|
||||
return SimpleNamespace(account_id="acct", access="token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||
lambda: SimpleNamespace(account_id="acct", access="token"),
|
||||
fake_token,
|
||||
)
|
||||
|
||||
async def fake_request(
|
||||
@@ -419,11 +503,12 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||
headers,
|
||||
body,
|
||||
verify,
|
||||
proxy=None,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = url, headers, verify, on_tool_call_delta
|
||||
_ = url, headers, verify, proxy, on_tool_call_delta
|
||||
assert body["reasoning"] == {"summary": "auto", "effort": "medium"}
|
||||
if on_content_delta:
|
||||
await on_content_delta("answer")
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
import nanobot.providers.openai_compat_provider as openai_compat_provider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
@@ -54,3 +55,32 @@ class TestCloudEndpointProxyEnabled:
|
||||
client = provider._client._client
|
||||
# trust_env should be True so httpx reads HTTP_PROXY etc.
|
||||
assert client._trust_env is True
|
||||
|
||||
async def test_explicit_provider_proxy_overrides_env(self, monkeypatch):
|
||||
spec = _make_spec(is_local=False)
|
||||
spec.env_key = ""
|
||||
spec.default_api_base = "https://api.openai.com/v1"
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
monkeypatch.delenv("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", raising=False)
|
||||
|
||||
http_client = MagicMock()
|
||||
async_client = MagicMock(return_value=http_client)
|
||||
openai_client = MagicMock(return_value=object())
|
||||
monkeypatch.setattr(httpx, "AsyncClient", async_client)
|
||||
monkeypatch.setattr(openai_compat_provider, "AsyncOpenAI", openai_client)
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test",
|
||||
api_base=None,
|
||||
spec=spec,
|
||||
proxy=proxy,
|
||||
)
|
||||
provider._build_client()
|
||||
|
||||
async_client.assert_called_once_with(
|
||||
timeout=120.0,
|
||||
proxy=proxy,
|
||||
trust_env=False,
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert openai_client.call_args.kwargs["http_client"] is http_client
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -12,6 +13,7 @@ from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
login_oauth_provider,
|
||||
provider_models_payload,
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
@@ -844,6 +846,39 @@ def test_openai_codex_oauth_status_rejects_unavailable_token(
|
||||
assert status["account"] is None
|
||||
|
||||
|
||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_TEST}"}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setenv("CODEX_PROXY_TEST", proxy)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
import oauth_cli_kit
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_get_token(*, proxy=None):
|
||||
captured["get_proxy"] = proxy
|
||||
raise RuntimeError("no-token")
|
||||
|
||||
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
||||
captured["login_proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
||||
|
||||
login_oauth_provider({"provider": ["openai-codex"]})
|
||||
|
||||
assert captured == {"get_proxy": proxy, "login_proxy": proxy}
|
||||
|
||||
|
||||
def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user