feat(image_gen): OpenAI image provider takes base_url/key_env from config, blanks OpenAI-Project, bypasses macOS system proxy

What: plugins/image_gen/openai resolves its endpoint and credential through one
resolver — image_gen.openai.base_url → OPENAI_BASE_URL → SDK default, and the env
var named by image_gen.openai.key_env → OPENAI_API_KEY — shared by is_available()
and generate() so the two cannot disagree. The client is built on
build_keepalive_http_client (env-only proxy policy) and sends a blank
OpenAI-Project header.

Why: the image endpoint could only be routed via the process-wide OPENAI_BASE_URL /
OPENAI_API_KEY, so a local or third-party image gateway could not be configured
independently of the chat provider (#65309, #97928, #13798). openai.OpenAI() with
trust_env routed localhost endpoints through a macOS system proxy whose
ExceptionsList httpx never sees (#64888). An OPENAI_PROJECT_ID set for chat made
/v1/images/generations 403 model_not_found on projects with a model allow-list
even though the key already carries the project (#60748).

Slim redo of the contributor direction in #18796 (@y0shua1ee), #37208/#37209
(@charzhou), #65312/#65323/#64893 (@asdlem), #60749 (@perelin); all predate the
StaticImageGenProvider refactor and no longer apply.
This commit is contained in:
teknium1
2026-09-18 23:58:53 -07:00
committed by Teknium
parent 0bd011872b
commit 3fa2c45bd8
3 changed files with 108 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
"""OpenAI GPT Image 2 and 2.5 Flare/Sunburst quality tiers;
base64 output → image cache. Selection: ``OPENAI_IMAGE_MODEL`` → ``image_gen.openai.model`` →
``image_gen.model`` → :data:`DEFAULT_MODEL`."""
``image_gen.model`` → :data:`DEFAULT_MODEL`. Endpoint: ``image_gen.openai.base_url`` →
``OPENAI_BASE_URL`` → SDK default; key: env named by ``image_gen.openai.key_env`` → ``OPENAI_API_KEY``."""
from __future__ import annotations
@@ -13,8 +14,9 @@ from agent.secret_scope import get_secret
from agent.image_gen_provider import DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, success_response
from plugins.image_gen._common import (
GPT_IMAGE_2_API_MODEL as API_MODEL, GPT_IMAGE_2_DEFAULT as DEFAULT_MODEL, GPT_IMAGE_2_TIERS,
StaticImageGenProvider, collect_source_images, error_factory, import_openai, materialize_image,
openai_importable, prompt_required_error, record_token_usage, resolve_static_model, size_for)
StaticImageGenProvider, collect_source_images, error_factory, import_openai, load_image_gen_config,
materialize_image, openai_importable, prompt_required_error, record_token_usage, resolve_static_model,
size_for)
logger = logging.getLogger(__name__)
@@ -43,6 +45,33 @@ def _resolve_model() -> Tuple[str, Dict[str, Any]]:
MODELS, DEFAULT_MODEL, env_var="OPENAI_IMAGE_MODEL", config_key="openai")
def _resolve_endpoint() -> Tuple[str, str]:
"""``(base_url, api_key)`` — ``image_gen.openai.base_url`` → ``OPENAI_BASE_URL`` → ``""`` (SDK default);
the env var named by ``image_gen.openai.key_env`` → ``OPENAI_API_KEY``. Only the var NAME lives in
config.yaml; ``is_available()`` and ``generate()`` share this so they cannot disagree (#65309)."""
cfg = load_image_gen_config("openai")
base_url = str(cfg.get("base_url") or "").strip().rstrip("/") or os.environ.get("OPENAI_BASE_URL", "").strip()
key_env = str(cfg.get("key_env") or "").strip()
api_key = (get_secret(key_env) if key_env else None) or get_secret("OPENAI_API_KEY") or ""
return base_url, api_key
def _build_client(openai: Any, base_url: str, api_key: str) -> Any:
"""``openai.OpenAI`` on Hermes' env-only-proxy httpx client, so a local/custom endpoint never
routes through a macOS system proxy whose ExceptionsList httpx cannot see (#64888). The project
header is blanked: an ``OPENAI_PROJECT_ID`` set for chat makes ``/images/generations`` 403 on
projects with a model allow-list, and the key already carries the project (#60748)."""
from agent.process_bootstrap import build_keepalive_http_client
kwargs: Dict[str, Any] = {"api_key": api_key, "default_headers": {"OpenAI-Project": ""}}
if base_url:
kwargs["base_url"] = base_url
http_client = build_keepalive_http_client(base_url)
if http_client is not None:
kwargs["http_client"] = http_client
return openai.OpenAI(**kwargs)
def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
"""Load ``(data, filename)`` from a URL, data URI or local path; raises on IO/network error."""
ref = ref.strip()
@@ -93,7 +122,7 @@ class OpenAIImageGenProvider(StaticImageGenProvider):
key="OPENAI_API_KEY", prompt="OpenAI API key", url="https://platform.openai.com/api-keys")
def is_available(self) -> bool:
return bool(get_secret("OPENAI_API_KEY")) and openai_importable()
return bool(_resolve_endpoint()[1]) and openai_importable()
def capabilities(self) -> Dict[str, Any]:
# images.edit() accepts up to 16 source images.
@@ -108,11 +137,11 @@ class OpenAIImageGenProvider(StaticImageGenProvider):
aspect = resolve_aspect_ratio(aspect_ratio)
if not prompt:
return prompt_required_error("openai", aspect)
api_key = get_secret("OPENAI_API_KEY")
base_url, api_key = _resolve_endpoint()
if not api_key:
return error_factory("openai", aspect)(
"OPENAI_API_KEY not set. Run `hermes tools` → Image "
"Generation → OpenAI to configure, or `hermes setup` "
"OPENAI_API_KEY not set (or the variable named by image_gen.openai.key_env is empty). "
"Run `hermes tools` → Image Generation → OpenAI to configure, or `hermes setup` "
"to add the key.",
"auth_required")
@@ -124,7 +153,7 @@ class OpenAIImageGenProvider(StaticImageGenProvider):
sources = collect_source_images(image_url, reference_image_urls, limit=16)
is_edit = bool(sources)
fail = error_factory("openai", aspect, model=tier_id, prompt=prompt)
client = openai.OpenAI(api_key=api_key)
client = _build_client(openai, base_url, api_key)
# gpt-image-2 returns b64_json unconditionally and REJECTS
# ``response_format`` as an unknown parameter. Don't send it.

View File

@@ -104,6 +104,50 @@ class TestModelResolution:
assert meta["quality"] == "low"
# ── Endpoint / credential routing ───────────────────────────────────────────
class TestEndpointConfig:
"""``image_gen.openai.base_url`` / ``key_env`` reach the client and its request (#65309, #97928,
#13798); the project header is blanked (#60748); custom endpoints bypass system proxies (#64888)."""
def test_config_base_url_and_key_env_reach_client_and_availability(self, monkeypatch, tmp_path):
import yaml
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.setenv("IMAGE_GATEWAY_TOKEN", "gateway-token")
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"image_gen": {"openai": {
"base_url": "http://localhost:18081/v1/", "key_env": "IMAGE_GATEWAY_TOKEN"}}}))
provider = openai_plugin.OpenAIImageGenProvider()
assert provider.is_available() is True # same resolver as generate(); no OPENAI_API_KEY needed
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
assert provider.generate("a cat")["success"] is True
kwargs = __import__("sys").modules["openai"].OpenAI.call_args.kwargs
assert kwargs["base_url"] == "http://localhost:18081/v1"
assert kwargs["api_key"] == "gateway-token"
assert kwargs["default_headers"]["OpenAI-Project"] == ""
kwargs["http_client"].close()
def test_custom_base_url_ignores_system_proxy(self, monkeypatch):
"""httpx only sees macOS system proxies via ``urllib.request.getproxies()`` (ExceptionsList
dropped); the plugin's client must carry no proxy mount when no proxy env var is set."""
import httpx
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy",
"NO_PROXY", "no_proxy"):
monkeypatch.delenv(key, raising=False)
fake_openai = MagicMock()
with patch("urllib.request.getproxies", return_value={"https": "http://127.0.0.1:7890",
"http": "http://127.0.0.1:7890"}):
openai_plugin._build_client(fake_openai, "http://localhost:18081/v1", "k")
http_client = fake_openai.OpenAI.call_args.kwargs["http_client"]
assert isinstance(http_client, httpx.Client)
assert not any(type(getattr(m, "_pool", None)).__name__ == "HTTPProxy"
for m in http_client._mounts.values() if m is not None)
http_client.close()
# ── Generate ────────────────────────────────────────────────────────────────

