Enforce the tool-call contract, asking servers only where it works
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 56s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 56s
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>
This commit is contained in:
1 parent
389b71a5d1
commit
b934c46714
16 files changed
+568
-86
No files matched your search
@@ -142,6 +142,7 @@ class LiteLLMClient:
|
||||
# a side channel rather than a return value, so the LLMClient
|
||||
# protocol stays the same for every implementation.
|
||||
self.last_usage: dict[str, int] = {}
|
||||
self.last_model: str | None = None
|
||||
|
||||
def complete(
|
||||
self,
|
||||
@@ -151,8 +152,12 @@ class LiteLLMClient:
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int | None = None,
|
||||
require_tool_call: bool = False,
|
||||
) -> list[CanonicalEvent]:
|
||||
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
|
||||
# only by the provider's default, and the same truncation check has
|
||||
# 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"
|
||||
)
|
||||
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 {}
|
||||
self.last_usage = {
|
||||
"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._sleep = sleep
|
||||
self.last_usage: dict[str, int] = {}
|
||||
self.last_model: str | None = None
|
||||
|
||||
def complete(
|
||||
self,
|
||||
@@ -263,6 +272,7 @@ class AnthropicPassthroughClient:
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int | None = None,
|
||||
require_tool_call: bool = False,
|
||||
) -> list[CanonicalEvent]:
|
||||
body = self._renderer.render(
|
||||
events,
|
||||
@@ -271,6 +281,8 @@ class AnthropicPassthroughClient:
|
||||
system=system,
|
||||
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(
|
||||
self._http, "/v1/messages", body, self._sleep
|
||||
)
|
||||
@@ -293,6 +305,9 @@ class AnthropicPassthroughClient:
|
||||
if not isinstance(content, list):
|
||||
raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
|
||||
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 {}
|
||||
self.last_usage = {
|
||||
"input": int(usage.get("input_tokens") or 0),
|
||||
|
||||
Reference in new issue
Block a user