/ tests / hermes_cli / test_runtime_provider_resolution.py
test_runtime_provider_resolution.py
   1  import pytest
   2  
   3  from hermes_cli import runtime_provider as rp
   4  
   5  
   6  def test_resolve_runtime_provider_uses_credential_pool(monkeypatch):
   7      class _Entry:
   8          access_token = "pool-token"
   9          source = "manual"
  10          base_url = "https://chatgpt.com/backend-api/codex"
  11  
  12      class _Pool:
  13          def has_credentials(self):
  14              return True
  15  
  16          def select(self):
  17              return _Entry()
  18  
  19      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
  20      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
  21  
  22      resolved = rp.resolve_runtime_provider(requested="openai-codex")
  23  
  24      assert resolved["provider"] == "openai-codex"
  25      assert resolved["api_key"] == "pool-token"
  26      assert resolved["credential_pool"] is not None
  27      assert resolved["source"] == "manual"
  28  
  29  
  30  def test_resolve_runtime_provider_anthropic_pool_respects_config_base_url(monkeypatch):
  31      class _Entry:
  32          access_token = "pool-token"
  33          source = "manual"
  34          base_url = "https://api.anthropic.com"
  35  
  36      class _Pool:
  37          def has_credentials(self):
  38              return True
  39  
  40          def select(self):
  41              return _Entry()
  42  
  43      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
  44      monkeypatch.setattr(
  45          rp,
  46          "_get_model_config",
  47          lambda: {
  48              "provider": "anthropic",
  49              "base_url": "https://proxy.example.com/anthropic",
  50          },
  51      )
  52      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
  53  
  54      resolved = rp.resolve_runtime_provider(requested="anthropic")
  55  
  56      assert resolved["provider"] == "anthropic"
  57      assert resolved["api_mode"] == "anthropic_messages"
  58      assert resolved["api_key"] == "pool-token"
  59      assert resolved["base_url"] == "https://proxy.example.com/anthropic"
  60  
  61  
  62  def test_resolve_runtime_provider_anthropic_explicit_override_skips_pool(monkeypatch):
  63      def _unexpected_pool(provider):
  64          raise AssertionError(f"load_pool should not be called for {provider}")
  65  
  66      def _unexpected_anthropic_token():
  67          raise AssertionError("resolve_anthropic_token should not be called")
  68  
  69      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
  70      monkeypatch.setattr(
  71          rp,
  72          "_get_model_config",
  73          lambda: {
  74              "provider": "anthropic",
  75              "base_url": "https://config.example.com/anthropic",
  76          },
  77      )
  78      monkeypatch.setattr(rp, "load_pool", _unexpected_pool)
  79      monkeypatch.setattr(
  80          "agent.anthropic_adapter.resolve_anthropic_token",
  81          _unexpected_anthropic_token,
  82      )
  83  
  84      resolved = rp.resolve_runtime_provider(
  85          requested="anthropic",
  86          explicit_api_key="anthropic-explicit-token",
  87          explicit_base_url="https://proxy.example.com/anthropic/",
  88      )
  89  
  90      assert resolved["provider"] == "anthropic"
  91      assert resolved["api_mode"] == "anthropic_messages"
  92      assert resolved["api_key"] == "anthropic-explicit-token"
  93      assert resolved["base_url"] == "https://proxy.example.com/anthropic"
  94      assert resolved["source"] == "explicit"
  95      assert resolved.get("credential_pool") is None
  96  
  97  
  98  def test_resolve_runtime_provider_falls_back_when_pool_empty(monkeypatch):
  99      class _Pool:
 100          def has_credentials(self):
 101              return False
 102  
 103      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
 104      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
 105      monkeypatch.setattr(
 106          rp,
 107          "resolve_codex_runtime_credentials",
 108          lambda: {
 109              "provider": "openai-codex",
 110              "base_url": "https://chatgpt.com/backend-api/codex",
 111              "api_key": "codex-token",
 112              "source": "hermes-auth-store",
 113              "last_refresh": "2026-02-26T00:00:00Z",
 114          },
 115      )
 116  
 117      resolved = rp.resolve_runtime_provider(requested="openai-codex")
 118  
 119      assert resolved["api_key"] == "codex-token"
 120      assert resolved.get("credential_pool") is None
 121  
 122  
 123  def test_resolve_runtime_provider_codex(monkeypatch):
 124      monkeypatch.setattr(
 125          rp,
 126          "load_pool",
 127          lambda provider: type("P", (), {"has_credentials": lambda self: False})(),
 128      )
 129      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
 130      monkeypatch.setattr(
 131          rp,
 132          "resolve_codex_runtime_credentials",
 133          lambda: {
 134              "provider": "openai-codex",
 135              "base_url": "https://chatgpt.com/backend-api/codex",
 136              "api_key": "codex-token",
 137              "source": "codex-auth-json",
 138              "auth_file": "/tmp/auth.json",
 139              "codex_home": "/tmp/codex",
 140              "last_refresh": "2026-02-26T00:00:00Z",
 141          },
 142      )
 143  
 144      resolved = rp.resolve_runtime_provider(requested="openai-codex")
 145  
 146      assert resolved["provider"] == "openai-codex"
 147      assert resolved["api_mode"] == "codex_responses"
 148      assert resolved["base_url"] == "https://chatgpt.com/backend-api/codex"
 149      assert resolved["api_key"] == "codex-token"
 150      assert resolved["requested_provider"] == "openai-codex"
 151  
 152  
 153  def test_resolve_runtime_provider_qwen_oauth(monkeypatch):
 154      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "qwen-oauth")
 155      monkeypatch.setattr(
 156          rp,
 157          "resolve_qwen_runtime_credentials",
 158          lambda: {
 159              "provider": "qwen-oauth",
 160              "base_url": "https://portal.qwen.ai/v1",
 161              "api_key": "qwen-token",
 162              "source": "qwen-cli",
 163              "expires_at_ms": 1775640710946,
 164          },
 165      )
 166  
 167      resolved = rp.resolve_runtime_provider(requested="qwen-oauth")
 168  
 169      assert resolved["provider"] == "qwen-oauth"
 170      assert resolved["api_mode"] == "chat_completions"
 171      assert resolved["base_url"] == "https://portal.qwen.ai/v1"
 172      assert resolved["api_key"] == "qwen-token"
 173      assert resolved["requested_provider"] == "qwen-oauth"
 174  
 175  
 176  def test_resolve_runtime_provider_uses_qwen_pool_entry(monkeypatch):
 177      class _Entry:
 178          access_token = "pool-qwen-token"
 179          source = "manual:qwen_cli"
 180          base_url = "https://portal.qwen.ai/v1"
 181  
 182      class _Pool:
 183          def has_credentials(self):
 184              return True
 185  
 186          def select(self):
 187              return _Entry()
 188  
 189      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "qwen-oauth")
 190      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
 191      monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "qwen-oauth", "default": "coder-model"})
 192  
 193      resolved = rp.resolve_runtime_provider(requested="qwen-oauth")
 194  
 195      assert resolved["provider"] == "qwen-oauth"
 196      assert resolved["api_mode"] == "chat_completions"
 197      assert resolved["base_url"] == "https://portal.qwen.ai/v1"
 198      assert resolved["api_key"] == "pool-qwen-token"
 199      assert resolved["source"] == "manual:qwen_cli"
 200  
 201  
 202  def test_resolve_provider_alias_qwen(monkeypatch):
 203      monkeypatch.setattr(rp.auth_mod, "_load_auth_store", lambda: {})
 204      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 205      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 206      assert rp.resolve_provider("qwen-portal") == "qwen-oauth"
 207      assert rp.resolve_provider("qwen-cli") == "qwen-oauth"
 208  
 209  
 210  def test_qwen_oauth_auto_fallthrough_on_auth_failure(monkeypatch):
 211      """When requested_provider is 'auto' and Qwen creds fail, fall through."""
 212      from hermes_cli.auth import AuthError
 213  
 214      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "qwen-oauth")
 215      monkeypatch.setattr(
 216          rp,
 217          "resolve_qwen_runtime_credentials",
 218          lambda **kw: (_ for _ in ()).throw(AuthError("stale", provider="qwen-oauth", code="qwen_auth_missing")),
 219      )
 220      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 221      monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
 222  
 223      # Should NOT raise — falls through to OpenRouter
 224      resolved = rp.resolve_runtime_provider(requested="auto")
 225      # The fallthrough means it won't be qwen-oauth
 226      assert resolved["provider"] != "qwen-oauth"
 227  
 228  
 229  def test_resolve_runtime_provider_ai_gateway(monkeypatch):
 230      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
 231      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 232      monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-ai-gw-key")
 233  
 234      resolved = rp.resolve_runtime_provider(requested="ai-gateway")
 235  
 236      assert resolved["provider"] == "ai-gateway"
 237      assert resolved["api_mode"] == "chat_completions"
 238      assert resolved["base_url"] == "https://ai-gateway.vercel.sh/v1"
 239      assert resolved["api_key"] == "test-ai-gw-key"
 240      assert resolved["requested_provider"] == "ai-gateway"
 241  
 242  
 243  def test_resolve_runtime_provider_lmstudio_uses_token_when_present(monkeypatch):
 244      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
 245      monkeypatch.setattr(
 246          rp,
 247          "_get_model_config",
 248          lambda: {
 249              "provider": "lmstudio",
 250              "base_url": "http://127.0.0.1:1234/v1",
 251              "default": "publisher/model-a",
 252          },
 253      )
 254      monkeypatch.setattr(
 255          rp,
 256          "load_pool",
 257          lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
 258      )
 259      monkeypatch.setattr(
 260          rp,
 261          "resolve_api_key_provider_credentials",
 262          lambda provider: {
 263              "provider": "lmstudio",
 264              "api_key": "lm-token",
 265              "base_url": "http://127.0.0.1:1234/v1",
 266              "source": "LM_API_KEY",
 267          },
 268      )
 269  
 270      resolved = rp.resolve_runtime_provider(requested="lmstudio")
 271  
 272      assert resolved["provider"] == "lmstudio"
 273      assert resolved["api_key"] == "lm-token"
 274      assert resolved["api_mode"] == "chat_completions"
 275      assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
 276  
 277  
 278  def test_resolve_runtime_provider_lmstudio_honors_saved_base_url(monkeypatch):
 279      """Pre-existing configs with `provider: lmstudio` + custom base_url must keep working.
 280  
 281      Before this PR, `lmstudio` aliased to `custom`, so a user with a remote
 282      LM Studio (e.g. lab box) could write `provider: "lmstudio"` plus
 283      `base_url: "http://192.168.1.10:1234/v1"` and the custom path honored it.
 284      Now that `lmstudio` is first-class with `inference_base_url=127.0.0.1`,
 285      the saved `base_url` from `model_cfg` must still win — otherwise this
 286      PR is a silent breaking change for those users.
 287      """
 288      monkeypatch.delenv("LM_API_KEY", raising=False)
 289      monkeypatch.delenv("LM_BASE_URL", raising=False)
 290      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
 291      monkeypatch.setattr(
 292          rp,
 293          "_get_model_config",
 294          lambda: {
 295              "provider": "lmstudio",
 296              "base_url": "http://192.168.1.10:1234/v1",
 297              "default": "qwen/qwen3-coder-30b",
 298          },
 299      )
 300      monkeypatch.setattr(
 301          rp,
 302          "load_pool",
 303          lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
 304      )
 305      # Don't mock resolve_api_key_provider_credentials — exercise the real
 306      # function so we test the end-to-end precedence between model_cfg and
 307      # the pconfig default.
 308  
 309      resolved = rp.resolve_runtime_provider(requested="lmstudio")
 310  
 311      assert resolved["provider"] == "lmstudio"
 312      assert resolved["api_mode"] == "chat_completions"
 313      # The saved base_url must NOT be shadowed by the 127.0.0.1 default.
 314      assert resolved["base_url"] == "http://192.168.1.10:1234/v1"
 315      # No-auth LM Studio: missing LM_API_KEY substitutes the placeholder.
 316      assert resolved["api_key"] == "dummy-lm-api-key"
 317  
 318  
 319  def test_resolve_runtime_provider_lmstudio_saved_base_url_wins_over_env(monkeypatch):
 320      """Saved model.base_url takes precedence over LM_BASE_URL env var.
 321  
 322      This matches the established contract for all api_key providers: the
 323      explicit config value (model.base_url) wins over the env-derived
 324      default.  Users who saved a remote LM Studio URL must not have it
 325      silently overridden by a stale shell variable.
 326      """
 327      monkeypatch.delenv("LM_API_KEY", raising=False)
 328      monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
 329      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
 330      monkeypatch.setattr(
 331          rp,
 332          "_get_model_config",
 333          lambda: {
 334              "provider": "lmstudio",
 335              "base_url": "http://192.168.1.10:1234/v1",
 336              "default": "qwen/qwen3-coder-30b",
 337          },
 338      )
 339      monkeypatch.setattr(
 340          rp,
 341          "load_pool",
 342          lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
 343      )
 344  
 345      resolved = rp.resolve_runtime_provider(requested="lmstudio")
 346  
 347      assert resolved["provider"] == "lmstudio"
 348      assert resolved["api_mode"] == "chat_completions"
 349      # Saved config base_url wins over env var (standard contract).
 350      assert resolved["base_url"] == "http://192.168.1.10:1234/v1"
 351      assert resolved["api_key"] == "dummy-lm-api-key"
 352  
 353  
 354  def test_resolve_runtime_provider_ai_gateway_explicit_override_skips_pool(monkeypatch):
 355      def _unexpected_pool(provider):
 356          raise AssertionError(f"load_pool should not be called for {provider}")
 357  
 358      def _unexpected_provider_resolution(provider):
 359          raise AssertionError(f"resolve_api_key_provider_credentials should not be called for {provider}")
 360  
 361      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
 362      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 363      monkeypatch.setattr(rp, "load_pool", _unexpected_pool)
 364      monkeypatch.setattr(
 365          rp,
 366          "resolve_api_key_provider_credentials",
 367          _unexpected_provider_resolution,
 368      )
 369  
 370      resolved = rp.resolve_runtime_provider(
 371          requested="ai-gateway",
 372          explicit_api_key="ai-gateway-explicit-token",
 373          explicit_base_url="https://proxy.example.com/v1/",
 374      )
 375  
 376      assert resolved["provider"] == "ai-gateway"
 377      assert resolved["api_mode"] == "chat_completions"
 378      assert resolved["api_key"] == "ai-gateway-explicit-token"
 379      assert resolved["base_url"] == "https://proxy.example.com/v1"
 380      assert resolved["source"] == "explicit"
 381      assert resolved.get("credential_pool") is None
 382  
 383  
 384  def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):
 385      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 386      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 387      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 388      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 389      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 390      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 391  
 392      resolved = rp.resolve_runtime_provider(
 393          requested="openrouter",
 394          explicit_api_key="test-key",
 395          explicit_base_url="https://example.com/v1/",
 396      )
 397  
 398      assert resolved["provider"] == "openrouter"
 399      assert resolved["api_mode"] == "chat_completions"
 400      assert resolved["api_key"] == "test-key"
 401      assert resolved["base_url"] == "https://example.com/v1"
 402      assert resolved["source"] == "explicit"
 403  
 404  
 405  def test_resolve_runtime_provider_auto_uses_openrouter_pool(monkeypatch):
 406      class _Entry:
 407          access_token = "pool-key"
 408          source = "manual"
 409          base_url = "https://openrouter.ai/api/v1"
 410  
 411      class _Pool:
 412          def has_credentials(self):
 413              return True
 414  
 415          def select(self):
 416              return _Entry()
 417  
 418      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 419      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 420      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
 421      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 422      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 423      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 424      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 425  
 426      resolved = rp.resolve_runtime_provider(requested="auto")
 427  
 428      assert resolved["provider"] == "openrouter"
 429      assert resolved["api_key"] == "pool-key"
 430      assert resolved["base_url"] == "https://openrouter.ai/api/v1"
 431      assert resolved["source"] == "manual"
 432      assert resolved.get("credential_pool") is not None
 433  
 434  
 435  def test_resolve_runtime_provider_openrouter_explicit_api_key_skips_pool(monkeypatch):
 436      class _Entry:
 437          access_token = "pool-key"
 438          source = "manual"
 439          base_url = "https://openrouter.ai/api/v1"
 440  
 441      class _Pool:
 442          def has_credentials(self):
 443              return True
 444  
 445          def select(self):
 446              return _Entry()
 447  
 448      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 449      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 450      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
 451      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 452      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 453      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 454      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 455  
 456      resolved = rp.resolve_runtime_provider(
 457          requested="openrouter",
 458          explicit_api_key="explicit-key",
 459      )
 460  
 461      assert resolved["provider"] == "openrouter"
 462      assert resolved["api_key"] == "explicit-key"
 463      assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
 464      assert resolved["source"] == "explicit"
 465      assert resolved.get("credential_pool") is None
 466  
 467  
 468  def test_resolve_runtime_provider_openrouter_ignores_codex_config_base_url(monkeypatch):
 469      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 470      monkeypatch.setattr(
 471          rp,
 472          "_get_model_config",
 473          lambda: {
 474              "provider": "openai-codex",
 475              "base_url": "https://chatgpt.com/backend-api/codex",
 476          },
 477      )
 478      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 479      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 480      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 481      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 482  
 483      resolved = rp.resolve_runtime_provider(requested="openrouter")
 484  
 485      assert resolved["provider"] == "openrouter"
 486      assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
 487  
 488  
 489  def test_resolve_runtime_provider_auto_uses_custom_config_base_url(monkeypatch):
 490      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 491      monkeypatch.setattr(
 492          rp,
 493          "_get_model_config",
 494          lambda: {
 495              "provider": "auto",
 496              "base_url": "https://custom.example/v1/",
 497          },
 498      )
 499      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 500      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 501      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 502      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 503  
 504      resolved = rp.resolve_runtime_provider(requested="auto")
 505  
 506      assert resolved["provider"] == "openrouter"
 507      assert resolved["base_url"] == "https://custom.example/v1"
 508  
 509  
 510  def test_openrouter_key_takes_priority_over_openai_key(monkeypatch):
 511      """OPENROUTER_API_KEY should be used over OPENAI_API_KEY when both are set.
 512  
 513      Regression test for #289: users with OPENAI_API_KEY in .bashrc had it
 514      sent to OpenRouter instead of their OPENROUTER_API_KEY.
 515      """
 516      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 517      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 518      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 519      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 520      monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-lose")
 521      monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-should-win")
 522  
 523      resolved = rp.resolve_runtime_provider(requested="openrouter")
 524  
 525      assert resolved["api_key"] == "sk-or-should-win"
 526  
 527  
 528  def test_openai_key_used_when_no_openrouter_key(monkeypatch):
 529      """OPENAI_API_KEY is used as fallback when OPENROUTER_API_KEY is not set."""
 530      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 531      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 532      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 533      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 534      monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-fallback")
 535      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 536  
 537      resolved = rp.resolve_runtime_provider(requested="openrouter")
 538  
 539      assert resolved["api_key"] == "sk-openai-fallback"
 540  
 541  
 542  def test_custom_endpoint_prefers_openai_key(monkeypatch):
 543      """Custom endpoint should use config api_key over OPENROUTER_API_KEY.
 544  
 545      Updated for #4165: config.yaml is now the source of truth for endpoint URLs,
 546      OPENAI_BASE_URL env var is no longer consulted.
 547      """
 548      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 549      monkeypatch.setattr(rp, "_get_model_config", lambda: {
 550          "provider": "custom",
 551          "base_url": "https://api.z.ai/api/coding/paas/v4",
 552          "api_key": "zai-key",
 553      })
 554      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 555      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 556      monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
 557  
 558      resolved = rp.resolve_runtime_provider(requested="custom")
 559  
 560      assert resolved["base_url"] == "https://api.z.ai/api/coding/paas/v4"
 561      assert resolved["api_key"] == "zai-key"
 562  
 563  
 564  def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch):
 565      """Persisted custom endpoints in config.yaml must still resolve when
 566      OPENAI_BASE_URL is absent from the current environment."""
 567      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 568      monkeypatch.setattr(
 569          rp,
 570          "_get_model_config",
 571          lambda: {
 572              "provider": "custom",
 573              "base_url": "http://127.0.0.1:1234/v1",
 574          },
 575      )
 576      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 577      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 578      monkeypatch.setenv("OPENAI_API_KEY", "local-key")
 579      monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
 580  
 581      resolved = rp.resolve_runtime_provider(requested="custom")
 582  
 583      assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
 584      assert resolved["api_key"] == "local-key"
 585  
 586  
 587  def test_custom_endpoint_uses_config_api_key_over_env(monkeypatch):
 588      """provider: custom with base_url and api_key in config uses them (#1760)."""
 589      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 590      monkeypatch.setattr(
 591          rp,
 592          "_get_model_config",
 593          lambda: {
 594              "provider": "custom",
 595              "base_url": "https://my-api.example.com/v1",
 596              "api_key": "config-api-key",
 597          },
 598      )
 599      monkeypatch.setenv("OPENAI_BASE_URL", "https://other.example.com/v1")
 600      monkeypatch.setenv("OPENAI_API_KEY", "env-key")
 601      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 602  
 603      resolved = rp.resolve_runtime_provider(requested="custom")
 604  
 605      assert resolved["base_url"] == "https://my-api.example.com/v1"
 606      assert resolved["api_key"] == "config-api-key"
 607  
 608  
 609  def test_custom_endpoint_uses_config_api_field_when_no_api_key(monkeypatch):
 610      """provider: custom with 'api' in config uses it as api_key (#1760)."""
 611      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 612      monkeypatch.setattr(
 613          rp,
 614          "_get_model_config",
 615          lambda: {
 616              "provider": "custom",
 617              "base_url": "https://custom.example.com/v1",
 618              "api": "config-api-field",
 619          },
 620      )
 621      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 622      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 623      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 624  
 625      resolved = rp.resolve_runtime_provider(requested="custom")
 626  
 627      assert resolved["base_url"] == "https://custom.example.com/v1"
 628      assert resolved["api_key"] == "config-api-field"
 629  
 630  
 631  def test_custom_endpoint_explicit_custom_prefers_config_key(monkeypatch):
 632      """Explicit 'custom' provider with config base_url+api_key should use them.
 633  
 634      Updated for #4165: config.yaml is the source of truth, not OPENAI_BASE_URL.
 635      """
 636      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 637      monkeypatch.setattr(rp, "_get_model_config", lambda: {
 638          "provider": "custom",
 639          "base_url": "https://my-vllm-server.example.com/v1",
 640          "api_key": "sk-vllm-key",
 641      })
 642      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 643      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 644      monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-...leak")
 645  
 646      resolved = rp.resolve_runtime_provider(requested="custom")
 647  
 648      assert resolved["base_url"] == "https://my-vllm-server.example.com/v1"
 649      assert resolved["api_key"] == "sk-vllm-key"
 650  
 651  
 652  def test_bare_custom_uses_loopback_model_base_url_when_provider_not_custom(monkeypatch):
 653      """Regression for #14676: /model can select Custom while YAML still lists another provider."""
 654      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 655      monkeypatch.setattr(
 656          rp,
 657          "_get_model_config",
 658          lambda: {
 659              "provider": "openrouter",
 660              "base_url": "http://127.0.0.1:8082/v1",
 661              "default": "my-local-model",
 662          },
 663      )
 664      monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
 665      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 666      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 667      monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
 668      monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
 669  
 670      resolved = rp.resolve_runtime_provider(requested="custom")
 671  
 672      assert resolved["provider"] == "custom"
 673      assert resolved["base_url"] == "http://127.0.0.1:8082/v1"
 674      assert resolved["api_key"] == "openai-key"
 675  
 676  
 677  def test_bare_custom_custom_base_url_env_overrides_remote_yaml(monkeypatch):
 678      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 679      monkeypatch.setattr(
 680          rp,
 681          "_get_model_config",
 682          lambda: {
 683              "provider": "openrouter",
 684              "base_url": "https://api.openrouter.ai/api/v1",
 685          },
 686      )
 687      monkeypatch.setenv("CUSTOM_BASE_URL", "http://localhost:9999/v1")
 688      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 689      monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
 690  
 691      resolved = rp.resolve_runtime_provider(requested="custom")
 692  
 693      assert resolved["provider"] == "custom"
 694      assert resolved["base_url"] == "http://localhost:9999/v1"
 695  
 696  
 697  def test_bare_custom_does_not_trust_non_loopback_when_provider_not_custom(monkeypatch):
 698      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 699      monkeypatch.setattr(
 700          rp,
 701          "_get_model_config",
 702          lambda: {
 703              "provider": "openrouter",
 704              "base_url": "https://remote.example.com/v1",
 705          },
 706      )
 707      monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
 708      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 709      monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
 710  
 711      resolved = rp.resolve_runtime_provider(requested="custom")
 712  
 713      assert resolved["provider"] == "custom"
 714      assert "openrouter.ai" in resolved["base_url"]
 715      assert "remote.example.com" not in resolved["base_url"]
 716  
 717  
 718  def test_named_custom_provider_uses_saved_credentials(monkeypatch):
 719      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 720      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 721      monkeypatch.setattr(
 722          rp,
 723          "load_config",
 724          lambda: {
 725              "custom_providers": [
 726                  {
 727                      "name": "Local",
 728                      "base_url": "http://1.2.3.4:1234/v1",
 729                      "api_key": "local-provider-key",
 730                  }
 731              ]
 732          },
 733      )
 734      monkeypatch.setattr(
 735          rp,
 736          "resolve_provider",
 737          lambda *a, **k: (_ for _ in ()).throw(
 738              AssertionError(
 739                  "resolve_provider should not be called for named custom providers"
 740              )
 741          ),
 742      )
 743  
 744      resolved = rp.resolve_runtime_provider(requested="local")
 745  
 746      assert resolved["provider"] == "custom"
 747      assert resolved["api_mode"] == "chat_completions"
 748      assert resolved["base_url"] == "http://1.2.3.4:1234/v1"
 749      assert resolved["api_key"] == "local-provider-key"
 750      assert resolved["requested_provider"] == "local"
 751      assert resolved["source"] == "custom_provider:Local"
 752  
 753  
 754  def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch):
 755      """After v11→v12 migration deletes custom_providers, resolution should
 756      still find entries in the providers dict via get_compatible_custom_providers."""
 757      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 758      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 759      monkeypatch.setattr(
 760          rp,
 761          "load_config",
 762          lambda: {
 763              "providers": {
 764                  "openai-direct-primary": {
 765                      "api": "https://api.openai.com/v1",
 766                      "api_key": "dir-key",
 767                      "default_model": "gpt-5-mini",
 768                      "name": "OpenAI Direct (Primary)",
 769                      "transport": "codex_responses",
 770                  }
 771              }
 772          },
 773      )
 774      monkeypatch.setattr(
 775          rp,
 776          "resolve_provider",
 777          lambda *a, **k: (_ for _ in ()).throw(
 778              AssertionError(
 779                  "resolve_provider should not be called for named custom providers"
 780              )
 781          ),
 782      )
 783  
 784      resolved = rp.resolve_runtime_provider(requested="openai-direct-primary")
 785  
 786      assert resolved["provider"] == "custom"
 787      assert resolved["api_mode"] == "codex_responses"
 788      assert resolved["base_url"] == "https://api.openai.com/v1"
 789      assert resolved["api_key"] == "dir-key"
 790      assert resolved["requested_provider"] == "openai-direct-primary"
 791      assert resolved["source"] == "custom_provider:OpenAI Direct (Primary)"
 792      assert resolved["model"] == "gpt-5-mini"
 793  
 794  
 795  def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):
 796      """providers dict entries with key_env should resolve API key from env var."""
 797      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 798      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 799      monkeypatch.setenv("MYCORP_API_KEY", "env-secret")
 800      monkeypatch.setattr(
 801          rp,
 802          "load_config",
 803          lambda: {
 804              "providers": {
 805                  "mycorp-proxy": {
 806                      "base_url": "https://proxy.example.com/v1",
 807                      "default_model": "acme-large",
 808                      "key_env": "MYCORP_API_KEY",
 809                      "name": "MyCorp Proxy",
 810                  }
 811              }
 812          },
 813      )
 814      monkeypatch.setattr(
 815          rp,
 816          "resolve_provider",
 817          lambda *a, **k: (_ for _ in ()).throw(
 818              AssertionError(
 819                  "resolve_provider should not be called for named custom providers"
 820              )
 821          ),
 822      )
 823  
 824      resolved = rp.resolve_runtime_provider(requested="mycorp-proxy")
 825  
 826      assert resolved["provider"] == "custom"
 827      assert resolved["api_mode"] == "chat_completions"
 828      assert resolved["base_url"] == "https://proxy.example.com/v1"
 829      assert resolved["api_key"] == "env-secret"
 830      assert resolved["requested_provider"] == "mycorp-proxy"
 831      assert resolved["source"] == "custom_provider:MyCorp Proxy"
 832      assert resolved["model"] == "acme-large"
 833  
 834  
 835  def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
 836      monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
 837      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
 838      monkeypatch.setattr(
 839          rp,
 840          "load_config",
 841          lambda: {
 842              "custom_providers": [
 843                  {
 844                      "name": "Local LLM",
 845                      "base_url": "http://localhost:1234/v1",
 846                  }
 847              ]
 848          },
 849      )
 850      monkeypatch.setattr(
 851          rp,
 852          "resolve_provider",
 853          lambda *a, **k: (_ for _ in ()).throw(
 854              AssertionError(
 855                  "resolve_provider should not be called for named custom providers"
 856              )
 857          ),
 858      )
 859  
 860      resolved = rp.resolve_runtime_provider(requested="custom:local-llm")
 861  
 862      assert resolved["base_url"] == "http://localhost:1234/v1"
 863      assert resolved["api_key"] == "env-openai-key"
 864      assert resolved["requested_provider"] == "custom:local-llm"
 865  
 866  
 867  def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch):
 868      monkeypatch.setattr(
 869          rp,
 870          "load_config",
 871          lambda: {
 872              "custom_providers": [
 873                  {
 874                      "name": "nous",
 875                      "base_url": "http://localhost:1234/v1",
 876                      "api_key": "shadow-key",
 877                  }
 878              ]
 879          },
 880      )
 881      monkeypatch.setattr(
 882          rp,
 883          "resolve_nous_runtime_credentials",
 884          lambda **kwargs: {
 885              "base_url": "https://inference-api.nousresearch.com/v1",
 886              "api_key": "nous-runtime-key",
 887              "source": "portal",
 888              "expires_at": None,
 889          },
 890      )
 891  
 892      resolved = rp.resolve_runtime_provider(requested="nous")
 893  
 894      assert resolved["provider"] == "nous"
 895      assert resolved["base_url"] == "https://inference-api.nousresearch.com/v1"
 896      assert resolved["api_key"] == "nous-runtime-key"
 897      assert resolved["requested_provider"] == "nous"
 898  
 899  
 900  def test_named_custom_provider_wins_over_builtin_alias(monkeypatch):
 901      """A custom_providers entry named after a built-in *alias* (not a canonical
 902      provider name) must win over the built-in.  Regression guard for #15743:
 903      when users define ``custom_providers: [{name: kimi, ...}]`` and reference
 904      ``provider: kimi``, the built-in alias rewriting (``kimi`` → ``kimi-coding``)
 905      would otherwise hijack the request and send it to the wrong endpoint.
 906      """
 907      monkeypatch.setattr(
 908          rp,
 909          "load_config",
 910          lambda: {
 911              "custom_providers": [
 912                  {
 913                      "name": "kimi",
 914                      "base_url": "https://my-custom-kimi.example.com/v1",
 915                      "api_key": "my-kimi-key",
 916                  }
 917              ]
 918          },
 919      )
 920  
 921      entry = rp._get_named_custom_provider("kimi")
 922  
 923      assert entry is not None
 924      assert entry["base_url"] == "https://my-custom-kimi.example.com/v1"
 925      assert entry["api_key"] == "my-kimi-key"
 926  
 927  
 928  def test_named_custom_provider_skipped_for_canonical_built_in(monkeypatch):
 929      """Companion to the test above: ``nous`` is a canonical provider name
 930      (``resolve_provider('nous') == 'nous'``), so a custom entry with that name
 931      should NOT be returned — the built-in wins as before.
 932      """
 933      monkeypatch.setattr(
 934          rp,
 935          "load_config",
 936          lambda: {
 937              "custom_providers": [
 938                  {
 939                      "name": "nous",
 940                      "base_url": "http://localhost:1234/v1",
 941                      "api_key": "shadow-key",
 942                  }
 943              ]
 944          },
 945      )
 946  
 947      entry = rp._get_named_custom_provider("nous")
 948  
 949      assert entry is None
 950  
 951  
 952  def test_explicit_openrouter_skips_openai_base_url(monkeypatch):
 953      """When the user explicitly requests openrouter, OPENAI_BASE_URL
 954      (which may point to a custom endpoint) must not override the
 955      OpenRouter base URL.  Regression test for #874."""
 956      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 957      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 958      monkeypatch.setenv("OPENAI_BASE_URL", "https://my-custom-llm.example.com/v1")
 959      monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
 960      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
 961      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 962  
 963      resolved = rp.resolve_runtime_provider(requested="openrouter")
 964  
 965      assert resolved["provider"] == "openrouter"
 966      assert "openrouter.ai" in resolved["base_url"]
 967      assert "my-custom-llm" not in resolved["base_url"]
 968      assert resolved["api_key"] == "or-test-key"
 969  
 970  
 971  def test_explicit_openrouter_honors_openrouter_base_url_over_pool(monkeypatch):
 972      class _Entry:
 973          access_token = "pool-key"
 974          source = "manual"
 975          base_url = "https://openrouter.ai/api/v1"
 976  
 977      class _Pool:
 978          def has_credentials(self):
 979              return True
 980  
 981          def select(self):
 982              return _Entry()
 983  
 984      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
 985      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
 986      monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
 987      monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
 988      monkeypatch.setenv("OPENROUTER_API_KEY", "mirror-key")
 989      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
 990      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
 991  
 992      resolved = rp.resolve_runtime_provider(requested="openrouter")
 993  
 994      assert resolved["provider"] == "openrouter"
 995      assert resolved["base_url"] == "https://mirror.example.com/v1"
 996      assert resolved["api_key"] == "mirror-key"
 997      assert resolved["source"] == "env/config"
 998      assert resolved.get("credential_pool") is None
 999  
