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
+10 -1
View File
@@ -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]")
+4
View File
@@ -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)
+1
View File
@@ -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
+12 -1
View File
@@ -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),
)
+17 -5
View File
@@ -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()
+10 -1
View File
@@ -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
+7 -2
View File
@@ -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)