mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
feat(acp): expose authenticated cross-provider model choices
This commit is contained in:
parent
431d2a628c
commit
33908ff9ff
2 changed files with 217 additions and 31 deletions
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue