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"]