Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 62 additions & 17 deletions plugins/image_gen/openai/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""OpenAI image generation backend.

Exposes OpenAI's ``gpt-image-2`` model at three quality tiers as an
:class:`ImageGenProvider` implementation. The tiers are implemented as
:class:`ImageGenProvider` implementation. The tiers are implemented as
three virtual model IDs so the ``hermes tools`` model picker and the
``image_gen.model`` config key behave like any other multi-model backend:

Expand All @@ -10,15 +10,20 @@
gpt-image-2-high ~2min slowest, highest fidelity

All three hit the same underlying API model (``gpt-image-2``) with a
different ``quality`` parameter. Output is base64 JSON → saved under
different ``quality`` parameter. Output is base64 JSON → saved under
``$HERMES_HOME/cache/images/``.

Selection precedence (first hit wins):
Model selection precedence (first hit wins):

1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
2. ``image_gen.openai.model`` in ``config.yaml``
3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``

Endpoint and credential precedence (applied per-call):

1. ``image_gen.openai.base_url`` → ``OPENAI_BASE_URL`` → OpenAI SDK default
2. ``image_gen.openai.key_env`` → resolve env var → ``OPENAI_API_KEY``
"""

from __future__ import annotations
Expand Down Expand Up @@ -46,7 +51,7 @@
# ---------------------------------------------------------------------------
#
# All three IDs resolve to the same underlying API model with a different
# ``quality`` setting. ``api_model`` is what gets sent to OpenAI;
# ``quality`` setting. ``api_model`` is what gets sent to OpenAI;
# ``quality`` is the knob that changes generation time and output fidelity.

API_MODEL = "gpt-image-2"
Expand Down Expand Up @@ -118,6 +123,52 @@ def _resolve_model() -> Tuple[str, Dict[str, Any]]:
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add focused temporary-HERMES_HOME coverage for this resolver: config-vs-environment precedence, key_env availability/generation behavior, and propagation into openai.OpenAI(). The current provider tests only cover OPENAI_API_KEY and tier resolution.



def _resolve_image_credentials() -> Tuple[str, str]:
"""Return ``(base_url, api_key)`` for the OpenAI image client.

Precedence:
1. ``image_gen.openai.base_url`` → ``OPENAI_BASE_URL`` → SDK default
2. ``image_gen.openai.key_env`` → resolve env var → ``OPENAI_API_KEY``
"""
cfg = _load_openai_config()
openai_cfg = cfg.get("openai") if isinstance(cfg.get("openai"), dict) else {}

# Base URL
base_url = ""
if isinstance(openai_cfg, dict):
bu = openai_cfg.get("base_url")
if isinstance(bu, str) and bu.strip():
base_url = bu.strip()
if not base_url:
base_url = os.environ.get("OPENAI_BASE_URL", "")

# API key — key_env points to an env var that holds the actual token
api_key = ""
if isinstance(openai_cfg, dict):
ke = openai_cfg.get("key_env")
if isinstance(ke, str) and ke.strip():
api_key = os.environ.get(ke.strip(), "")
if not api_key:
api_key = os.environ.get("OPENAI_API_KEY", "")

return base_url, api_key


def _build_image_client(base_url: str, api_key: str):
"""Build an ``openai.OpenAI`` client with proxy-bypass keepalive transport."""
from agent.process_bootstrap import build_keepalive_http_client

import openai

http_client = build_keepalive_http_client(base_url)
kwargs: Dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
if http_client:
kwargs["http_client"] = http_client
return openai.OpenAI(**kwargs)


# ---------------------------------------------------------------------------
# Source-image loading (for image-to-image / edit)
# ---------------------------------------------------------------------------
Expand All @@ -126,7 +177,7 @@ def _resolve_model() -> Tuple[str, Dict[str, Any]]:
def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
"""Load image bytes from a URL or local file path.

