feat(providers): support provider-scoped proxy config
This commit is contained in:
+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