Skip to content
Closed
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
64 changes: 61 additions & 3 deletions agent/bedrock_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,17 @@ def discover_bedrock_models(
except Exception as e:
logger.warning("Failed to list Bedrock foundation models: %s", e)

# Build a lookup map from foundation models so inference profiles can
# inherit modalities. Without this, profiles default to TEXT-only and
# models like Claude (which support IMAGE input) lose that capability,
# excluding them from vision-capable model filtering in the /model picker.
_foundation_modalities: Dict[str, Dict[str, List[str]]] = {}
for m in models:
_foundation_modalities[m["id"].lower()] = {
"input": m["input_modalities"],
"output": m["output_modalities"],
}

# 2. Discover inference profiles (cross-region, better capacity)
try:
profiles = []
Expand All @@ -1661,22 +1672,45 @@ def discover_bedrock_models(
if profile_id.lower() in seen_ids:
continue

profile_models = profile.get("models", [])

# Apply provider filter to underlying models
if filter_set:
profile_models = profile.get("models", [])
matches = any(
_extract_provider_from_arn(m.get("modelArn", "")).lower() in filter_set
for m in profile_models
)
if not matches:
continue

# Inherit modalities from the underlying foundation model.
# Inference profiles wrap foundation models but ListInferenceProfiles
# doesn't expose modalities — resolve the base model from the ARN
# (arn:aws:bedrock:*::foundation-model/<model-id>) and look it up.
# Falls back to stripping the regional prefix
# ("us.anthropic.claude-v2" → "anthropic.claude-v2").
base_model_id = None
for pm in profile_models:
arn = pm.get("modelArn", "")
match = re.search(r"foundation-model/(.+)$", arn)
if match:
base_model_id = match.group(1)
break
if base_model_id is None and "." in profile_id:
parts = profile_id.split(".", 1)
if len(parts) == 2 and parts[0] in ("us", "eu", "ap", "global", "jp", "apac"):
base_model_id = parts[1]

base_mods = _foundation_modalities.get((base_model_id or "").lower(), {})
input_mods = base_mods.get("input", ["TEXT"])
output_mods = base_mods.get("output", ["TEXT"])

models.append({
"id": profile_id,
"name": (profile.get("inferenceProfileName") or profile_id).strip(),
"provider": "inference-profile",
"input_modalities": ["TEXT"],
"output_modalities": ["TEXT"],
"input_modalities": input_mods,
"output_modalities": output_mods,
"streaming": True,
})
seen_ids.add(profile_id.lower())
Expand Down Expand Up @@ -1704,6 +1738,30 @@ def _extract_provider_from_arn(arn: str) -> str:
"""
match = re.search(r"foundation-model/([^.]+)", arn)
return match.group(1) if match else ""


def vision_capable_bedrock_model_ids(models: List[Dict[str, Any]]) -> List[str]:
"""Return the IDs of discovered Bedrock models that accept image input.

A model qualifies when ``"IMAGE"`` appears in its ``input_modalities``.
This is the production consumer of the inference-profile modality
inheritance above: ``ListFoundationModels`` reports modalities, but
``ListInferenceProfiles`` does not, so without that inheritance every
profile — which is what the ``/model`` picker actually offers, since bare
foundation IDs are deduplicated away in favour of profiles — would arrive
stamped ``TEXT``-only and every Claude profile would be misreported here as
text-only.

Order follows *models*, so callers keep whatever sort they applied.
"""
ids: List[str] = []
for m in models:
modalities = [str(x).upper() for x in (m.get("input_modalities") or [])]
if "IMAGE" in modalities:
ids.append(m["id"])
return ids


# ---------------------------------------------------------------------------
# Error classification — Bedrock-specific exceptions
# ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7733,6 +7733,7 @@ def _prompt_model_selection(
confirm_provider: str = "",
confirm_base_url: str = "",
confirm_api_key: str = "",
vision_model_ids: Optional[List[str]] = None,
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.

Expand All @@ -7741,6 +7742,11 @@ def _prompt_model_selection(

If *unavailable_models* is provided, those models are shown grayed out
and unselectable, with an upgrade link to *portal_url*.

If *vision_model_ids* is provided, those models get a ``(vision)`` marker
in their displayed label so image-capable models are distinguishable at the
point of choosing one. Display-only — the returned value is always the
plain model ID.
"""
from hermes_cli.cli_output import line_input
from hermes_cli.models import (
Expand All @@ -7749,6 +7755,7 @@ def _prompt_model_selection(
)

_unavailable = unavailable_models or []
_vision = set(vision_model_ids or [])
# Sale chrome (★ / -N% / was) is Nous Portal-only — never for OpenRouter
# or other providers even if pricing.original is somehow present.
sale_chrome = (confirm_provider or "").strip().lower() == "nous"
Expand Down Expand Up @@ -7853,6 +7860,8 @@ def _label_segments(mid):
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices."""
if not has_pricing:
segs: list[tuple[str, str | None]] = [(mid, None)]
if mid in _vision:
segs.append((" (vision)", "dim"))
if mid == current_model:
segs.append((" ← currently in use", None))
return segs
Expand All @@ -7879,6 +7888,8 @@ def _label_segments(mid):
segs.append((f" -{pct}%", "yellow"))
if was_inp or was_out:
segs.append((f" was {was_inp}/{was_out}", "dim"))
if mid in _vision:
segs.append((" (vision)", "dim"))
if mid == current_model:
segs.append((" ← currently in use", None))
return segs
Expand Down
22 changes: 19 additions & 3 deletions hermes_cli/model_setup_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2583,10 +2583,25 @@ def _sort_key(m):

deduped.sort(key=_sort_key)
model_list = [m["id"] for m in deduped]
print(
f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)"
)

# Which of these accept image input. The picker offers inference
# profiles (bare foundation IDs are deduplicated away just above), and
# ListInferenceProfiles reports no modalities, so this is only accurate
# because profiles inherit them from their foundation model.
from agent.bedrock_adapter import vision_capable_bedrock_model_ids

vision_ids = vision_capable_bedrock_model_ids(deduped)
if vision_ids:
print(
f" Found {len(model_list)} text model(s), {len(vision_ids)} also "
f"accept image input (filtered from {len(live_models)} total)"
)
else:
print(
f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)"
)
else:
vision_ids = []
model_list = _PROVIDER_MODELS.get("bedrock", [])
if model_list:
print(
Expand All @@ -2605,6 +2620,7 @@ def _sort_key(m):
current_model=current_model,
confirm_provider="bedrock",
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
vision_model_ids=vision_ids,
)
else:
try:
Expand Down
Loading
Loading