Files
nisse/nisse-dev/src/nisse_dev/probe.py
T
dongarandClaude Opus 5.5 b934c46714
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 56s
Enforce the tool-call contract, asking servers only where it works
The executor ended a milestone on any reply without a tool call, and
nothing held a model to the offered tools. Measured on the 4070:
tool_choice "required" is enforced by vLLM (local-fast) and Anthropic,
but qwen3-14b (local-coder) under it cannot stop and writes until the
output ceiling, a known model bug. So enforcement is asked for per
model, and the loop holds the contract itself everywhere.

- The catalog reads supports_tool_choice from LiteLLM's model_info;
  BudgetedClient sends tool_choice only to models declared true, as
  "required" on the normalized route and {"type": "any"} on the
  Anthropic passthrough, and only when tools are offered
- The executor gains an explicit finish tool, the only way to end a
  milestone. Finishing with nothing changed is refused once; a prose
  reply gets one nudge, then escalates; a call to a tool not offered
  counts toward failure_limit, like a failed command; calls after
  finish are recorded as not run
- Clients record the model that actually answered. ModelFacts.answered
  recognises a proxy fallback, which once credited Claude's answers to
  a local model that could not call tools at all
- The context probe reports a fallback instead of trusting it; rerun,
  both local models now refuse over-long prompts, as they should
- The capability matrix checks every answer came from the model asked,
  that models call only offered tools, and that a declared
  supports_tool_choice is true
- Spec A15 and the tuning guide say how enforcement is requested,
  declared and backed up

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:54:49 +00:00

143 lines
5.4 KiB
Python

"""Check a model's declared context window against what its server does.
LiteLLM's ``max_input_tokens`` for a local model is only a declaration; the
real limit is whatever the model server allocates. This sends one prompt
half again as long as the declared window and reads back what the server did
with it:
- **It refused.** Over-long prompts fail loudly, which is the safe behaviour.
- **It cut the prompt.** The reported prompt size is where it cut, which is
the server's real window. Ollama does this, silently.
- **It took the whole prompt.** The server allows more than is declared, so
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
request on the model, so it refuses windows above a ceiling unless told
otherwise: commercial models' limits are already known to LiteLLM.
"""
from dataclasses import dataclass
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
# chat template adds a little, and a tokenizer counts a little differently.
_UNCUT = 0.9
# How close the server's cut must be to the declaration to count as a match.
_MATCH = 0.02
@dataclass(frozen=True)
class ProbeResult:
verdict: str # "matches", "declared_too_high", "declared_conservative", ...
message: str
server_window: int | None = None
def interpret(
declared: int, sent: int, reported: int | None, error: str | None
) -> ProbeResult:
"""What one over-long probe says about the server's window.
``sent`` is roughly how many tokens the probe held; ``reported`` is the
prompt size the server reported, if it answered; ``error`` its error
text, if it refused.
"""
if error is not None:
return ProbeResult(
"refuses",
f"the server refused a ~{sent}-token prompt ({error[:200]}). Over-long "
"prompts fail loudly here, which is safe; the declared window of "
f"{declared} was not contradicted.",
)
assert reported is not None
if reported >= sent * _UNCUT:
return ProbeResult(
"declared_conservative",
f"the server took the whole ~{sent}-token prompt ({reported} reported), "
f"more than the declared {declared}. The declaration is safe; raise it "
"if you want the room.",
server_window=None,
)
if abs(reported - declared) <= declared * _MATCH:
return ProbeResult(
"matches",
f"the server cut the prompt at {reported} tokens, matching the declared "
f"{declared}.",
server_window=reported,
)
if reported < declared:
return ProbeResult(
"declared_too_high",
f"the server cut the prompt at {reported} tokens, but LiteLLM declares "
f"{declared}. Prompts between the two would be silently cut: set "
f"max_input_tokens to {reported}.",
server_window=reported,
)
return ProbeResult(
"declared_conservative",
f"the server cut the prompt at {reported} tokens, above the declared "
f"{declared}. The declaration is safe; it could be raised to {reported}.",
server_window=reported,
)
def _prompt(words: int) -> str:
return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok"
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(
"/chat/completions",
json={
"model": model,
"messages": [{"role": "user", "content": _prompt(words)}],
"max_tokens": 5,
},
)
if response.status_code != 200:
return None, f"{response.status_code}: {response.text}"
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
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
send a prompt half again as long as the declared window."""
calibration_words = 200
try:
tokens, error = _send(http, model, calibration_words, backing)
if error is not None or not tokens:
return ProbeResult("failed", f"a short calibration request failed: {error}")
per_word = tokens / calibration_words
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)