1000  
1001  def test_resolve_requested_provider_precedence(monkeypatch):
1002      monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")
1003      monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"})
1004      assert rp.resolve_requested_provider("openrouter") == "openrouter"
1005      assert rp.resolve_requested_provider() == "openai-codex"
1006  
1007      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1008      assert rp.resolve_requested_provider() == "nous"
1009  
1010      monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
1011      assert rp.resolve_requested_provider() == "auto"
1012  
1013  
1014  # ── api_mode config override tests ──────────────────────────────────────
1015  
1016  
1017  def test_model_config_api_mode(monkeypatch):
1018      """model.api_mode in config.yaml should override the default chat_completions."""
1019      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
1020      monkeypatch.setattr(
1021          rp, "_get_model_config",
1022          lambda: {
1023              "provider": "custom",
1024              "base_url": "http://127.0.0.1:9208/v1",
1025              "api_mode": "codex_responses",
1026          },
1027      )
1028      monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:9208/v1")
1029      monkeypatch.setenv("OPENAI_API_KEY", "test-key")
1030      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1031      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
1032  
1033      resolved = rp.resolve_runtime_provider(requested="custom")
1034  
1035      assert resolved["api_mode"] == "codex_responses"
1036      assert resolved["base_url"] == "http://127.0.0.1:9208/v1"
1037  
1038  
1039  def test_model_config_api_mode_ignored_when_provider_differs(monkeypatch):
1040      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai")
1041      monkeypatch.setattr(
1042          rp,
1043          "_get_model_config",
1044          lambda: {
1045              "provider": "opencode-go",
1046              "default": "minimax-m2.5",
1047              "api_mode": "anthropic_messages",
1048          },
1049      )
1050      monkeypatch.setattr(
1051          rp,
1052          "resolve_api_key_provider_credentials",
1053          lambda provider: {
1054              "provider": provider,
1055              "api_key": "test-key",
1056              "base_url": "https://api.z.ai/api/paas/v4",
1057              "source": "env",
1058          },
1059      )
1060  
1061      resolved = rp.resolve_runtime_provider(requested="zai")
1062  
1063      assert resolved["provider"] == "zai"
1064      assert resolved["api_mode"] == "chat_completions"
1065  
1066  
1067  def test_invalid_api_mode_ignored(monkeypatch):
1068      """Invalid api_mode values should fall back to chat_completions."""
1069      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
1070      monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "bogus_mode"})
1071      monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:9208/v1")
1072      monkeypatch.setenv("OPENAI_API_KEY", "test-key")
1073      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1074      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
1075  
1076      resolved = rp.resolve_runtime_provider(requested="custom")
1077  
1078      assert resolved["api_mode"] == "chat_completions"
1079  
1080  
1081  def test_named_custom_provider_api_mode(monkeypatch):
1082      """custom_providers entries with api_mode should use it."""
1083      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
1084      monkeypatch.setattr(
1085          rp, "_get_named_custom_provider",
1086          lambda p: {
1087              "name": "my-server",
1088              "base_url": "http://localhost:8000/v1",
1089              "api_key": "sk-test",
1090              "api_mode": "codex_responses",
1091          },
1092      )
1093  
1094      resolved = rp.resolve_runtime_provider(requested="my-server")
1095  
1096      assert resolved["api_mode"] == "codex_responses"
1097      assert resolved["base_url"] == "http://localhost:8000/v1"
1098  
1099  
1100  def test_named_custom_provider_without_api_mode_defaults(monkeypatch):
1101      """custom_providers entries without api_mode should default to chat_completions."""
1102      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
1103      monkeypatch.setattr(
1104          rp, "_get_named_custom_provider",
1105          lambda p: {
1106              "name": "my-server",
1107              "base_url": "http://localhost:8000/v1",
1108              "api_key": "***",
1109          },
1110      )
1111  
1112      resolved = rp.resolve_runtime_provider(requested="my-server")
1113  
1114      assert resolved["api_mode"] == "chat_completions"
1115  
1116  
1117  def test_anthropic_messages_in_valid_api_modes():
1118      """anthropic_messages should be accepted by _parse_api_mode."""
1119      assert rp._parse_api_mode("anthropic_messages") == "anthropic_messages"
1120  
1121  
1122  def test_api_key_provider_anthropic_url_auto_detection(monkeypatch):
1123      """API-key providers with /anthropic base URL should auto-detect anthropic_messages mode."""
1124      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1125      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1126      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1127      monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/anthropic")
1128  
1129      resolved = rp.resolve_runtime_provider(requested="minimax")
1130  
1131      assert resolved["provider"] == "minimax"
1132      assert resolved["api_mode"] == "anthropic_messages"
1133      assert resolved["base_url"] == "https://api.minimax.io/anthropic"
1134  
1135  
1136  def test_api_key_provider_explicit_api_mode_config(monkeypatch):
1137      """API-key providers should respect api_mode from model config."""
1138      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1139      monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "anthropic_messages"})
1140      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1141      monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
1142  
1143      resolved = rp.resolve_runtime_provider(requested="minimax")
1144  
1145      assert resolved["provider"] == "minimax"
1146      assert resolved["api_mode"] == "anthropic_messages"
1147  
1148  
1149  def test_minimax_default_url_uses_anthropic_messages(monkeypatch):
1150      """MiniMax with default /anthropic URL should auto-detect anthropic_messages mode."""
1151      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1152      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1153      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1154      monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
1155  
1156      resolved = rp.resolve_runtime_provider(requested="minimax")
1157  
1158      assert resolved["provider"] == "minimax"
1159      assert resolved["api_mode"] == "anthropic_messages"
1160      assert resolved["base_url"] == "https://api.minimax.io/anthropic"
1161  
1162  
1163  def test_minimax_v1_url_uses_chat_completions(monkeypatch):
1164      """MiniMax with /v1 base URL should use chat_completions (user override for regions where /anthropic 404s)."""
1165      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1166      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1167      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1168      monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1")
1169  
1170      resolved = rp.resolve_runtime_provider(requested="minimax")
1171  
1172      assert resolved["provider"] == "minimax"
1173      assert resolved["api_mode"] == "chat_completions"
1174      assert resolved["base_url"] == "https://api.minimax.chat/v1"
1175  
1176  
1177  def test_minimax_cn_v1_url_uses_chat_completions(monkeypatch):
1178      """MiniMax-CN with /v1 base URL should use chat_completions (user override)."""
1179      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-cn")
1180      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1181      monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-minimax-cn-key")
1182      monkeypatch.setenv("MINIMAX_CN_BASE_URL", "https://api.minimaxi.com/v1")
1183  
1184      resolved = rp.resolve_runtime_provider(requested="minimax-cn")
1185  
1186      assert resolved["provider"] == "minimax-cn"
1187      assert resolved["api_mode"] == "chat_completions"
1188      assert resolved["base_url"] == "https://api.minimaxi.com/v1"
1189  
1190  
1191  def test_minimax_explicit_api_mode_respected(monkeypatch):
1192      """Explicit api_mode config should override MiniMax auto-detection."""
1193      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1194      monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "chat_completions"})
1195      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1196      monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
1197  
1198      resolved = rp.resolve_runtime_provider(requested="minimax")
1199  
1200      assert resolved["provider"] == "minimax"
1201      assert resolved["api_mode"] == "chat_completions"
1202  
1203  
1204  def test_minimax_config_base_url_overrides_hardcoded_default(monkeypatch):
1205      """model.base_url in config.yaml should override the hardcoded default (#6039)."""
1206      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1207      monkeypatch.setattr(rp, "_get_model_config", lambda: {
1208          "provider": "minimax",
1209          "base_url": "https://api.minimaxi.com/anthropic",
1210      })
1211      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1212      monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
1213  
1214      resolved = rp.resolve_runtime_provider(requested="minimax")
1215  
1216      assert resolved["provider"] == "minimax"
1217      assert resolved["base_url"] == "https://api.minimaxi.com/anthropic"
1218      assert resolved["api_mode"] == "anthropic_messages"
1219  
1220  
1221  def test_minimax_env_base_url_still_wins_over_config(monkeypatch):
1222      """MINIMAX_BASE_URL env var should take priority over config.yaml model.base_url."""
1223      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1224      monkeypatch.setattr(rp, "_get_model_config", lambda: {
1225          "provider": "minimax",
1226          "base_url": "https://api.minimaxi.com/anthropic",
1227      })
1228      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1229      monkeypatch.setenv("MINIMAX_BASE_URL", "https://custom.example.com/v1")
1230  
1231      resolved = rp.resolve_runtime_provider(requested="minimax")
1232  
1233      # Env var wins because resolve_api_key_provider_credentials prefers it
1234      assert resolved["base_url"] == "https://custom.example.com/v1"
1235  
1236  
1237  def test_minimax_config_base_url_ignored_for_different_provider(monkeypatch):
1238      """model.base_url should NOT be used when model.provider doesn't match."""
1239      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
1240      monkeypatch.setattr(rp, "_get_model_config", lambda: {
1241          "provider": "openrouter",
1242          "base_url": "https://some-other-endpoint.com/v1",
1243      })
1244      monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
1245      monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
1246  
1247      resolved = rp.resolve_runtime_provider(requested="minimax")
1248  
1249      # Should use the default, NOT the config base_url from a different provider
1250      assert resolved["base_url"] == "https://api.minimax.io/anthropic"
1251  
1252  
1253  def test_alibaba_default_coding_intl_endpoint_uses_chat_completions(monkeypatch):
1254      """Alibaba default coding-intl /v1 URL should use chat_completions mode."""
1255      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "alibaba")
1256      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1257      monkeypatch.setenv("DASHSCOPE_API_KEY", "test-dashscope-key")
1258      monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False)
1259  
1260      resolved = rp.resolve_runtime_provider(requested="alibaba")
1261  
1262      assert resolved["provider"] == "alibaba"
1263      assert resolved["api_mode"] == "chat_completions"
1264      assert resolved["base_url"] == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
1265  
1266  
1267  def test_alibaba_anthropic_endpoint_override_uses_anthropic_messages(monkeypatch):
1268      """Alibaba with /apps/anthropic URL override should auto-detect anthropic_messages mode."""
1269      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "alibaba")
1270      monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1271      monkeypatch.setenv("DASHSCOPE_API_KEY", "test-dashscope-key")
1272      monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic")
1273  
1274      resolved = rp.resolve_runtime_provider(requested="alibaba")
1275  
1276      assert resolved["provider"] == "alibaba"
1277      assert resolved["api_mode"] == "anthropic_messages"
1278      assert resolved["base_url"] == "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
1279  
1280  
1281  def test_opencode_zen_gpt_defaults_to_responses(monkeypatch):
1282      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
1283      monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "gpt-5.4"})
1284      monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
1285      monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
1286  
1287      resolved = rp.resolve_runtime_provider(requested="opencode-zen")
1288  
1289      assert resolved["provider"] == "opencode-zen"
1290      assert resolved["api_mode"] == "codex_responses"
1291      assert resolved["base_url"] == "https://opencode.ai/zen/v1"
1292  
1293  
1294  def test_opencode_zen_claude_defaults_to_messages(monkeypatch):
1295      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
1296      monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "claude-sonnet-4-6"})
1297      monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
1298      monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
1299  
1300      resolved = rp.resolve_runtime_provider(requested="opencode-zen")
1301  
1302      assert resolved["provider"] == "opencode-zen"
1303      assert resolved["api_mode"] == "anthropic_messages"
1304      # Trailing /v1 stripped for anthropic_messages mode — the Anthropic SDK
1305      # appends its own /v1/messages to the base_url.
1306      assert resolved["base_url"] == "https://opencode.ai/zen"
1307  
1308  
1309  def test_opencode_go_minimax_defaults_to_messages(monkeypatch):
1310      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
1311      monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "minimax-m2.5"})
1312      monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
1313      monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
1314  
1315      resolved = rp.resolve_runtime_provider(requested="opencode-go")
1316  
1317      assert resolved["provider"] == "opencode-go"
1318      assert resolved["api_mode"] == "anthropic_messages"
1319      # Trailing /v1 stripped — Anthropic SDK appends /v1/messages itself.
1320      assert resolved["base_url"] == "https://opencode.ai/zen/go"
1321  
1322  
1323  def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch):
1324      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
1325      monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "glm-5"})
1326      monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
1327      monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
1328  
1329      resolved = rp.resolve_runtime_provider(requested="opencode-go")
1330  
1331      assert resolved["provider"] == "opencode-go"
1332      assert resolved["api_mode"] == "chat_completions"
1333      assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
1334  
1335  
1336  def test_opencode_go_model_derivation_beats_stale_persisted_api_mode(monkeypatch):
1337      """opencode-zen/go re-derive api_mode from the effective model on every
1338      resolve, ignoring any persisted ``api_mode`` in config. Refs #16878 /
1339      PR #16888: the persisted mode from the previous default model must not
1340      leak across /model switches (a stale ``anthropic_messages`` on a
1341      chat_completions target would strip /v1 from base_url and 404).
1342  
1343      minimax-m2.5 is an Anthropic-routed model on opencode-go, so even when
1344      the config claims ``api_mode: chat_completions`` the runtime must pick
1345      ``anthropic_messages`` — the model dictates the mode, not the stale
1346      persisted setting.
1347      """
1348      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
1349      monkeypatch.setattr(
1350          rp,
1351          "_get_model_config",
1352          lambda: {
1353              "provider": "opencode-go",
1354              "default": "minimax-m2.5",
1355              "api_mode": "chat_completions",
1356          },
1357      )
1358      monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
1359      monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
1360  
1361      resolved = rp.resolve_runtime_provider(requested="opencode-go")
1362  
1363      assert resolved["provider"] == "opencode-go"
1364      assert resolved["api_mode"] == "anthropic_messages"
1365  
1366  
1367  def test_named_custom_provider_anthropic_api_mode(monkeypatch):
1368      """Custom providers should accept api_mode: anthropic_messages."""
1369      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy")
1370      monkeypatch.setattr(
1371          rp, "_get_named_custom_provider",
1372          lambda p: {
1373              "name": "my-anthropic-proxy",
1374              "base_url": "https://proxy.example.com/anthropic",
1375              "api_key": "test-key",
1376              "api_mode": "anthropic_messages",
1377          },
1378      )
1379  
1380      resolved = rp.resolve_runtime_provider(requested="my-anthropic-proxy")
1381  
1382      assert resolved["api_mode"] == "anthropic_messages"
1383      assert resolved["base_url"] == "https://proxy.example.com/anthropic"
1384  
1385  
1386  # ------------------------------------------------------------------
1387  # fix #2562 — resolve_provider("custom") must not remap to "openrouter"
1388  # ------------------------------------------------------------------
1389  
1390  
1391  def test_resolve_provider_custom_returns_custom():
1392      """resolve_provider('custom') must return 'custom', not 'openrouter'."""
1393      from hermes_cli.auth import resolve_provider
1394      assert resolve_provider("custom") == "custom"
1395  
1396  
1397  def test_resolve_provider_openrouter_unchanged():
1398      """resolve_provider('openrouter') must still return 'openrouter'."""
1399      from hermes_cli.auth import resolve_provider
1400      assert resolve_provider("openrouter") == "openrouter"
1401  
1402  
1403  def test_resolve_provider_lmstudio_returns_lmstudio(monkeypatch):
1404      """resolve_provider('lmstudio') must return 'lmstudio', not 'custom'.
1405  
1406      Regression for the alias-map bug where 'lmstudio' was rewritten to
1407      'custom' before the PROVIDER_REGISTRY lookup, bypassing the first-class
1408      LM Studio provider entirely at runtime.
1409      """
1410      from hermes_cli.auth import resolve_provider
1411      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1412      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
1413      assert resolve_provider("lmstudio") == "lmstudio"
1414      assert resolve_provider("lm-studio") == "lmstudio"
1415      assert resolve_provider("lm_studio") == "lmstudio"
1416  
1417  
1418  def test_custom_provider_runtime_preserves_provider_name(monkeypatch):
1419      """resolve_runtime_provider with provider='custom' must return provider='custom'."""
1420      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1421      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
1422      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
1423      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1424      monkeypatch.setattr(
1425          rp,
1426          "load_config",
1427          lambda: {
1428              "model": {
1429                  "provider": "custom",
1430                  "base_url": "http://localhost:8080/v1",
1431                  "api_key": "test-key-123",
1432              }
1433          },
1434      )
1435  
1436      resolved = rp.resolve_runtime_provider(requested="custom")
1437      assert resolved["provider"] == "custom", (
1438          f"Expected provider='custom', got provider='{resolved['provider']}'"
1439      )
1440      assert resolved["base_url"] == "http://localhost:8080/v1"
1441      assert resolved["api_key"] == "test-key-123"
1442  
1443  
1444  def test_custom_provider_no_key_gets_placeholder(monkeypatch):
1445      """Local server with no API key should get 'no-key-required' placeholder."""
1446      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1447      monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
1448      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
1449      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1450      monkeypatch.setattr(
1451          rp,
1452          "load_config",
1453          lambda: {
1454              "model": {
1455                  "provider": "custom",
1456                  "base_url": "http://localhost:8080/v1",
1457              }
1458          },
1459      )
1460  
1461      resolved = rp.resolve_runtime_provider(requested="custom")
1462      assert resolved["provider"] == "custom"
1463      assert resolved["api_key"] == "no-key-required"
1464      assert resolved["base_url"] == "http://localhost:8080/v1"
1465  
1466  
1467  def test_auto_detected_nous_auth_failure_falls_through_to_openrouter(monkeypatch):
1468      """When auto-detect picks Nous but credentials are revoked, fall through to OpenRouter."""
1469      from hermes_cli.auth import AuthError
1470  
1471      monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
1472      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1473      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
1474      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1475      monkeypatch.setattr(rp, "load_config", lambda: {})
1476  
1477      # resolve_provider returns "nous" (stale active_provider in auth.json)
1478      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous")
1479      # load_pool returns empty pool so we hit the direct credential resolution
1480      monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
1481          "has_credentials": lambda self: False,
1482      })())
1483      # Nous credential resolution fails with revoked token
1484      monkeypatch.setattr(
1485          rp, "resolve_nous_runtime_credentials",
1486          lambda **kw: (_ for _ in ()).throw(
1487              AuthError("Refresh session has been revoked",
1488                        provider="nous", code="invalid_grant", relogin_required=True)
1489          ),
1490      )
1491  
1492      # With requested="auto", should fall through to OpenRouter
1493      resolved = rp.resolve_runtime_provider(requested="auto")
1494      assert resolved["provider"] == "openrouter"
1495      assert resolved["api_key"] == "test-or-key"
1496  
1497  
1498  def test_auto_detected_codex_auth_failure_falls_through_to_openrouter(monkeypatch):
1499      """When auto-detect picks Codex but credentials are revoked, fall through to OpenRouter."""
1500      from hermes_cli.auth import AuthError
1501  
1502      monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
1503      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1504      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
1505      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1506      monkeypatch.setattr(rp, "load_config", lambda: {})
1507  
1508      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
1509      monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
1510          "has_credentials": lambda self: False,
1511      })())
1512      monkeypatch.setattr(
1513          rp, "resolve_codex_runtime_credentials",
1514          lambda **kw: (_ for _ in ()).throw(
1515              AuthError("Codex token refresh failed: session revoked",
1516                        provider="openai-codex", code="invalid_grant", relogin_required=True)
1517          ),
1518      )
1519  
1520      resolved = rp.resolve_runtime_provider(requested="auto")
1521      assert resolved["provider"] == "openrouter"
1522      assert resolved["api_key"] == "test-or-key"
1523  
1524  
1525  def test_explicit_nous_auth_failure_still_raises(monkeypatch):
1526      """When user explicitly requests Nous and auth fails, the error should propagate."""
1527      from hermes_cli.auth import AuthError
1528      import pytest
1529  
1530      monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
1531      monkeypatch.setattr(rp, "load_config", lambda: {})
1532  
1533      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous")
1534      monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
1535          "has_credentials": lambda self: False,
1536      })())
1537      monkeypatch.setattr(
1538          rp, "resolve_nous_runtime_credentials",
1539          lambda **kw: (_ for _ in ()).throw(
1540              AuthError("Refresh session has been revoked",
1541                        provider="nous", code="invalid_grant", relogin_required=True)
1542          ),
1543      )
1544  
1545      # With explicit "nous", should raise — don't silently switch providers
1546      with pytest.raises(AuthError, match="Refresh session has been revoked"):
1547          rp.resolve_runtime_provider(requested="nous")
1548  
1549  
1550  def test_openrouter_provider_not_affected_by_custom_fix(monkeypatch):
1551      """Fixing custom must not change openrouter behavior."""
1552      monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1553      monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
1554      monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
1555      monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
1556      monkeypatch.setattr(rp, "load_config", lambda: {})
1557  
1558      resolved = rp.resolve_runtime_provider(requested="openrouter")
1559      assert resolved["provider"] == "openrouter"
1560  
1561  
1562  # ------------------------------------------------------------------
1563  # fix #7828 — custom_providers model field must propagate to runtime
1564  # ------------------------------------------------------------------
1565  
1566  
1567  def test_get_named_custom_provider_includes_model(monkeypatch):
1568      """_get_named_custom_provider should include the model field from config."""
1569      monkeypatch.setattr(rp, "load_config", lambda: {
1570          "custom_providers": [{
1571              "name": "my-dashscope",
1572              "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
1573              "api_key": "test-key",
1574              "api_mode": "chat_completions",
1575              "model": "qwen3.6-plus",
1576          }],
1577      })
1578  
1579      result = rp._get_named_custom_provider("my-dashscope")
1580      assert result is not None
1581      assert result["model"] == "qwen3.6-plus"
1582  
1583  
1584  def test_get_named_custom_provider_excludes_empty_model(monkeypatch):
1585      """Empty or whitespace-only model field should not appear in result."""
1586      for model_val in ["", "   ", None]:
1587          entry = {
1588              "name": "test-ep",
1589              "base_url": "https://example.com/v1",
1590              "api_key": "key",
1591          }
1592          if model_val is not None:
1593              entry["model"] = model_val
1594  
1595          monkeypatch.setattr(rp, "load_config", lambda e=entry: {
1596              "custom_providers": [e],
1597          })
1598  
1599          result = rp._get_named_custom_provider("test-ep")
1600          assert result is not None
1601          assert "model" not in result, (
1602              f"model field {model_val!r} should not be included in result"
1603          )
1604  
1605  
1606  def test_named_custom_runtime_propagates_model_direct_path(monkeypatch):
1607      """Model should propagate through the direct (non-pool) resolution path."""
1608      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
1609      monkeypatch.setattr(
1610          rp, "_get_named_custom_provider",
1611          lambda p: {
1612              "name": "my-server",
1613              "base_url": "http://localhost:8000/v1",
1614              "api_key": "test-key",
1615              "model": "qwen3.6-plus",
1616          },
1617      )
1618      # Ensure pool doesn't intercept
1619      monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1620  
1621      resolved = rp.resolve_runtime_provider(requested="my-server")
1622      assert resolved["model"] == "qwen3.6-plus"
1623      assert resolved["provider"] == "custom"
1624  
1625  
1626  def test_named_custom_runtime_propagates_model_pool_path(monkeypatch):
1627      """Model should propagate even when credential pool handles credentials."""
1628      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
1629      monkeypatch.setattr(
1630          rp, "_get_named_custom_provider",
1631          lambda p: {
1632              "name": "my-server",
1633              "base_url": "http://localhost:8000/v1",
1634              "api_key": "test-key",
1635              "model": "qwen3.6-plus",
1636          },
1637      )
1638      # Pool returns a result (intercepting the normal path)
1639      monkeypatch.setattr(
1640          rp, "_try_resolve_from_custom_pool",
1641          lambda *a, **k: {
1642              "provider": "custom",
1643              "api_mode": "chat_completions",
1644              "base_url": "http://localhost:8000/v1",
1645              "api_key": "pool-key",
1646              "source": "pool:custom:my-server",
1647          },
1648      )
1649  
1650      resolved = rp.resolve_runtime_provider(requested="my-server")
1651      assert resolved["model"] == "qwen3.6-plus", (
1652          "model must be injected into pool result"
1653      )
1654      assert resolved["api_key"] == "pool-key", "pool credentials should be used"
1655  
1656  
1657  def test_named_custom_runtime_no_model_when_absent(monkeypatch):
1658      """When custom_providers entry has no model field, runtime should not either."""
1659      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
1660      monkeypatch.setattr(
1661          rp, "_get_named_custom_provider",
1662          lambda p: {
1663              "name": "my-server",
1664              "base_url": "http://localhost:8000/v1",
1665              "api_key": "test-key",
1666          },
1667      )
1668      monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1669  
1670      resolved = rp.resolve_runtime_provider(requested="my-server")
1671      assert "model" not in resolved
1672  
1673  
1674  # ---------------------------------------------------------------------------
1675  # GHSA-76xc-57q6-vm5m — Ollama URL substring leak
1676  #
1677  # Same bug class as the previously-fixed GHSA-xf8p-v2cg-h7h5 (OpenRouter).
1678  # _resolve_openrouter_runtime's custom-endpoint branch selects OLLAMA_API_KEY
1679  # when the base_url "looks like" ollama.com. Previous implementation used
1680  # raw substring match; a custom base_url whose PATH or look-alike host
1681  # merely contained "ollama.com" leaked OLLAMA_API_KEY to that endpoint.
1682  # Fix: use base_url_host_matches (same helper as the OpenRouter sweep).
1683  # ---------------------------------------------------------------------------
1684  
1685  class TestOllamaUrlSubstringLeak:
1686      """Call-site regression tests for the fix in _resolve_openrouter_runtime."""
1687  
1688      def _make_cfg(self, base_url):
1689          return {"base_url": base_url, "api_key": "", "provider": "custom"}
1690  
1691      def test_ollama_key_not_leaked_to_path_injection(self, monkeypatch):
1692          """http://127.0.0.1:9000/ollama.com/v1 — attacker endpoint with
1693          ollama.com in PATH. Must resolve to OPENAI_API_KEY, not OLLAMA_API_KEY."""
1694          monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
1695          monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
1696          monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
1697          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
1698          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1699              "http://127.0.0.1:9000/ollama.com/v1"
1700          ))
1701          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1702          monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1703  
1704          resolved = rp.resolve_runtime_provider(requested="custom")
1705  
1706          assert "ol-SECRET" not in resolved["api_key"], (
1707              "OLLAMA_API_KEY must not be sent to an endpoint whose "
1708              "hostname is not ollama.com (GHSA-76xc-57q6-vm5m)"
1709          )
1710          assert resolved["api_key"] == "oa-secret"
1711  
1712      def test_ollama_key_not_leaked_to_lookalike_host(self, monkeypatch):
1713          """ollama.com.attacker.test — look-alike host. OLLAMA_API_KEY
1714          must not be sent."""
1715          monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
1716          monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
1717          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
1718          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1719              "http://ollama.com.attacker.test:9000/v1"
1720          ))
1721          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1722          monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1723  
1724          resolved = rp.resolve_runtime_provider(requested="custom")
1725  
1726          assert "ol-SECRET" not in resolved["api_key"]
1727          assert resolved["api_key"] == "oa-secret"
1728  
1729      def test_ollama_key_sent_to_genuine_ollama_com(self, monkeypatch):
1730          """https://ollama.com/v1 — legit Ollama Cloud. OLLAMA_API_KEY
1731          should be used."""
1732          monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
1733          monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
1734          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
1735          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1736              "https://ollama.com/v1"
1737          ))
1738          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1739          monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1740  
1741          resolved = rp.resolve_runtime_provider(requested="custom")
1742  
1743          assert resolved["api_key"] == "ol-legit-key"
1744  
1745      def test_ollama_key_sent_to_ollama_subdomain(self, monkeypatch):
1746          """https://api.ollama.com/v1 — legit subdomain."""
1747          monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
1748          monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
1749          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
1750          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1751              "https://api.ollama.com/v1"
1752          ))
1753          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1754          monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
1755  
1756          resolved = rp.resolve_runtime_provider(requested="custom")
1757  
1758          assert resolved["api_key"] == "ol-legit-key"
1759  
1760  
1761  # =============================================================================
1762  # Azure Foundry — both OpenAI-style and Anthropic-style endpoints
1763  # =============================================================================
1764  
1765  class TestAzureFoundryResolution:
1766      """Verify Azure Foundry resolves correctly for both API modes."""
1767  
1768      def _make_cfg(self, base_url: str, api_mode: str = "chat_completions"):
1769          return {
1770              "provider": "azure-foundry",
1771              "base_url": base_url,
1772              "api_mode": api_mode,
1773              # GPT-4 speaks chat completions on Azure, so this test's assertion
1774              # about chat_completions stays valid across the Apr 2026 fix that
1775              # upgrades GPT-5.x / codex deployments to codex_responses.
1776              "default": "gpt-4.1",
1777          }
1778  
1779      def test_azure_foundry_openai_style_explicit(self, monkeypatch):
1780          """OpenAI-style Azure Foundry → chat_completions, keeps base_url as-is."""
1781          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key-openai")
1782          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1783          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1784              "https://my-resource.openai.azure.com/openai/v1",
1785              "chat_completions",
1786          ))
1787          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1788  
1789          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1790  
1791          assert resolved["provider"] == "azure-foundry"
1792          assert resolved["api_mode"] == "chat_completions"
1793          assert resolved["base_url"] == "https://my-resource.openai.azure.com/openai/v1"
1794          assert resolved["api_key"] == "az-key-openai"
1795  
1796      def test_azure_foundry_anthropic_style_strips_v1_suffix(self, monkeypatch):
1797          """Anthropic-style Azure Foundry → anthropic_messages, /v1 stripped
1798          because the Anthropic SDK appends /v1/messages itself."""
1799          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key-ant")
1800          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1801          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1802              "https://my-resource.services.ai.azure.com/anthropic/v1",
1803              "anthropic_messages",
1804          ))
1805          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1806  
1807          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1808  
1809          assert resolved["provider"] == "azure-foundry"
1810          assert resolved["api_mode"] == "anthropic_messages"
1811          # /v1 stripped so SDK can append /v1/messages cleanly
1812          assert resolved["base_url"] == "https://my-resource.services.ai.azure.com/anthropic"
1813  
1814      def test_azure_foundry_missing_base_url_raises(self, monkeypatch):
1815          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1816          monkeypatch.delenv("AZURE_FOUNDRY_BASE_URL", raising=False)
1817          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1818          monkeypatch.setattr(rp, "_get_model_config", lambda: {})
1819          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1820  
1821          with pytest.raises(rp.AuthError, match="base URL"):
1822              rp.resolve_runtime_provider(requested="azure-foundry")
1823  
1824      def test_azure_foundry_missing_api_key_raises(self, monkeypatch):
1825          monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
1826          # `get_env_value` reads from ~/.hermes/.env — mock it to return None
1827          # so the resolver can't find a key there either.
1828          import hermes_cli.config as cfg_mod
1829          monkeypatch.setattr(cfg_mod, "get_env_value", lambda k: None)
1830          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1831          monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
1832              "https://my-resource.openai.azure.com/openai/v1"
1833          ))
1834          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1835  
1836          with pytest.raises(rp.AuthError, match="API key"):
1837              rp.resolve_runtime_provider(requested="azure-foundry")
1838  
1839      # -- Model-family api_mode inference -------------------------------------
1840      # Azure rejects /chat/completions on GPT-5.x / codex / o-series with
1841      # ``400 "The requested operation is unsupported."`` — the resolver must
1842      # upgrade api_mode to ``codex_responses`` for those models even when the
1843      # config was persisted as ``chat_completions`` (the default the setup
1844      # wizard writes when the user didn't pick explicitly).
1845  
1846      def _make_cfg_with_model(self, model: str, api_mode: str = "chat_completions"):
1847          return {
1848              "provider": "azure-foundry",
1849              "base_url": "https://synopsisse.openai.azure.com/openai/v1",
1850              "api_mode": api_mode,
1851              "default": model,
1852          }
1853  
1854      def test_gpt5_codex_upgrades_chat_completions_to_responses(self, monkeypatch):
1855          """Reproduces Bob's April 2026 bug: gpt-5.3-codex on chat_completions."""
1856          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1857          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1858          monkeypatch.setattr(rp, "_get_model_config",
1859                              lambda: self._make_cfg_with_model("gpt-5.3-codex", "chat_completions"))
1860          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1861  
1862          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1863  
1864          assert resolved["api_mode"] == "codex_responses"
1865          assert resolved["base_url"] == "https://synopsisse.openai.azure.com/openai/v1"
1866  
1867      def test_gpt4o_stays_on_chat_completions(self, monkeypatch):
1868          """gpt-4o-pure worked on Bob's endpoint — must not get upgraded."""
1869          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1870          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1871          monkeypatch.setattr(rp, "_get_model_config",
1872                              lambda: self._make_cfg_with_model("gpt-4o-pure", "chat_completions"))
1873          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1874  
1875          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1876  
1877          assert resolved["api_mode"] == "chat_completions"
1878  
1879      def test_anthropic_messages_not_downgraded(self, monkeypatch):
1880          """Anthropic-style endpoint: keep anthropic_messages even for gpt-5 names."""
1881          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1882          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1883          monkeypatch.setattr(rp, "_get_model_config", lambda: {
1884              "provider": "azure-foundry",
1885              "base_url": "https://my-resource.services.ai.azure.com/anthropic/v1",
1886              "api_mode": "anthropic_messages",
1887              "default": "gpt-5.3-codex",  # nonsensical on Anthropic but tests the guard
1888          })
1889          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1890  
1891          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1892  
1893          assert resolved["api_mode"] == "anthropic_messages"
1894  
1895      def test_target_model_overrides_stale_default(self, monkeypatch):
1896          """/model switch: target_model should drive api_mode, not the stale config default."""
1897          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1898          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1899          # Config still pinned to gpt-4o, but user just ran /model gpt-5.3-codex
1900          monkeypatch.setattr(rp, "_get_model_config",
1901                              lambda: self._make_cfg_with_model("gpt-4o-pure", "chat_completions"))
1902          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1903  
1904          resolved = rp.resolve_runtime_provider(
1905              requested="azure-foundry",
1906              target_model="gpt-5.3-codex",
1907          )
1908  
1909          assert resolved["api_mode"] == "codex_responses"
1910  
1911      def test_target_model_downgrade_path(self, monkeypatch):
1912          """/model switch gpt-5.3-codex → gpt-4o: api_mode follows new model."""
1913          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1914          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1915          # Config was upgraded to codex_responses for the previous model; user
1916          # now switches to gpt-4o which speaks chat completions.
1917          monkeypatch.setattr(rp, "_get_model_config",
1918                              lambda: self._make_cfg_with_model("gpt-5.3-codex", "codex_responses"))
1919          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1920  
1921          resolved = rp.resolve_runtime_provider(
1922              requested="azure-foundry",
1923              target_model="gpt-4o-pure",
1924          )
1925  
1926          # codex_responses was persisted; we keep it because gpt-4o can speak
1927          # both protocols but the explicit persisted mode is the safer signal.
1928          # (gpt-4o returning None from the inference function means "don't
1929          # override" — the persisted codex_responses survives.)
1930          assert resolved["api_mode"] == "codex_responses"
1931  
1932      def test_o3_mini_upgrades(self, monkeypatch):
1933          monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "az-key")
1934          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "azure-foundry")
1935          monkeypatch.setattr(rp, "_get_model_config",
1936                              lambda: self._make_cfg_with_model("o3-mini", "chat_completions"))
1937          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1938  
1939          resolved = rp.resolve_runtime_provider(requested="azure-foundry")
1940  
1941          assert resolved["api_mode"] == "codex_responses"
1942  
1943  
1944  # ──────────────────────────────────────────────────────────────────────────
1945  # Azure Anthropic — honor user-specified env var hints (key_env / api_key_env)
1946  #
1947  # When the user points provider=anthropic at an Azure Foundry base URL, the
1948  # runtime resolver previously hardcoded `AZURE_ANTHROPIC_KEY` and
1949  # `ANTHROPIC_API_KEY` as the only env var sources.  This meant
1950  # `key_env: MY_CUSTOM_VAR` on the model config was silently ignored — and
1951  # the Azure Foundry docs that showed `api_key_env:` were broken as a result.
1952  #
1953  # These tests lock in the priority chain:
1954  #   1. model_cfg.key_env → os.getenv(value)
1955  #   2. model_cfg.api_key_env → os.getenv(value) (docs alias)
1956  #   3. model_cfg.api_key (inline value)
1957  #   4. AZURE_ANTHROPIC_KEY env var
1958  #   5. ANTHROPIC_API_KEY env var
1959  # ──────────────────────────────────────────────────────────────────────────
1960  
1961  
1962  class TestAzureAnthropicEnvVarHint:
1963      _AZURE_URL = "https://my-resource.services.ai.azure.com/anthropic"
1964  
1965      def _cfg(self, **overrides):
1966          base = {"provider": "anthropic", "base_url": self._AZURE_URL}
1967          base.update(overrides)
1968          return base
1969  
1970      def test_key_env_hint_picks_custom_var(self, monkeypatch):
1971          """model.key_env names a non-default env var → that var's value is used."""
1972          monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False)
1973          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
1974          monkeypatch.setenv("MY_CUSTOM_AZURE_KEY", "from-custom-var")
1975          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
1976          monkeypatch.setattr(rp, "_get_model_config",
1977                              lambda: self._cfg(key_env="MY_CUSTOM_AZURE_KEY"))
1978          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1979  
1980          resolved = rp.resolve_runtime_provider(requested="anthropic")
1981  
1982          assert resolved["api_key"] == "from-custom-var"
1983          assert resolved["base_url"] == self._AZURE_URL
1984  
1985      def test_api_key_env_alias_honored(self, monkeypatch):
1986          """The `api_key_env` alias (used in azure-foundry docs) also works."""
1987          monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False)
1988          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
1989          monkeypatch.setenv("DOCS_VARIANT_KEY", "from-docs-alias")
1990          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
1991          monkeypatch.setattr(rp, "_get_model_config",
1992                              lambda: self._cfg(api_key_env="DOCS_VARIANT_KEY"))
1993          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
1994  
1995          resolved = rp.resolve_runtime_provider(requested="anthropic")
1996  
1997          assert resolved["api_key"] == "from-docs-alias"
1998  
1999      def test_key_env_beats_fallback_chain(self, monkeypatch):
2000          """key_env takes priority over AZURE_ANTHROPIC_KEY / ANTHROPIC_API_KEY."""
2001          monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "should-not-win")
2002          monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-win-either")
2003          monkeypatch.setenv("MY_PROVIDER_KEY", "winning-key")
2004          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2005          monkeypatch.setattr(rp, "_get_model_config",
2006                              lambda: self._cfg(key_env="MY_PROVIDER_KEY"))
2007          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2008  
2009          resolved = rp.resolve_runtime_provider(requested="anthropic")
2010  
2011          assert resolved["api_key"] == "winning-key"
2012  
2013      def test_inline_api_key_on_model_cfg(self, monkeypatch):
2014          """model.api_key (inline value) works for single-config setups."""
2015          monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False)
2016          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
2017          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2018          monkeypatch.setattr(rp, "_get_model_config",
2019                              lambda: self._cfg(api_key="inline-azure-key"))
2020          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2021  
2022          resolved = rp.resolve_runtime_provider(requested="anthropic")
2023  
2024          assert resolved["api_key"] == "inline-azure-key"
2025  
2026      def test_azure_anthropic_key_still_works_as_fallback(self, monkeypatch):
2027          """Historical fixed-name env vars still resolve when no hint is set."""
2028          monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "historical-key")
2029          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
2030          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2031          monkeypatch.setattr(rp, "_get_model_config", lambda: self._cfg())
2032          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2033  
2034          resolved = rp.resolve_runtime_provider(requested="anthropic")
2035  
2036          assert resolved["api_key"] == "historical-key"
2037  
2038      def test_key_env_points_at_unset_var_falls_through(self, monkeypatch):
2039          """If key_env names an env var that isn't set, fall through to the
2040          historical fixed names rather than failing outright."""
2041          monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "fallback-works")
2042          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
2043          monkeypatch.delenv("UNSET_VAR", raising=False)
2044          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2045          monkeypatch.setattr(rp, "_get_model_config",
2046                              lambda: self._cfg(key_env="UNSET_VAR"))
2047          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2048  
2049          resolved = rp.resolve_runtime_provider(requested="anthropic")
2050  
2051          assert resolved["api_key"] == "fallback-works"
2052  
2053  
2054      def test_no_key_anywhere_raises_helpful_error(self, monkeypatch):
2055          """When nothing resolves, the error message mentions key_env as an option."""
2056          monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False)
2057          monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
2058          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2059          monkeypatch.setattr(rp, "_get_model_config", lambda: self._cfg())
2060          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2061  
2062          with pytest.raises(rp.AuthError, match="key_env"):
2063              rp.resolve_runtime_provider(requested="anthropic")
2064  
2065      def test_non_azure_anthropic_path_ignores_key_env(self, monkeypatch):
2066          """key_env is only consulted on Azure endpoints — non-Azure Anthropic
2067          still goes through the regular resolve_anthropic_token chain."""
2068          monkeypatch.setenv("MY_KEY", "custom-key-value")
2069          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
2070          monkeypatch.setattr(rp, "_get_model_config", lambda: {
2071              "provider": "anthropic",
2072              "base_url": "https://api.anthropic.com",  # non-Azure
2073              "key_env": "MY_KEY",
2074          })
2075          monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2076          called = {"resolve_anthropic_token": False}
2077          def _fake_resolve():
2078              called["resolve_anthropic_token"] = True
2079              return "token-from-resolver"
2080          monkeypatch.setattr(
2081              "agent.anthropic_adapter.resolve_anthropic_token",
2082              _fake_resolve,
2083          )
2084  
2085          resolved = rp.resolve_runtime_provider(requested="anthropic")
2086  
2087          # The normal chain runs — key_env is not consulted off-Azure.
2088          assert called["resolve_anthropic_token"] is True
2089          assert resolved["api_key"] == "token-from-resolver"
2090  
2091  
2092  # ──────────────────────────────────────────────────────────────────────────
2093  # custom_providers / providers normalizer — api_key_env alias for key_env
2094  # ──────────────────────────────────────────────────────────────────────────
2095  
2096  
2097  class TestProviderEntryApiKeyEnvAlias:
2098      """The `providers.<name>` and `custom_providers[i]` normalizer must accept
2099      `api_key_env` as an alias for `key_env` so configs written against the
2100      documented Azure Foundry YAML shape (or imported from other tools that
2101      use `api_key_env`) resolve correctly."""
2102  
2103      def test_snake_case_api_key_env_normalizes_to_key_env(self):
2104          from hermes_cli.config import _normalize_custom_provider_entry
2105          entry = {
2106              "name": "vendor",
2107              "base_url": "https://api.vendor.example.com/v1",
2108              "api_key_env": "MY_VENDOR_KEY",
2109          }
2110          normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor")
2111          assert normalized is not None
2112          assert normalized.get("key_env") == "MY_VENDOR_KEY"
2113  
2114      def test_camel_case_api_key_env_normalizes_to_key_env(self):
2115          from hermes_cli.config import _normalize_custom_provider_entry
2116          entry = {
2117              "name": "vendor",
2118              "base_url": "https://api.vendor.example.com/v1",
2119              "apiKeyEnv": "MY_VENDOR_KEY",
2120          }
2121          normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor")
2122          assert normalized is not None
2123          assert normalized.get("key_env") == "MY_VENDOR_KEY"
2124  
2125      def test_key_env_wins_if_both_forms_present(self):
2126          """If both key_env and api_key_env are set, the canonical key_env wins."""
2127          from hermes_cli.config import _normalize_custom_provider_entry
2128          entry = {
2129              "name": "vendor",
2130              "base_url": "https://api.vendor.example.com/v1",
2131              "key_env": "CANONICAL",
2132              "api_key_env": "ALIAS",
2133          }
2134          normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor")
2135          assert normalized is not None
2136          assert normalized.get("key_env") == "CANONICAL"
2137  
2138      def test_valid_fields_set_lists_key_env(self):
2139          """The _VALID_CUSTOM_PROVIDER_FIELDS documentation set must include
2140          key_env so the set stays in sync with what the runtime actually reads."""
2141          from hermes_cli.config import _VALID_CUSTOM_PROVIDER_FIELDS
2142          assert "key_env" in _VALID_CUSTOM_PROVIDER_FIELDS
2143  # =============================================================================
2144  # Tencent TokenHub — API-key provider runtime resolution
2145  # =============================================================================
2146  
2147  class TestTencentTokenhubRuntimeResolution:
2148      """Verify Tencent TokenHub resolves correctly through the generic
2149      API-key provider path in resolve_runtime_provider."""
2150  
2151      def test_resolves_with_env_key(self, monkeypatch):
2152          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
2153          monkeypatch.setattr(rp, "_get_model_config", lambda: {})
2154          monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
2155          monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
2156  
2157          resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
2158  
2159          assert resolved["provider"] == "tencent-tokenhub"
2160          assert resolved["api_mode"] == "chat_completions"
2161          assert resolved["base_url"] == "https://tokenhub.tencentmaas.com/v1"
2162          assert resolved["api_key"] == "test-tokenhub-key"
2163          assert resolved["requested_provider"] == "tencent-tokenhub"
2164  
2165      def test_custom_base_url_from_env(self, monkeypatch):
2166          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
2167          monkeypatch.setattr(rp, "_get_model_config", lambda: {})
2168          monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
2169          monkeypatch.setenv("TOKENHUB_BASE_URL", "https://custom-proxy.example.com/v1")
2170  
2171          resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
2172  
2173          assert resolved["provider"] == "tencent-tokenhub"
2174          assert resolved["base_url"] == "https://custom-proxy.example.com/v1"
2175          assert resolved["api_key"] == "test-tokenhub-key"
2176  
2177      def test_config_base_url_honoured_when_provider_matches(self, monkeypatch):
2178          """model.base_url in config.yaml should override the hardcoded default
2179          when model.provider == tencent-tokenhub."""
2180          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
2181          monkeypatch.setattr(rp, "_get_model_config", lambda: {
2182              "provider": "tencent-tokenhub",
2183              "base_url": "https://proxy.internal.com/v1",
2184          })
2185          monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
2186          monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
2187  
2188          resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
2189  
2190          assert resolved["base_url"] == "https://proxy.internal.com/v1"
2191  
2192      def test_config_base_url_ignored_for_different_provider(self, monkeypatch):
2193          """model.base_url should NOT be used when model.provider doesn't match."""
2194          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
2195          monkeypatch.setattr(rp, "_get_model_config", lambda: {
2196              "provider": "openrouter",
2197              "base_url": "https://some-other-endpoint.com/v1",
2198          })
2199          monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
2200          monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
2201  
2202          resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
2203  
2204          # Should use the default, NOT the config base_url from a different provider
2205          assert resolved["base_url"] == "https://tokenhub.tencentmaas.com/v1"
2206  
2207      def test_explicit_override_skips_env(self, monkeypatch):
2208          monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
2209          monkeypatch.setattr(rp, "_get_model_config", lambda: {})
2210          monkeypatch.setenv("TOKENHUB_API_KEY", "env-key-should-lose")
2211          monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
2212  
2213          resolved = rp.resolve_runtime_provider(
2214              requested="tencent-tokenhub",
2215              explicit_api_key="explicit-tokenhub-key",
2216              explicit_base_url="https://explicit-proxy.example.com/v1/",
2217          )
2218  
2219          assert resolved["provider"] == "tencent-tokenhub"
2220          assert resolved["api_key"] == "explicit-tokenhub-key"
2221          assert resolved["base_url"] == "https://explicit-proxy.example.com/v1"
2222          assert resolved["source"] == "explicit"
2223  
2224  # ---------------------------------------------------------------------------
2225  # minimax-oauth runtime resolution tests (added by feat/minimax-oauth-provider)
2226  # ---------------------------------------------------------------------------
2227  
2228  def test_minimax_oauth_runtime_returns_anthropic_messages_mode(monkeypatch):
2229      """resolve_runtime_provider for minimax-oauth must return api_mode='anthropic_messages'."""
2230      from hermes_cli.auth import MINIMAX_OAUTH_GLOBAL_INFERENCE
2231  
2232      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
2233      monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"})
2234      monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2235      monkeypatch.setattr(
2236          rp,
2237          "_resolve_named_custom_runtime",
2238          lambda **k: None,
2239      )
2240      monkeypatch.setattr(
2241          rp,
2242          "_resolve_explicit_runtime",
2243          lambda **k: None,
2244      )
2245  
2246      fake_creds = {
2247          "provider": "minimax-oauth",
2248          "api_key": "mock-access-token",
2249          "base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE.rstrip("/"),
2250          "source": "oauth",
2251      }
2252  
2253      import hermes_cli.auth as auth_mod
2254      monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials",
2255                          lambda **k: fake_creds)
2256  
2257      resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
2258  
2259      assert resolved["provider"] == "minimax-oauth"
2260      assert resolved["api_mode"] == "anthropic_messages"
2261      assert resolved["api_key"] == "mock-access-token"
2262  
2263  
2264  def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
2265      """Base URL returned by resolve_runtime_provider should match the OAuth credentials."""
2266      from hermes_cli.auth import MINIMAX_OAUTH_CN_INFERENCE
2267  
2268      monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
2269      monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"})
2270      monkeypatch.setattr(rp, "load_pool", lambda provider: None)
2271      monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
2272      monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
2273  
2274      fake_creds = {
2275          "provider": "minimax-oauth",
2276          "api_key": "cn-token",
2277          "base_url": MINIMAX_OAUTH_CN_INFERENCE.rstrip("/"),
2278          "source": "oauth",
2279      }
2280  
2281      import hermes_cli.auth as auth_mod
2282      monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials",
2283                          lambda **k: fake_creds)
2284  
2285      resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
2286  
2287      assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]