Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions hermes_cli/model_setup_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2308,10 +2308,25 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["provider"] = "custom"
model["base_url"] = mantle_base_url
model.pop("api_mode", None) # chat_completions is the default
clear_model_endpoint_credentials(model, clear_api_mode=False)
model["provider"] = "custom:bedrock-mantle"
clear_model_endpoint_credentials(
model, clear_api_mode=True, clear_base_url=True
)

# Deliver the bearer token through a named provider entry. A bare
# ``provider: custom`` cannot carry a credential for this host:
# OPENAI_API_KEY is deliberately gated to openai.com (#28660), so the
# token was dropped and requests went out as "no-key-required".
providers = cfg.get("providers")
if not isinstance(providers, dict):
providers = {}
cfg["providers"] = providers
mantle_entry = providers.get("bedrock-mantle")
if not isinstance(mantle_entry, dict):
mantle_entry = {}
mantle_entry["base_url"] = mantle_base_url
mantle_entry["key_env"] = "AWS_BEARER_TOKEN_BEDROCK"
providers["bedrock-mantle"] = mantle_entry

# Also save region in bedrock config for reference
bedrock_cfg = cfg.get("bedrock", {})
Expand All @@ -2320,10 +2335,6 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
bedrock_cfg["region"] = region
cfg["bedrock"] = bedrock_cfg

# Save the API key env var name so hermes knows where to find it
save_env_value("OPENAI_API_KEY", existing_key)
save_env_value("OPENAI_BASE_URL", mantle_base_url)

save_config(cfg)
deactivate_provider()

Expand Down
107 changes: 107 additions & 0 deletions tests/hermes_cli/test_bedrock_mantle_key_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Bedrock API-key setup must produce a config that actually authenticates.

The wizard used to stash the Bedrock bearer token in ``OPENAI_API_KEY`` and set
a bare ``provider: custom``. Since the cross-provider credential gate landed
(#28660) that variable is only honoured for ``openai.com`` hosts, so the token
was silently dropped and every request went out as ``no-key-required``.

These tests lock the seam the bug lived in: what the wizard writes, and what the
runtime resolver then makes of it.
"""

import os

import yaml

import hermes_cli.runtime_provider as rp
from hermes_cli.model_setup_flows import _model_flow_bedrock_api_key


REGION = "us-east-1"
TOKEN = "test-bedrock-bearer-token"


def _run_wizard(monkeypatch, selected="openai.gpt-5.6-terra"):
"""Drive the real setup flow non-interactively and return the saved config."""
import hermes_cli.auth as auth_mod

monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", TOKEN)
monkeypatch.setattr(
auth_mod, "_prompt_model_selection", lambda *a, **k: selected
)
monkeypatch.setattr(auth_mod, "_save_model_choice", lambda *a, **k: None)
monkeypatch.setattr(auth_mod, "deactivate_provider", lambda *a, **k: None)

_model_flow_bedrock_api_key({}, REGION)

from hermes_constants import get_hermes_home

return get_hermes_home(), yaml.safe_load(
(get_hermes_home() / "config.yaml").read_text(encoding="utf-8")
)


def test_wizard_writes_named_provider_carrying_the_key_env(monkeypatch):
home, cfg = _run_wizard(monkeypatch)

# The credential must travel via a named provider entry: that is the only
# resolution branch that reads key_env.
entry = cfg["providers"]["bedrock-mantle"]
assert entry["key_env"] == "AWS_BEARER_TOKEN_BEDROCK"
assert entry["base_url"].startswith(f"https://bedrock-mantle.{REGION}.api.aws")
assert cfg["model"]["provider"] == "custom:bedrock-mantle"

# A bare ``custom`` provider plus model.base_url is the shape that could not
# carry the token; make sure we did not leave it behind.
assert "base_url" not in cfg["model"]


def test_wizard_does_not_park_the_bedrock_token_in_openai_api_key(monkeypatch):
home, _cfg = _run_wizard(monkeypatch)

env_file = home / ".env"
written = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
names = {
line.split("=", 1)[0].strip()
for line in written.splitlines()
if "=" in line and not line.lstrip().startswith("#")
}

# Writing a Bedrock credential into another vendor's variable is what the
# #28660 gate exists to prevent. (The token itself already came from the
# environment here, so the flow has no reason to re-write it.)
assert "OPENAI_API_KEY" not in names
assert "OPENAI_BASE_URL" not in names


def test_saved_config_resolves_the_bearer_token_not_a_placeholder(monkeypatch):
"""The regression contract: the token must reach the resolved runtime."""
_home, cfg = _run_wizard(monkeypatch)

# Nothing else may supply a credential for this host.
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", TOKEN)
monkeypatch.setattr(rp, "load_config", lambda: cfg)

resolved = rp.resolve_runtime_provider(
requested=cfg["model"]["provider"],
)

assert resolved["api_key"] == TOKEN
assert resolved["api_key"] != "no-key-required"
assert "bedrock-mantle" in resolved["base_url"]


def test_resolution_fails_closed_when_the_token_is_absent(monkeypatch):
"""No token in the environment must not silently resolve to a placeholder key."""
_home, cfg = _run_wizard(monkeypatch)

monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setattr(rp, "load_config", lambda: cfg)

resolved = rp.resolve_runtime_provider(requested=cfg["model"]["provider"])

assert resolved["api_key"] != TOKEN
Loading