Merge pull request #2810 from nesquena/release/stage-batch2

Release CR: stage-batch2 — 3-PR low-risk batch (v0.51.120) — Bedrock provider / update past-tag / CORS preflight
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-23 20:49:09 -07:00
commit 465b97a9f5
6 files changed
+257

No files matched your search

+12
View File
@@ -3,6 +3,18 @@
## [Unreleased]
## [v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight)
### Added
- **PR #2786** by @munim — Surface AWS Bedrock as a configurable provider in the WebUI model picker. `api/config.py` registers `"bedrock": "AWS Bedrock"` in `PROVIDER_LABELS`, adds 6 default Bedrock model IDs (Claude Opus 4.7 / 4.6 / 4.5, Sonnet 4.6 / 4.5, Haiku 4.5) to `DEFAULT_MODELS["bedrock"]`, and teaches `_build_configured_model_badges()` to detect Bedrock when both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are present (IAM-style auth, not single-API-key). Static fallback list is overridden at runtime by `hermes_cli.models.provider_model_ids("bedrock")` when the live AWS model list is reachable. Adds `tests/test_issue2720_bedrock_model_picker.py` with 11 test cases covering registry, defaults, env-detection, and runtime override. Resolves #2720.
### Fixed
- **PR #2789** by @munim — Update check no longer falsely reports "Up to date" when HEAD has moved hundreds of commits past the latest tag. The hermes-agent repository keeps committing to master between tagged releases, and the old `_check_repo_release()` returned `behind=0` (since `current_tag == latest_tag`) and stopped — so the user saw "Up to date" while the working tree was hundreds of commits behind. The fix: when `behind == 0`, run `git describe --tags --always`; if the result contains the `-N-gSHA` suffix (HEAD past tag), return `None` so `_check_repo_branch()` runs and reports the real commit gap. Adds 8 new test cases in `tests/test_updates.py` covering past-tag detection, equal-tag-and-HEAD pass-through, untagged-repo behavior, and the agent-cadence #2653 scenario. Resolves #2653.
- **PR #2790** by @weidzhou — Add `do_OPTIONS()` handler in `server.py` so CORS preflight requests return `200 OK` with appropriate `Access-Control-Allow-*` headers instead of `501 Not Implemented`. Browsers sending a preflight OPTIONS for cross-origin API calls previously hit the BaseHTTPRequestHandler default and the entire CORS exchange was blocked. The handler narrowly responds only to OPTIONS — no broader CORS posture change to other endpoints. Resubmit of closed #2750 (which bundled unrelated session-index changes); this PR is the minimal preflight-only split that @nesquena-hermes and @AJV20 requested.
## [v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin)
### Fixed
+17
View File
@@ -713,6 +713,7 @@ _PROVIDER_DISPLAY = {
"x-ai": "xAI",
"nvidia": "NVIDIA NIM",
"xiaomi": "Xiaomi",
"bedrock": "AWS Bedrock",
}
# Provider alias → canonical slug. Users configure providers using the
@@ -1213,6 +1214,16 @@ _PROVIDER_MODELS = {
"xai-oauth": [
{"id": "grok-4.20", "label": "Grok 4.20"},
],
# AWS Bedrock — static fallback list; live model list is fetched via
# hermes_cli.models.provider_model_ids("bedrock") when available (#2720).
"bedrock": [
{"id": "global.anthropic.claude-opus-4-7", "label": "Global Anthropic Claude Opus 4.7"},
{"id": "global.anthropic.claude-opus-4-6-v1", "label": "Global Anthropic Claude Opus 4.6"},
{"id": "global.anthropic.claude-sonnet-4-6", "label": "Global Anthropic Claude Sonnet 4.6"},
{"id": "global.anthropic.claude-opus-4-5-20251101-v1:0", "label": "GLOBAL Anthropic Claude Opus 4.5"},
{"id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", "label": "Global Claude Sonnet 4.5"},
{"id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Global Anthropic Claude Haiku 4.5"},
],
}
@@ -3007,6 +3018,8 @@ def get_available_models() -> dict:
"MINIMAX_CN_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
):
val = os.getenv(k)
if val:
@@ -3046,6 +3059,10 @@ def get_available_models() -> dict:
detected_providers.add("opencode-zen")
if all_env.get("OPENCODE_GO_API_KEY"):
detected_providers.add("opencode-go")
# AWS Bedrock uses IAM credentials rather than a single API key.
# Detect when both access key and secret are available (#2720).
if all_env.get("AWS_ACCESS_KEY_ID") and all_env.get("AWS_SECRET_ACCESS_KEY"):
detected_providers.add("bedrock")
# LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env
if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"):
detected_providers.add("lmstudio")
+12
View File
@@ -381,6 +381,18 @@ def _check_repo_release(path, name):
current_tag = _current_release_tag(path)
behind = _release_gap(tags, current_tag, latest_tag)
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
# keeps committing to master between tagged releases), the release check
# would report "Up to date" even though hundreds of commits are missing.
# Detect this by comparing the short describe output (which includes the
# -N-gSHA suffix when HEAD is past a tag) against the bare tag name.
# When HEAD is ahead of the latest tag, fall through to _check_repo_branch
# so the real commit count is reported instead. See #2653.
if behind == 0:
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
if ok and full_desc and full_desc != current_tag:
return None
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
+9
View File
@@ -289,6 +289,15 @@ class Handler(BaseHTTPRequestHandler):
def do_PATCH(self) -> None:
self._handle_write(handle_patch)
def do_OPTIONS(self) -> None:
"""Handle CORS preflight requests."""
self._req_t0 = time.time()
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
def do_DELETE(self) -> None:
self._handle_write(handle_delete)
@@ -0,0 +1,96 @@
"""Regression coverage for #2720: Bedrock models must appear in the WebUI model picker."""
from __future__ import annotations
import builtins
import api.config as config
def _force_env_fallback(monkeypatch):
"""Force get_available_models() down its explicit env-var fallback path."""
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name in ("hermes_cli.models", "hermes_cli.auth"):
raise ImportError(name)
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", fake_import)
def _run_available_models_with_cfg(monkeypatch, tmp_path, cfg):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml")
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: tmp_path, raising=False)
config.cfg.clear()
config.cfg.update(cfg)
config._cfg_mtime = 0.0
config.invalidate_models_cache()
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def test_bedrock_in_provider_display():
"""_PROVIDER_DISPLAY must have a human-readable label for 'bedrock'."""
assert "bedrock" in config._PROVIDER_DISPLAY, (
"_PROVIDER_DISPLAY is missing 'bedrock' — the group header in the model picker "
"will fall back to 'Bedrock' (title-cased id) instead of 'AWS Bedrock'"
)
assert config._PROVIDER_DISPLAY["bedrock"] == "AWS Bedrock"
def test_bedrock_in_provider_models():
"""_PROVIDER_MODELS must have a static fallback list for 'bedrock'."""
assert "bedrock" in config._PROVIDER_MODELS, (
"_PROVIDER_MODELS is missing 'bedrock' — the group builder falls to the "
"else/auto-detected branch where an empty model list silently drops the group"
)
assert len(config._PROVIDER_MODELS["bedrock"]) > 0, (
"_PROVIDER_MODELS['bedrock'] must have at least one static fallback model"
)
def test_bedrock_static_models_have_required_fields():
"""Every static bedrock model entry must have both 'id' and 'label'."""
for model in config._PROVIDER_MODELS["bedrock"]:
assert "id" in model and model["id"], f"Missing id in bedrock model entry: {model}"
assert "label" in model and model["label"], f"Missing label in bedrock model entry: {model}"
def test_bedrock_aws_credentials_detected_in_env_fallback(monkeypatch, tmp_path):
"""AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY must trigger bedrock group (no hermes_cli)."""
_force_env_fallback(monkeypatch)
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}})
groups = {group["provider_id"]: group for group in result["groups"]}
assert "bedrock" in groups, (
"bedrock group missing from model picker even with AWS_ACCESS_KEY_ID and "
"AWS_SECRET_ACCESS_KEY set — env-var fallback path does not detect bedrock (#2720)"
)
assert groups["bedrock"]["provider"] == "AWS Bedrock"
assert len(groups["bedrock"]["models"]) > 0
def test_bedrock_missing_secret_key_not_detected(monkeypatch, tmp_path):
"""Only AWS_ACCESS_KEY_ID (without the secret) must NOT trigger bedrock group."""
_force_env_fallback(monkeypatch)
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}})
groups = {group["provider_id"]: group for group in result["groups"]}
assert "bedrock" not in groups, (
"bedrock must not appear when only AWS_ACCESS_KEY_ID is set without the secret"
)
+111
View File
@@ -260,3 +260,114 @@ def test_check_repo_recovers_from_remote_retag(tmp_path):
assert info.get('stale_check') is not True, (
'fetch with --force should have succeeded, not marked stale'
)
# ---------------------------------------------------------------------------
# #2653 — Update check reports "Up to date" while the repo is hundreds of
# commits past the latest tag (agent cadence bug).
#
# When current_tag == latest_tag (behind==0 from the release check) but HEAD
# has moved past that tag (git describe --tags --always returns a -N-gSHA
# suffix), _check_repo_release must return None so the branch check runs and
# reports the real commit gap.
# ---------------------------------------------------------------------------
def test_check_repo_release_falls_through_when_head_is_past_tag(tmp_path):
"""_check_repo_release returns None when behind==0 but HEAD is past the tag.
Simulates the hermes-agent case: latest tag == current tag (v2026.5.16)
but git describe shows 608 commits past it. The release check must
not report 'Up to date'; it should fall through so the branch check
counts the real gap.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag — describe includes a suffix.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
result = updates._check_repo_release(tmp_path, 'test-repo')
assert result is None, (
'_check_repo_release should return None when HEAD is past the latest tag '
'so the branch check can report the real commit gap (#2653)'
)
def test_check_repo_release_not_affected_when_head_exactly_on_tag(tmp_path):
"""_check_repo_release works normally when HEAD is exactly on the latest tag."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# No -N-gSHA suffix: HEAD is exactly on the tag.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-agent.git', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
result = updates._check_repo_release(tmp_path, 'agent')
assert result is not None
assert result['behind'] == 0
assert result['current_version'] == 'v2026.5.16'
assert result['latest_version'] == 'v2026.5.16'
def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path):
"""End-to-end: when HEAD is past latest tag, _check_repo uses branch check.
Mirrors the exact scenario in issue #2653 where Agent: v2026.5.16-593-g...
was displayed alongside 'Up to date' in Settings.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['fetch', 'origin', '--tags', '--force']:
return '', True
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
# Branch-check path follows: rev-parse upstream, default branch, rev-list.
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return '', False
if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']:
return 'refs/remotes/origin/master', True
if args[:2] == ['rev-list', '--count']:
return '608', True
# merge-base and short SHA lookups for compare URL
if args[0] == 'merge-base':
return 'abc1234' * 5, True
if args[:2] == ['rev-parse', '--short']:
return 'abc1234', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-agent.git', True
return '', True
with patch.object(updates, '_run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'agent')
assert info is not None
assert info['behind'] == 608, (
f"expected behind=608 (branch check result), got {info['behind']!r} (#2653)"
)
assert info.get('release_based') is not True, (
'post-tag HEAD should use branch check, not release-based check'
)