feat(providers): support provider-scoped proxy config

This commit is contained in:
chengyongru
2026-06-30 17:33:36 +08:00
committed by Xubin Ren
parent 4c0e9b9f46
commit 44a5ed1bc0
15 changed files with 403 additions and 24 deletions
+105 -2
View File
@@ -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",
},
}
},
}
}
)
+26
View File
@@ -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
+91 -6
View File
@@ -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")
+30
View File
@@ -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
+35
View File
@@ -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,