feat(acp): expose authenticated cross-provider model choices

This commit is contained in:
amanning3390 2026-07-23 18:51:56 -05:00 committed by Teknium
parent 431d2a628c
commit 33908ff9ff
2 changed files with 217 additions and 31 deletions

View file

@ -585,46 +585,86 @@ class HermesACPAgent(acp.Agent):
return f"{raw_provider}:{raw_model}"
def _build_model_state(self, state: SessionState) -> SessionModelState | None:
"""Return the ACP model selector payload for editors like Zed."""
"""Return authenticated providers and their models for ACP clients.
The shared Hermes inventory is also used by ``hermes model``, the TUI,
and the dashboard. Keeping ACP on that substrate prevents its selector
from silently collapsing to the current provider's curated list.
"""
model = str(state.model or getattr(state.agent, "model", "") or "").strip()
provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter"
try:
from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label
from hermes_cli.inventory import build_models_payload, load_picker_context
from hermes_cli.models import normalize_provider, provider_label
normalized_provider = normalize_provider(provider)
provider_name = provider_label(normalized_provider)
context = load_picker_context().with_overrides(
current_provider=normalized_provider,
current_model=model,
current_base_url=str(getattr(state.agent, "base_url", "") or ""),
)
payload = build_models_payload(
context,
explicit_only=True,
include_unconfigured=False,
picker_hints=False,
canonical_order=True,
pricing=False,
capabilities=False,
refresh=False,
probe_custom_providers=False,
probe_current_custom_provider=False,
max_models=None,
)
available_models: list[ModelInfo] = []
seen_ids: set[str] = set()
for model_id, description in curated_models_for_provider(normalized_provider):
rendered_model = str(model_id or "").strip()
if not rendered_model:
for row in payload.get("providers") or []:
row_provider = normalize_provider(str(row.get("slug") or "").strip())
if not row_provider:
continue
choice_id = self._encode_model_choice(normalized_provider, rendered_model)
if choice_id in seen_ids:
continue
desc_parts = [f"Provider: {provider_name}"]
if description:
desc_parts.append(str(description).strip())
if rendered_model == model:
desc_parts.append("current")
available_models.append(
ModelInfo(
model_id=choice_id,
name=rendered_model,
description="".join(part for part in desc_parts if part),
)
provider_name = str(row.get("name") or "").strip() or provider_label(
row_provider
)
seen_ids.add(choice_id)
for model_entry in row.get("models") or []:
if isinstance(model_entry, dict):
rendered_model = str(
model_entry.get("id")
or model_entry.get("model")
or model_entry.get("name")
or ""
).strip()
else:
rendered_model = str(model_entry or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(row_provider, rendered_model)
if choice_id in seen_ids:
continue
is_current = (
row_provider == normalized_provider and rendered_model == model
)
description = f"Provider: {provider_name}"
if is_current:
description += " • current"
available_models.append(
ModelInfo(
model_id=choice_id,
name=f"{provider_name} · {rendered_model}",
description=description,
)
)
seen_ids.add(choice_id)
current_model_id = self._encode_model_choice(normalized_provider, model)
if current_model_id and current_model_id not in seen_ids:
provider_name = provider_label(normalized_provider)
available_models.insert(
0,
ModelInfo(
model_id=current_model_id,
name=model,
name=f"{provider_name} · {model}",
description=f"Provider: {provider_name} • current",
),
)

View file

@ -238,23 +238,116 @@ class TestSessionOps:
assert state.cwd == "/home/user/project"
@pytest.mark.asyncio
async def test_new_session_returns_model_state(self):
async def test_new_session_returns_authenticated_cross_provider_model_state(self):
manager = SessionManager(
agent_factory=lambda: SimpleNamespace(model="gpt-5.4", provider="openai-codex")
agent_factory=lambda: SimpleNamespace(
model="gpt-5.4",
provider="openai-codex",
base_url="https://api.openai.com/v1",
)
)
acp_agent = HermesACPAgent(session_manager=manager)
picker_context = MagicMock()
picker_context.with_overrides.return_value = picker_context
payload = {
"providers": [
{
"slug": "anthropic",
"name": "Anthropic",
"models": ["claude-sonnet-4-6", "claude-sonnet-4-6"],
},
{
"slug": "openai-codex",
"name": "OpenAI Codex",
"models": [
{"id": "gpt-5.4"},
"gpt-5.4-mini",
],
},
],
}
with patch(
"hermes_cli.models.curated_models_for_provider",
return_value=[("gpt-5.4", "recommended"), ("gpt-5.4-mini", "")],
with (
patch("hermes_cli.inventory.load_picker_context", return_value=picker_context),
patch("hermes_cli.inventory.build_models_payload", return_value=payload) as build_payload,
):
resp = await acp_agent.new_session(cwd="/tmp")
assert isinstance(resp.models, SessionModelState)
assert resp.models.current_model_id == "openai-codex:gpt-5.4"
assert resp.models.available_models[0].model_id == "openai-codex:gpt-5.4"
assert resp.models.available_models[0].description is not None
assert "Provider:" in resp.models.available_models[0].description
assert [model.model_id for model in resp.models.available_models] == [
"anthropic:claude-sonnet-4-6",
"openai-codex:gpt-5.4",
"openai-codex:gpt-5.4-mini",
]
assert [model.name for model in resp.models.available_models] == [
"Anthropic · claude-sonnet-4-6",
"OpenAI Codex · gpt-5.4",
"OpenAI Codex · gpt-5.4-mini",
]
assert resp.models.available_models[1].description is not None
assert "current" in resp.models.available_models[1].description
picker_context.with_overrides.assert_called_once_with(
current_provider="openai-codex",
current_model="gpt-5.4",
current_base_url="https://api.openai.com/v1",
)
build_payload.assert_called_once_with(
picker_context,
explicit_only=True,
include_unconfigured=False,
picker_hints=False,
canonical_order=True,
pricing=False,
capabilities=False,
refresh=False,
probe_custom_providers=False,
probe_current_custom_provider=False,
max_models=None,
)
@pytest.mark.asyncio
async def test_new_session_keeps_current_model_missing_from_inventory(self):
manager = SessionManager(
agent_factory=lambda: SimpleNamespace(
model="claude-custom",
provider="anthropic",
base_url="https://api.anthropic.com",
)
)
acp_agent = HermesACPAgent(session_manager=manager)
picker_context = MagicMock()
picker_context.with_overrides.return_value = picker_context
payload = {
"providers": [
{
"slug": "anthropic",
"name": "Anthropic",
"models": ["claude-sonnet-4-6"],
},
{
"slug": "openai-codex",
"name": "OpenAI Codex",
"models": ["gpt-5.4"],
},
],
}
with (
patch("hermes_cli.inventory.load_picker_context", return_value=picker_context),
patch("hermes_cli.inventory.build_models_payload", return_value=payload),
):
resp = await acp_agent.new_session(cwd="/tmp")
assert resp.models is not None
assert resp.models.current_model_id == "anthropic:claude-custom"
assert [model.model_id for model in resp.models.available_models] == [
"anthropic:claude-custom",
"anthropic:claude-sonnet-4-6",
"openai-codex:gpt-5.4",
]
assert resp.models.available_models[0].name == "Anthropic · claude-custom"
assert resp.models.available_models[0].description == "Provider: Anthropic • current"
@pytest.mark.asyncio
async def test_available_commands_include_help(self, agent):
@ -1106,6 +1199,9 @@ class TestSessionConfiguration:
with patch("run_agent.AIAgent", side_effect=fake_agent):
acp_agent = HermesACPAgent(session_manager=manager)
state = manager.create_session(cwd="/tmp")
assert state.agent.provider == "openrouter"
assert state.agent.base_url == "https://openrouter.example/v1"
assert state.agent.api_mode == "chat_completions"
result = await acp_agent.set_session_model(
model_id="anthropic:claude-sonnet-4-6",
session_id=state.session_id,
@ -1115,8 +1211,58 @@ class TestSessionConfiguration:
assert state.model == "claude-sonnet-4-6"
assert state.agent.provider == "anthropic"
assert state.agent.base_url == "https://anthropic.example/v1"
assert state.agent.api_mode == "anthropic_messages"
assert runtime_calls[-1] == "anthropic"
@pytest.mark.asyncio
async def test_set_session_model_plain_choice_keeps_current_provider_runtime(
self, tmp_path, monkeypatch
):
manager = SessionManager(
db=SessionDB(tmp_path / "state.db"),
agent_factory=lambda: SimpleNamespace(
model="old-model",
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
),
)
acp_agent = HermesACPAgent(session_manager=manager)
state = manager.create_session(cwd="/tmp")
replacement_agent = SimpleNamespace(
model="new-model",
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
)
make_agent = MagicMock(return_value=replacement_agent)
monkeypatch.setattr(manager, "_make_agent", make_agent)
monkeypatch.setattr(
"hermes_cli.models.parse_model_input",
lambda raw, current: (current, raw),
)
monkeypatch.setattr(
"hermes_cli.models.detect_provider_for_model",
lambda model, current: None,
)
result = await acp_agent.set_session_model(
model_id="new-model",
session_id=state.session_id,
)
assert isinstance(result, SetSessionModelResponse)
assert state.model == "new-model"
assert state.agent is replacement_agent
make_agent.assert_called_once_with(
session_id=state.session_id,
cwd="/tmp",
model="new-model",
requested_provider="openrouter",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
)
# ---------------------------------------------------------------------------
# prompt