Returns ``(data, filename)``. Raises on any network / IO error so the
Returns ``(data, filename)``. Raises on any network / IO error so the
caller can surface a clean error_response.
"""
ref = ref.strip()
Expand Down Expand Up @@ -173,7 +224,8 @@ def display_name(self) -> str:
return "OpenAI"

def is_available(self) -> bool:
if not os.environ.get("OPENAI_API_KEY"):
_, api_key = _resolve_image_credentials()
if not api_key:
return False
try:
import openai # noqa: F401
Expand Down Expand Up @@ -235,7 +287,8 @@ def generate(
aspect_ratio=aspect,
)

if not os.environ.get("OPENAI_API_KEY"):
base_url, api_key = _resolve_image_credentials()
if not api_key:
return error_response(
error=(
"OPENAI_API_KEY not set. Run `hermes tools` → Image "
Expand Down Expand Up @@ -270,11 +323,9 @@ def generate(
is_edit = bool(sources)
modality = "image" if is_edit else "text"

client = openai.OpenAI()
client = _build_image_client(base_url, api_key)

if is_edit:
# images.edit() expects file-like objects. Download/read each
# source into a named BytesIO so the SDK sends correct multipart.
import io

try:
Expand All @@ -299,7 +350,7 @@ def generate(
model=API_MODEL,
image=files if len(files) > 1 else files[0],
prompt=prompt,
size=size, # type: ignore[arg-type] # _SIZES values are valid gpt-image sizes
size=size,
quality=meta["quality"],
n=1,
)
Expand All @@ -314,8 +365,6 @@ def generate(
aspect_ratio=aspect,
)
else:
# gpt-image-2 returns b64_json unconditionally and REJECTS
# ``response_format`` as an unknown parameter. Don't send it.
payload: Dict[str, Any] = {
"model": API_MODEL,
"prompt": prompt,
Expand Down Expand Up @@ -367,10 +416,6 @@ def generate(
)
image_ref = str(saved_path)
elif url:
# Defensive — gpt-image-2 returns b64 today, but OpenAI's API
# has previously returned URLs. Cache the bytes locally so the
# gateway never tries to fetch an ephemeral / signed URL after
# it expires — same rationale as the xAI provider (#26942).
try:
saved_path = save_url_image(url, prefix=f"openai_{tier_id}")
except Exception as exc:
Expand Down
196 changes: 196 additions & 0 deletions tests/plugins/image_gen/test_openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,199 @@ def test_url_response_falls_back_to_bare_url_when_download_fails(self, provider)

assert result["success"] is True
assert result["image"] == "https://example.com/img.png"


# ── Credential resolution ───────────────────────────────────────────────────


class TestCredentialResolution:
"""Tests for _resolve_image_credentials() and _build_image_client()."""

# ------------------------------------------------------------------ helpers

@staticmethod
def _write_config(tmp_path, cfg: dict) -> None:
import yaml

(tmp_path / "config.yaml").write_text(yaml.safe_dump(cfg))

# ------------------------------------------------------------ base_url tests

def test_config_base_url_priority_over_env(self, tmp_path, monkeypatch):
"""config.yaml base_url takes priority over OPENAI_BASE_URL env var."""
monkeypatch.setenv("OPENAI_BASE_URL", "https://env.example.com/v1")
self._write_config(
tmp_path,
{"image_gen": {"openai": {"base_url": "https://config.example.com/v1"}}},
)
base_url, _api_key = openai_plugin._resolve_image_credentials()
assert base_url == "https://config.example.com/v1"

def test_base_url_falls_back_to_env_when_config_absent(self, tmp_path, monkeypatch):
"""When config has no base_url, OPENAI_BASE_URL env var is used."""
monkeypatch.setenv("OPENAI_BASE_URL", "https://env.example.com/v1")
self._write_config(tmp_path, {"image_gen": {"openai": {}}})
base_url, _api_key = openai_plugin._resolve_image_credentials()
assert base_url == "https://env.example.com/v1"

def test_base_url_empty_when_neither_config_nor_env(self, tmp_path, monkeypatch):
"""Returns empty base_url when nothing is configured."""
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
self._write_config(tmp_path, {"image_gen": {"openai": {}}})
base_url, _api_key = openai_plugin._resolve_image_credentials()
assert base_url == ""

# ----------------------------------------------------------- key_env tests

def test_key_env_resolves_to_env_var_value(self, tmp_path, monkeypatch):
"""key_env points to an env var whose value is returned as api_key."""
monkeypatch.setenv("MY_CUSTOM_KEY", "sk-custom-secret")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
self._write_config(
tmp_path,
{"image_gen": {"openai": {"key_env": "MY_CUSTOM_KEY"}}},
)
base_url, api_key = openai_plugin._resolve_image_credentials()
assert base_url == ""
assert api_key == "sk-custom-secret"

def test_key_env_falls_back_to_openai_api_key(self, tmp_path, monkeypatch):
"""When key_env points to an unset env var, fall back to OPENAI_API_KEY."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fallback")
monkeypatch.delenv("MY_CUSTOM_KEY", raising=False)
self._write_config(
tmp_path,
{"image_gen": {"openai": {"key_env": "MY_CUSTOM_KEY"}}},
)
_base_url, api_key = openai_plugin._resolve_image_credentials()
assert api_key == "sk-fallback"

