feat: consolidate desktop and provider updates
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled

This commit is contained in:
m4
2026-09-27 17:37:19 +08:00
parent 226350cd04
commit fb13457f6f
174 changed files with 7669 additions and 1477 deletions
@@ -0,0 +1,158 @@
"""A user-defined endpoint prices itself from its own upstream (sub2api's key-pricing API) —
there is no canonical catalog to whitelist it into.
"""
import hermes_cli.inventory as inv
import hermes_cli.models_pricing as mp
def _stub_endpoint(monkeypatch, payload, *, calls=None, key="sk-test", reset=True):
"""Serve *payload* (or raise when it is an Exception) for any upstream GET."""
if reset:
monkeypatch.setattr(mp, "_pricing_cache", {})
monkeypatch.setattr(mp, "_pricing_cache_retry_after", {})
monkeypatch.setattr(mp, "_custom_endpoint_api_key", lambda _slug: key)
monkeypatch.setattr(mp, "get_pricing_for_provider", lambda *_a, **_kw: {})
def fake_get_json(url, headers, timeout, *_a, **_kw):
if calls is not None:
calls.append((url, headers.get("Authorization")))
if isinstance(payload, Exception):
raise payload
return payload
monkeypatch.setattr(mp, "_get_json", fake_get_json)
def _row():
return {
"slug": "freemodel2api",
"name": "FreeModel2API",
"is_user_defined": True,
"api_url": "http://124.232.163.75:3020/v1",
"models": ["deepseek-flash", "glm-5.3", "no-price-model"],
}
def test_custom_endpoint_prices_reach_the_row_and_scale_by_rate_multiplier(monkeypatch):
"""Per-token upstream USD → the picker's $/Mtok columns, times the key's rate multiplier."""
calls = []
_stub_endpoint(
monkeypatch,
{
"models": [
{"name": "deepseek-flash", "pricing": {
"billing_mode": "token", "input_price": 1.5e-07,
"output_price": 6e-07, "cache_read_price": 3e-09}},
# 2x group multiplier: every rate doubles.
{"name": "glm-5.3", "pricing": {
"billing_mode": "token", "input_price": 1e-06, "output_price": 2e-06}},
{"name": "no-price-model", "pricing": {"billing_mode": "unavailable", "intervals": []}},
],
"rate_multiplier": {"effective_rate_multiplier": 2},
},
calls=calls,
)
rows = [_row()]
inv._apply_pricing(rows)
pricing = rows[0]["pricing"]
assert pricing["deepseek-flash"] == {"input": "$0.30", "output": "$1.20", "cache": "$0.006", "free": False}
assert pricing["glm-5.3"] == {"input": "$2.00", "output": "$4.00", "cache": None, "free": False}
# "unavailable" carries no per-token rate — the model is simply absent, not zero-priced.
assert "no-price-model" not in pricing
assert calls == [("http://124.232.163.75:3020/v1/sub2api/pricing", "Bearer sk-test")]
def test_non_sub2api_endpoint_goes_unpriced_without_breaking_the_row(monkeypatch):
"""An endpoint that is not a sub2api instance 404s / answers non-JSON; the row survives."""
_stub_endpoint(monkeypatch, ValueError("not JSON"))
rows = [_row()]
inv._apply_pricing(rows)
assert "pricing" not in rows[0]
assert rows[0]["models"] == ["deepseek-flash", "glm-5.3", "no-price-model"]
def test_custom_endpoint_pricing_is_cached_only_on_the_picker_path(monkeypatch):
"""Picker opens read the cache; only the prewarm worker may start endpoint I/O."""
_stub_endpoint(monkeypatch, RuntimeError("network fetch started"))
rows = [_row()]
inv._apply_pricing(rows, cached_only=True)
assert "pricing" not in rows[0]
def test_warm_cache_answers_the_picker_path_without_io(monkeypatch):
"""The prewarm's result is what a later cached-only open renders."""
_stub_endpoint(monkeypatch, {
"models": [{"name": "deepseek-flash", "pricing": {
"billing_mode": "token", "input_price": 1.5e-07, "output_price": 6e-07}}],
})
url = _row()["api_url"]
assert mp.get_custom_endpoint_pricing("freemodel2api", url) # fills the cache
_stub_endpoint(monkeypatch, RuntimeError("network fetch started"), reset=False)
assert mp.get_custom_endpoint_pricing("freemodel2api", url, cached_only=True)
rows = [_row()]
inv._apply_pricing(rows, cached_only=True)
assert rows[0]["pricing"]["deepseek-flash"]["input"] == "$0.15"
def test_endpoint_probe_returns_formatted_prices_and_the_rate_multiplier(monkeypatch):
"""面板「测试」那一次:价格格式化成 $/Mtok(和选择器一致),倍率另给一个数 ——
价格里已经乘过倍率,但用户要看的就是这个数。"""
_stub_endpoint(monkeypatch, {
"models": [{"name": "deepseek-flash", "pricing": {
"billing_mode": "token", "input_price": 1.5e-07, "output_price": 6e-07,
"cache_read_price": 3e-09}}],
"rate_multiplier": {"effective_rate_multiplier": 2},
})
monkeypatch.setattr(mp, "_RATE_MULTIPLIERS", {})
probe = mp.sub2api_endpoint_probe_pricing("http://h:3020/v1", "sk-test")
assert probe["pricing"]["deepseek-flash"] == {
"input": "$0.30", "output": "$1.20", "cache": "$0.006", "free": False}
assert probe["rate_multiplier"] == 2.0
def test_endpoint_probe_on_a_non_sub2api_endpoint_is_empty_but_not_fatal(monkeypatch):
_stub_endpoint(monkeypatch, ValueError("not JSON"))
monkeypatch.setattr(mp, "_RATE_MULTIPLIERS", {})
probe = mp.sub2api_endpoint_probe_pricing("http://h:3020/v1", "sk-test")
# 倍率 0 = 不知道,不是「倍率是 0」
assert probe == {"pricing": {}, "rate_multiplier": 0.0}
def test_provider_form_prices_from_its_own_env_credentials(monkeypatch):
"""Same endpoint, provider form: FreeModel2API is a provider plugin, so its key and base URL come
from its own env slots — no ``providers:`` entry, no custom endpoint row."""
from hermes_cli import auth
calls = []
monkeypatch.setattr(mp, "_pricing_cache", {})
monkeypatch.setattr(mp, "_pricing_cache_retry_after", {})
monkeypatch.setattr(
auth, "resolve_api_key_provider_credentials",
lambda _pid: {"api_key": "sk-x", "base_url": "http://h:3020/v1"})
def fake_get_json(url, headers, timeout, *_a, **_kw):
calls.append((url, headers.get("Authorization")))
return {"models": [{"name": "deepseek-flash", "pricing": {
"billing_mode": "token", "input_price": 1.5e-07, "output_price": 6e-07}}]}
monkeypatch.setattr(mp, "_get_json", fake_get_json)
# Per-token USD, the shape _apply_pricing formats into the picker's $/Mtok columns.
assert mp.get_pricing_for_provider("freemodel2api")["deepseek-flash"] == {
"prompt": "1.5e-07", "completion": "6e-07"}
assert calls == [("http://h:3020/v1/sub2api/pricing", "Bearer sk-x")]
# The fetcher's remembered cache key must be the one cached_only reads back, else a picker open
# (which never starts I/O) would show prices only on the second open.
def boom(*_a, **_kw):
raise RuntimeError("network fetch started")
monkeypatch.setattr(mp, "_get_json", boom)
assert mp.get_pricing_for_provider("freemodel2api", cached_only=True)["deepseek-flash"]["prompt"] == "1.5e-07"
@@ -0,0 +1,135 @@
"""FreeModel2API is a first-class provider plugin (``plugins/model-providers/freemodel2api/``):
its credentials live in their own env slots, its model catalog is whatever the saved key may use,
and nothing about it routes through a ``providers:`` / ``custom_providers:`` entry.
"""
import hermes_cli.models as models
import hermes_cli.models_pricing as mp
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
from hermes_cli.config import invalidate_env_cache
from hermes_cli.models_catalog_static import CANONICAL_PROVIDERS
from hermes_cli.provider_catalog import provider_catalog_by_slug
from hermes_cli.providers import get_provider, normalize_provider
def test_freemodel2api_is_registered_as_an_independent_provider():
desc = provider_catalog_by_slug()["freemodel2api"]
assert desc.label == "FreeModel2API"
assert desc.api_key_env_vars == ("FREEMODEL2API_API_KEY",)
assert desc.base_url_env_var == "FREEMODEL2API_BASE_URL"
assert desc.tab == "keys"
# The desktop picker only reaches rows in CANONICAL_PROVIDERS; a plugin profile lands there
# automatically, which is what makes this provider selectable at all.
assert "freemodel2api" in {e.slug for e in CANONICAL_PROVIDERS}
assert normalize_provider("freemodel2api") == "freemodel2api"
def test_freemodel2api_is_the_default_route_before_credentials_are_configured():
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_requested_provider
model = load_config()["model"]
assert model["provider"] == "freemodel2api"
assert model["default"] == ""
assert resolve_requested_provider() == "freemodel2api"
def test_freemodel2api_has_live_catalog_and_pricing_hooks():
# A live probe is the only catalog source: the profile ships fallback_models=(), so a miss
# would leave the row with no models and drop it from the picker.
assert models._PROVIDER_CATALOG_FETCHERS["freemodel2api"] is models._api_key_provider_live
assert mp._PRICING_FETCHERS["freemodel2api"] is mp._fetch_freemodel2api_pricing
assert mp._STATIC_PRICING_SCOPES["freemodel2api"] is mp._freemodel2api_pricing_scope
def test_credentials_resolve_from_the_provider_env_slots(monkeypatch):
"""No base URL is shipped (per-install), so an unconfigured provider must resolve to nothing
rather than to a wrong default host — the picker then simply omits the row."""
assert resolve_api_key_provider_credentials("freemodel2api")["base_url"] == ""
assert PROVIDER_REGISTRY["freemodel2api"].base_url_env_var == "FREEMODEL2API_BASE_URL"
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-env")
monkeypatch.setenv("FREEMODEL2API_BASE_URL", "http://h:3020/v1")
creds = resolve_api_key_provider_credentials("freemodel2api")
assert creds["api_key"] == "sk-env"
assert creds["base_url"] == "http://h:3020/v1"
def test_default_runtime_uses_saved_api_key_without_account_login(tmp_path, monkeypatch):
"""The account session only manages keys; inference needs no FreeModel2API login once the
profile has a key, endpoint and concrete model."""
home = tmp_path / "hermes"
home.mkdir()
(home / "config.yaml").write_text("model:\n default: gpt-5.4\n", encoding="utf-8")
(home / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-direct\n"
"FREEMODEL2API_BASE_URL=https://fm2.example/v1\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("FREEMODEL2API_API_KEY", raising=False)
monkeypatch.delenv("FREEMODEL2API_BASE_URL", raising=False)
invalidate_env_cache()
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider()
assert runtime["provider"] == "freemodel2api"
assert runtime["requested_provider"] == "freemodel2api"
assert runtime["api_key"] == "sk-direct"
assert runtime["base_url"] == "https://fm2.example/v1"
def test_picker_hint_exposes_base_url_env_only_for_per_install_providers():
"""The onboarding key form renders a second (required) base-URL field only for providers that
ship no default endpoint. freemodel2api qualifies; an override-slot provider like deepseek
(default URL exists) must NOT, or every ordinary provider would grow a mandatory URL field."""
from hermes_cli.inventory import _provider_auth_hint
auth_type, key_env, base_url_env = _provider_auth_hint("freemodel2api")
assert (auth_type, key_env, base_url_env) == (
"api_key", "FREEMODEL2API_API_KEY", "FREEMODEL2API_BASE_URL")
assert _provider_auth_hint("deepseek")[2] == ""
def test_credentials_and_endpoint_follow_profile_scope_a_b_a(tmp_path, monkeypatch):
"""A multiplexed Desktop backend must never reuse the launch profile's FreeModel2API route."""
from agent import secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
home_a = tmp_path / "a"
home_b = tmp_path / "b"
home_a.mkdir()
home_b.mkdir()
(home_a / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-a\nFREEMODEL2API_BASE_URL=https://a.example/v1\n",
encoding="utf-8",
)
(home_b / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-b\nFREEMODEL2API_BASE_URL=https://b.example/v1\n",
encoding="utf-8",
)
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-launch")
monkeypatch.setenv("FREEMODEL2API_BASE_URL", "https://launch.example/v1")
def resolve(home):
home_token = set_hermes_home_override(str(home))
scope_token = secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(home))
try:
invalidate_env_cache()
creds = resolve_api_key_provider_credentials("freemodel2api")
provider = get_provider("freemodel2api", allow_network=False)
return creds["api_key"], creds["base_url"], provider.base_url if provider else ""
finally:
secret_scope.reset_secret_scope(scope_token)
reset_hermes_home_override(home_token)
secret_scope.set_multiplex_active(True)
try:
assert resolve(home_a) == ("sk-a", "https://a.example/v1", "https://a.example/v1")
assert resolve(home_b) == ("sk-b", "https://b.example/v1", "https://b.example/v1")
assert resolve(home_a) == ("sk-a", "https://a.example/v1", "https://a.example/v1")
finally:
secret_scope.set_multiplex_active(False)
invalidate_env_cache()
+46 -8
View File
@@ -314,10 +314,6 @@ def test_anthropic_oauth_presence_accepts_pool_only_oauth_entry():
"agent.anthropic_credentials.read_hermes_oauth_credentials",
return_value=None,
),
patch(
"agent.anthropic_credentials.read_claude_code_credentials",
return_value=None,
),
patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
@@ -334,10 +330,6 @@ def test_anthropic_oauth_presence_accepts_pool_only_oauth_entry():
"agent.anthropic_credentials.read_hermes_oauth_credentials",
return_value=None,
),
patch(
"agent.anthropic_credentials.read_claude_code_credentials",
return_value=None,
),
patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
@@ -348,6 +340,52 @@ def test_anthropic_oauth_presence_accepts_pool_only_oauth_entry():
assert _anthropic_oauth_credentials_present() is False
def test_borrowed_credentials_never_surface_rows():
"""Project-config-only policy: a Claude Code login (~/.claude) must not keep the Anthropic
row, and a verified external-process CLI (gh → copilot) must not keep its row — only
credentials Hermes itself manages count for the desktop explicit-only filter."""
from hermes_cli.inventory import _anthropic_oauth_credentials_present
# Claude Code files are no longer read at all: even a live token reads as absent.
with (
patch(
"agent.anthropic_credentials.read_hermes_oauth_credentials",
return_value=None,
),
patch(
"agent.anthropic_credentials.read_claude_code_credentials",
return_value={"accessToken": "sk-ant-oat01-borrowed"},
),
patch(
"hermes_cli.auth.read_credential_pool",
return_value=[],
),
):
assert _anthropic_oauth_credentials_present() is False
rows = [
{"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"],
"total_models": 1, "is_current": False, "is_user_defined": False,
"source": "hermes"},
]
ctx = _empty_ctx(provider="opencode-go", model="glm-5.3")
with (
_list_auth_returning(rows),
patch("hermes_cli.config.read_raw_config", return_value={}),
patch(
"hermes_cli.auth.is_provider_explicitly_configured",
return_value=False,
),
patch(
"hermes_cli.auth.get_external_process_provider_status",
return_value={"auth_verified": True},
),
):
payload = build_models_payload(ctx, explicit_only=True)
assert "copilot" not in [row["slug"] for row in payload["providers"]]
# ─── picker_hints ──────────────────────────────────────────────────────
@@ -240,3 +240,47 @@ def test_distinct_kimi_china_credential_still_listed(monkeypatch):
assert slugs.count("kimi-coding") == 1
assert "kimi" not in slugs # alias collapsed into the canonical row
assert "kimi-coding-cn" in slugs # distinct China endpoint preserved
def _stub_canonical_only_catalog(monkeypatch, model_ids):
"""Isolate a section-2b row: a canonical provider that is NOT a models.dev
built-in, so sections 1/2 cannot claim the slug first."""
import hermes_cli.models as hm
monkeypatch.setattr(hm, "cached_provider_model_ids", lambda *a, **k: list(model_ids))
monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None)
def test_canonical_row_honours_a_pinned_models_whitelist(monkeypatch):
"""``providers.<canonical>.models`` + ``discover_models: false`` pins the 2b row.
The FreeModel2API panel writes this so the picker row offers only the models
the user ticked, instead of everything the endpoint reports.
"""
_stub_canonical_only_catalog(monkeypatch, ["fm-1", "fm-2", "fm-3"])
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-test-fm2")
rows = model_switch.list_authenticated_providers(
max_models=10,
user_providers={"freemodel2api": {"discover_models": False, "models": ["fm-2"]}},
)
fm2_rows = [r for r in rows if r["slug"] == "freemodel2api"]
assert len(fm2_rows) == 1, f"whitelist must not spawn a second row: {[r['slug'] for r in rows]}"
assert fm2_rows[0]["models"] == ["fm-2"]
assert fm2_rows[0]["source"] == "canonical"
assert fm2_rows[0]["is_user_defined"] is False
def test_canonical_row_extends_the_catalog_when_only_models_are_declared(monkeypatch):
"""Without the opt-out the same block extends discovery, matching section 1."""
_stub_canonical_only_catalog(monkeypatch, ["fm-1", "fm-2"])
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-test-fm2")
rows = model_switch.list_authenticated_providers(
max_models=10,
user_providers={"freemodel2api": {"models": ["fm-extra"]}},
)
fm2 = [r for r in rows if r["slug"] == "freemodel2api"][0]
assert fm2["models"] == ["fm-extra", "fm-1", "fm-2"]
@@ -2240,3 +2240,31 @@ def test_same_provider_switch_on_session_only_custom_endpoint_keeps_endpoint(mon
assert result.success
assert result.base_url == "http://10.0.0.5:8000/v1"
assert result.api_key == "session-secret"
def test_metadata_only_user_provider_does_not_shadow_the_real_endpoint():
"""A ``providers.<name>`` entry with no URL and no credential slot is metadata, not an endpoint.
The FreeModel2API settings panel writes ``providers.freemodel2api.{discover_models,models}``
(its picker allow-list) and users carry flags like ``providers.openai-api.stale_timeout_seconds``;
letting either win the chain returned a def with ``base_url=""`` and broke routing.
"""
resolved = resolve_provider_full("freemodel2api", user_providers={
"freemodel2api": {"discover_models": False, "models": ["gpt-5.5"]},
})
assert resolved is None or resolved.source != "user-config", (
f"metadata-only entry won the chain: {resolved!r}"
)
def test_user_provider_with_an_endpoint_still_wins_the_chain():
"""Negative control: the guard must not demote a genuine endpoint entry."""
resolved = resolve_provider_full("mine", user_providers={
"mine": {"base_url": "http://127.0.0.1:4141/v1", "key_env": "MINE_KEY"},
})
assert resolved is not None
assert resolved.source == "user-config"
assert resolved.base_url == "http://127.0.0.1:4141/v1"
assert resolved.api_key_env_vars == ("MINE_KEY",)
@@ -53,6 +53,19 @@ class TestNormalizeCustomProviderEntry:
def test_desktop_bookkeeping_keys_do_not_warn(self, caplog):
"""``key_id`` is written by the Desktop FreeModel2API panel (which key it
last put in that provider's env slot) and is not runtime config; warning
about it on every picker load would be noise our own writer caused."""
entry = {
"discover_models": False,
"key_id": 81,
"models": ["gpt-5.5"],
}
with caplog.at_level(logging.WARNING):
_normalize_custom_provider_entry(dict(entry), provider_key="freemodel2api")
assert not [r for r in caplog.records if "unknown config keys" in r.message.lower()]
def test_env_var_placeholder_in_base_url_not_rejected(self):
"""A base_url that is an un-expanded ${ENV_VAR} placeholder must not be
rejected as an invalid URL — it is expanded at runtime, so a caller
@@ -91,12 +91,19 @@ class TestResolveProviderCorruptConfig:
_load_config_fresh()
from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.runtime_provider import resolve_requested_provider
with pytest.raises(AuthError) as excinfo:
resolve_provider("auto")
assert excinfo.value.code == "corrupt_config"
assert "config.yaml" in str(excinfo.value)
# The normal chat path resolves the product default before calling resolve_provider; it
# must reject the same broken file instead of silently routing to that fallback default.
with pytest.raises(AuthError) as runtime_exc:
resolve_requested_provider()
assert runtime_exc.value.code == "corrupt_config"
def test_corrupt_config_blocks_pool_probe_adoption(self, tmp_path, monkeypatch):
"""Corrupt config + pool-only credential must NOT resolve to openrouter."""
_setup_home(tmp_path, monkeypatch, CORRUPT_YAML)
+4 -2
View File
@@ -1801,7 +1801,7 @@ class TestWebServerEndpoints:
authenticating to the deleted host, and the credential the operator
just removed through the dashboard survives the delete.
"""
from hermes_cli.config import custom_endpoint_key_env, get_env_value, load_config
from hermes_cli.config import DEFAULT_CONFIG, custom_endpoint_key_env, get_env_value, load_config
self.client.post(
"/api/providers/custom-endpoints",
@@ -1832,7 +1832,9 @@ class TestWebServerEndpoints:
assert not model_cfg.get("api_key"), "deleted endpoint's key still in config.yaml"
assert not model_cfg.get("key_env"), "deleted endpoint's key ref still in config.yaml"
assert not model_cfg.get("base_url"), "deleted endpoint's host still routed to"
assert not model_cfg.get("provider")
assert model_cfg.get("provider") == DEFAULT_CONFIG["model"]["provider"]
assert model_cfg.get("default") == DEFAULT_CONFIG["model"]["default"]
assert not model_cfg.get("api_mode"), "deleted endpoint's transport still routed to"
assert not get_env_value(env_var), "deleted endpoint's key still in .env"
@@ -68,6 +68,21 @@ class TestMetadata:
assert entry["speed"]
assert entry["strengths"]
def test_setup_schema_uses_configured_image_key(self, provider, tmp_path):
import yaml
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"image_gen": {"openai": {"key_env": "OPENAI_IMAGE_GEN_API_KEY"}}
}))
schema = provider.get_setup_schema()
assert schema["env_vars"] == [{
"key": "OPENAI_IMAGE_GEN_API_KEY",
"prompt": "Image generation API key (OPENAI_IMAGE_GEN_API_KEY)",
"url": "",
}]
# ── Availability ────────────────────────────────────────────────────────────
@@ -81,6 +96,17 @@ class TestAvailability:
monkeypatch.setenv("OPENAI_API_KEY", "test")
assert openai_plugin.OpenAIImageGenProvider().is_available() is True
def test_configured_key_is_independent_from_generic_openai_key(self, tmp_path, monkeypatch):
import yaml
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"image_gen": {"openai": {"key_env": "OPENAI_IMAGE_GEN_API_KEY"}}
}))
monkeypatch.setenv("OPENAI_API_KEY", "chat-provider-key")
monkeypatch.delenv("OPENAI_IMAGE_GEN_API_KEY", raising=False)
assert openai_plugin.OpenAIImageGenProvider().is_available() is False
# ── Model resolution ────────────────────────────────────────────────────────
@@ -172,6 +198,29 @@ class TestGenerate:
# gpt-image-2 rejects response_format — we must NOT send it.
assert "response_format" not in call_kwargs
def test_configured_key_and_base_url_reach_image_client(self, provider, tmp_path, monkeypatch):
import yaml
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"image_gen": {"openai": {
"key_env": "OPENAI_IMAGE_GEN_API_KEY",
"base_url": "http://image-gateway.test/v1",
}}
}))
monkeypatch.setenv("OPENAI_IMAGE_GEN_API_KEY", "image-key")
monkeypatch.setenv("OPENAI_BASE_URL", "http://chat-gateway.test/v1")
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
with patch.dict("sys.modules", {"openai": fake_openai}):
result = provider.generate("a cat")
assert result["success"] is True
fake_openai.OpenAI.assert_called_once_with(
api_key="image-key", base_url="http://image-gateway.test/v1")
@pytest.mark.parametrize("api_model,quality", [
("gpt-image-2", quality) for quality in ("low", "medium", "high")
] + [
@@ -256,4 +305,3 @@ class TestGenerate:
assert result["image"].startswith("/")
assert "example.com" not in result["image"]
mock_save_url.assert_called_once()