From a9acb400ba061e1199ec4670d85848679b3f499f Mon Sep 17 00:00:00 2001 From: Justin Bennington Date: Fri, 15 May 2026 13:44:39 -0400 Subject: [PATCH] feat(providers): add Actual Computer inference provider --- agent/auxiliary_client.py | 22 ++- hermes_cli/auth.py | 66 ++++++- hermes_cli/doctor.py | 2 + hermes_cli/models.py | 3 + hermes_cli/providers.py | 12 ++ hermes_cli/runtime_provider.py | 24 ++- plugins/model-providers/actual/__init__.py | 88 +++++++++ plugins/model-providers/actual/plugin.yaml | 5 + run_agent.py | 2 + tests/hermes_cli/test_actual_provider.py | 198 +++++++++++++++++++++ 10 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 plugins/model-providers/actual/__init__.py create mode 100644 plugins/model-providers/actual/plugin.yaml create mode 100644 tests/hermes_cli/test_actual_provider.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a4b531209fe77..c6db27104de91 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -509,6 +509,9 @@ _PROVIDER_ALIASES = { "moonshot-cn": "kimi-coding-cn", "gmi-cloud": "gmi", "gmicloud": "gmi", + "actual-computer": "actual", + "actualcomputer": "actual", + "aci": "actual", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "claude": "anthropic", @@ -5802,6 +5805,8 @@ def resolve_provider_client( return False if raw_codex: return False + if provider == "actual": + return True if api_mode == "codex_responses": return True # Auto-detect: api.openai.com + codex model name pattern @@ -6219,6 +6224,22 @@ def resolve_provider_client( # credential is registered for this provider alias. if explicit_api_key: api_key = explicit_api_key.strip() or api_key + raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url + if explicit_base_url: + raw_base_url = explicit_base_url.strip().rstrip("/") + if provider == "actual": + try: + from hermes_cli.auth import ( + ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, + is_actual_local_base_url, + normalize_actual_base_url, + ) + + raw_base_url = normalize_actual_base_url(raw_base_url) + if not api_key and is_actual_local_base_url(raw_base_url): + api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + except Exception: + pass if not api_key: tried_sources = list(pconfig.api_key_env_vars) if provider == "copilot": @@ -6228,7 +6249,6 @@ def resolve_provider_client( provider, ", ".join(tried_sources)) return None, None - raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url base_url = _to_openai_base_url(raw_base_url) # Honour an explicit base_url override from the caller — used when a # fallback_model entry (or custom_providers lookup) routes through a diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index bf7b00f203138..db568081b8694 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -97,6 +97,8 @@ DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1" DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1" +DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1" +DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1" STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1" STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" @@ -150,6 +152,39 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = { # provider as configured. This sentinel is sent only to LM Studio, never to # any remote service. LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key" +ACTUAL_LOCAL_NOAUTH_PLACEHOLDER = "dummy-actual-local-api-key" + + +def is_actual_local_base_url(base_url: str) -> bool: + """Return True for Actual's loopback local API endpoint.""" + try: + host = (urlparse(base_url or "").hostname or "").lower().rstrip(".") + except Exception: + return False + return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} + + +def normalize_actual_base_url(base_url: str) -> str: + """Return Actual's OpenAI-compatible base URL. + + Actual hosted inference is exposed at api.actual.inc, while the Actual + client's offline local server binds a loopback host. Both use a /v1 API + surface for Hermes' Responses transport. + """ + url = str(base_url or "").strip().rstrip("/") + if not url: + return DEFAULT_ACTUAL_BASE_URL + try: + parsed = urlparse(url) + host = (parsed.hostname or "").lower().rstrip(".") + path = parsed.path.rstrip("/") + except Exception: + return url + if host == "api.actual.inc" and path in {"", "/"}: + return url + "/v1" + if is_actual_local_base_url(url) and path in {"", "/"}: + return url + "/v1" + return url # ============================================================================= @@ -290,6 +325,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=("GMI_API_KEY",), base_url_env_var="GMI_BASE_URL", ), + "actual": ProviderConfig( + id="actual", + name="Actual Computer", + auth_type="api_key", + inference_base_url=DEFAULT_ACTUAL_BASE_URL, + api_key_env_vars=("ACTUAL_API_KEY",), + base_url_env_var="ACTUAL_BASE_URL", + ), "minimax": ProviderConfig( id="minimax", name="MiniMax", @@ -1968,6 +2011,7 @@ def resolve_provider( "step": "stepfun", "stepfun-coding-plan": "stepfun", "arcee-ai": "arcee", "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", + "actual-computer": "actual", "actualcomputer": "actual", "aci": "actual", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth", "alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan", @@ -6937,13 +6981,22 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: else: base_url = pconfig.inference_base_url + if provider_id == "actual": + base_url = normalize_actual_base_url(base_url) + + actual_local_noauth = ( + provider_id == "actual" + and not api_key + and is_actual_local_base_url(base_url) + ) + return { - "configured": bool(api_key), + "configured": bool(api_key) or actual_local_noauth, "provider": provider_id, "name": pconfig.name, - "key_source": key_source, + "key_source": key_source or ("local-offline" if actual_local_noauth else ""), "base_url": base_url, - "logged_in": bool(api_key), # compat with OAuth status shape + "logged_in": bool(api_key) or actual_local_noauth, # compat with OAuth status shape } @@ -7149,12 +7202,19 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if provider_id == "lmstudio": base_url = _normalize_lmstudio_runtime_base_url(base_url) + if provider_id == "actual": + base_url = normalize_actual_base_url(base_url) + # Last-resort guard: an API-key provider must never hand back an empty # base URL (a set-but-empty COPILOT_API_BASE_URL or similar env override # otherwise wedges chat inference — #50252). if not (isinstance(base_url, str) and base_url.strip()): base_url = pconfig.inference_base_url + if not api_key and provider_id == "actual" and is_actual_local_base_url(base_url): + api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + key_source = key_source or "local-offline" + return { "provider": provider_id, "api_key": api_key, diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 18ea422f3e247..238d54827fd10 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -52,6 +52,8 @@ _PROVIDER_ENV_HINTS = ( "KIMI_CN_API_KEY", "GMI_API_KEY", "FIREWORKS_API_KEY", + "ACTUAL_API_KEY", + "ACTUAL_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e2e6c967f9d22..2a4e342b51bcf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1309,6 +1309,9 @@ _PROVIDER_ALIASES = { "gmicloud": "gmi", "fireworks-ai": "fireworks", "fw": "fireworks", + "actual-computer": "actual", + "actualcomputer": "actual", + "aci": "actual", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 241b51d700f53..ab47df203ff5d 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -205,6 +205,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { extra_env_vars=("FIREWORKS_API_KEY",), base_url_override="https://api.fireworks.ai/inference/v1", ), + "actual": HermesOverlay( + transport="codex_responses", + extra_env_vars=("ACTUAL_API_KEY", "ACTUAL_BASE_URL"), + base_url_override="https://api.actual.inc/v1", + base_url_env_var="ACTUAL_BASE_URL", + ), "upstage": HermesOverlay( transport="openai_chat", extra_env_vars=("UPSTAGE_API_KEY",), @@ -383,6 +389,11 @@ ALIASES: Dict[str, str] = { # upstage "solar": "upstage", + # Actual Computer + "actual-computer": "actual", + "actualcomputer": "actual", + "aci": "actual", + # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", "lm-studio": "lmstudio", @@ -408,6 +419,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "xiaomi": "Xiaomi MiMo", "gmi": "GMI Cloud", "upstage": "Upstage Solar", + "actual": "Actual Computer", "tencent-tokenhub": "Tencent TokenHub", "lmstudio": "LM Studio", "local": "Local endpoint", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 285f4e3f2b357..c77545b983088 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -20,6 +20,7 @@ from agent.credential_pool import ( ) from agent.secret_scope import get_secret as _get_secret from hermes_cli.auth import ( + ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, AuthError, DEFAULT_CODEX_BASE_URL, DEFAULT_QWEN_BASE_URL, @@ -36,6 +37,8 @@ from hermes_cli.auth import ( resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, has_usable_secret, + is_actual_local_base_url, + normalize_actual_base_url, ) from hermes_cli.config import ( get_compatible_custom_providers, @@ -131,6 +134,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: # providers.is_official_openai_host for the spoof-rejection contract. if is_official_openai_host(base_url): return "codex_responses" + if hostname == "api.actual.inc": + return "codex_responses" # Direct native Anthropic host: realign with providers.determine_api_mode, # which already maps this host to anthropic_messages. The exact-hostname # match rejects lookalike subdomains (api.anthropic.com.attacker.test) and @@ -1607,12 +1612,17 @@ def _resolve_explicit_runtime( else: base_url = env_url or pconfig.inference_base_url + if provider == "actual": + base_url = normalize_actual_base_url(base_url) + api_key = explicit_api_key if not api_key: creds = resolve_api_key_provider_credentials(provider) api_key = creds.get("api_key", "") if not base_url: base_url = creds.get("base_url", "").rstrip("/") + if provider == "actual": + base_url = normalize_actual_base_url(base_url) api_mode = "chat_completions" if provider == "copilot": @@ -1623,6 +1633,8 @@ def _resolve_explicit_runtime( ) elif provider == "xai": api_mode = "codex_responses" + elif provider == "actual": + api_mode = "codex_responses" else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() configured_mode = _parse_api_mode(model_cfg.get("api_mode")) @@ -1635,6 +1647,9 @@ def _resolve_explicit_runtime( provider, base_url, target_model or model_cfg.get("default", "") ) + if provider == "actual" and not api_key and is_actual_local_base_url(base_url): + api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + return { "provider": provider, "api_mode": api_mode, @@ -2196,6 +2211,8 @@ def resolve_runtime_provider( if cfg_provider == provider: cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") + if provider == "actual": + base_url = normalize_actual_base_url(base_url) api_mode = "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode( @@ -2205,6 +2222,8 @@ def resolve_runtime_provider( ) elif provider == "xai": api_mode = "codex_responses" + elif provider == "actual": + api_mode = "codex_responses" else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Only honor persisted api_mode when it belongs to the same provider family. @@ -2236,11 +2255,14 @@ def resolve_runtime_provider( base_url = normalize_opencode_base_url(provider, api_mode, base_url) if provider == "lmstudio": base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url) + api_key = creds.get("api_key", "") + if provider == "actual" and not api_key and is_actual_local_base_url(base_url): + api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER return { "provider": provider, "api_mode": api_mode, "base_url": base_url, - "api_key": creds.get("api_key", ""), + "api_key": api_key, "source": creds.get("source", "env"), "requested_provider": requested_provider, } diff --git a/plugins/model-providers/actual/__init__.py b/plugins/model-providers/actual/__init__.py new file mode 100644 index 0000000000000..b2102f562572a --- /dev/null +++ b/plugins/model-providers/actual/__init__.py @@ -0,0 +1,88 @@ +"""Actual Computer provider profile.""" + +from __future__ import annotations + +import json +import logging +import os +from urllib.parse import urlparse +import urllib.request + +from providers import register_provider +from providers.base import ProviderProfile, _profile_user_agent + +logger = logging.getLogger(__name__) + +DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1" +DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1" + + +def _normalize_actual_base_url(base_url: str) -> str: + url = str(base_url or "").strip().rstrip("/") + if not url: + return DEFAULT_ACTUAL_BASE_URL + try: + parsed = urlparse(url) + host = (parsed.hostname or "").lower().rstrip(".") + path = parsed.path.rstrip("/") + except Exception: + return url + if host == "api.actual.inc" and path in {"", "/"}: + return url + "/v1" + if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} and path in {"", "/"}: + return url + "/v1" + return url + + +class ActualProfile(ProviderProfile): + """Actual Computer provider. + + Hosted inference defaults to api.actual.inc. Local inference is exposed by + the Actual client only when it runs in offline mode, so users opt into it by + setting ACTUAL_BASE_URL to the local API URL. + """ + + def fetch_models( + self, + *, + api_key: str | None = None, + timeout: float = 8.0, + ) -> list[str] | None: + base_url = _normalize_actual_base_url( + os.getenv("ACTUAL_BASE_URL", "").strip() or self.base_url + ) + if not base_url: + return None + + req = urllib.request.Request(base_url + "/models") + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + req.add_header("Accept", "application/json") + req.add_header("User-Agent", _profile_user_agent()) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + items = data if isinstance(data, list) else data.get("data", []) + return [m["id"] for m in items if isinstance(m, dict) and "id" in m] + except Exception as exc: + logger.debug("fetch_models(actual): %s", exc) + return None + + +actual = ActualProfile( + name="actual", + aliases=("actual-computer", "actualcomputer", "aci"), + display_name="Actual Computer", + description=( + "Actual Computer - hosted inference via api.actual.inc, or local " + "offline inference via ACTUAL_BASE_URL" + ), + signup_url="https://actual.inc", + env_vars=("ACTUAL_API_KEY", "ACTUAL_BASE_URL"), + base_url=DEFAULT_ACTUAL_BASE_URL, + auth_type="api_key", + api_mode="codex_responses", +) + +register_provider(actual) diff --git a/plugins/model-providers/actual/plugin.yaml b/plugins/model-providers/actual/plugin.yaml new file mode 100644 index 0000000000000..ab78164a568be --- /dev/null +++ b/plugins/model-providers/actual/plugin.yaml @@ -0,0 +1,5 @@ +name: actual-provider +kind: model-provider +version: 1.0.0 +description: Actual Computer inference +author: Actual Computer diff --git a/run_agent.py b/run_agent.py index 3e13c6493f662..92942116e91c9 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1586,6 +1586,8 @@ class AIAgent: ) -> bool: """Return True when this provider/model pair should use Responses API.""" normalized_provider = (provider or "").strip().lower() + if normalized_provider == "actual": + return True # Nous serves GPT-5.x models via its OpenAI-compatible chat # completions endpoint; its /v1/responses endpoint returns 404. if normalized_provider == "nous": diff --git a/tests/hermes_cli/test_actual_provider.py b/tests/hermes_cli/test_actual_provider.py new file mode 100644 index 0000000000000..aac61d6ecbf1e --- /dev/null +++ b/tests/hermes_cli/test_actual_provider.py @@ -0,0 +1,198 @@ +"""Regression tests for the Actual Computer provider wiring.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +from agent.auxiliary_client import _normalize_aux_provider +from hermes_cli import runtime_provider as rp +from hermes_cli.auth import ( + ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, + DEFAULT_ACTUAL_BASE_URL, + DEFAULT_ACTUAL_LOCAL_BASE_URL, + get_api_key_provider_status, + normalize_actual_base_url, + resolve_api_key_provider_credentials, + resolve_provider, +) +from hermes_cli.models import normalize_provider as normalize_model_provider +from hermes_cli.models import provider_model_ids +from hermes_cli.providers import determine_api_mode +from hermes_cli.providers import get_label +from hermes_cli.providers import normalize_provider as normalize_overlay_provider +from providers import get_provider_profile + + +def _clear_actual_env(monkeypatch): + monkeypatch.delenv("ACTUAL_API_KEY", raising=False) + monkeypatch.delenv("ACTUAL_BASE_URL", raising=False) + + +def test_actual_aliases_and_profile_metadata(): + profile = get_provider_profile("actual-computer") + + assert profile is not None + assert profile.name == "actual" + assert profile.display_name == "Actual Computer" + assert profile.base_url == DEFAULT_ACTUAL_BASE_URL + assert profile.api_mode == "codex_responses" + assert profile.auth_type == "api_key" + assert profile.env_vars == ("ACTUAL_API_KEY", "ACTUAL_BASE_URL") + assert normalize_overlay_provider("aci") == "actual" + assert normalize_model_provider("actualcomputer") == "actual" + assert resolve_provider("actual-computer") == "actual" + assert _normalize_aux_provider("aci") == "actual" + assert get_label("actual") == "Actual Computer" + assert determine_api_mode("actual", "https://api.actual.inc") == "codex_responses" + + +def test_actual_base_url_normalization(): + assert normalize_actual_base_url("https://api.actual.inc") == DEFAULT_ACTUAL_BASE_URL + assert normalize_actual_base_url("https://api.actual.inc/v1") == DEFAULT_ACTUAL_BASE_URL + assert normalize_actual_base_url("http://127.0.0.1:8080") == DEFAULT_ACTUAL_LOCAL_BASE_URL + assert normalize_actual_base_url("http://127.0.0.1:8080/v1") == DEFAULT_ACTUAL_LOCAL_BASE_URL + assert normalize_actual_base_url("http://localhost:8080/") == "http://localhost:8080/v1" + + +def test_actual_credentials_default_to_hosted_api(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key") + + creds = resolve_api_key_provider_credentials("actual") + + assert creds["provider"] == "actual" + assert creds["api_key"] == "actual-test-key" + assert creds["base_url"] == DEFAULT_ACTUAL_BASE_URL + + +def test_actual_local_loopback_allows_no_auth(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080") + + creds = resolve_api_key_provider_credentials("actual") + status = get_api_key_provider_status("actual") + + assert creds["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + assert creds["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + assert creds["source"] == "local-offline" + assert status["configured"] is True + assert status["logged_in"] is True + assert status["key_source"] == "local-offline" + assert status["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + + +def test_actual_runtime_uses_hosted_default(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "actual", "default": "actual/test-model"}, + ) + + resolved = rp.resolve_runtime_provider(requested="actual") + + assert resolved["provider"] == "actual" + assert resolved["api_mode"] == "codex_responses" + assert resolved["api_key"] == "actual-test-key" + assert resolved["base_url"] == DEFAULT_ACTUAL_BASE_URL + + +def test_actual_runtime_uses_local_env_without_key(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "actual", "default": "actual/local-model"}, + ) + + resolved = rp.resolve_runtime_provider(requested="actual") + + assert resolved["provider"] == "actual" + assert resolved["api_mode"] == "codex_responses" + assert resolved["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + assert resolved["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + + +def test_actual_runtime_uses_local_config_without_key(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "actual", + "base_url": "http://127.0.0.1:8080", + "default": "actual/local-model", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="actual") + + assert resolved["provider"] == "actual" + assert resolved["api_mode"] == "codex_responses" + assert resolved["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER + assert resolved["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + + +def test_actual_runtime_normalizes_explicit_hosted_base_url(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "actual", "default": "actual/test-model"}, + ) + + resolved = rp.resolve_runtime_provider( + requested="actual", + explicit_api_key="actual-test-key", + explicit_base_url="https://api.actual.inc", + ) + + assert resolved["provider"] == "actual" + assert resolved["api_mode"] == "codex_responses" + assert resolved["api_key"] == "actual-test-key" + assert resolved["base_url"] == DEFAULT_ACTUAL_BASE_URL + assert resolved["source"] == "explicit" + + +def test_actual_profile_fetch_models_normalizes_env_base_url(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080") + profile = get_provider_profile("actual") + seen = {} + + class _Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self): + return json.dumps({"data": [{"id": "actual/local-model"}]}).encode() + + def _urlopen(req, timeout=0): + seen["url"] = req.full_url + seen["auth"] = req.get_header("Authorization") + seen["timeout"] = timeout + return _Response() + + monkeypatch.setattr("urllib.request.urlopen", _urlopen) + + assert profile.fetch_models(api_key=None, timeout=1.5) == ["actual/local-model"] + assert seen["url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + "/models" + assert seen["auth"] is None + assert seen["timeout"] == 1.5 + + +def test_actual_provider_model_ids_use_local_profile_catalog(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080") + profile = get_provider_profile("actual") + + with patch.object(profile, "fetch_models", return_value=["actual/local-model"]) as fetch: + assert provider_model_ids("actual") == ["actual/local-model"] + + fetch.assert_called_once_with(api_key=ACTUAL_LOCAL_NOAUTH_PLACEHOLDER)