def test_key_env_ignored_when_empty_string(self, tmp_path, monkeypatch):
"""Empty key_env in config is treated as not set — use OPENAI_API_KEY."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-direct")
self._write_config(
tmp_path,
{"image_gen": {"openai": {"key_env": ""}}},
)
_base_url, api_key = openai_plugin._resolve_image_credentials()
assert api_key == "sk-direct"

def test_config_base_url_and_key_env_together(self, tmp_path, monkeypatch):
"""Both base_url and key_env are resolved in a single call."""
monkeypatch.setenv("SECRET_STORE", "sk-from-secret")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
self._write_config(
tmp_path,
{
"image_gen": {
"openai": {
"base_url": "https://custom.example.com",
"key_env": "SECRET_STORE",
}
}
},
)
base_url, api_key = openai_plugin._resolve_image_credentials()
assert base_url == "https://custom.example.com"
assert api_key == "sk-from-secret"

# ------------------------------------------------------- is_available tests

def test_is_available_true_when_key_env_valid(self, tmp_path, monkeypatch):
"""is_available() returns True when key_env points to a set env var."""
monkeypatch.setenv("MY_KEY", "sk-valid")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
self._write_config(
tmp_path,
{"image_gen": {"openai": {"key_env": "MY_KEY"}}},
)
assert openai_plugin.OpenAIImageGenProvider().is_available() is True

def test_is_available_false_when_no_api_key_and_no_key_env(
self, tmp_path, monkeypatch
):
"""is_available() returns False when OPENAI_API_KEY is unset and no key_env."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# No config.yaml — default empty config
assert openai_plugin.OpenAIImageGenProvider().is_available() is False

# ------------------------------------------------ _build_image_client tests

def test_build_image_client_passes_http_client_to_openai(self, monkeypatch):
"""_build_image_client passes the keepalive http_client to openai.OpenAI."""
fake_http_client = MagicMock(name="keepalive_http_client")
fake_openai_mod = MagicMock()

with patch(
"agent.process_bootstrap.build_keepalive_http_client",
return_value=fake_http_client,
) as mock_build:
with patch.dict("sys.modules", {"openai": fake_openai_mod}):
openai_plugin._build_image_client(
"https://api.openai.com/v1", "sk-test"
)

# build_keepalive_http_client called with base_url
mock_build.assert_called_once_with("https://api.openai.com/v1")
# openai.OpenAI called with http_client from build_keepalive_http_client
call_kwargs = fake_openai_mod.OpenAI.call_args.kwargs
assert call_kwargs["http_client"] is fake_http_client
assert call_kwargs["api_key"] == "sk-test"
assert call_kwargs["base_url"] == "https://api.openai.com/v1"

