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 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.
|
> - **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"`.
|
> - **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 |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -632,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
<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:**
|
**1. Login:**
|
||||||
```bash
|
```bash
|
||||||
nanobot provider login openai-codex
|
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
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
"codex": {
|
"codex": {
|
||||||
"provider": "openai_codex",
|
"provider": "openai_codex",
|
||||||
"model": "openai-codex/gpt-5.1-codex"
|
"model": "gpt-5.1-codex",
|
||||||
|
"reasoningEffort": "high"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"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
|
```bash
|
||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
|
|
||||||
@@ -675,7 +695,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
<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:
|
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -61,9 +61,12 @@ These fields answer different questions:
|
|||||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
| `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. |
|
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
| `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`.
|
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
|
## Common Provider Patterns
|
||||||
|
|
||||||
### OpenRouter Gateway
|
### 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.
|
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
|
## Provider Resolution
|
||||||
|
|
||||||
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
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:
|
try:
|
||||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
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
|
token = None
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
token = get_token()
|
token = get_token(proxy=proxy)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||||
token = login_oauth_interactive(
|
token = login_oauth_interactive(
|
||||||
print_fn=lambda s: console.print(s),
|
print_fn=lambda s: console.print(s),
|
||||||
prompt_fn=lambda s: typer.prompt(s),
|
prompt_fn=lambda s: typer.prompt(s),
|
||||||
|
proxy=proxy,
|
||||||
)
|
)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
console.print("[red]✗ Authentication failed[/red]")
|
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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
data = config.model_dump(mode="json", by_alias=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:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
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_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_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)
|
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
|
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
||||||
|
|
||||||
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
# 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:
|
if spec and spec.is_transcription_only:
|
||||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||||
backend = spec.backend if spec else "openai_compat"
|
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 backend == "azure_openai":
|
||||||
if not p or not p.api_base:
|
if not p or not p.api_base:
|
||||||
@@ -79,7 +84,10 @@ def _make_provider_core(
|
|||||||
if backend == "openai_codex":
|
if backend == "openai_codex":
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
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":
|
elif backend == "azure_openai":
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
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,
|
extra_body=p.extra_body if p else None,
|
||||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||||
extra_query=p.extra_query if p else None,
|
extra_query=p.extra_query if p else None,
|
||||||
|
proxy=p.proxy if p else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = resolved.to_generation_settings()
|
provider.generation = resolved.to_generation_settings()
|
||||||
@@ -218,6 +227,7 @@ def provider_signature(
|
|||||||
fallback.temperature,
|
fallback.temperature,
|
||||||
fallback.reasoning_effort,
|
fallback.reasoning_effort,
|
||||||
fallback.context_window_tokens,
|
fallback.context_window_tokens,
|
||||||
|
getattr(fp, "proxy", None) if fp else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||||
@@ -237,6 +247,7 @@ def provider_signature(
|
|||||||
resolved.temperature,
|
resolved.temperature,
|
||||||
resolved.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
resolved.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
|
getattr(p, "proxy", None) if p else None,
|
||||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
|
|
||||||
supports_progress_deltas = True
|
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)
|
super().__init__(api_key=None, api_base=None)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
|
self.proxy = proxy or None
|
||||||
|
|
||||||
async def _call_codex(
|
async def _call_codex(
|
||||||
self,
|
self,
|
||||||
@@ -52,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
system_prompt, input_items = convert_messages(messages)
|
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] = {
|
body: dict[str, Any] = {
|
||||||
"model": _strip_model_prefix(model),
|
"model": _strip_model_prefix(model),
|
||||||
"store": False,
|
"store": False,
|
||||||
@@ -74,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||||
|
headers = _build_headers(token.account_id, token.access)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||||
|
proxy=self.proxy,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_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")
|
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||||
|
proxy=self.proxy,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
@@ -199,12 +206,17 @@ async def _request_codex(
|
|||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
verify: bool,
|
verify: bool,
|
||||||
|
proxy: str | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_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,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
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:
|
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
text = await response.aread()
|
text = await response.aread()
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
extra_body: dict[str, Any] | None = None,
|
extra_body: dict[str, Any] | None = None,
|
||||||
api_type: str = "auto",
|
api_type: str = "auto",
|
||||||
extra_query: dict[str, str] | None = None,
|
extra_query: dict[str, str] | None = None,
|
||||||
|
proxy: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._extra_body = extra_body or {}
|
self._extra_body = extra_body or {}
|
||||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||||
self._extra_query = extra_query or {}
|
self._extra_query = extra_query or {}
|
||||||
|
self._proxy = proxy or None
|
||||||
|
|
||||||
if api_key and spec and spec.env_key:
|
if api_key and spec and spec.env_key:
|
||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
timeout_s = _openai_compat_timeout_s()
|
timeout_s = _openai_compat_timeout_s()
|
||||||
http_client: httpx.AsyncClient | None = None
|
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
|
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||||
# HTTP connections before the client-side keepalive expires. When
|
# HTTP connections before the client-side keepalive expires. When
|
||||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from nanobot.audio.transcription_registry import (
|
|||||||
resolve_transcription_provider,
|
resolve_transcription_provider,
|
||||||
transcription_provider_names,
|
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.config.schema import ModelPresetConfig, ProviderConfig
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
@@ -1166,14 +1166,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
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
|
token = None
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
token = get_token()
|
token = get_token(proxy=proxy)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
token = login_oauth_interactive(
|
token = login_oauth_interactive(
|
||||||
print_fn=lambda message: messages.append(str(message)),
|
print_fn=lambda message: messages.append(str(message)),
|
||||||
prompt_fn=lambda _prompt: "",
|
prompt_fn=lambda _prompt: "",
|
||||||
|
proxy=proxy,
|
||||||
)
|
)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ dependencies = [
|
|||||||
"websocket-client>=1.9.0,<2.0.0",
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
"ddgs>=9.5.5,<10.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",
|
"loguru>=0.7.3,<1.0.0",
|
||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
|
|||||||
+105
-2
@@ -5,6 +5,7 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
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.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
||||||
from nanobot.cron.types import CronJob, CronPayload
|
from nanobot.cron.types import CronJob, CronPayload
|
||||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
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.openai_codex_provider import _strip_model_prefix
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
@@ -434,6 +435,60 @@ def test_provider_login_rejects_unknown_provider():
|
|||||||
assert "Unknown OAuth provider" in result.stdout
|
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():
|
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "ollama/llama3.2"
|
config.agents.defaults.model = "ollama/llama3.2"
|
||||||
@@ -685,6 +740,54 @@ def test_make_provider_uses_github_copilot_backend():
|
|||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
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():
|
def test_github_copilot_provider_strips_prefixed_model_name():
|
||||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
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",
|
"x-session-affinity": "sticky-session",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nanobot.config.loader import (
|
|||||||
resolve_config_env_vars,
|
resolve_config_env_vars,
|
||||||
save_config,
|
save_config,
|
||||||
)
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
class TestResolveEnvVars:
|
class TestResolveEnvVars:
|
||||||
@@ -127,6 +128,31 @@ class TestResolveConfig:
|
|||||||
assert "githubCopilot" not in saved["providers"]
|
assert "githubCopilot" not in saved["providers"]
|
||||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
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):
|
def test_preserves_excluded_fields_when_no_env_refs(self, tmp_path):
|
||||||
"""Regression: fields with ``exclude=True`` (e.g. ProviderConfig.openai_codex)
|
"""Regression: fields with ``exclude=True`` (e.g. ProviderConfig.openai_codex)
|
||||||
must survive ``resolve_config_env_vars`` when the config has no
|
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 _mock_codex_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def fake_token(**_kwargs):
|
||||||
|
return SimpleNamespace(account_id="acct", access="token")
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
"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,
|
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 timeout == 90
|
||||||
assert verify is True
|
assert verify is True
|
||||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
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:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, request=request)
|
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
|
seen["timeout"] = timeout
|
||||||
return original_client(transport=httpx.MockTransport(handler), 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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatch) -> None:
|
async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatch) -> None:
|
||||||
bodies: list[dict] = []
|
bodies: list[dict] = []
|
||||||
@@ -128,11 +174,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
|
|||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
verify,
|
verify,
|
||||||
|
proxy=None,
|
||||||
on_content_delta=None,
|
on_content_delta=None,
|
||||||
on_thinking_delta=None,
|
on_thinking_delta=None,
|
||||||
on_tool_call_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)
|
bodies.append(body)
|
||||||
return "ok", [], "stop", {}, None
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_codex_timeout_error_writes_diagnostic_log(monkeypatch) -> None:
|
async def test_codex_timeout_error_writes_diagnostic_log(monkeypatch) -> None:
|
||||||
log_capture = _capture_codex_warnings(monkeypatch)
|
log_capture = _capture_codex_warnings(monkeypatch)
|
||||||
@@ -409,9 +490,12 @@ def test_codex_reasoning_options_request_summary_without_forcing_effort() -> Non
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||||
|
def fake_token(**_kwargs):
|
||||||
|
return SimpleNamespace(account_id="acct", access="token")
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||||
lambda: SimpleNamespace(account_id="acct", access="token"),
|
fake_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_request(
|
async def fake_request(
|
||||||
@@ -419,11 +503,12 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
|||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
verify,
|
verify,
|
||||||
|
proxy=None,
|
||||||
on_content_delta=None,
|
on_content_delta=None,
|
||||||
on_thinking_delta=None,
|
on_thinking_delta=None,
|
||||||
on_tool_call_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"}
|
assert body["reasoning"] == {"summary": "auto", "effort": "medium"}
|
||||||
if on_content_delta:
|
if on_content_delta:
|
||||||
await on_content_delta("answer")
|
await on_content_delta("answer")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
import nanobot.providers.openai_compat_provider as openai_compat_provider
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -54,3 +55,32 @@ class TestCloudEndpointProxyEnabled:
|
|||||||
client = provider._client._client
|
client = provider._client._client
|
||||||
# trust_env should be True so httpx reads HTTP_PROXY etc.
|
# trust_env should be True so httpx reads HTTP_PROXY etc.
|
||||||
assert client._trust_env is True
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,6 +13,7 @@ from nanobot.webui.settings_api import (
|
|||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
create_model_configuration,
|
create_model_configuration,
|
||||||
|
login_oauth_provider,
|
||||||
provider_models_payload,
|
provider_models_payload,
|
||||||
settings_payload,
|
settings_payload,
|
||||||
settings_usage_payload,
|
settings_usage_payload,
|
||||||
@@ -844,6 +846,39 @@ def test_openai_codex_oauth_status_rejects_unavailable_token(
|
|||||||
assert status["account"] is None
|
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(
|
def test_provider_models_payload_fetches_openai_compatible_models(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user