refactor(image_gen,browser): compact module/function docstrings and comments across providers (WHYs kept)
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
"""Shared base for the bundled cloud-browser provider plugins.
|
||||
|
||||
Every vendor (Browserbase, Browser Use, Firecrawl) speaks the same REST shape:
|
||||
POST to create a session, one request to release it. :class:`CloudBrowserProvider`
|
||||
owns that lifecycle; subclasses supply class attributes and small hooks. Log
|
||||
messages carry the vendor label and go to the subclass module's logger so the
|
||||
emitted text matches the pre-refactor per-vendor modules.
|
||||
"""
|
||||
"""Shared base for the bundled cloud-browser plugins. Every vendor speaks the same REST shape
|
||||
(POST to create a session, one request to release it); :class:`CloudBrowserProvider` owns that
|
||||
lifecycle, subclasses supply attributes + hooks. Logs go to the subclass module's logger with the
|
||||
vendor label so emitted text matches the pre-refactor per-vendor modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,26 +17,19 @@ _CLOSE_OK = {200, 201, 204}
|
||||
|
||||
|
||||
class CloudBrowserProvider(BrowserProvider):
|
||||
"""REST cloud-browser provider driven by class attributes + hooks.
|
||||
|
||||
Subclasses set ``provider_id`` / ``label``, ``release_method`` / ``release_path``
|
||||
(``{session_id}`` placeholder, appended to ``config["base_url"]``), implement
|
||||
``_get_config_or_none()`` and ``_headers(config)``, and write ``create_session``
|
||||
on top of :meth:`_post_create` / :meth:`_check_created` / :meth:`_session_name`.
|
||||
"""
|
||||
"""Subclasses set ``provider_id``/``label``, ``release_method``/``release_path`` (``{session_id}``
|
||||
placeholder appended to ``config["base_url"]``), implement ``_get_config_or_none()`` and
|
||||
``_headers(config)``, and build ``create_session`` on ``_post_create``/``_check_created``."""
|
||||
|
||||
provider_id: str
|
||||
label: str
|
||||
release_method: str
|
||||
release_path: str
|
||||
missing_credentials_error: str = ""
|
||||
# Setup-picker metadata; ``setup_tag=None`` hides the provider from the picker.
|
||||
setup_tag: Optional[str] = None
|
||||
setup_tag: Optional[str] = None # ``None`` hides the provider from the setup picker
|
||||
setup_env_vars: List[Dict[str, str]] = []
|
||||
# Rendered noun in "Failed to create <label> session"; Firecrawl says "browser session".
|
||||
create_label_suffix: str = ""
|
||||
# Browserbase's close warning historically omits the vendor name.
|
||||
close_fail_fmt: Optional[str] = None
|
||||
create_label_suffix: str = "" # "Failed to create <label><suffix> session"; Firecrawl: " browser"
|
||||
close_fail_fmt: Optional[str] = None # Browserbase's close warning historically omits the vendor
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -90,8 +79,8 @@ class CloudBrowserProvider(BrowserProvider):
|
||||
def _post_create(
|
||||
self, url: str, headers: Dict[str, str], payload: Dict[str, object], *, wrap_errors: bool = True
|
||||
) -> requests.Response:
|
||||
"""POST the create request; network failures become RuntimeError unless
|
||||
the caller (managed gateway) needs the raw exception to retry."""
|
||||
"""POST the create request; network failures → RuntimeError unless the managed gateway
|
||||
caller needs the raw exception to retry."""
|
||||
try:
|
||||
return requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Browser Use cloud browser provider.
|
||||
|
||||
The only browser backend with dual auth: a direct ``BROWSER_USE_API_KEY``
|
||||
(https://browser-use.com) or the managed Nous tool gateway (bills to a Nous
|
||||
subscription). Direct key first, managed second, unless ``tool_gateway.browser:
|
||||
gateway`` flips it. Config: ``browser.cloud_provider: "browser-use"``.
|
||||
"""
|
||||
"""Browser Use cloud browser provider — the only backend with dual auth: direct
|
||||
``BROWSER_USE_API_KEY`` (https://browser-use.com) or the managed Nous tool gateway (bills to a
|
||||
Nous subscription). Direct first, managed second, unless ``tool_gateway.browser: gateway`` flips
|
||||
it. Config: ``browser.cloud_provider: "browser-use"``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,9 +17,8 @@ from plugins.browser._common import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Idempotency keys for managed-mode session creation: the gateway returns 409
|
||||
# "already in progress" on retried POSTs, so the original key is forwarded.
|
||||
# Cleared on success or terminal failure.
|
||||
# Managed-mode create idempotency keys: the gateway answers retried POSTs with 409 "already in
|
||||
# progress", so the original key is forwarded; cleared on success or terminal failure.
|
||||
_pending_create_keys: Dict[str, str] = {}
|
||||
_pending_create_keys_lock = threading.Lock()
|
||||
|
||||
@@ -47,9 +43,8 @@ def _clear_pending_create_key(task_id: str) -> None:
|
||||
|
||||
|
||||
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
||||
"""Keep the idempotency key when the failure is retryable: any 5xx, or a 409
|
||||
whose error message says the original request is still in flight. Any other
|
||||
4xx (auth, bad request) will not succeed on retry, so the key is dropped."""
|
||||
"""Keep the key when retryable: any 5xx, or a 409 saying the original request is still in
|
||||
flight. Other 4xx (auth, bad request) won't succeed on retry, so the key is dropped."""
|
||||
if response.status_code >= 500:
|
||||
return True
|
||||
if response.status_code != 409:
|
||||
@@ -69,17 +64,15 @@ class BrowserUseBrowserProvider(CloudBrowserProvider):
|
||||
label = "Browser Use"
|
||||
release_method = "patch"
|
||||
release_path = "/browsers/{session_id}"
|
||||
# Hidden from the picker: the "Browser Use" row activates the CLI backend
|
||||
# (tools/browser_use_cli.py). This provider stays registered for the Nous
|
||||
# gateway path and un-migrated legacy cloud_provider configs.
|
||||
# Hidden from the picker (its "Browser Use" row activates tools/browser_use_cli.py); stays
|
||||
# registered for the Nous gateway path and legacy cloud_provider configs.
|
||||
setup_tag = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none(refresh_token=False) is not None
|
||||
|
||||
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
|
||||
# Lazy: managed_tool_gateway pulls in the Nous auth stack, which
|
||||
# direct-API-key users never need.
|
||||
# Lazy: managed_tool_gateway pulls in the Nous auth stack direct-key users never need.
|
||||
from tools.managed_tool_gateway import peek_nous_access_token, resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection
|
||||
|
||||
@@ -99,9 +92,8 @@ class BrowserUseBrowserProvider(CloudBrowserProvider):
|
||||
selected = read_selection("browser")
|
||||
direct = {"api_key": api_key, "base_url": _BASE_URL, "managed_mode": False}
|
||||
|
||||
# Strict selection: "nous" (or legacy use_gateway: true) → managed ONLY;
|
||||
# any other stored selection → direct key ONLY (no silent managed
|
||||
# fallback); never-configured → direct key when present, else managed.
|
||||
# Strict: "nous" (or legacy use_gateway: true) → managed ONLY; any other stored selection →
|
||||
# direct ONLY (no silent managed fallback); never-configured → direct if present, else managed.
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
return _managed_config()
|
||||
if selected is not None:
|
||||
@@ -141,13 +133,11 @@ class BrowserUseBrowserProvider(CloudBrowserProvider):
|
||||
headers = self._headers(config)
|
||||
if managed_mode:
|
||||
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
||||
# Keep gateway-backed sessions short so billing authorization does not
|
||||
# default to a long Browser-Use timeout for a task-scoped browser.
|
||||
# Short gateway sessions: billing authorization must not default to a long Browser-Use timeout.
|
||||
payload = (
|
||||
{"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES, "proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE}
|
||||
if managed_mode else {})
|
||||
# Managed mode propagates network errors raw so callers can retry with
|
||||
# the preserved idempotency key; direct mode wraps them.
|
||||
# Managed mode propagates network errors raw (retry with the preserved key); direct wraps them.
|
||||
response = self._post_create(
|
||||
f"{config['base_url']}/browsers", headers, payload, wrap_errors=not managed_mode)
|
||||
if not response.ok and managed_mode and not _should_preserve_pending_create_key(response):
|
||||
@@ -163,8 +153,7 @@ class BrowserUseBrowserProvider(CloudBrowserProvider):
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data.get("cdpUrl") or session_data.get("connectUrl") or "",
|
||||
# Sessions have a fixed server-side lifetime; preserve the API's
|
||||
# authority so the dispatcher retires an expired CDP endpoint.
|
||||
# Fixed server-side lifetime: keep the API's authority so an expired CDP endpoint is retired.
|
||||
"expires_at": session_data.get("timeoutAt"),
|
||||
"features": {"browser_use": True},
|
||||
"external_call_id": response.headers.get("x-external-call-id") if managed_mode else None,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Browserbase cloud browser provider.
|
||||
|
||||
Direct ``BROWSERBASE_API_KEY`` + ``BROWSERBASE_PROJECT_ID`` only (the Nous
|
||||
subscription routes through Browser Use). Config: ``browser.cloud_provider:
|
||||
"""Browserbase cloud browser provider: direct ``BROWSERBASE_API_KEY`` + ``BROWSERBASE_PROJECT_ID``
|
||||
only (the Nous subscription routes through Browser Use). Config ``browser.cloud_provider:
|
||||
"browserbase"``; knobs ``BROWSERBASE_BASE_URL``, ``BROWSERBASE_PROXIES`` (true),
|
||||
``BROWSERBASE_ADVANCED_STEALTH`` (false), ``BROWSERBASE_KEEP_ALIVE`` (true),
|
||||
``BROWSERBASE_SESSION_TIMEOUT`` (seconds, max 21600).
|
||||
"""
|
||||
``BROWSERBASE_SESSION_TIMEOUT`` (seconds, max 21600)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,7 +19,8 @@ logger = logging.getLogger(__name__)
|
||||
_PAID_FEATURE_FALLBACKS = (
|
||||
("keepAlive", "keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."),
|
||||
("proxies", "Proxies unavailable (402), retrying without proxies. Bot detection may be less effective."),
|
||||
("proxies", "Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Firecrawl cloud browser provider (``/v2/browser`` only — the firecrawl WEB
|
||||
plugin under ``plugins/web/firecrawl/`` shares ``FIRECRAWL_API_KEY``).
|
||||
|
||||
Config: ``browser.cloud_provider: "firecrawl"`` (explicit selection only — not
|
||||
in the legacy auto-detect walk). Env: ``FIRECRAWL_API_KEY``,
|
||||
``FIRECRAWL_API_URL`` (default https://api.firecrawl.dev), ``FIRECRAWL_BROWSER_TTL``
|
||||
(default 300 seconds).
|
||||
"""
|
||||
"""Firecrawl cloud browser (``/v2/browser`` only; the web plugin under ``plugins/web/firecrawl/``
|
||||
shares ``FIRECRAWL_API_KEY``). Config ``browser.cloud_provider: "firecrawl"`` (explicit only — not
|
||||
in the legacy auto-detect walk). Env: ``FIRECRAWL_API_KEY``, ``FIRECRAWL_API_URL`` (default
|
||||
https://api.firecrawl.dev), ``FIRECRAWL_BROWSER_TTL`` (default 300 s)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,8 +37,8 @@ class FirecrawlBrowserProvider(CloudBrowserProvider):
|
||||
return {"base_url": self._api_url()} if get_secret("FIRECRAWL_API_KEY") else None
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
# Never raises: a missing key surfaces from _headers() inside the request
|
||||
# try-block, so close_session logs it as an exception (legacy behaviour).
|
||||
# Never raises: a missing key surfaces from _headers() inside the request try-block, so
|
||||
# close_session logs it as an exception (legacy behaviour).
|
||||
return {"base_url": self._api_url()}
|
||||
|
||||
def _headers(self, config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||
|
||||
@@ -189,7 +189,8 @@ def import_openai(provider: str, aspect: str) -> Tuple[Any, Optional[Dict[str, A
|
||||
|
||||
def materialize_image(
|
||||
b64: Optional[str], url: Optional[str], *, prefix: str, label: str, provider: str, model: str,
|
||||
prompt: str, aspect: str, log: logging.Logger = logger, on_url_fail: Optional[Callable[[Exception], None]] = None,
|
||||
prompt: str, aspect: str, log: logging.Logger = logger,
|
||||
on_url_fail: Optional[Callable[[Exception], None]] = None,
|
||||
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""``(image_ref, None)`` or ``(None, error)`` for a ``(b64_json, url)`` pair. Base64 is always
|
||||
cached (write failure → ``io_error``); a URL is cached best-effort, falling back to the bare URL."""
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"""DeepInfra image generation backend.
|
||||
|
||||
Exposes DeepInfra's image-gen catalog (FLUX, Qwen-Image-Edit, …) through the
|
||||
OpenAI-compatible ``/v1/openai/images/generations`` endpoint. Model discovery
|
||||
is fully dynamic: ``list_models()`` filters the tagged catalog via
|
||||
:func:`hermes_cli.models._fetch_deepinfra_models_by_tag` (``image-gen``), so
|
||||
no model ids are hardcoded here.
|
||||
|
||||
Model selection: ``DEEPINFRA_IMAGE_MODEL`` env → ``image_gen.deepinfra.model``
|
||||
→ first model from the live catalog; when all are absent ``generate()`` errors
|
||||
rather than guessing.
|
||||
"""
|
||||
"""DeepInfra image generation (FLUX, Qwen-Image-Edit, …) via the OpenAI-compatible
|
||||
``/v1/openai/images/generations`` endpoint. The catalog is fully dynamic (``image-gen``-tagged
|
||||
models from :func:`hermes_cli.models._fetch_deepinfra_models_by_tag`; no ids hardcoded).
|
||||
Selection: ``DEEPINFRA_IMAGE_MODEL`` → ``image_gen.deepinfra.model`` → first live model;
|
||||
when all are absent ``generate()`` errors rather than guessing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -57,8 +50,7 @@ def _format_catalog_row(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _resolve_model(catalog: List[Dict[str, Any]], cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""Model id: env > config > first live result, else None. Takes the loaded
|
||||
``image_gen.deepinfra`` config so ``generate()`` reads config once."""
|
||||
"""env > config > first live result, else None (``cfg`` = loaded ``image_gen.deepinfra``)."""
|
||||
env_override = os.environ.get("DEEPINFRA_IMAGE_MODEL", "").strip()
|
||||
if env_override:
|
||||
return env_override
|
||||
@@ -123,7 +115,7 @@ class DeepInfraImageGenProvider(StaticImageGenProvider):
|
||||
size = size_for(aspect)
|
||||
from hermes_cli.models import deepinfra_base_url
|
||||
|
||||
# OpenAI-compatible endpoint — the openai SDK supplies retry, timeout and error mapping.
|
||||
# The openai SDK supplies retry, timeout and error mapping.
|
||||
openai, err = import_openai("deepinfra", aspect)
|
||||
if err:
|
||||
return err
|
||||
@@ -143,14 +135,15 @@ class DeepInfraImageGenProvider(StaticImageGenProvider):
|
||||
if not data:
|
||||
return fail("DeepInfra returned no image data", "empty_response")
|
||||
first = data[0]
|
||||
# Drop the ``vendor/`` prefix and any colons so the saved filename
|
||||
# stays a single path component on every OS. Delivery URLs are often
|
||||
# short-lived; materialise locally, best-effort.
|
||||
# Prefix drops ``vendor/`` and colons (single path component on every OS); delivery URLs
|
||||
# are short-lived, so materialise locally best-effort.
|
||||
image_ref, err = materialize_image(
|
||||
getattr(first, "b64_json", None), getattr(first, "url", None),
|
||||
prefix=f"deepinfra_{model_id.split('/', 1)[-1].replace(':', '_')}", label="DeepInfra",
|
||||
provider="deepinfra", model=model_id, prompt=prompt, aspect=aspect,
|
||||
on_url_fail=lambda exc: logger.debug("DeepInfra: caching delivery URL failed (%s); returning URL", exc),
|
||||
on_url_fail=lambda exc: logger.debug(
|
||||
"DeepInfra: caching delivery URL failed (%s); returning URL", exc,
|
||||
),
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""FAL.ai image generation backend — registration adapter.
|
||||
|
||||
Catalog, payload construction, submission, managed-Nous-gateway selection and
|
||||
Clarity Upscaler chaining live in :mod:`tools.image_generation_tool`; this
|
||||
plugin reaches into it at call time (``import tools.image_generation_tool as
|
||||
_it``) so tests keep patching ``image_tool.*`` and there is one FAL code path.
|
||||
"""
|
||||
"""FAL.ai registration adapter. Catalog, payload, submission, managed-gateway selection and
|
||||
Clarity Upscaler chaining live in :mod:`tools.image_generation_tool`; this plugin imports it at
|
||||
call time so tests keep patching ``image_tool.*`` and there is one FAL code path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -49,8 +45,7 @@ class FalImageGenProvider(StaticImageGenProvider):
|
||||
return _it.DEFAULT_MODEL
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# Image-to-image depends on the selected FAL model (``edit_endpoint``);
|
||||
# Clarity Upscaler chains on request for any model.
|
||||
# Image-to-image depends on the selected model (``edit_endpoint``); upscale works for any.
|
||||
import tools.image_generation_tool as _it
|
||||
|
||||
try:
|
||||
@@ -70,8 +65,7 @@ class FalImageGenProvider(StaticImageGenProvider):
|
||||
image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward to :func:`tools.image_generation_tool.image_generate_tool` and
|
||||
reshape its JSON-string response into the provider-ABC dict."""
|
||||
"""Forward to ``image_generate_tool`` and reshape its JSON-string response into the ABC dict."""
|
||||
import tools.image_generation_tool as _it
|
||||
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
@@ -101,8 +95,7 @@ class FalImageGenProvider(StaticImageGenProvider):
|
||||
"success": False, "image": None, "error": "FAL pipeline returned a non-dict response",
|
||||
"error_type": "provider_contract",
|
||||
}
|
||||
# Stamp the uniform provider shape; the legacy pipeline resolves the
|
||||
# model internally, so query it after the fact.
|
||||
# Stamp the uniform shape; the legacy pipeline resolves the model internally.
|
||||
response.setdefault("provider", "fal")
|
||||
response.setdefault("prompt", prompt)
|
||||
response.setdefault("aspect_ratio", aspect)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Meta Model API image generation backend (``muse-image``).
|
||||
|
||||
The Meta Model API (https://api.meta.ai/v1) is OpenAI-compatible, so the OpenAI
|
||||
SDK is pointed at Meta's base URL with ``META_MODEL_API_KEY``. Output is base64
|
||||
WebP → ``$HERMES_HOME/cache/images/``. Selection: ``model`` kwarg →
|
||||
``META_IMAGE_MODEL`` → ``image_gen.meta-ai.model`` → ``image_gen.model`` →
|
||||
:data:`DEFAULT_MODEL`.
|
||||
"""
|
||||
"""Meta Model API (``muse-image``): OpenAI-compatible (https://api.meta.ai/v1), so the OpenAI SDK
|
||||
is pointed at Meta's base URL with ``META_MODEL_API_KEY``. Output is base64 WebP → image cache.
|
||||
Selection: ``model`` kwarg → ``META_IMAGE_MODEL`` → ``image_gen.meta-ai.model`` → ``image_gen.model``
|
||||
→ :data:`DEFAULT_MODEL`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,13 +19,11 @@ from plugins.image_gen._common import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.meta.ai/v1"
|
||||
# Auth env vars, in priority order (mirrors the ``meta-ai`` chat provider):
|
||||
# MODEL_API_KEY is Meta's documented var; the rest are accepted aliases.
|
||||
# Auth env vars in priority order (mirrors the ``meta-ai`` chat provider); MODEL_API_KEY is Meta's
|
||||
# documented var, the rest are aliases. ``API_KEY_ENV`` is the one shown in setup/errors.
|
||||
API_KEY_ENVS = ("MODEL_API_KEY", "META_API_KEY", "META_MODEL_API_KEY")
|
||||
# Primary key shown in setup prompts / error messages.
|
||||
API_KEY_ENV = "META_MODEL_API_KEY"
|
||||
# Optional base-url override (same var the chat provider honors).
|
||||
BASE_URL_ENV = "META_BASE_URL"
|
||||
BASE_URL_ENV = "META_BASE_URL" # optional override, same var the chat provider honors
|
||||
|
||||
|
||||
def _resolve_api_key() -> Optional[str]:
|
||||
|
||||
@@ -30,7 +30,8 @@ logger = logging.getLogger(__name__)
|
||||
_MAX_ERROR_BODY_CHARS = 500
|
||||
_MODELS: Dict[str, Dict[str, Any]] = dict(GPT_IMAGE_2_TIERS)
|
||||
|
||||
_CODEX_CHAT_MODEL = "gpt-5.5" # hosts the ``image_generation`` tool call; ``API_MODEL`` does the image work
|
||||
# Hosts the ``image_generation`` tool call; ``API_MODEL`` does the image work.
|
||||
_CODEX_CHAT_MODEL = "gpt-5.5"
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
_CODEX_INSTRUCTIONS = (
|
||||
"You are an assistant that must fulfill image generation and image editing "
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""OpenAI image generation backend: ``gpt-image-2`` at three quality tiers
|
||||
(``gpt-image-2-low/-medium/-high``) exposed as virtual model ids. Output is
|
||||
base64 → ``$HERMES_HOME/cache/images/``. Selection: ``OPENAI_IMAGE_MODEL`` →
|
||||
``image_gen.openai.model`` → ``image_gen.model`` → :data:`DEFAULT_MODEL`.
|
||||
"""
|
||||
"""OpenAI ``gpt-image-2`` at three quality tiers (virtual ids ``gpt-image-2-low/-medium/-high``);
|
||||
base64 output → image cache. Selection: ``OPENAI_IMAGE_MODEL`` → ``image_gen.openai.model`` →
|
||||
``image_gen.model`` → :data:`DEFAULT_MODEL`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,8 +42,7 @@ def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
|
||||
header, _, b64 = ref.partition(",")
|
||||
ext = (header.split("image/", 1)[1].split(";", 1)[0] if "image/" in header else "") or "png"
|
||||
return base64.b64decode(b64), f"image.{ext}"
|
||||
# Local file path — enforce the shared credential-read guard before reading.
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
from agent.file_safety import raise_if_read_blocked # credential-read guard before local bytes
|
||||
|
||||
raise_if_read_blocked(ref)
|
||||
with open(ref, "rb") as fh:
|
||||
@@ -110,7 +107,9 @@ class OpenAIImageGenProvider(StaticImageGenProvider):
|
||||
|
||||
# gpt-image-2 returns b64_json unconditionally and REJECTS
|
||||
# ``response_format`` as an unknown parameter. Don't send it.
|
||||
request: Dict[str, Any] = dict(model=API_MODEL, prompt=prompt, size=size, n=1, quality=meta["quality"])
|
||||
request: Dict[str, Any] = dict(
|
||||
model=API_MODEL, prompt=prompt, size=size, n=1, quality=meta["quality"],
|
||||
)
|
||||
if is_edit:
|
||||
try:
|
||||
files = [_named_bytes_io(ref) for ref in sources]
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"""xAI image generation backend.
|
||||
|
||||
Exposes xAI's ``grok-imagine-image`` models as an :class:`ImageGenProvider`:
|
||||
text-to-image (``/v1/images/generations``) and image editing
|
||||
(``/v1/images/edits``), base64 output saved to cache.
|
||||
|
||||
Selection: ``model`` kwarg → ``XAI_IMAGE_MODEL`` → ``image_gen.xai.model`` →
|
||||
:data:`DEFAULT_MODEL`.
|
||||
"""
|
||||
"""xAI ``grok-imagine-image`` backend: text-to-image (``/v1/images/generations``) and editing
|
||||
(``/v1/images/edits``), base64 output saved to cache. Selection: ``model`` kwarg →
|
||||
``XAI_IMAGE_MODEL`` → ``image_gen.xai.model`` → :data:`DEFAULT_MODEL`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -46,9 +40,8 @@ DEFAULT_MODEL = "grok-imagine-image"
|
||||
# xAI documents the quality model as the edit-capable baseline.
|
||||
_EDIT_FALLBACK_MODEL = "grok-imagine-image-quality"
|
||||
|
||||
# Live catalog cache: (models_dict, fetched_monotonic). ``/image-generation-models``
|
||||
# is the source of truth so new Imagine models appear without a code change;
|
||||
# ``_MODELS`` is the offline fallback and supplies curated text for known ids.
|
||||
# Live catalog cache ``(models, fetched_monotonic)``: ``/image-generation-models`` is the source of
|
||||
# truth (new models need no code change); ``_MODELS`` is the offline fallback + curated text.
|
||||
_LIVE_CACHE: Optional[Tuple[Dict[str, Dict[str, Any]], float]] = None
|
||||
_LIVE_CACHE_TTL = 300.0
|
||||
_LIVE_TIMEOUT = 10.0
|
||||
@@ -72,8 +65,7 @@ def _base_url(creds: Dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _fetch_live_models() -> Dict[str, Dict[str, Any]]:
|
||||
"""``{model_id: {"input_modalities", "aliases"}}`` from ``/image-generation-models``;
|
||||
raises on any failure (callers fall back to the static table)."""
|
||||
"""``{model_id: {"input_modalities", "aliases"}}`` from the live endpoint; raises on failure."""
|
||||
creds = resolve_xai_http_credentials()
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
@@ -109,9 +101,8 @@ def _live_models() -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
|
||||
def _catalog() -> Dict[str, Dict[str, Any]]:
|
||||
"""Merged catalog: live ids + curated static metadata. Unknown live models get
|
||||
generic text; curated entries the live list omits are kept; static table
|
||||
alone when the API is unreachable."""
|
||||
"""Live ids + curated metadata (unknown live models get generic text; curated entries the live
|
||||
list omits are kept); the static table alone when the API is unreachable."""
|
||||
live = _live_models()
|
||||
if not live:
|
||||
return dict(_MODELS)
|
||||
@@ -132,8 +123,7 @@ def _configured_model() -> Optional[str]:
|
||||
|
||||
|
||||
def _resolve_model(caller_model: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
|
||||
"""``(model_id, meta)``: caller kwarg → ``XAI_IMAGE_MODEL`` → config → default,
|
||||
each validated against the merged live+static catalog."""
|
||||
"""caller kwarg → ``XAI_IMAGE_MODEL`` → config → default, validated against the merged catalog."""
|
||||
catalog = _catalog()
|
||||
for candidate in (caller_model, os.environ.get("XAI_IMAGE_MODEL"), _configured_model()):
|
||||
if candidate and candidate in catalog:
|
||||
@@ -142,8 +132,7 @@ def _resolve_model(caller_model: Optional[str] = None) -> Tuple[str, Dict[str, A
|
||||
|
||||
|
||||
def _resolve_edit_model(caller_model: Optional[str] = None) -> str:
|
||||
"""Model for ``/v1/images/edits``: an explicitly selected model that accepts
|
||||
image input is honored; otherwise the documented quality baseline."""
|
||||
"""Edit model: an explicit selection that accepts image input, else the documented quality baseline."""
|
||||
catalog = _catalog()
|
||||
explicit = caller_model or os.environ.get("XAI_IMAGE_MODEL") or _configured_model()
|
||||
if explicit and explicit in catalog and "image" in (catalog[explicit].get("input_modalities") or []):
|
||||
@@ -157,15 +146,13 @@ def _resolve_resolution() -> str:
|
||||
|
||||
|
||||
def _xai_image_field(source: str) -> Dict[str, str]:
|
||||
"""xAI ``image`` field for an edit request: public HTTPS URL or base64 data
|
||||
URI; local paths are read and encoded into a ``data:`` URI."""
|
||||
"""Edit ``image`` field: URL / data URI pass through; local paths are inlined as ``data:`` URIs."""
|
||||
source = source.strip()
|
||||
if source.lower().startswith(_REMOTE_PREFIXES):
|
||||
return {"url": source, "type": "image_url"}
|
||||
import base64
|
||||
|
||||
# Shared credential-read guard before reading local bytes.
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
from agent.file_safety import raise_if_read_blocked # credential-read guard before local bytes
|
||||
|
||||
raise_if_read_blocked(source)
|
||||
with open(os.path.expanduser(source), "rb") as fh: # windows-footgun: ok
|
||||
@@ -213,20 +200,21 @@ class XAIImageGenProvider(StaticImageGenProvider):
|
||||
return next(iter(_catalog()), None)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
# Auth resolution is delegated to the shared ``xai_grok`` post_setup hook
|
||||
# so every xAI service shows the same OAuth-or-API-key choice.
|
||||
# Auth goes through the shared ``xai_grok`` post_setup hook (same OAuth-or-key choice everywhere).
|
||||
storage_notice = xai_storage_notice_text("image_gen")
|
||||
tag = "grok-imagine-image - text-to-image & image editing; uses xAI Grok OAuth or XAI_API_KEY"
|
||||
if storage_notice:
|
||||
tag += f". {storage_notice}"
|
||||
return {
|
||||
"name": "xAI Grok Imagine (image)", "badge": "paid", "tag": tag, "env_vars": [], "post_setup": "xai_grok",
|
||||
"name": "xAI Grok Imagine (image)", "badge": "paid", "tag": tag, "env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# /v1/images/edits accepts up to 3 total source images.
|
||||
return {
|
||||
"modalities": ["text", "image"], "max_reference_images": 2, "max_source_images": _MAX_SOURCE_IMAGES,
|
||||
"modalities": ["text", "image"], "max_reference_images": 2,
|
||||
"max_source_images": _MAX_SOURCE_IMAGES,
|
||||
}
|
||||
|
||||
def generate(
|
||||
@@ -234,9 +222,8 @@ class XAIImageGenProvider(StaticImageGenProvider):
|
||||
image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Text-to-image, or image editing via ``/v1/images/edits`` (JSON body —
|
||||
xAI does not support the SDK's multipart ``images.edit()``) when source
|
||||
images are supplied."""
|
||||
"""Text-to-image, or editing via ``/v1/images/edits`` (JSON body — xAI does not support the
|
||||
SDK's multipart ``images.edit()``) when source images are supplied."""
|
||||
creds = resolve_xai_http_credentials()
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
provider_name = str(creds.get("provider") or "xai").strip() or "xai"
|
||||
@@ -308,8 +295,7 @@ class XAIImageGenProvider(StaticImageGenProvider):
|
||||
if public_url:
|
||||
image_ref = public_url
|
||||
else:
|
||||
# grok-imagine-image URLs (``imgen.x.ai/xai-tmp-*``) 404 within minutes;
|
||||
# materialise locally so downstream consumers get a stable path.
|
||||
# ``imgen.x.ai/xai-tmp-*`` URLs 404 within minutes; materialise locally for a stable path.
|
||||
image_ref, err = materialize_image(
|
||||
first.get("b64_json"), first.get("url"), prefix=f"xai_{model_id}", label="xAI", provider="xai",
|
||||
model=model_id, prompt=prompt, aspect=aspect, log=logger)
|
||||
|
||||
Reference in New Issue
Block a user