View File

@@ -189,6 +189,33 @@ accepts any `model` value (including nonexistent ids) and generates with its
own server-managed engine, so a "selected" Flare or Sunburst tier would be a
label with no effect. Pick the direct OpenAI API provider or FAL for 2.5.
### Custom OpenAI-compatible image endpoint
The **OpenAI** provider can point at any OpenAI-compatible `/v1/images/generations`
endpoint (a local gateway, a task-scoped proxy, a third-party API gateway),
independently of the chat provider, and take its key from a variable of your choice:
```yaml
image_gen:
provider: openai
openai:
model: gpt-image-2-medium
base_url: http://localhost:18081/v1 # → OPENAI_BASE_URL → api.openai.com
key_env: IMAGE_GATEWAY_TOKEN # → OPENAI_API_KEY
```
Only the variable *name* is stored in `config.yaml`; the secret stays in `.env`
or the process environment. The model catalog is unchanged: `gpt-image-2-medium`
is sent as `model: gpt-image-2` + `quality: medium`, so the gateway must serve
OpenAI's image model names. Availability checks and
generation use the same resolution, so a configured `key_env` is enough — no
`OPENAI_API_KEY` is required. Requests go through Hermes' own HTTP client, which
honours `HTTP(S)_PROXY`/`NO_PROXY` but ignores macOS system proxies (whose
exception list is invisible to Python), so `localhost` endpoints connect directly.
The `OpenAI-Project` header is sent blank on image requests: an `OPENAI_PROJECT_ID`
set for chat otherwise makes the image endpoint return 403 `model_not_found` on
projects with a model allow-list, while the key itself already carries the project.
## Usage
The agent-facing schema is intentionally minimal — the model picks up whatever you've configured: