Files
nisse/tests/integration/test_model_capability.py
T
dongarandClaude Opus 5 e38adf40ca
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 47s
Discover a model's vendor from LiteLLM instead of its name
Route selection sniffed model names: anything starting "claude-" or
"anthropic/" took the vendor-native passthrough, everything else took the
normalized route. That works only while an alias names its vendor. A
semantic role alias like "remote-architecture" is meant to point at
Anthropic today and somewhere else tomorrow, and the name says nothing
either way -- so it would have taken the normalized route regardless of
what was behind it, silently losing the fidelity A13 wants for delegations.

LiteLLM already knows the answer. /model/info reports each alias's backing
model, provider, and declared tool support, so the agent asks at startup and
routes on the provider it discovers. That keeps vendor knowledge where A5
already put it -- in the proxy -- rather than duplicating it as name patterns
here.

There is deliberately no name-based fallback: PASSTHROUGH_PREFIXES and
has_native_adapter are gone, and select_client now requires a catalog. A
second heuristic could only ever disagree with the proxy. An unknown model
raises instead of guessing a route.

Misconfiguration is therefore loud and early. build_agent validates every
role against the catalog before it clones anything, naming all the bad ones
at once. Discovery is per key -- /model/info returns only what the presented
token may use -- so the same check catches a role pointed at a real model
this key cannot reach. An unreachable proxy is reported separately, since an
outage and a typo call for different responses from the operator.

Two things now get recorded rather than inferred: the resolved model is
pinned alongside the role mapping at task start, for the reason A11 already
pins roles, and DelegationStarted carries backing_model so A16 eval history
is not averaged across whatever an alias happened to point at.

A model LiteLLM declares tool-less warns at startup rather than refusing;
A15 expects weak local models to be accommodated, and they are observed
driving tool loops anyway.

Verified live: all four aliases the production key can see route correctly
through discovery, and each chosen route works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:47:58 +00:00

172 lines
6.6 KiB
Python

"""Capability matrix across models (spec Part H, A15).
Part H says the thresholds that depend on local capability — A12's failure
count, A14's consult budget, A15's manifest size — must be tuned "at
build-order step 5 on the real hardware". This is the measurement that makes
that possible, rather than reasoning about model strength from reputation.
What it measures is the thing that actually breaks: whether a model can hold
the tool-call contract our loops depend on. llama3.2 could not — it returned
`{` as an entire plan — and no amount of prompt work fixed it.
Opt in with NISSE_CAPABILITY_MODELS, comma separated. Skipped otherwise,
because it spends real tokens across several models.
"""
import os
from pathlib import Path
import pytest
from nisse_dev.canonical import (
AssistantText,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.envfile import load_env_file
from nisse_dev.expert import parse_answer
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
from nisse_dev.planner import PLANNER_TOOLS
from nisse_dev.tools import EXECUTOR_TOOLS
load_env_file(Path(__file__).resolve().parents[2] / ".env")
SENTINEL = "NISSE_CAPABILITY_MODELS"
MODELS = [m.strip() for m in os.environ.get(SENTINEL, "").split(",") if m.strip()]
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not MODELS, reason=f"set {SENTINEL} to a comma-separated model list"
),
]
def clients() -> tuple[LiteLLMClient, AnthropicPassthroughClient]:
return LiteLLMClient.from_env(), AnthropicPassthroughClient.from_env()
@pytest.fixture(scope="module")
def catalog() -> ModelCatalog:
"""The live catalog this key can see. /model/info is scoped per key, so
the probe list and the key have to agree."""
return ModelCatalog.discover(
os.environ["NISSE_LLM_BASE_URL"], os.environ["NISSE_LLM_TOKEN"]
)
def user(text: str) -> list[StoredEvent]:
return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))]
@pytest.mark.parametrize("model", MODELS)
def test_model_answers_at_all(model: str, catalog: ModelCatalog) -> None:
normalized, passthrough = clients()
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Reply with exactly one word: pong"), None, model, system="Be terse."
)
texts = [e.text for e in produced if isinstance(e, AssistantText)]
assert texts, f"{model} produced no text: {produced!r}"
@pytest.mark.parametrize("model", MODELS)
def test_model_holds_the_planner_contract(model: str, catalog: ModelCatalog) -> None:
"""A4's gating is structural at the tool layer: a model that answers in
prose cannot be gated, because there is no plan to approve."""
normalized, passthrough = clients()
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Draft an architecture plan for a command-line slug generator."),
PLANNER_TOOLS,
model,
system=(
"You must answer by calling exactly one tool: submit_plan or "
"ask_human. Plain prose is not accepted."
),
)
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, f"{model} answered in prose instead of calling a tool"
assert calls[0].tool in {"submit_plan", "ask_human"}
if calls[0].tool == "submit_plan":
content = calls[0].arguments.get("content", "")
# llama3.2 emitted "{" here — structurally a tool call, substantively
# nothing. Length is a crude proxy, but it separates the two.
assert len(str(content)) > 80, (
f"{model} submitted a degenerate plan: {content!r}"
)
@pytest.mark.parametrize("model", MODELS)
def test_model_round_trips_a_tool_result(model: str, catalog: ModelCatalog) -> None:
"""The executor loop feeds results back; a model that cannot consume its
own tool result cannot run a milestone."""
normalized, passthrough = clients()
client = select_client(model, normalized, passthrough, catalog)
events = user("List the files in the current directory using your tools.")
produced = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.")
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, f"{model} made no tool call"
next_id = 2
for event in produced:
events.append(StoredEvent(id=next_id, ts="t", event=event))
next_id += 1
events.append(
StoredEvent(
id=next_id,
ts="t",
event=ToolResult(
call_id=calls[0].call_id, result="app.py\ntests/", success=True
),
)
)
follow_up = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.")
assert follow_up, f"{model} produced nothing after its tool result"
@pytest.mark.parametrize("model", MODELS)
def test_model_holds_the_prompt_level_json_contract(
model: str, catalog: ModelCatalog
) -> None:
"""A14 contracts structured output at the prompt level because vendors
differ on JSON enforcement. This measures whether that holds per model."""
normalized, passthrough = clients()
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Is SQLite sufficient for a single-process agent's task state?"),
None,
model,
system=(
"Reply with a single JSON object and nothing else: "
'{"answer": "...", "confidence": "high"|"medium"|"low", '
'"caveats": "..."}'
),
)
text = "\n".join(e.text for e in produced if isinstance(e, AssistantText))
assert parse_answer(text) is not None, (
f"{model} did not produce parseable JSON: {text[:200]!r}"
)
@pytest.mark.parametrize("model", MODELS)
def test_route_selection_matches_the_model(model: str, catalog: ModelCatalog) -> None:
"""A non-Anthropic model must not be sent Anthropic-shaped bodies. This
was hardcoded until gpt-6-astra made it observable, and name-sniffed until
semantic aliases made the name stop meaning anything."""
normalized, passthrough = clients()
facts = catalog.facts(model)
chosen = select_client(model, normalized, passthrough, catalog)
if facts.provider == "anthropic":
assert chosen is passthrough
else:
assert chosen is normalized
# And the chosen route must actually work for this model.
try:
chosen.complete(user("say pong"), None, model, system="Be terse.")
except LLMError as exc:
pytest.fail(f"{model} failed on its selected route: {exc}")