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:
@@ -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.
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user