Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
63c6d73
fix(discovery): retry a provider's transient model-list fetch once
claude Aug 30, 2026
8060d6e
fix(discovery): cap retry timeout at the caller's own budget
claude Aug 30, 2026
261f2d8
Merge branch 'main' into claude/noema-contextualwisdomlab-commerciali…
opencode-agent[bot] Aug 30, 2026
6b7efc4
fix(server): allow stream_options.include_usage with tools passthrough
claude Aug 30, 2026
f2b9338
Merge remote-tracking branch 'origin/claude/noema-contextualwisdomlab…
claude Aug 30, 2026
eb45344
fix(orchestrator): unwrap URLError-wrapped TLS cert failures in is_tr…
claude Aug 30, 2026
f191fc4
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Aug 31, 2026
897eb2b
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Aug 31, 2026
529138e
Merge branch 'main' into claude/noema-contextualwisdomlab-commerciali…
opencode-agent[bot] Aug 31, 2026
4229320
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Aug 31, 2026
3eeeae6
fix(discovery): isolate DNS/body-read failures instead of aborting
claude Aug 31, 2026
b9bf0c6
fix(discovery): retry a temporary DNS failure through its RuntimeErro…
claude Aug 31, 2026
bb8a248
test(discovery): fix two tests left stale by the provider-family removal
claude Aug 31, 2026
52855e9
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Aug 31, 2026
ad4dce1
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Sep 1, 2026
daf43e5
fix(passthrough): fail over on single-tool-call-limit provider errors
claude Sep 1, 2026
ee7154e
fix(passthrough): exempt capability mismatches from the health penalty
claude Sep 1, 2026
a283928
Merge remote-tracking branch 'origin/main' into claude/noema-contextu…
claude Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions contextual_orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
_PROVIDER_TOOL_DESCRIPTION_LIMIT_MESSAGE = (
"each tool.function.description must be at most 1024 characters"
)
_SINGLE_TOOL_CALL_LIMIT_MESSAGE = "this model only supports single tool-calls at once"
DEFAULT_PROVIDER_PROBE_TIMEOUT = 5.0
MODEL_CAPABILITIES = frozenset(
{"text", "image", "video", "speech", "transcription", "embedding", "rerank", "audio"}
Expand Down Expand Up @@ -764,6 +765,29 @@ def _is_oversized_tool_description_error(error: urllib.error.HTTPError) -> bool:
)


def _is_single_tool_call_limit_error(error: urllib.error.HTTPError) -> bool:
"""Recognize a model that rejects a request making more than one tool call.

Some NVIDIA NIM-hosted models (observed: a vision-capable Llama variant)
reject any turn with more than one tool call, wrapping the sentence in a
longer agent-prefixed message under the generic ``invalid_request_error``
code rather than a distinct error code -- unlike the tool-description
limit above, the message text is the only reliable signal here.
"""
if error.code != 400:
return False
payload = _http_error_payload(error)
details = payload.get("error") if isinstance(payload, dict) else None
message = (
details.get("message")
if isinstance(details, dict)
else details
if isinstance(details, str)
else None
)
return isinstance(message, str) and _SINGLE_TOOL_CALL_LIMIT_MESSAGE in message.casefold()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.


def _provider_tool_execution_stopped(agent: ModelAgent) -> ToolFallbackStoppedError:
"""Convert the provider's terminal tool-stop contract to the public safe error."""
decision = classify_tool_failure(
Expand Down Expand Up @@ -1252,6 +1276,11 @@ def _is_passthrough_failover_error(exc: BaseException) -> bool:
and _is_provider_tool_description_limit_error(current)
):
return True
if (
isinstance(current, urllib.error.HTTPError)
and _is_single_tool_call_limit_error(current)
):
Comment thread
seonghobae marked this conversation as resolved.
return True
Comment thread
seonghobae marked this conversation as resolved.
if isinstance(current, socket.gaierror) and current.errno == socket.EAI_AGAIN:
return True
if current.__cause__ is not None:
Expand Down
55 changes: 55 additions & 0 deletions tests/test_passthrough_provider_failover.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,61 @@ def test_virtual_passthrough_fails_over_on_provider_tool_description_limit() ->
]


def test_virtual_passthrough_fails_over_on_single_tool_call_limit() -> None:
"""A model that rejects multi-tool-call turns advances to the next provider.

Reproduces the exact NVIDIA NIM litellm error shape observed live (Strix
scan, ContextualWisdomLab/naruon#1486, 2026-09-01): a generic
``invalid_request_error`` code (not ``invalid_tools``) with the model's
own capability-limit sentence embedded in a longer, agent-prefixed
message. Recognizing this is what stops that single-tool-call-only model
from ending an entire scan instead of the orchestrator just moving on to
a capability-matched agent.
"""
failure = _http_error(
400,
{
"error": {
"code": "invalid_request_error",
"message": (
"Model 'meta/llama-3.2-11b-vision-instruct' via agent "
"'nvidia_nim_meta_llama_3_2_11b_vision_instruct': This "
"model only supports single tool-calls at once! This "
"model only supports single tool-calls at once!. Adjust "
"the request parameters and retry."
),
}
},
)
client = SequencedProxyClient(
{
"primary_agent": failure,
"fallback_agent": {"model": "fallback-model"},
}
)
orchestrator = _build(client)
orchestrator.agents = [
replace(agent, tags=(*agent.tags, "cost:free")) for agent in orchestrator.agents
]

result = orchestrator.proxy_completion(
{
"model": TaskOrchestrator.FREE_MODEL,
"messages": [{"role": "user", "content": "use two tools at once"}],
"tools": [
{"type": "function", "function": {"name": "inspect", "description": "x"}},
{"type": "function", "function": {"name": "scan", "description": "y"}},
],
}
)

assert result["model"] == "fallback-model"
assert [agent_id for agent_id, _ in client.calls] == [
"primary_agent",
"fallback_agent",
]


@pytest.mark.parametrize(
"provider_error",
[
Expand Down
Loading