Choose the LLM route by model, and add a capability matrix #34

Merged
dongar merged 4 commits from model-routing into main 2026-09-23 01:47:35 +00:00
4 changed files with 112 additions and 17 deletions
Showing only changes of commit 8cfa116aa1 - Show all commits

No files matched your search

+3 -3
View File
@@ -36,7 +36,7 @@ from nisse_dev.canonical import (
ToolResult, ToolResult,
) )
from nisse_dev.git import GitError, GitRepo from nisse_dev.git import GitError, GitRepo
from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError from nisse_dev.llm import LLMClient, LLMError, UpstreamUnavailable
from nisse_dev.reconcile import Reconciler from nisse_dev.reconcile import Reconciler
from nisse_dev.state import Intent, StateStore from nisse_dev.state import Intent, StateStore
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
@@ -330,10 +330,10 @@ class Delegation:
model=self._model, model=self._model,
system=system, system=system,
) )
except BudgetExhausted as exc: except UpstreamUnavailable as exc:
return DelegationResult( return DelegationResult(
"interrupted", "interrupted",
f"external models are unavailable: {exc}", f"stopped by something upstream, not by the work: {exc}",
branch=branch, branch=branch,
artifacts=sorted(touched), artifacts=sorted(touched),
tokens=spent, tokens=spent,
+38 -13
View File
@@ -54,12 +54,24 @@ def max_output_tokens(env: Mapping[str, str] = os.environ) -> int:
return int(raw) if raw else DEFAULT_MAX_OUTPUT_TOKENS return int(raw) if raw else DEFAULT_MAX_OUTPUT_TOKENS
class BudgetExhausted(LLMError): class UpstreamUnavailable(LLMError):
"""The proxy or provider could not be reached, or refused for reasons
unrelated to what was asked: a network blip, a timeout, a 5xx, a rate
limit that did not clear, an exhausted budget.
Worth distinguishing because the *work* is not at fault. A delegation
stopped by one of these has a branch that may be perfectly good, so it is
preserved and resumed rather than discarded — unlike a delegate that made
a mess, which is cleaned up.
"""
class BudgetExhausted(UpstreamUnavailable):
"""The proxy refused because a spend limit is gone. """The proxy refused because a spend limit is gone.
Distinct from a rate limit, which clears in a minute: an exhausted daily Distinct from the rest of its family because it will not clear on its
budget takes external models away until it resets, so retrying wastes own within the hour: an exhausted daily budget takes external models away
time and the operator needs telling rather than the loop quietly failing. until it resets, so retrying wastes time and the operator needs telling.
A5 keeps the budget itself at the proxy; recognising its refusal is ours. A5 keeps the budget itself at the proxy; recognising its refusal is ours.
""" """
@@ -174,10 +186,7 @@ class LiteLLMClient:
self._http, "/chat/completions", body, self._sleep self._http, "/chat/completions", body, self._sleep
) )
if response.status_code != 200: if response.status_code != 200:
if _looks_like_budget_refusal(response.text): _raise_for_upstream(response)
raise BudgetExhausted(
f"the proxy refused on budget grounds: {response.text[:300]}"
)
raise LLMError( raise LLMError(
f"LLM request returned {response.status_code}: {response.text[:500]}" f"LLM request returned {response.status_code}: {response.text[:500]}"
) )
@@ -294,10 +303,7 @@ class AnthropicPassthroughClient:
self._http, "/v1/messages", body, self._sleep self._http, "/v1/messages", body, self._sleep
) )
if response.status_code != 200: if response.status_code != 200:
if _looks_like_budget_refusal(response.text): _raise_for_upstream(response)
raise BudgetExhausted(
f"the proxy refused on budget grounds: {response.text[:300]}"
)
raise LLMError( raise LLMError(
f"passthrough returned {response.status_code}: {response.text[:500]}" f"passthrough returned {response.status_code}: {response.text[:500]}"
) )
@@ -353,7 +359,9 @@ def _post_with_rate_limit_retry(
try: try:
response = http.post(path, json=body) response = http.post(path, json=body)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LLMError(f"LLM request failed: {exc}") from exc # Could not reach the proxy at all: nothing about the request was
# wrong, so callers holding work in progress should keep it.
raise UpstreamUnavailable(f"LLM request failed: {exc}") from exc
if response.status_code != RATE_LIMIT_STATUS or attempt == 2: if response.status_code != RATE_LIMIT_STATUS or attempt == 2:
return response return response
sleep(RATE_LIMIT_RETRY_S) sleep(RATE_LIMIT_RETRY_S)
@@ -383,3 +391,20 @@ def select_client(
which is the opposite of the vendor fluidity A5 requires. which is the opposite of the vendor fluidity A5 requires.
""" """
return passthrough if has_native_adapter(model) else normalized return passthrough if has_native_adapter(model) else normalized
def _raise_for_upstream(response: httpx.Response) -> None:
"""Classify a non-200 as upstream trouble where it plainly is.
A 4xx that is not a rate limit means the request itself was wrong, and
preserving work on the strength of one would hide a real bug — so the
benefit of the doubt stops at the server's own door.
"""
if _looks_like_budget_refusal(response.text):
raise BudgetExhausted(
f"the proxy refused on budget grounds: {response.text[:300]}"
)
if response.status_code >= 500 or response.status_code == RATE_LIMIT_STATUS:
raise UpstreamUnavailable(
f"upstream returned {response.status_code}: {response.text[:300]}"
)
+34 -1
View File
@@ -21,7 +21,7 @@ from nisse_dev.delegation import (
delegation_reconciler, delegation_reconciler,
) )
from nisse_dev.git import GitRepo from nisse_dev.git import GitRepo
from nisse_dev.llm import BudgetExhausted, LLMError from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
from nisse_dev.oplog import OpLog from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace from nisse_dev.tools import Workspace
@@ -614,3 +614,36 @@ def test_an_ordinary_failure_still_discards(
assert not result.interrupted assert not result.interrupted
assert repo.current_branch() == "main" assert repo.current_branch() == "main"
assert store.pending_intents() == [] assert store.pending_intents() == []
def test_a_network_outage_also_preserves_the_work(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# Same reasoning as an exhausted budget: the branch may be fine, and the
# outage says nothing about the quality of the work on it.
class FlakyClient(ScriptedClient):
def complete(self, *args: Any, **kwargs: Any):
if self.calls:
raise UpstreamUnavailable("LLM request failed: connection refused")
return super().complete(*args, **kwargs)
client = FlakyClient([[call("write_file", path="progress.py", content="real")]])
result = build(client, store, ws, repo).run(brief="b", branch="d")
assert result.status == "interrupted"
assert (repo.root / "progress.py").exists()
assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT]
def test_a_bad_request_is_not_given_the_benefit_of_the_doubt(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# A 4xx that is not a rate limit means we sent something wrong; treating
# that as an outage would preserve branches and hide a real bug.
class BadRequestClient(ScriptedClient):
def complete(self, *args: Any, **kwargs: Any):
raise LLMError("LLM request returned 400: malformed tool schema")
result = build(BadRequestClient([]), store, ws, repo).run(brief="b", branch="d")
assert result.status == "failed"
assert not result.interrupted
+37
View File
@@ -15,10 +15,12 @@ from nisse_dev.llm import (
DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS,
RATE_LIMIT_RETRY_S, RATE_LIMIT_RETRY_S,
AnthropicPassthroughClient, AnthropicPassthroughClient,
BudgetExhausted,
LiteLLMClient, LiteLLMClient,
LLMError, LLMError,
RoleMap, RoleMap,
TruncatedResponse, TruncatedResponse,
UpstreamUnavailable,
has_native_adapter, has_native_adapter,
max_output_tokens, max_output_tokens,
select_client, select_client,
@@ -343,3 +345,38 @@ def test_truncation_is_an_llm_error_so_callers_already_handle_it() -> None:
# The planner retries, the executor and delegation gate. None of them # The planner retries, the executor and delegation gate. None of them
# need to learn a new exception type. # need to learn a new exception type.
assert issubclass(TruncatedResponse, LLMError) assert issubclass(TruncatedResponse, LLMError)
def test_a_5xx_is_upstream_trouble() -> None:
client = make_client(lambda request: httpx.Response(503, text="bad gateway"))
with pytest.raises(UpstreamUnavailable, match="503"):
client.complete(EVENTS, tools=None, model="local-fast")
def test_a_connection_failure_is_upstream_trouble() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
with pytest.raises(UpstreamUnavailable, match="request failed"):
make_client(handler).complete(EVENTS, tools=None, model="local-fast")
def test_a_persistent_rate_limit_is_upstream_trouble() -> None:
# It cleared for neither attempt, so the caller should keep its work
# rather than discard it.
client = make_client(lambda request: httpx.Response(429, text="slow down"))
with pytest.raises(UpstreamUnavailable):
client.complete(EVENTS, tools=None, model="local-fast")
def test_a_bad_request_is_not_upstream_trouble() -> None:
client = make_client(lambda request: httpx.Response(400, text="bad schema"))
with pytest.raises(LLMError) as excinfo:
client.complete(EVENTS, tools=None, model="local-fast")
assert not isinstance(excinfo.value, UpstreamUnavailable)
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
# the difference matters — it will not clear within the hour.
assert issubclass(BudgetExhausted, UpstreamUnavailable)