Files
hermes-agent/tests/hermes_cli/test_shared_metrics_model_attribution.py
teknium1 1763066334 fix(telemetry): Azure deployment names leave only when they are a public model id
Azure calls a model by a deployment name its owner chose, which can name
a team, customer or environment. On azure* providers the model id now
passes only when it is in Hermes' static catalogs or the local models.dev
cache (never a network call); anything else reads custom.
2026-09-28 12:43:03 -07:00

361 lines
18 KiB
Python

"""Provider/model attribution invariants for the per-model shared metrics: which route a row names,
that user-named models never leave the machine, which profile records it, and how often."""
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
import pytest
from agent.portal_tags import reset_conversation_context, set_conversation_context
from hermes_cli import lifecycle
from hermes_cli.observability import relay_shared_metrics
from tests.hermes_cli.test_relay_shared_metrics_runtime import ( # noqa: F401 - fixture
_stored_values,
direct_runtime,
)
SECRET = "acme-internal-secret-v2"
PUBLIC = "anthropic/claude-sonnet-4"
# The stored install snapshot reflects the fixture's config; the snapshot built from the probed
# config is checked separately.
_PROVIDER_FIELDS = ("provider", "from_provider")
def _flush() -> None:
for runtime in list(relay_shared_metrics._RUNTIMES.values()):
runtime.relay.subscribers.flush()
def _all_rows(home) -> list[tuple[str, dict]]:
from hermes_cli.observability.shared_metrics import SharedMetricsStore
root = home / "telemetry" / "shared_metrics"
if not (root / "metrics.sqlite3").exists():
return []
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
return [(c["metric_name"], c["dimensions"]) for c in store.counter_snapshot()]
def _turn(session_id, task_id, provider, model, *, prompt_tokens=1_000, context_length=200_000, **start):
base = {"session_id": session_id, "task_id": task_id, "api_request_id": f"{task_id}-r",
"provider": provider, "model": model}
lifecycle.invoke_hook("pre_llm_call", **base, platform="cli", **start)
lifecycle.invoke_hook("pre_api_request", **base)
lifecycle.invoke_hook("post_api_request", **base, usage={"prompt_tokens": prompt_tokens, "output_tokens": 5},
context_length=context_length)
relay_shared_metrics.finish_task_run(session_id=session_id, task_id=task_id, platform="cli",
result={"completed": True})
def _emit_every_model_metric(provider, model) -> dict:
"""Drive every emitter that carries a provider/model dimension through its real entry point."""
from hermes_cli.observability import shared_metrics_events as events
from hermes_cli.observability.shared_metrics_model import record_model_friction, record_tool_call_quality
from hermes_cli.observability.shared_metrics_snapshot import collect_install_snapshot
_turn("s1", "t1", provider, model) # model_route, model_tokens (primary), context_peak
lifecycle.invoke_hook("post_auxiliary_call", session_id="s1", provider=provider, model=model,
aux_task="compression", usage={"input_tokens": 10})
events.record_fallback(from_provider=provider, to_provider="openrouter", reason="rate_limit")
events.record_model_switch(from_provider=provider, to_provider="openrouter", surface="cli", from_model=model)
events.record_setup_completed(surface="cli", provider=provider)
record_model_friction("retry", session_id="not-seen", provider=provider, model=model)
agent = SimpleNamespace(provider=provider, model=model, valid_tool_names={"todo"}, tools=[])
record_tool_call_quality(agent, [SimpleNamespace(function=SimpleNamespace(name="todo", arguments="{}"))], set())
lifecycle.finalize_session(session_id="s1")
_flush()
return collect_install_snapshot({"model": {"provider": provider, "default": model}})
_MODEL_METRICS = {
"hermes.model_route.count", "hermes.model_tokens.sum", "hermes.context_peak.count",
"hermes.fallback.count", "hermes.model_switch.count", "hermes.setup.completed",
"hermes.model_friction.count", "hermes.model_tool_quality.count",
}
@pytest.mark.parametrize("provider", [
"ollama", "local", "vllm", "llamacpp", "llama.cpp", "custom:acme-lab", None, "", "acme-gpu-box",
])
def test_user_named_models_never_leave_in_any_model_metric(direct_runtime, tmp_path, provider):
"""Local-server aliases of ``custom``, a missing provider and unknown ids all read custom/unknown
with model ``custom`` in every provider/model-bearing metric; the raw id is nowhere."""
snapshot = _emit_every_model_metric(provider, SECRET)
rows = _all_rows(tmp_path / "hermes-home")
assert SECRET not in json.dumps(rows) + json.dumps(snapshot)
assert _MODEL_METRICS <= {name for name, _ in rows}
assert snapshot["main_provider"] in {"custom", "unknown", "none"}
for name, dims in rows:
for key in _PROVIDER_FIELDS:
if key in dims:
assert dims[key] in {"custom", "unknown", "none"}, (name, dims)
if "model" in dims:
assert dims["model"] == "custom", (name, dims)
def test_shipped_provider_and_public_model_are_reported_as_is(direct_runtime, tmp_path):
snapshot = _emit_every_model_metric("openrouter", PUBLIC)
rows = _all_rows(tmp_path / "hermes-home")
assert snapshot["main_provider"] == "openrouter"
assert _MODEL_METRICS <= {name for name, _ in rows}
for name, dims in rows:
for key in _PROVIDER_FIELDS:
if key in dims:
assert dims[key] == "openrouter", (name, dims)
if "model" in dims:
assert dims["model"] == PUBLIC, (name, dims)
def test_user_provider_plugin_name_and_model_never_leave(direct_runtime, tmp_path, monkeypatch):
"""A ``$HERMES_HOME/plugins/model-providers`` profile joins PROVIDER_REGISTRY under a name (and
aliases) the user chose: every metric, the provider-setup marker and the snapshot read
``custom``/``custom``. An in-tree provider plugin (``deepinfra``) keeps its public name."""
from hermes_cli import auth, auth_plugin_providers, models_catalog_static as mcs
from hermes_cli.observability import shared_metrics_catalog as catalog, shared_metrics_setup as setup
from providers import get_provider_profile
for mod, attr in ((auth, "PROVIDER_REGISTRY"), (auth_plugin_providers, "PLUGIN_MIRRORED_PROVIDERS"),
(mcs, "CANONICAL_PROVIDERS"), (mcs, "_canonical_slugs"), (mcs, "_PROVIDER_LABELS")):
monkeypatch.setattr(mod, attr, type(getattr(mod, attr))(getattr(mod, attr))) # no registry leak
home = tmp_path / "hermes-home"
plugin = home / "plugins" / "model-providers" / "acmecorp-internal"
plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text("name: acmecorp-internal\nkind: model-provider\n")
(plugin / "__init__.py").write_text(
"from providers import register_provider\nfrom providers.base import ProviderProfile\n"
"register_provider(ProviderProfile(name='acmecorp-internal', aliases=('acme-llm',),\n"
" env_vars=('ACMECORP_LLM_API_KEY',), base_url='https://llm.acmecorp.internal/v1'))\n")
assert get_provider_profile("acmecorp-internal") is not None
assert "acmecorp-internal" in auth.PROVIDER_REGISTRY
catalog.provider_names.cache_clear()
snapshot = _emit_every_model_metric("acmecorp-internal", SECRET)
flow = setup.begin_provider_setup("cli_model", "acme-llm")
marker_dir = home / "telemetry" / "shared_metrics" / "provider_setup_markers"
markers = [p.read_text() for p in marker_dir.iterdir()]
setup.finish_provider_setup(flow, "completed")
_flush()
rows = _all_rows(home)
assert markers and not any("acme" in m for m in markers)
assert "acme" not in json.dumps(rows) + json.dumps(snapshot)
assert snapshot["main_provider"] == "custom"
assert _MODEL_METRICS | {"hermes.provider_setup.count"} <= {name for name, _ in rows}
for name, dims in rows:
for key in (*_PROVIDER_FIELDS, "to_provider"):
if dims.get(key) not in (None, "openrouter"):
assert dims[key] == "custom", (name, dims)
if "model" in dims:
assert dims["model"] == "custom", (name, dims)
assert catalog.provider_metric_name("deepinfra") == "deepinfra"
def test_network_address_model_ids_and_raw_injected_marks_never_reach_counters(direct_runtime, tmp_path):
"""A ``host:port``/IP/localhost model id on a public provider reads ``custom`` (Bedrock's
``-v1:0`` and OpenRouter's ``:free`` stay), and the subscriber re-runs the catalog on a mark's
provider/model so a raw mark that skipped the producer's pass stores nothing."""
from hermes_cli.observability import shared_metrics_contract as contract
from hermes_cli.observability.shared_metrics_model import model_route
for model in ("127.0.0.1:8080/x", "localhost/qwen", "10.0.0.5/x", "gpu-box.lan:8000/qwen", "gpu-box.lan:8000"):
assert model_route("openrouter", model)["model"] == "custom", model
# An AWS ARN carries the account id; an alias spelling of a loopback provider names the user's model.
for provider, model in (("bedrock", "arn:aws:bedrock:us-east-1:123456789012:inference-profile/x"),
("lm-studio", "my-model-x"), ("lm_studio", "my-model-x")):
assert model_route(provider, model)["model"] == "custom", (provider, model)
# Azure deployment names are chosen by their owner: only a public model id passes.
for provider in ("azure-foundry", "azure"):
assert model_route(provider, "acme-legal-prod-eastus")["model"] == "custom"
assert model_route(provider, "GPT-4o")["model"] == "gpt-4o"
for provider, model in (("bedrock", "anthropic.claude-3-5-sonnet-20241022-v2:0"),
("openrouter", "openai/gpt-4o:free"), ("ollama-cloud", "gpt-oss:120b")):
assert model_route(provider, model)["model"] == model
runtime = relay_shared_metrics._get_runtime(retry_failed=True)
for provider, model in (("openrouter", "127.0.0.1:8080/x"), ("openrouter", "http://127.0.0.1:8080/v1"),
("custom:acme", "x"), ("ollama", "qwen-private"), ("acmecorp-gateway", "acme-7b"),
("anthropic", "claude-sonnet-4-5")):
runtime.record_process_mark(contract.MODEL_SWITCH_AFTER_MARK, {
"model": model, "provider": provider, "turns_before_switch_bucket": "1"})
_flush()
assert _stored_values(tmp_path, contract.MODEL_SWITCH_AFTER_METRIC) == [
({"model": "claude-sonnet-4-5", "provider": "anthropic", "turns_before_switch_bucket": "1"}, 1)]
@pytest.mark.parametrize(("configured", "expected"), [
(("custom", "acme-private-llama", "http://localhost:8080/v1"), ("custom", "custom")),
(("openrouter", PUBLIC, ""), ("openrouter", PUBLIC)),
])
def test_tui_switch_before_first_prompt_blames_the_configured_route(
direct_runtime, tmp_path, monkeypatch, configured, expected,
):
"""With no agent yet and ``--provider``, switch_away names the model the session was launched on
with ITS provider, never the target provider."""
provider, model, base_url = configured
home = tmp_path / "hermes-home"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
f"model:\n provider: {provider}\n default: {model}\n" + (f" base_url: {base_url}\n" if base_url else ""))
import tui_gateway.server as server
monkeypatch.setattr(server, "_hermes_home", home) # captured at first import
result = SimpleNamespace(
success=True, new_model="gpt-5", target_provider="openai", base_url="https://api.openai.com/v1",
api_key="k", api_mode="chat_completions", warning_message="", error_message="")
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: result)
server._apply_model_switch("", {"agent": None}, "gpt-5 --provider openai", confirm_expensive_model=True)
_flush()
assert _stored_values(tmp_path, "hermes.model_friction.count") == [
({"model": expected[1], "provider": expected[0], "signal": "switch_away"}, 1)]
assert _stored_values(tmp_path, "hermes.model_switch.count") == [
({"execution_surface": "tui", "from_provider": expected[0], "to_provider": "openai"}, 1)]
def _gateway_runner(multiplex_home=None):
from gateway.slash_commands_model import GatewayModelCommandsMixin
class Runner(GatewayModelCommandsMixin):
def __init__(self):
self.config = SimpleNamespace(multiplex_profiles=multiplex_home is not None)
def _resolve_profile_home_for_source(self, source):
return multiplex_home
def _switch_cached_agent_model(self, result, ctx, picker):
return None
async def _record_model_switch(self, *a, **k):
return None
async def _model_switch_confirmation(self, *a, **k):
return "ok"
return Runner()
def _gateway_switch(runner, config_path):
from gateway.slash_commands_model import _ModelSwitchContext
ctx = _ModelSwitchContext(session_key="k", source=None, config_path=config_path, persist_global=False)
ctx.read_config()
result = SimpleNamespace(target_provider="anthropic", new_model="claude-opus-4")
asyncio.run(runner._commit_model_switch_locked(result, ctx, source=None, picker=True))
_flush()
@pytest.mark.parametrize(("model_block", "expected"), [
(" provider: vllm\n default: acme-private-llama\n", ("custom", "custom")),
(" default: acme-private-llama\n base_url: http://10.0.0.5:8000/v1\n", ("unknown", "custom")),
(f" provider: openrouter\n default: {PUBLIC}\n", ("openrouter", PUBLIC)),
])
def test_gateway_switch_reports_the_configured_route_not_the_switch_default(
direct_runtime, tmp_path, model_block, expected,
):
"""Gateway /model reads config.yaml: an unset provider must not borrow switch_model's
``openrouter`` default, and a local alias must not ship the configured model id."""
cfg = tmp_path / "gw-config.yaml"
cfg.write_text("model:\n" + model_block)
_gateway_switch(_gateway_runner(), cfg)
assert _stored_values(tmp_path, "hermes.model_friction.count") == [
({"model": expected[1], "provider": expected[0], "signal": "switch_away"}, 1)]
assert _stored_values(tmp_path, "hermes.model_switch.count") == [
({"execution_surface": "gateway", "from_provider": expected[0], "to_provider": "anthropic"}, 1)]
def test_multiplexed_gateway_switch_records_in_the_owning_profile(direct_runtime, tmp_path):
home_b = tmp_path / "profiles" / "b"
home_b.mkdir(parents=True)
cfg = home_b / "config.yaml"
cfg.write_text(f"model:\n provider: openrouter\n default: {PUBLIC}\n")
_gateway_switch(_gateway_runner(multiplex_home=home_b), cfg)
assert not _all_rows(tmp_path / "hermes-home")
assert sorted(name for name, _ in _all_rows(home_b)) == ["hermes.model_friction.count", "hermes.model_switch.count"]
def _cli(db):
from hermes_cli.cli_loops_mixin import CLILoopsMixin
from hermes_cli.cli_session_mixin import CLISessionMixin
class FakeCLI(CLILoopsMixin, CLISessionMixin):
_slash_metrics_surface = "cli"
def __init__(self):
self.conversation_history = [
{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
self._session_db, self.session_id, self.agent = db, "s-cli" if db else None, None
self.provider, self.model = "openrouter", PUBLIC
def _confirm_destructive_slash(self, *a, **k):
return True
def _prefill_input_buffer(self, text):
return None
return FakeCLI()
def test_cli_undo_counts_friction_only_when_something_was_undone(direct_runtime, tmp_path):
class LeasedDB:
def rewind_user_turn(self, *a, **k):
raise RuntimeError("active turn lease")
failed = _cli(LeasedDB())
failed._cmd_undo("/undo")
_flush()
assert len(failed.conversation_history) == 2
assert not _stored_values(tmp_path, "hermes.model_friction.count")
done = _cli(None)
done._cmd_undo("/undo")
_flush()
assert done.conversation_history == []
assert _stored_values(tmp_path, "hermes.model_friction.count") == [
({"model": PUBLIC, "provider": "openrouter", "signal": "undo"}, 1)]
def test_cli_undo_n_counts_the_user_turns_actually_undone_not_compaction_handoffs(direct_runtime, tmp_path):
from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY, SUMMARY_PREFIX
cli = _cli(None)
cli.conversation_history = [
{"role": "user", "content": SUMMARY_PREFIX + "\nearlier work", COMPRESSED_SUMMARY_METADATA_KEY: True},
{"role": "assistant", "content": "ack"},
{"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1"},
{"role": "user", "content": "q2"}, {"role": "assistant", "content": "a2"}]
cli._cmd_undo("/undo 5")
_flush()
assert sum(value for _, value in _stored_values(tmp_path, "hermes.wasted_tokens.count")) == 2
def test_context_peak_is_one_row_per_conversation_across_compression_rotation(direct_runtime, tmp_path):
"""Compression hands the session id off (s1 -> s1c): the
conversation reports its fullest segment once, whatever order the segments close in. A
delegated child shares the root but is its own (unreported) conversation and holds nothing open."""
token = set_conversation_context("s1")
try:
_turn("s1", "t1", "openrouter", PUBLIC, prompt_tokens=176_000)
_turn("child", "c1", "openrouter", PUBLIC, prompt_tokens=199_000, parent_session_id="s1")
relay_shared_metrics.rotate_segment("s1", "s1c")
_turn("s1c", "t2", "openrouter", PUBLIC, prompt_tokens=30_000)
finally:
reset_conversation_context(token)
lifecycle.finalize_session(session_id="child")
lifecycle.finalize_session(session_id="s1")
_flush()
assert not _stored_values(tmp_path, "hermes.context_peak.count"), "emitted before the lineage closed"
lifecycle.finalize_session(session_id="s1c")
_flush()
assert _stored_values(tmp_path, "hermes.context_peak.count") == [
({"limit_hit": "no", "model": PUBLIC, "peak_fill_bucket": "75_to_90", "provider": "openrouter",
"window_bucket": "128k_to_256k"}, 1)]