diff --git a/docs/configuration.md b/docs/configuration.md index dbe03c60..8b309ecb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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..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."
OpenAI Codex (OAuth) -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 -
GitHub Copilot (OAuth) -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 diff --git a/docs/providers.md b/docs/providers.md index 697173fb..028d92b2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -61,9 +61,12 @@ These fields answer different questions: | `model` | `modelPresets..model` | The model ID expected by that provider or gateway. | | `apiKey` | `providers..apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. | | `apiBase` | `providers..apiBase` | HTTP base URL of the provider endpoint. | +| `proxy` | `providers..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: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 67d70dbd..499c9753 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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]") diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 0addc842..384f0f5a 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -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) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9e8b0ce4..5b50407e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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 diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 4fd75cdd..756eadc5 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -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), ) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 64430cdc..b544bad4 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -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() diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 7aec961c..57eccdb6 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -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 diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 8d378b15..5b1f9bb4 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 285078b5..cdc6cc0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 3d9e79c6..e265d1c2 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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", }, } - }, + } } ) diff --git a/tests/config/test_env_interpolation.py b/tests/config/test_env_interpolation.py index dad44382..329010cc 100644 --- a/tests/config/test_env_interpolation.py +++ b/tests/config/test_env_interpolation.py @@ -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 diff --git a/tests/providers/test_openai_codex_provider.py b/tests/providers/test_openai_codex_provider.py index cbd3ed48..8cba9df0 100644 --- a/tests/providers/test_openai_codex_provider.py +++ b/tests/providers/test_openai_codex_provider.py @@ -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") diff --git a/tests/providers/test_proxy_env.py b/tests/providers/test_proxy_env.py index 640c9dfb..340d1bc5 100644 --- a/tests/providers/test_proxy_env.py +++ b/tests/providers/test_proxy_env.py @@ -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 diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 4a41328f..f772ce5a 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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,