def test_build_image_client_no_base_url_skips_base_url_kwarg(self, monkeypatch):
"""When base_url is empty, it is not passed to openai.OpenAI."""
fake_http_client = MagicMock(name="keepalive_http_client")
fake_openai_mod = MagicMock()

with patch(
"agent.process_bootstrap.build_keepalive_http_client",
return_value=fake_http_client,
):
with patch.dict("sys.modules", {"openai": fake_openai_mod}):
openai_plugin._build_image_client("", "sk-test")

call_kwargs = fake_openai_mod.OpenAI.call_args.kwargs
assert "base_url" not in call_kwargs
assert call_kwargs["http_client"] is fake_http_client

def test_proxy_bypass_via_build_keepalive_http_client(self, monkeypatch):
"""When NO_PROXY covers the base_url host, the client bypasses the proxy.

Uses the real build_keepalive_http_client from agent.process_bootstrap
to verify the end-to-end proxy bypass behavior.
"""
from agent.process_bootstrap import build_keepalive_http_client

monkeypatch.setenv("HTTPS_PROXY", "http://proxy:8080")
monkeypatch.setenv("NO_PROXY", "api.openai.com,.internal.example.com")

client = build_keepalive_http_client("https://api.openai.com/v1")
assert client is not None, "build_keepalive_http_client returned None"

# Proxy bypass sets explicit mounts with plain transports (proxy=None
# path), which results in 2 entries (http + https). When the proxy is
# active, httpx auto-populates a single "all://" entry.
bypassed = len(client._mounts) >= 2
assert bypassed, (
"expected mounts with 2+ entries (proxy bypass), "
f"got {len(client._mounts)} entries"
)

def test_proxy_not_bypassed_when_no_proxy_does_not_cover_host(
self, monkeypatch,
):
"""Proxy stays active when NO_PROXY does not match the target host."""
from agent.process_bootstrap import build_keepalive_http_client

monkeypatch.setenv("HTTPS_PROXY", "http://proxy:8080")
monkeypatch.setenv("NO_PROXY", ".internal.example.com")

client = build_keepalive_http_client("https://api.openai.com/v1")
assert client is not None, "build_keepalive_http_client returned None"

# Proxy active — httpx auto-populates a single "all://" entry.
assert len(client._mounts) == 1, (
"expected 1 mount entry (proxy in use), "
f"got {len(client._mounts)} entries"
)
24 changes: 24 additions & 0 deletions website/docs/user-guide/features/image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ image_gen:

The `fal-ai/gpt-image-1.5` and `fal-ai/gpt-image-2` request quality is pinned to `medium` (~$0.034–$0.06/image at 1024×1024). We don't expose the `low` / `high` tiers as a user-facing option so that Nous Portal billing stays predictable across all users — the cost spread between tiers is 3–22×. If you want a cheaper option, pick Klein 9B or Z-Image Turbo; if you want higher quality, use Nano Banana Pro or Recraft V4 Pro.

### Configuring the OpenAI Endpoint

When using the OpenAI backend (e.g., for `gpt-image-2` via `images.edit()`), you can customize the API endpoint URL and control how the API key is sourced:

```yaml
image_gen:
openai:
base_url: https://api.openai.com/v1 # Custom endpoint URL
key_env: OPENAI_API_KEY # Env var name to read the key from
```

Each option has a specific fallback order:

**`base_url`** — Overrides the OpenAI API base URL when set. Fallback order:

1. `image_gen.openai.base_url` in `config.yaml`
2. `OPENAI_BASE_URL` environment variable
3. OpenAI SDK default (`https://api.openai.com/v1`)

**`key_env`** — Specifies the environment variable name to read the API key from. Fallback order:

1. `image_gen.openai.key_env` in `config.yaml` (reads the named env var)
2. `OPENAI_API_KEY` environment variable

## Usage

The agent-facing schema is intentionally minimal — the model picks up whatever you've configured:
Expand Down
Loading