Merge pull request 'Enforce the tool-call contract, asking servers only where it works' (#45) from enforce-tool-calls into main
CI / checks (push) Successful in 53s

Reviewed-on: dongar/nisse#45
Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #45.
This commit is contained in:
dongar committed 2026-09-25 00:05:46 +00:00
commit b2bbf70b7b
16 files changed
+568 -86

No files matched your search

+7 -1
View File
@@ -668,7 +668,13 @@ subsystem.
### A15. Local Model Accommodations ### A15. Local Model Accommodations
- **Constrained decoding for tool-call JSON.** - **Constrained decoding for tool-call JSON.** Requested with `tool_choice`
(`required`), and only of models whose LiteLLM entry declares
`supports_tool_choice`: enforcement is the model server's, and asking one that
can't honour it can be worse than not asking. Where it isn't available, the
loop enforces the contract itself: an explicit `finish` tool ends the work, a
prose reply is a violation, and a call to a tool not offered counts as a
failure (A12).
- **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no - **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no
deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at
request time like everything else. request time like everything else.
+55 -6
View File
@@ -282,6 +282,48 @@ log.
- **Deciding:** if milestones stop with "cannot fit", look first at - **Deciding:** if milestones stop with "cannot fit", look first at
`tools.max_output_chars` and the model's window, not at these. `tools.max_output_chars` and the model's window, not at these.
### Enforced tool calls — `supports_tool_choice`, declared in LiteLLM
The agent's loops depend on every turn being a well-formed call to a real
tool. A model only *writes text*: a "tool call" is text in a format the model
was trained to produce, which the server recognises and turns into a
structured call. By default (`tool_choice: auto`) the server just parses
whatever comes out, so prose, broken JSON and calls to invented tools are all
possible.
With `tool_choice: required`, a server that supports it **constrains
decoding**: at every step it rules out any token that couldn't lead to a
valid call to one of the offered tools, with arguments matching its schema.
The model still chooses the tool and the arguments; it can't produce anything
else. That makes it a property of the **model server** (and sometimes of the
model), not something a prompt can buy.
So the agent asks for it only where it is known to work: models whose LiteLLM
entry declares `supports_tool_choice: true`. Measured on the 4070:
| Alias | Server | `required` on a chat prompt | Declare |
|---|---|---|---|
| `local-fast` (Qwen3-8B-AWQ) | vLLM 0.29 | calls `finish` every time | `true` |
| `local-coder` (Qwen3-14B) | llama.cpp | can't stop; writes until the output ceiling (a known Qwen3-14B bug) | `false` |
| `claude-haiku-4-5` | Anthropic | calls `finish` every time | `true` (LiteLLM already says so) |
Asking a model that can't honour it can be worse than not asking: the
Qwen3-14B turn above costs a full output ceiling of generation, where a plain
prose reply would have cost a few hundred tokens.
Where it isn't enforced, the executor holds the contract itself:
- **`finish` is the only way to end a milestone.** It is refused once if
nothing has been changed yet.
- **A prose reply is a violation.** The model is told once; a second escalates.
- **A call to a tool that wasn't offered counts as a failure** toward
`failure_limit`, like a failed command, so an inventing model escalates in a
couple of turns instead of using up `max_turns`.
The capability matrix checks the declaration: a model declared `true` that
answers a required call in prose fails
`test_supports_tool_choice_is_declared_truthfully`.
### Declaring a local model's limits ### Declaring a local model's limits
LiteLLM knows commercial models' limits. For a local model, declare them in LiteLLM knows commercial models' limits. For a local model, declare them in
@@ -292,8 +334,10 @@ model_list:
- model_name: local-fast - model_name: local-fast
litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1} litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1}
model_info: model_info:
max_input_tokens: 32768 # the server's per-request context max_input_tokens: 32768 # the server's per-request context
max_output_tokens: 4096 # the most one response may use max_output_tokens: 4096 # the most one response may use
supports_function_calling: true
supports_tool_choice: true # only once measured; see above
``` ```
`max_input_tokens` is the *whole* window, prompt plus response. The agent `max_input_tokens` is the *whole* window, prompt plus response. The agent
@@ -332,7 +376,7 @@ Upstream choices change what's right downstream, so tune in this order and
change one thing at a time. change one thing at a time.
1. **Model and quantization.** Start with whether a candidate holds the 1. **Model and quantization.** Start with whether a candidate holds the
tool-call contract at all: tool-call contract at all, and whether its server enforces `tool_choice`:
uv run pytest -m integration tests/integration/test_model_capability.py \ uv run pytest -m integration tests/integration/test_model_capability.py \
--capability-models=alias-a,alias-b --capability-models=alias-a,alias-b
@@ -342,6 +386,14 @@ change one thing at a time.
each one actually points at in LiteLLM (`/model/info`, the each one actually points at in LiteLLM (`/model/info`, the
`litellm_params.model` field). Two aliases believed to be the same model `litellm_params.model` field). Two aliases believed to be the same model
once weren't, and that cost a lot of debugging. once weren't, and that cost a lot of debugging.
**Make sure LiteLLM has no fallback for a model you are measuring.** A
fallback answers a failed request with another model, under that model's
name, in a response that otherwise looks normal. One made a local model
that couldn't call tools at all look like it "held the tool contract 20 of
20": every answer was Claude's. The capability matrix and the probe now
check which model answered and fail on a mismatch, but the fallback also
quietly turns local work into frontier spending in production.
2. **Context length and KV-cache precision** on the server. Take the largest 2. **Context length and KV-cache precision** on the server. Take the largest
context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit
KV cache before giving up context. KV cache before giving up context.
@@ -369,8 +421,5 @@ section for each:
- **Compaction.** A model-written summary replacing old turns, instead of - **Compaction.** A model-written summary replacing old turns, instead of
dropping them. The log already has an event for it; it needs a summarizing dropping them. The log already has an event for it; it needs a summarizing
model and a policy for what is safe to summarize. model and a policy for what is safe to summarize.
- **Enforced tool calls.** An explicit `finish` tool, so the server can be told
a tool call is required and hold the model to the contract, where today it
can fall back to prose.
- **A benchmark script** that runs the capability matrix and representative - **A benchmark script** that runs the capability matrix and representative
milestones across candidate models and prints a comparison table. milestones across candidate models and prints a comparison table.
+3
View File
@@ -174,6 +174,9 @@ def build_agent(
max_output_tokens=config.max_output_tokens, max_output_tokens=config.max_output_tokens,
keep_recent_turns=tuning.context.keep_recent_turns, keep_recent_turns=tuning.context.keep_recent_turns,
safety_margin=tuning.context.safety_margin, safety_margin=tuning.context.safety_margin,
enforces_tool_choice=lambda m: (
catalog.facts(m).supports_tool_choice is True
),
) )
normalized = budgeted( normalized = budgeted(
+6 -4
View File
@@ -29,10 +29,12 @@ class Cancel:
return self._event.is_set() return self._event.is_set()
def skip_calls(store: StateStore, calls: list[ToolCall]) -> None: def skip_calls(
"""Record the calls a stop left unrun. Every tool call needs a result for store: StateStore, calls: list[ToolCall], reason: str = STOPPED_RESULT
the transcript to render again, and the result says what happened.""" ) -> None:
"""Record calls left unrun. Every tool call needs a result for the
transcript to render again, and the result says what happened."""
for call in calls: for call in calls:
store.append_event( store.append_event(
ToolResult(call_id=call.call_id, result=STOPPED_RESULT, success=False) ToolResult(call_id=call.call_id, result=reason, success=False)
) )
+16
View File
@@ -54,11 +54,26 @@ class ModelFacts:
max_input_tokens: int | None = None max_input_tokens: int | None = None
# The most one response may use (model_info.max_output_tokens). # The most one response may use (model_info.max_output_tokens).
max_output_tokens: int | None = None max_output_tokens: int | None = None
# Whether its server enforces tool_choice (model_info.supports_tool_choice).
# Only a declared True counts: a model that says nothing is not asked.
supports_tool_choice: bool | None = None
@property @property
def native_anthropic(self) -> bool: def native_anthropic(self) -> bool:
return self.provider == ANTHROPIC_PROVIDER return self.provider == ANTHROPIC_PROVIDER
def answered(self, reported: str | None) -> bool:
"""Whether a response reporting this model name came from this model.
A proxy fallback answers under another model's name. LiteLLM reports
the alias; a provider may report its own name for the backing model,
sometimes with a date suffix ("claude-haiku-4-5-20251001").
"""
if not reported:
return True # nothing reported, nothing contradicted
own_name = self.backing.partition("/")[2] or self.backing
return reported in (self.alias, self.backing) or reported.startswith(own_name)
class ModelCatalog: class ModelCatalog:
"""What one LiteLLM key can see, as of the moment it was asked.""" """What one LiteLLM key can see, as of the moment it was asked."""
@@ -87,6 +102,7 @@ class ModelCatalog:
supports_tools=info.get("supports_function_calling"), supports_tools=info.get("supports_function_calling"),
max_input_tokens=_positive_int(info.get("max_input_tokens")), max_input_tokens=_positive_int(info.get("max_input_tokens")),
max_output_tokens=_positive_int(info.get("max_output_tokens")), max_output_tokens=_positive_int(info.get("max_output_tokens")),
supports_tool_choice=info.get("supports_tool_choice"),
) )
return cls(facts) return cls(facts)
+19 -1
View File
@@ -215,9 +215,15 @@ class BudgetedClient:
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS, max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
keep_recent_turns: int = 6, keep_recent_turns: int = 6,
safety_margin: float = 0.1, safety_margin: float = 0.1,
enforces_tool_choice: Callable[[str], bool] = lambda model: False,
) -> None: ) -> None:
self._inner = inner self._inner = inner
self._limits_for = limits_for self._limits_for = limits_for
# Whether a model's server holds it to the offered tools when asked
# (LiteLLM's supports_tool_choice). Asking one that doesn't can be
# worse than not asking: qwen3-14b can't stop, and rambles until the
# output ceiling.
self._enforces_tool_choice = enforces_tool_choice
self._max_output_tokens = max_output_tokens self._max_output_tokens = max_output_tokens
self._keep_recent_turns = keep_recent_turns self._keep_recent_turns = keep_recent_turns
self._safety_margin = safety_margin self._safety_margin = safety_margin
@@ -229,6 +235,10 @@ class BudgetedClient:
def last_usage(self) -> dict[str, int]: def last_usage(self) -> dict[str, int]:
return getattr(self._inner, "last_usage", {}) or {} return getattr(self._inner, "last_usage", {}) or {}
@property
def last_model(self) -> str | None:
return getattr(self._inner, "last_model", None)
def budget_for(self, model: str) -> Budget: def budget_for(self, model: str) -> Budget:
window, model_max_output = self._limits_for(model) window, model_max_output = self._limits_for(model)
ceiling = self._max_output_tokens ceiling = self._max_output_tokens
@@ -252,7 +262,15 @@ class BudgetedClient:
factor = self._factors.get(model, 1.0) factor = self._factors.get(model, 1.0)
fitted = fit(events, system, tools, budget, factor) fitted = fit(events, system, tools, budget, factor)
produced = self._inner.complete( produced = self._inner.complete(
fitted, tools, model, system=system, max_tokens=budget.max_output fitted,
tools,
model,
system=system,
max_tokens=budget.max_output,
# Every caller that offers tools needs one called: the executor
# finishes with finish, the planner with submit_plan or
# ask_human, a delegation with hand_back.
require_tool_call=bool(tools) and self._enforces_tool_choice(model),
) )
reported = int(self.last_usage.get("input", 0)) reported = int(self.last_usage.get("input", 0))
if reported > 0: if reported > 0:
+86 -31
View File
@@ -27,20 +27,42 @@ EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work.
Follow the approved plan below exactly. It was written to be executed Follow the approved plan below exactly. It was written to be executed
verbatim; do not redesign it, expand its scope, or skip steps. verbatim; do not redesign it, expand its scope, or skip steps.
Work by calling tools. Verify your work by running the project's tests. Every reply must be a tool call. Verify your work by running the project's
When every step of the plan is done and the tests pass, reply with a short tests. When every step of the plan is done and the tests pass, call finish
plain-text summary and no tool call — that ends the milestone.""" with a short summary — that, and nothing else, ends the milestone."""
DEFAULT_MAX_TURNS = 24 DEFAULT_MAX_TURNS = 24
DEFAULT_FAILURE_LIMIT = 2 DEFAULT_FAILURE_LIMIT = 2
# A turn with no tool call means "the plan is done". Believing that before FINISH_TOOL: dict[str, Any] = {
# any real work has happened would record an empty milestone as a success, so "type": "function",
# the claim is challenged once first. "function": {
_NUDGE = ( "name": "finish",
"\n\nYou have not made any change yet, so the plan cannot be complete. " "description": (
"Carry out the next step of the plan with a tool call, or run the tests " "End the milestone: every step of the plan is done and the tests pass."
"to show the work is already done." ),
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "What was done."}
},
"required": ["summary"],
},
},
}
# Finishing before any real work has happened would record an empty
# milestone as a success, so the claim is refused once first.
_NOT_YET = (
"You have not made any change yet, so the plan cannot be complete. Carry "
"out the next step of the plan, or run the tests to show the work is "
"already done, before calling finish."
)
# A reply in prose breaks the tool contract. A server that enforces
# tool_choice never allows one; on one that doesn't, the model is told once.
_PROSE_NUDGE = (
"\n\nYour last reply was prose. Every reply must be a tool call: carry "
"out the next step, or call finish if the plan is complete."
) )
@@ -128,8 +150,8 @@ class LLMExecutor:
# A12: ask_expert exists in addition to the observable escalation # A12: ask_expert exists in addition to the observable escalation
# triggers, never as a replacement for them. # triggers, never as a replacement for them.
if self._expert is None: if self._expert is None:
return EXECUTOR_TOOLS return [*EXECUTOR_TOOLS, FINISH_TOOL]
return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL] return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL, FINISH_TOOL]
def _consult(self, arguments: dict[str, Any]) -> ToolOutcome: def _consult(self, arguments: dict[str, Any]) -> ToolOutcome:
assert self._expert is not None assert self._expert is not None
@@ -173,9 +195,12 @@ class LLMExecutor:
if plan is not None: if plan is not None:
# In the system prompt, so the context budget never drops it. # In the system prompt, so the context budget never drops it.
system += f"\n\nThe approved plan:\n{plan}" system += f"\n\nThe approved plan:\n{plan}"
tools = self._tools()
offered = {tool["function"]["name"] for tool in tools}
consecutive_failures = 0 consecutive_failures = 0
did_work = False did_work = False
challenged = False finish_refused = False
prose_nudged = False
for turn in range(self._max_turns): for turn in range(self._max_turns):
# A4: `stop` takes effect at the next tool boundary. The start of # A4: `stop` takes effect at the next tool boundary. The start of
@@ -185,7 +210,7 @@ class LLMExecutor:
try: try:
produced = self._client.complete( produced = self._client.complete(
self._store.read_events(after=after), self._store.read_events(after=after),
tools=self._tools(), tools=tools,
model=model, model=model,
system=system, system=system,
) )
@@ -199,19 +224,12 @@ class LLMExecutor:
calls.append(event) calls.append(event)
if not calls: if not calls:
if did_work: if not prose_nudged:
return ExecutorOutcome( prose_nudged = True
"completed", "executor reported the plan done" system += _PROSE_NUDGE
)
if not challenged:
# Claiming completion without having touched anything is
# not evidence of completion; ask once before believing it.
challenged = True
system += _NUDGE
continue continue
return ExecutorOutcome( return ExecutorOutcome(
"escalate", "escalate", "executor kept replying in prose instead of tools"
"executor claimed the milestone was done without making any change",
) )
self._report(turn, calls) self._report(turn, calls)
@@ -219,11 +237,47 @@ class LLMExecutor:
if self._cancel.requested: if self._cancel.requested:
skip_calls(self._store, calls[index:]) skip_calls(self._store, calls[index:])
return ExecutorOutcome("stopped", f"during turn {turn + 1}") return ExecutorOutcome("stopped", f"during turn {turn + 1}")
if call.tool in MUTATING_TOOLS: if call.tool == "finish":
did_work = True if not did_work and not finish_refused:
if call.tool == "ask_expert": finish_refused = True
outcome = ToolOutcome(_NOT_YET, success=False)
elif not did_work:
return ExecutorOutcome(
"escalate",
"executor claimed the milestone was done without "
"making any change",
)
else:
self._store.append_event(
ToolResult(
call_id=call.call_id,
result="milestone finished",
success=True,
)
)
skip_calls(
self._store,
calls[index + 1 :],
"not run: finish had already ended the milestone",
)
summary = str(call.arguments.get("summary", "")).strip()
return ExecutorOutcome(
"completed", summary or "executor reported the plan done"
)
elif call.tool not in offered:
# A model inventing tools is not doing the work, and with
# no server enforcement nothing else would stop it, so it
# counts toward escalation like a failed command (A12).
outcome = ToolOutcome(
f"no such tool: {call.tool}; the tools are "
+ ", ".join(sorted(offered)),
success=False,
)
elif call.tool == "ask_expert":
outcome = self._consult(call.arguments) outcome = self._consult(call.arguments)
else: else:
if call.tool in MUTATING_TOOLS:
did_work = True
outcome = self._workspace.dispatch(call.tool, call.arguments) outcome = self._workspace.dispatch(call.tool, call.arguments)
self._store.append_event( self._store.append_event(
ToolResult( ToolResult(
@@ -232,9 +286,10 @@ class LLMExecutor:
success=outcome.success, success=outcome.success,
) )
) )
# Only commands count toward escalation: a failed read is the # Commands and invented tools count toward escalation: a
# model looking around, a failed command is work not working. # failed read is the model looking around, a failed command
if call.tool == "run_command": # is work not working.
if call.tool == "run_command" or call.tool not in offered:
consecutive_failures = ( consecutive_failures = (
0 if outcome.success else consecutive_failures + 1 0 if outcome.success else consecutive_failures + 1
) )
+15
View File
@@ -142,6 +142,7 @@ class LiteLLMClient:
# a side channel rather than a return value, so the LLMClient # a side channel rather than a return value, so the LLMClient
# protocol stays the same for every implementation. # protocol stays the same for every implementation.
self.last_usage: dict[str, int] = {} self.last_usage: dict[str, int] = {}
self.last_model: str | None = None
def complete( def complete(
self, self,
@@ -151,8 +152,12 @@ class LiteLLMClient:
system: str | None = None, system: str | None = None,
*, *,
max_tokens: int | None = None, max_tokens: int | None = None,
require_tool_call: bool = False,
) -> list[CanonicalEvent]: ) -> list[CanonicalEvent]:
body = self._renderer.render(events, tools, model, system=system) body = self._renderer.render(events, tools, model, system=system)
if require_tool_call and tools:
# Only for models whose server enforces it; see BudgetedClient.
body["tool_choice"] = "required"
# A ceiling on this route too: without one a runaway turn is bounded # A ceiling on this route too: without one a runaway turn is bounded
# only by the provider's default, and the same truncation check has # only by the provider's default, and the same truncation check has
# nothing to check against. A caller that knows the model's limits # nothing to check against. A caller that knows the model's limits
@@ -182,6 +187,9 @@ class LiteLLMClient:
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM" "llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
) )
produced_by = data.get("model") or requested_model produced_by = data.get("model") or requested_model
# What actually answered, which is not always what was asked for: a
# proxy fallback answers under another model's name.
self.last_model = produced_by
usage = data.get("usage") or {} usage = data.get("usage") or {}
self.last_usage = { self.last_usage = {
"input": int(usage.get("prompt_tokens") or 0), "input": int(usage.get("prompt_tokens") or 0),
@@ -254,6 +262,7 @@ class AnthropicPassthroughClient:
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
self._sleep = sleep self._sleep = sleep
self.last_usage: dict[str, int] = {} self.last_usage: dict[str, int] = {}
self.last_model: str | None = None
def complete( def complete(
self, self,
@@ -263,6 +272,7 @@ class AnthropicPassthroughClient:
system: str | None = None, system: str | None = None,
*, *,
max_tokens: int | None = None, max_tokens: int | None = None,
require_tool_call: bool = False,
) -> list[CanonicalEvent]: ) -> list[CanonicalEvent]:
body = self._renderer.render( body = self._renderer.render(
events, events,
@@ -271,6 +281,8 @@ class AnthropicPassthroughClient:
system=system, system=system,
max_tokens=max_tokens or self._max_tokens, max_tokens=max_tokens or self._max_tokens,
) )
if require_tool_call and tools:
body["tool_choice"] = {"type": "any"} # Anthropic's name for it
response = _post_with_rate_limit_retry( response = _post_with_rate_limit_retry(
self._http, "/v1/messages", body, self._sleep self._http, "/v1/messages", body, self._sleep
) )
@@ -293,6 +305,9 @@ class AnthropicPassthroughClient:
if not isinstance(content, list): if not isinstance(content, list):
raise LLMError(f"malformed passthrough response: {str(data)[:300]}") raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
produced_by = data.get("model") or requested_model produced_by = data.get("model") or requested_model
# What actually answered, which is not always what was asked for: a
# proxy fallback answers under another model's name.
self.last_model = produced_by
usage = data.get("usage") or {} usage = data.get("usage") or {}
self.last_usage = { self.last_usage = {
"input": int(usage.get("input_tokens") or 0), "input": int(usage.get("input_tokens") or 0),
+37 -9
View File
@@ -11,6 +11,10 @@ with it:
- **It took the whole prompt.** The server allows more than is declared, so - **It took the whole prompt.** The server allows more than is declared, so
the declaration is conservative. the declaration is conservative.
Every answer is checked for which model gave it. A proxy that falls back to
another model on an error returns a normal-looking answer under that model's
name, and once made this probe report a refusing server as roomy.
Run it with ``scripts/probe_context.py``. It spends one small and one large Run it with ``scripts/probe_context.py``. It spends one small and one large
request on the model, so it refuses windows above a ceiling unless told request on the model, so it refuses windows above a ceiling unless told
otherwise: commercial models' limits are already known to LiteLLM. otherwise: commercial models' limits are already known to LiteLLM.
@@ -20,6 +24,8 @@ from dataclasses import dataclass
import httpx import httpx
from nisse_dev.catalog import ModelFacts
# Of a probe's size, how much must come back for it to count as uncut. A # Of a probe's size, how much must come back for it to count as uncut. A
# chat template adds a little, and a tokenizer counts a little differently. # chat template adds a little, and a tokenizer counts a little differently.
_UNCUT = 0.9 _UNCUT = 0.9
@@ -86,7 +92,14 @@ def _prompt(words: int) -> str:
return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok" return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok"
def _send(http: httpx.Client, model: str, words: int) -> tuple[int | None, str | None]: class Fallback(Exception):
def __init__(self, answered_by: str) -> None:
self.answered_by = answered_by
def _send(
http: httpx.Client, model: str, words: int, backing: str
) -> tuple[int | None, str | None]:
response = http.post( response = http.post(
"/chat/completions", "/chat/completions",
json={ json={
@@ -97,18 +110,33 @@ def _send(http: httpx.Client, model: str, words: int) -> tuple[int | None, str |
) )
if response.status_code != 200: if response.status_code != 200:
return None, f"{response.status_code}: {response.text}" return None, f"{response.status_code}: {response.text}"
usage = response.json().get("usage") or {} data = response.json()
answered = str(data.get("model") or "")
facts = ModelFacts(alias=model, backing=backing, provider="", supports_tools=None)
if not facts.answered(answered):
raise Fallback(answered)
usage = data.get("usage") or {}
return int(usage.get("prompt_tokens") or 0), None return int(usage.get("prompt_tokens") or 0), None
def probe(http: httpx.Client, model: str, declared: int) -> ProbeResult: def probe(
http: httpx.Client, model: str, declared: int, backing: str = ""
) -> ProbeResult:
"""Measure how many tokens a word of the probe costs on this model, then """Measure how many tokens a word of the probe costs on this model, then
send a prompt half again as long as the declared window.""" send a prompt half again as long as the declared window."""
calibration_words = 200 calibration_words = 200
tokens, error = _send(http, model, calibration_words) try:
if error is not None or not tokens: tokens, error = _send(http, model, calibration_words, backing)
return ProbeResult("failed", f"a short calibration request failed: {error}") if error is not None or not tokens:
per_word = tokens / calibration_words return ProbeResult("failed", f"a short calibration request failed: {error}")
sent = int(declared * 1.5) per_word = tokens / calibration_words
reported, error = _send(http, model, int(sent / per_word)) sent = int(declared * 1.5)
reported, error = _send(http, model, int(sent / per_word), backing)
except Fallback as fallback:
return ProbeResult(
"fallback",
f"{fallback.answered_by} answered instead of {model}: the proxy fell "
"back to another model, so nothing here describes this one. Remove "
"the fallback, or probe the server directly.",
)
return interpret(declared, sent, reported, error) return interpret(declared, sent, reported, error)
+2
View File
@@ -344,4 +344,6 @@ def test_tuning_reaches_every_component(
assert executor._client._inner._max_tokens == 8192 assert executor._client._inner._max_tokens == 8192
assert delegation._client._inner._max_tokens == 8192 assert delegation._client._inner._max_tokens == 8192
assert executor._client._max_output_tokens == 8192 assert executor._client._max_output_tokens == 8192
# tool_choice is asked of exactly the models LiteLLM declares enforce it.
assert executor._client._enforces_tool_choice("claude-opus-5") is False
agent.stop() agent.stop()
+50 -2
View File
@@ -150,8 +150,8 @@ class RecordingClient:
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
self.last_usage = {"input": reported_input, "output": 5} self.last_usage = {"input": reported_input, "output": 5}
def complete(self, events, tools, model, system=None, *, max_tokens=None): def complete(self, events, tools, model, system=None, *, max_tokens=None, **kwargs):
self.calls.append({"events": events, "max_tokens": max_tokens}) self.calls.append({"events": events, "max_tokens": max_tokens, **kwargs})
return [AssistantText(text="ok", model=model)] return [AssistantText(text="ok", model=model)]
@@ -292,3 +292,51 @@ def test_a_request_that_cannot_fit_stops_the_milestone(tmp_path: Path) -> None:
assert outcome.status == "escalate" assert outcome.status == "escalate"
assert "fit" in outcome.note assert "fit" in outcome.note
assert inner.calls == [] assert inner.calls == []
# --- tool_choice is only asked of servers that enforce it -------------------
FINISH = [{"type": "function", "function": {"name": "finish", "parameters": {}}}]
def test_required_is_asked_only_of_models_declared_to_enforce_it() -> None:
# Measured: qwen3-14b under "required" cannot stop and rambles to the
# output ceiling, so a model that is not declared is never asked.
inner = RecordingClient()
client = BudgetedClient(
inner, lambda m: (32768, 8192), enforces_tool_choice=lambda m: m == "local-fast"
)
client.complete(transcript(1, 10), FINISH, "local-fast")
client.complete(transcript(1, 10), FINISH, "local-coder")
client.complete(transcript(1, 10), None, "local-fast")
assert [c["require_tool_call"] for c in inner.calls] == [True, False, False]
def test_the_catalog_reads_supports_tool_choice() -> None:
catalog = ModelCatalog.from_payload(
{
"data": [
{"model_name": "a", "model_info": {"supports_tool_choice": True}},
{"model_name": "b", "model_info": {"supports_tool_choice": False}},
{"model_name": "c", "model_info": {}},
]
}
)
assert [catalog.facts(m).supports_tool_choice for m in "abc"] == [True, False, None]
def test_a_fallback_answer_is_recognised() -> None:
facts = ModelCatalog.from_payload(
{
"data": [
{
"model_name": "claude-haiku-4-5",
"litellm_params": {"model": "anthropic/claude-haiku-4-5"},
}
]
}
).facts("claude-haiku-4-5")
assert facts.answered("claude-haiku-4-5") # LiteLLM's alias
assert facts.answered("claude-haiku-4-5-20251001") # the provider's own name
assert facts.answered(None) # nothing reported
assert not facts.answered("claude-sonnet-5") # somebody else answered
+90 -29
View File
@@ -4,7 +4,13 @@ from typing import Any
import pytest import pytest
from nisse_dev.canonical import AssistantText, CanonicalEvent, StoredEvent, ToolCall from nisse_dev.canonical import (
AssistantText,
CanonicalEvent,
StoredEvent,
ToolCall,
ToolResult,
)
from nisse_dev.executor import LLMExecutor from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import LLMError, RoleMap from nisse_dev.llm import LLMError, RoleMap
from nisse_dev.oplog import OpLog from nisse_dev.oplog import OpLog
@@ -33,7 +39,7 @@ class ScriptedClient:
) -> list[CanonicalEvent]: ) -> list[CanonicalEvent]:
self.calls.append({"tools": tools, "model": model, "system": system}) self.calls.append({"tools": tools, "model": model, "system": system})
if not self._turns: if not self._turns:
return [AssistantText(text="done", model=model)] return [finish(call_id=f"fin-{len(self.calls)}")]
return self._turns.pop(0) return self._turns.pop(0)
@@ -46,6 +52,10 @@ def call(tool: str, call_id: str = "c1", **arguments: Any) -> ToolCall:
return ToolCall(call_id=call_id, tool=tool, arguments=arguments) return ToolCall(call_id=call_id, tool=tool, arguments=arguments)
def finish(summary: str = "done", call_id: str = "fin") -> ToolCall:
return ToolCall(call_id=call_id, tool="finish", arguments={"summary": summary})
@pytest.fixture @pytest.fixture
def store(tmp_path: Path) -> StateStore: def store(tmp_path: Path) -> StateStore:
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO())) return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
@@ -66,15 +76,16 @@ def test_tool_calls_are_executed_and_results_logged(
client = ScriptedClient( client = ScriptedClient(
[ [
[call("write_file", path="app.py", content="print(1)\n")], [call("write_file", path="app.py", content="print(1)\n")],
[AssistantText(text="wrote the file", model="llama3.2")], [finish("wrote the file")],
] ]
) )
outcome = build(client, store, ws).run(milestone=1) outcome = build(client, store, ws).run(milestone=1)
assert outcome.status == "completed" assert outcome.status == "completed"
assert outcome.note == "wrote the file"
assert (ws.root / "app.py").read_text() == "print(1)\n" assert (ws.root / "app.py").read_text() == "print(1)\n"
kinds = [type(e.event).__name__ for e in store.read_events()] kinds = [type(e.event).__name__ for e in store.read_events()]
assert kinds == ["ToolCall", "ToolResult", "AssistantText"] assert kinds == ["ToolCall", "ToolResult", "ToolCall", "ToolResult"]
def test_uses_executor_role_and_reduced_manifest( def test_uses_executor_role_and_reduced_manifest(
@@ -84,50 +95,99 @@ def test_uses_executor_role_and_reduced_manifest(
build(client, store, ws).run(milestone=2) build(client, store, ws).run(milestone=2)
assert client.calls[0]["model"] == "llama3.2" # never the planner assert client.calls[0]["model"] == "llama3.2" # never the planner
names = [t["function"]["name"] for t in client.calls[0]["tools"]] names = [t["function"]["name"] for t in client.calls[0]["tools"]]
assert names == ["read_file", "write_file", "list_dir", "run_command"] assert names == ["read_file", "write_file", "list_dir", "run_command", "finish"]
assert "milestone 2" in client.calls[0]["system"] assert "milestone 2" in client.calls[0]["system"]
def test_no_tool_call_after_work_ends_the_milestone( def test_prose_no_longer_ends_the_milestone(store: StateStore, ws: Workspace) -> None:
store: StateStore, ws: Workspace # Only finish ends a milestone. Prose breaks the tool contract: the model
) -> None: # is told once, and here it gets back to work.
client = ScriptedClient( client = ScriptedClient(
[ [
[call("write_file", path="a.py", content="x")], [call("write_file", path="a.py", content="x")],
[AssistantText(text="all steps done", model="m")], [AssistantText(text="all steps done", model="m")],
[finish()],
] ]
) )
assert build(client, store, ws).run(milestone=1).status == "completed" assert build(client, store, ws).run(milestone=1).status == "completed"
assert "Every reply must be a tool call" in client.calls[2]["system"]
def test_completion_claimed_without_any_work_is_challenged( def test_persisting_in_prose_escalates(store: StateStore, ws: Workspace) -> None:
prose = [AssistantText(text="let me just chat", model="m")]
client = ScriptedClient([prose, prose])
outcome = build(client, store, ws).run(milestone=1)
assert outcome.status == "escalate"
assert "prose" in outcome.note
def test_finishing_without_any_work_is_refused_once(
store: StateStore, ws: Workspace store: StateStore, ws: Workspace
) -> None: ) -> None:
# A model that answers with prose on turn one has done nothing; believing # A model that finishes on turn one has done nothing; believing it would
# it would record an empty milestone as a success. # record an empty milestone as a success.
client = ScriptedClient( client = ScriptedClient(
[ [
[AssistantText(text="looks done to me", model="m")], [finish("looks done to me", call_id="f1")],
[call("write_file", path="a.py", content="x")], [call("write_file", path="a.py", content="x")],
[AssistantText(text="now it is done", model="m")], [finish("now it is done", call_id="f2")],
] ]
) )
outcome = build(client, store, ws).run(milestone=1) outcome = build(client, store, ws).run(milestone=1)
assert outcome.status == "completed" assert outcome.status == "completed"
assert (ws.root / "a.py").exists() # the nudge produced actual work assert (ws.root / "a.py").exists() # the refusal produced actual work
assert "not made any change yet" in client.calls[1]["system"] [refusal] = [
e.event
for e in store.read_events()
if isinstance(e.event, ToolResult) and e.event.call_id == "f1"
]
assert not refusal.success
assert "not made any change yet" in refusal.result
def test_persisting_in_an_empty_claim_escalates( def test_persisting_in_an_empty_finish_escalates(
store: StateStore, ws: Workspace store: StateStore, ws: Workspace
) -> None: ) -> None:
prose = [AssistantText(text="nothing to do", model="m")] client = ScriptedClient([[finish(call_id="f1")], [finish(call_id="f2")]])
client = ScriptedClient([prose, prose])
outcome = build(client, store, ws).run(milestone=1) outcome = build(client, store, ws).run(milestone=1)
assert outcome.status == "escalate" assert outcome.status == "escalate"
assert "without making any change" in outcome.note assert "without making any change" in outcome.note
def test_calls_after_finish_are_not_run(store: StateStore, ws: Workspace) -> None:
client = ScriptedClient(
[
[call("write_file", path="a.py", content="x")],
[finish(), call("write_file", call_id="late", path="b.py", content="y")],
]
)
assert build(client, store, ws).run(milestone=1).status == "completed"
assert not (ws.root / "b.py").exists()
[late] = [
e.event
for e in store.read_events()
if isinstance(e.event, ToolResult) and e.event.call_id == "late"
]
assert "finish had already ended the milestone" in late.result
def test_an_invented_tool_counts_toward_escalation(
store: StateStore, ws: Workspace
) -> None:
# Measured: a model on a server that doesn't enforce tool_choice will
# call tools that were never offered ("chat", "respond"). Without this it
# would burn every turn up to the cap.
client = ScriptedClient(
[[call("chat", call_id="x1")], [call("respond_user", call_id="x2")]]
)
outcome = build(client, store, ws, failure_limit=2).run(milestone=1)
assert outcome.status == "escalate"
assert len(client.calls) == 2
results = [e.event for e in store.read_events() if isinstance(e.event, ToolResult)]
assert "no such tool: chat" in results[0].result
assert "finish" in results[0].result # it is told what does exist
def test_two_consecutive_command_failures_escalate( def test_two_consecutive_command_failures_escalate(
store: StateStore, ws: Workspace store: StateStore, ws: Workspace
) -> None: ) -> None:
@@ -150,7 +210,7 @@ def test_a_success_resets_the_failure_count(store: StateStore, ws: Workspace) ->
[call("run_command", command="exit 1")], [call("run_command", command="exit 1")],
[call("run_command", command="true")], [call("run_command", command="true")],
[call("run_command", command="exit 1")], [call("run_command", command="exit 1")],
[AssistantText(text="done", model="m")], [finish()],
] ]
) )
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == ( assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
@@ -168,7 +228,7 @@ def test_failed_reads_do_not_count_toward_escalation(
[call("read_file", path="missing-a.txt")], [call("read_file", path="missing-a.txt")],
[call("read_file", path="missing-b.txt")], [call("read_file", path="missing-b.txt")],
[call("write_file", path="a.py", content="x")], [call("write_file", path="a.py", content="x")],
[AssistantText(text="done", model="m")], [finish()],
] ]
) )
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == ( assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
@@ -182,8 +242,8 @@ def test_reading_alone_is_not_doing_the_work(store: StateStore, ws: Workspace) -
client = ScriptedClient( client = ScriptedClient(
[ [
[call("list_dir", path=".")], [call("list_dir", path=".")],
[AssistantText(text="looks done to me", model="m")], [finish("looks done to me", call_id="f1")],
[AssistantText(text="really, done", model="m")], [finish("really, done", call_id="f2")],
] ]
) )
outcome = build(client, store, ws).run(milestone=1) outcome = build(client, store, ws).run(milestone=1)
@@ -214,7 +274,7 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
call("write_file", call_id="a", path="one.txt", content="1"), call("write_file", call_id="a", path="one.txt", content="1"),
call("write_file", call_id="b", path="two.txt", content="2"), call("write_file", call_id="b", path="two.txt", content="2"),
], ],
[AssistantText(text="done", model="m")], [finish()],
] ]
) )
build(client, store, ws).run(milestone=1) build(client, store, ws).run(milestone=1)
@@ -222,7 +282,7 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
results = [ results = [
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult" e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
] ]
assert {r.call_id for r in results} == {"a", "b"} assert {r.call_id for r in results} == {"a", "b", "fin"}
class StubDesk: class StubDesk:
@@ -255,7 +315,8 @@ def test_ask_expert_is_offered_only_when_a_desk_exists(
client2 = ScriptedClient([]) client2 = ScriptedClient([])
build(client2, store, ws, expert=StubDesk()).run(milestone=1) build(client2, store, ws, expert=StubDesk()).run(milestone=1)
names = [t["function"]["name"] for t in client2.calls[0]["tools"]] names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
assert names[-1] == "ask_expert" assert "ask_expert" in names
assert names[-1] == "finish"
# A12: in addition to the observable triggers, not instead of them. # A12: in addition to the observable triggers, not instead of them.
assert "run_command" in names assert "run_command" in names
@@ -268,7 +329,7 @@ def test_ask_expert_answer_comes_back_as_a_tool_result(
[ [
[call("ask_expert", question="sqlite or postgres?")], [call("ask_expert", question="sqlite or postgres?")],
[call("write_file", path="a.py", content="x")], [call("write_file", path="a.py", content="x")],
[AssistantText(text="done", model="m")], [finish()],
] ]
) )
build(client, store, ws, expert=desk).run(milestone=1) build(client, store, ws, expert=desk).run(milestone=1)
@@ -286,8 +347,8 @@ def test_consulting_is_not_doing_the_work(store: StateStore, ws: Workspace) -> N
client = ScriptedClient( client = ScriptedClient(
[ [
[call("ask_expert", question="what should I do?")], [call("ask_expert", question="what should I do?")],
[AssistantText(text="I think it is done", model="m")], [finish("I think it is done", call_id="f1")],
[AssistantText(text="really done", model="m")], [finish("really done", call_id="f2")],
] ]
) )
outcome = build(client, store, ws, expert=StubDesk()).run(milestone=1) outcome = build(client, store, ws, expert=StubDesk()).run(milestone=1)
@@ -304,7 +365,7 @@ def test_exhausted_budget_is_reported_to_the_model_not_raised(
[ [
[call("ask_expert", question="again?")], [call("ask_expert", question="again?")],
[call("write_file", path="a.py", content="x")], [call("write_file", path="a.py", content="x")],
[AssistantText(text="done", model="m")], [finish()],
] ]
) )
outcome = build(client, store, ws, expert=desk).run(milestone=1) outcome = build(client, store, ws, expert=desk).run(milestone=1)
+71
View File
@@ -1,5 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any
import httpx import httpx
import pytest import pytest
@@ -341,3 +342,73 @@ def test_budget_refusal_is_a_kind_of_upstream_trouble() -> None:
# So one catch covers the family, but it can still be told apart where # So one catch covers the family, but it can still be told apart where
# the difference matters — it will not clear within the hour. # the difference matters — it will not clear within the hour.
assert issubclass(BudgetExhausted, UpstreamUnavailable) assert issubclass(BudgetExhausted, UpstreamUnavailable)
# --- tool_choice (A15: constrained decoding) --------------------------------
TOOLS = [
{
"type": "function",
"function": {
"name": "finish",
"description": "done",
"parameters": {"type": "object", "properties": {}},
},
}
]
def capture_bodies() -> tuple[list[dict], Any]:
bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
bodies.append(json.loads(request.content))
return completion({"role": "assistant", "content": "ok"})
return bodies, handler
def test_required_is_sent_only_when_asked_and_tools_are_offered() -> None:
bodies, handler = capture_bodies()
client = make_client(handler)
client.complete(EVENTS, tools=TOOLS, model="m", require_tool_call=True)
client.complete(EVENTS, tools=TOOLS, model="m")
client.complete(EVENTS, tools=None, model="m", require_tool_call=True)
assert bodies[0]["tool_choice"] == "required"
assert "tool_choice" not in bodies[1] # the server's default, auto
assert "tool_choice" not in bodies[2] # nothing to choose from
def test_the_passthrough_asks_in_anthropics_terms() -> None:
bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
bodies.append(json.loads(request.content))
return httpx.Response(
200,
json={
"model": "claude-haiku-4-5",
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "t", "name": "finish", "input": {}}
],
},
)
client = AnthropicPassthroughClient(
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
)
client.complete(
EVENTS, tools=TOOLS, model="claude-haiku-4-5", require_tool_call=True
)
assert bodies[0]["tool_choice"] == {"type": "any"}
def test_the_answering_model_is_recorded() -> None:
# A proxy fallback answers under another model's name; this is how a
# caller can tell.
client = make_client(
lambda request: completion({"role": "assistant", "content": "hi"})
)
client.complete(EVENTS, tools=None, model="local-fast")
assert client.last_model == "gpt-test-1"
+37 -1
View File
@@ -42,7 +42,9 @@ def test_probe_calibrates_then_sends_half_again_the_window() -> None:
words = len(json.loads(request.content)["messages"][0]["content"].split()) words = len(json.loads(request.content)["messages"][0]["content"].split())
sizes.append(words * 2) sizes.append(words * 2)
reported = min(words * 2, 4096) reported = min(words * 2, 4096)
return httpx.Response(200, json={"usage": {"prompt_tokens": reported}}) return httpx.Response(
200, json={"model": "llama3.2", "usage": {"prompt_tokens": reported}}
)
with httpx.Client( with httpx.Client(
base_url="http://litellm", transport=httpx.MockTransport(handler) base_url="http://litellm", transport=httpx.MockTransport(handler)
@@ -51,3 +53,37 @@ def test_probe_calibrates_then_sends_half_again_the_window() -> None:
assert result.verdict == "matches" assert result.verdict == "matches"
assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window
def test_a_fallback_answer_is_never_credited_to_the_model() -> None:
"""Measured: LiteLLM answered an oversized prompt to local-coder with
claude-sonnet-5, and the probe called the refusing server roomy."""
def handler(request: httpx.Request) -> httpx.Response:
words = len(json.loads(request.content)["messages"][0]["content"].split())
model = "local-coder" if words < 1000 else "claude-sonnet-5"
return httpx.Response(
200, json={"model": model, "usage": {"prompt_tokens": words * 2}}
)
with httpx.Client(
base_url="http://litellm", transport=httpx.MockTransport(handler)
) as http:
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
assert result.verdict == "fallback"
assert "claude-sonnet-5 answered instead of local-coder" in result.message
def test_a_provider_reporting_its_own_model_name_is_not_a_fallback() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"model": "qwen3-14b", "usage": {"prompt_tokens": 400}}
)
with httpx.Client(
base_url="http://litellm", transport=httpx.MockTransport(handler)
) as http:
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
assert result.verdict != "fallback"
+3 -2
View File
@@ -45,7 +45,8 @@ def main() -> int:
return 2 return 2
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token) catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
declared = catalog.facts(args.alias).max_input_tokens facts = catalog.facts(args.alias)
declared = facts.max_input_tokens
if declared is None: if declared is None:
print( print(
f"LiteLLM declares no max_input_tokens for {args.alias}; set one first", f"LiteLLM declares no max_input_tokens for {args.alias}; set one first",
@@ -65,7 +66,7 @@ def main() -> int:
headers={"Authorization": f"Bearer {config.llm_token}"}, headers={"Authorization": f"Bearer {config.llm_token}"},
timeout=600, timeout=600,
) as http: ) as http:
result = probe(http, args.alias, declared) result = probe(http, args.alias, declared, facts.backing)
print(f"{args.alias}: {result.message}") print(f"{args.alias}: {result.message}")
return 0 if result.verdict in ("matches", "refuses", "declared_conservative") else 1 return 0 if result.verdict in ("matches", "refuses", "declared_conservative") else 1
@@ -30,6 +30,7 @@ from nisse_dev.canonical import (
) )
from nisse_dev.catalog import ModelCatalog, select_client from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig from nisse_dev.config import AgentConfig
from nisse_dev.executor import FINISH_TOOL
from nisse_dev.expert import parse_answer from nisse_dev.expert import parse_answer
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
from nisse_dev.planner import PLANNER_TOOLS from nisse_dev.planner import PLANNER_TOOLS
@@ -73,6 +74,16 @@ def catalog(live_config: AgentConfig) -> ModelCatalog:
return ModelCatalog.discover(live_config.llm_base_url, live_config.llm_token) return ModelCatalog.discover(live_config.llm_base_url, live_config.llm_token)
def assert_answered(client: object, model: str, catalog: ModelCatalog) -> None:
"""A proxy fallback answers under another model's name. Crediting its
answer to this model is how a local model once 'held the tool contract
20/20' while never answering at all."""
reported = getattr(client, "last_model", None)
assert catalog.facts(model).answered(reported), (
f"{reported} answered instead of {model}: a fallback, not a measurement"
)
def user(text: str) -> list[StoredEvent]: def user(text: str) -> list[StoredEvent]:
return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))] return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))]
@@ -83,6 +94,7 @@ def test_model_answers_at_all(model: str, catalog: ModelCatalog, routes) -> None
produced = client.complete( produced = client.complete(
user("Reply with exactly one word: pong"), None, model, system="Be terse." user("Reply with exactly one word: pong"), None, model, system="Be terse."
) )
assert_answered(client, model, catalog)
texts = [e.text for e in produced if isinstance(e, AssistantText)] texts = [e.text for e in produced if isinstance(e, AssistantText)]
assert texts, f"{model} produced no text: {produced!r}" assert texts, f"{model} produced no text: {produced!r}"
@@ -103,6 +115,7 @@ def test_model_holds_the_planner_contract(
"ask_human. Plain prose is not accepted." "ask_human. Plain prose is not accepted."
), ),
) )
assert_answered(client, model, catalog)
calls = [e for e in produced if isinstance(e, ToolCall)] calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, f"{model} answered in prose instead of calling a tool" assert calls, f"{model} answered in prose instead of calling a tool"
assert calls[0].tool in {"submit_plan", "ask_human"} assert calls[0].tool in {"submit_plan", "ask_human"}
@@ -140,6 +153,7 @@ def test_model_round_trips_a_tool_result(
), ),
) )
) )
assert_answered(client, model, catalog)
follow_up = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.") follow_up = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.")
assert follow_up, f"{model} produced nothing after its tool result" assert follow_up, f"{model} produced nothing after its tool result"
@@ -161,6 +175,7 @@ def test_model_holds_the_prompt_level_json_contract(
'"caveats": "..."}' '"caveats": "..."}'
), ),
) )
assert_answered(client, model, catalog)
text = "\n".join(e.text for e in produced if isinstance(e, AssistantText)) text = "\n".join(e.text for e in produced if isinstance(e, AssistantText))
assert parse_answer(text) is not None, ( assert parse_answer(text) is not None, (
f"{model} did not produce parseable JSON: {text[:200]!r}" f"{model} did not produce parseable JSON: {text[:200]!r}"
@@ -185,3 +200,59 @@ def test_route_selection_matches_the_model(
chosen.complete(user("say pong"), None, model, system="Be terse.") chosen.complete(user("say pong"), None, model, system="Be terse.")
except LLMError as exc: except LLMError as exc:
pytest.fail(f"{model} failed on its selected route: {exc}") pytest.fail(f"{model} failed on its selected route: {exc}")
assert_answered(chosen, model, catalog)
FINISH_TOOLS = [*EXECUTOR_TOOLS, FINISH_TOOL]
OFFERED = {tool["function"]["name"] for tool in FINISH_TOOLS}
def test_model_calls_only_the_tools_it_was_offered(
model: str, catalog: ModelCatalog, routes
) -> None:
"""Measured on a server that did not enforce tool_choice: a model asked
to chat called "chat", "say_hello" and "respond", none of them offered.
The executor counts those as failures; this measures how often."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Hi! How are you today? Just chat with me for a moment."),
FINISH_TOOLS,
model,
system="You are an agent. Every reply must be a tool call.",
)
assert_answered(client, model, catalog)
called = {e.tool for e in produced if isinstance(e, ToolCall)}
assert called <= OFFERED, (
f"{model} called tools it was never offered: {called - OFFERED}"
)
def test_supports_tool_choice_is_declared_truthfully(
model: str, catalog: ModelCatalog, routes
) -> None:
"""The agent asks for tool_choice only of models LiteLLM declares
supports_tool_choice for, so the declaration must be true. Asked of a
model that can't honour it, "required" can be worse than not asking:
qwen3-14b can't stop, and writes until the output ceiling."""
facts = catalog.facts(model)
if facts.supports_tool_choice is not True:
pytest.skip(
f"{model} is not declared to support tool_choice, so is never asked"
)
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Hi! How are you today? Just chat with me for a moment."),
FINISH_TOOLS,
model,
system="You are a helpful assistant.",
require_tool_call=True,
)
assert_answered(client, model, catalog)
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, (
f"{model} is declared supports_tool_choice but answered a required call "
"in prose; set it to false in LiteLLM"
)
assert {c.tool for c in calls} <= OFFERED