Skip to content

fix(passthrough): fail over on single-tool-call-limit provider errors - #986

Closed
seonghobae wants to merge 18 commits into
mainfrom
claude/noema-contextualwisdomlab-commercialization-afow1j
Closed

fix(passthrough): fail over on single-tool-call-limit provider errors#986
seonghobae wants to merge 18 commits into
mainfrom
claude/noema-contextualwisdomlab-commercialization-afow1j

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

Summary

  • Some NVIDIA NIM-hosted models (observed live: a vision-capable Llama variant, surfaced via a Strix scan on ContextualWisdomLab/naruon#1486) reject any turn that makes more than one tool call.
  • The rejection comes back as a generic invalid_request_error (not a distinct error code), with the model's own capability-limit sentence embedded in a longer, agent-prefixed message — so the message text is the only reliable signal, similar in shape to the existing _is_provider_tool_description_limit_error carve-out.
  • Without recognizing this, _is_passthrough_failover_error did not treat it as a safe-to-fail-over condition, so the orchestrator raised instead of moving on to the next capability-matched agent — ending an entire scan on one model's tool-call-count limitation.
  • Adds _SINGLE_TOOL_CALL_LIMIT_MESSAGE and _is_single_tool_call_limit_error(error) (mirroring the existing tool-description-limit classifier), and wires it into _is_passthrough_failover_error alongside the other narrow, deliberate 400-class failover carve-outs.

Test plan

  • New regression test test_virtual_passthrough_fails_over_on_single_tool_call_limit in tests/test_passthrough_provider_failover.py, reproducing the exact observed NVIDIA NIM litellm error shape (invalid_request_error code, agent-prefixed message containing "This model only supports single tool-calls at once!").
  • Verified genuine RED before the fix (ProviderUpstreamError raised instead of failing over to the next agent) and GREEN after (53/53 passed in this file).
  • Full suite: python -m pytest tests -q — 2824 passed, 1 skipped, 1 failed. The one failure (tests/test_psychometric_routing.py::test_fast_mlsirm_fit_uses_judge_acceptance_item_for_context_score, ModuleNotFoundError: No module named 'fast_mlsirm') is a pre-existing optional-dependency gap in this sandbox, unrelated to this change — this PR touches only contextual_orchestrator/orchestrator.py and tests/test_passthrough_provider_failover.py.

Generated by Claude Code

claude and others added 16 commits August 30, 2026 11:13
discover_provider_models made exactly one HTTP attempt per provider and
raised ProviderDiscoveryError immediately on any transient failure,
zeroing out that whole provider's contribution to the discovery pass.
Observed live: a single Bytez HTTP 500 during a hosted noema-review run
propagated all the way to the org's shared orchestrator/free review
pool failing closed for every consumer repo, even though Bytez models
were never free-eligible in the first place (a separate, pre-existing
fact unrelated to this specific incident).

Reuse the existing is_transient_error classifier (already trusted for
completion-call retries) to add one bounded retry -- short fixed delay,
shortened timeout -- for exactly the 5xx/timeout/connection-reset class
that a retry can actually fix. Non-transient failures (bad credential,
malformed response) are never retried, matching existing behavior.

Deliberately out of scope: ModelClient.proxy_send_once's single-shot
completion-call guarantee ("cross-provider failover cannot amplify
load") is left untouched. Retrying there too was considered and
rejected after review found it risks stacking latency past CI callers'
own time budgets and reintroducing exactly the request amplification
that guarantee exists to prevent.
Devin review on #923: the retry attempt hardcoded
_DISCOVERY_RETRY_TIMEOUT_SECONDS (5.0s) regardless of what timeout the
caller requested, so a caller budgeting e.g. 2s per attempt could see
the retry alone exceed that budget. Use min(timeout,
_DISCOVERY_RETRY_TIMEOUT_SECONDS) instead.
Root-caused a live Strix required-check failure blocking contextual-orchestrator#923:
openai.BadRequestError 400 invalid_stream_options, "stream_options.include_usage=true
is not supported with tools or response_format" -- raised by this gateway's own
/v1/chat/completions validation, not an upstream provider limitation. Strix's
openai-agents SDK always sends tools + stream_options.include_usage=true together,
which is normal agentic-client behavior.

proxy_completion's single-agent tool passthrough always fetches a complete,
non-streamed upstream response (upstream["stream"] = False) and frames it locally
via _chat_response_sse_chunks, which already emits a real, honestly-labeled usage
chunk (usage_source: reported/estimated) alongside tool-call deltas -- the
combination was already fully supported downstream, so the upfront rejection was
a stale, avoidable restriction. Narrowed the check to still reject
response_format's separate multi-agent "conduct" path, which has no equivalent
aggregate-usage story yet.

Updated the two tests whose docstrings encoded the old (incorrect) assumption
that "structured passthrough cannot emit usage SSE" -- both now verify the tools
case returns a real 200 SSE with a usage chunk, while response_format-only still
fails closed with invalid_stream_options.

Verified: targeted files 15 passed; full suite run in progress.
…-commercialization-afow1j' into claude/noema-contextualwisdomlab-commercialization-afow1j
…ansient_error

Devin review on #923 found: urlopen wraps a TLS handshake's
ssl.SSLCertVerificationError as URLError(reason=...), not as a bare
ssl.SSLError. is_transient_error's blanket "any URLError is transient"
branch matched first and returned True before the existing bare-SSLError
unwrap could ever see it, so a permanently invalid certificate was being
retried as if it were a network blip.

Fixed the shared classifier itself (not just the discovery retry call
site Devin's suggested diff targeted), so every current and future caller
benefits. Added a regression test covering both the previously-broken case
and that an ordinary URLError(ConnectionResetError(...)) is still
correctly transient.

Verified: targeted test_provider_reliability.py 25 passed; full suite
run in progress.
…alwisdomlab-commercialization-afow1j

# Conflicts:
#	contextual_orchestrator/server.py
#	tests/test_chat_tools_passthrough_controls_http_honesty.py
#	tests/test_stream_options_null_flags_noop_http_honesty.py
…alwisdomlab-commercialization-afow1j

# Conflicts:
#	contextual_orchestrator/model_discovery.py
…alwisdomlab-commercialization-afow1j

# Conflicts:
#	CHANGELOG.md
#	contextual_orchestrator/model_discovery.py
Two real gaps Devin's review found in #923's own retry loop, both
capable of crashing the entire discover_all_models sweep instead of
isolating a single provider's failure:

- provider_error_body's exc.read() had no guard, so a stalled/dropped
  connection raising http.client.IncompleteRead (not an OSError
  subclass) during is_transient_error's HTTP-error-body inspection
  would escape classification entirely. Now degrades to an empty body
  on any read failure, matching safe_provider_message's existing
  "bodies are untrusted input" handling.
- discover_provider_models's retry loop didn't catch RuntimeError, but
  ModelClient._resolve_addresses (used by the configured_gateway
  transport) wraps a DNS resolution failure as plain RuntimeError. Now
  caught and classified as transport_error, isolated to that provider.
…r wrapper

Devin's follow-up on the prior DNS-isolation fix: ModelClient._resolve_addresses
wraps socket.gaierror as plain RuntimeError, so is_transient_error rejected it
outright -- a genuinely temporary DNS hiccup (EAI_AGAIN) was isolated instead
of retried, unlike every other transient failure this PR retries.

is_transient_error now unwraps a RuntimeError's __cause__ and defers to the
existing EAI_AGAIN check when it's a socket.gaierror; any other RuntimeError
(malformed URL, no resolvable address) still falls through to non-transient.
Full-suite run surfaced two failures #941 (which removed
model_discovery._provider_family) missed:

- test_discovery_bootstrap_selection.py's nim-primary/sub test asserted
  the old family-collapsing outcome for select_bootstrap_discovered_agents
  (a sibling of provider_bootstrap.py's select_provider_diverse_models,
  which #941 did update) -- nvidia_nim and nvidia_nim_sub are independent
  providers now, so both occupy first-pass diversity slots directly.
- test_model_discovery_boundaries.py's "unclassified failure" contract
  test used a plain RuntimeError as its example of an unclassified type,
  but RuntimeError is now deliberately classified (transport_error, for
  the configured-gateway DNS/validation wrapper). Switched to KeyError,
  a type genuinely outside every classified branch.
…alwisdomlab-commercialization-afow1j

# Conflicts:
#	tests/test_discovery_bootstrap_selection.py
Some NVIDIA NIM-hosted models (observed live: a vision-capable Llama
variant, ContextualWisdomLab/naruon#1486 Strix scan) reject any turn
with more than one tool call. The rejection surfaces as a generic
invalid_request_error (not a distinct error code), with the model's
own capability-limit sentence embedded in a longer agent-prefixed
message -- so the message text is the only reliable signal.

Without recognizing this, the orchestrator raised instead of moving
on to the next capability-matched agent, ending the whole scan on a
single model's tool-call-count limitation.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 56 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 1dcc683b-119e-4461-a0b1-38aebea6541e

📥 Commits

Reviewing files that changed from the base of the PR and between c6c3a0c and ee7154e.

📒 Files selected for processing (2)
  • contextual_orchestrator/orchestrator.py
  • tests/test_passthrough_provider_failover.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

seonghobae pushed a commit to ContextualWisdomLab/.github that referenced this pull request Sep 1, 2026
…ling

The org's standing directive requires central Strix/OpenCode/Noema scans
to get at least a 3-hour floor (observed real runs go well past that).
Strix's own budget was 150-minute process / 155-minute total (obfuscated
via budget_suffix, per contract, so the literal env var names never
appear in workflow logs), bounded by a 170-minute step and 200-minute
job -- 155 minutes falls short of the 3-hour floor.

Raise process/total/step/job proportionally to the actual maximum job
execution time GitHub-hosted runners allow (6 hours): job 200->360
(the platform ceiling itself), step 170->330, total budget 9300->18900s
(315 min), process budget 9000->18600s (310 min) -- preserving the
original buffer ratios between each layer. Update the matching
scripts/ci/test_strix_quick_gate.sh contract assertions in lockstep.

Also records this investigation, plus the contextual-orchestrator
single-tool-call-limit failover fix (ContextualWisdomLab/contextual-orchestrator#986)
and confirmation that Strix already scans the full codebase and the
@opencode-agent mention convention is already correct, in
docs/product-technical-gap-baseline.md.
@seonghobae
seonghobae marked this pull request as ready for review September 1, 2026 02:22
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T02:25:18.000868Z daf43e5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Codex Review (#986): the new
_is_single_tool_call_limit_error was wired into the failover decision
(_is_passthrough_failover_error) but not into the exemption that keeps a
failure from tripping the circuit breaker or counting as a failed
stability observation. A model rejecting a request shape it can never
support (too many tool descriptions, more than one tool call per turn)
says nothing about that model's health for a differently-shaped future
request -- unlike a reliability failure, it must not penalize the model.

Add _is_capability_mismatch_failover_error(exc), covering both
_is_provider_tool_description_limit_error and
_is_single_tool_call_limit_error, and check it alongside the existing
_is_request_too_large_error exemption at the health-penalty call site.
every_failure_was_request_too_large (which decides whether to raise
ProviderRequestTooLargeError when every candidate failed) is untouched
-- it has a distinct, size-specific meaning.

New parametrized test
(test_capability_mismatch_failover_does_not_penalize_the_model) proves
the circuit breaker stays clean for both capability-mismatch shapes.
Verified genuine RED on the single-tool-call-limit case before the fix
(the circuit recorded a real failure) and GREEN after.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment thread contextual_orchestrator/orchestrator.py
Comment thread contextual_orchestrator/orchestrator.py

Copy link
Copy Markdown
Contributor Author

The noema-review required check just failed with a Python traceback (json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes), not a review verdict — this isn't this PR's diff at fault.

Root cause: noema-review fetches scripts/ci/noema_review_gate.py at CI time as a trusted tarball from ContextualWisdomLab/.github's main branch (workflow_sha: 7b1a028e70...), since sibling repos can't be trusted to modify the org's central review logic themselves. That version of extract_json_object() does a bare json.loads(stripped) with no exception handling around it — if the selected free-tier model (this run picked meta/llama-3.2-11b-vision-instruct) returns a verdict with malformed JSON, the whole job crashes with an uncaught exception instead of failing closed or retrying, and the required check goes red for a reason entirely unrelated to whatever's actually being reviewed.

This exact defect is already fixed in ContextualWisdomLab/.github#1438 (not yet merged to .github main) — that branch's noema_review_gate.py wraps JSON extraction with a bounded repair-retry instead of crashing. There's nothing to port into this PR's own diff since the defective code doesn't live in this repository at all; it's fetched externally from .github's trusted main. Once .github#1438 merges, this failure mode goes away for every repo in the org, this one included.

I've triggered one re-run of the failed job as due diligence — the specific JSON malformation depends on which free-tier model got selected this time, so a re-run has a real chance of landing on a model that returns well-formed JSON and passing incidentally. That would not fix the underlying issue (still present on .github main until #1438 merges), just avoid tripping it this once. I'm actively driving .github#1438 to merge; will keep this PR watched until noema-review is reliably green on it.


Generated by Claude Code

seonghobae pushed a commit that referenced this pull request Sep 1, 2026
Codex Review (#986): the new
_is_single_tool_call_limit_error was wired into the failover decision
(_is_passthrough_failover_error) but not into the exemption that keeps a
failure from tripping the circuit breaker or counting as a failed
stability observation. A model rejecting a request shape it can never
support (too many tool descriptions, more than one tool call per turn)
says nothing about that model's health for a differently-shaped future
request -- unlike a reliability failure, it must not penalize the model.

Add _is_capability_mismatch_failover_error(exc), covering both
_is_provider_tool_description_limit_error and
_is_single_tool_call_limit_error, and check it alongside the existing
_is_request_too_large_error exemption at the health-penalty call site.
every_failure_was_request_too_large (which decides whether to raise
ProviderRequestTooLargeError when every candidate failed) is untouched
-- it has a distinct, size-specific meaning.

New parametrized test
(test_capability_mismatch_failover_does_not_penalize_the_model) proves
the circuit breaker stays clean for both capability-mismatch shapes.
Verified genuine RED on the single-tool-call-limit case before the fix
(the circuit recorded a real failure) and GREEN after.

Copy link
Copy Markdown
Contributor Author

Merged current main (c6c3a0c94d143601) into this branch to clear the "behind" mergeable state — clean merge, no conflicts.

Re-ran the full suite post-merge and saw 5 failures: test_orchestrated_responses_stream.py::test_virtual_models_stream_openai_reasoning_summaries[orchestrator/free], test_http_virtual_responses_preserves_message_array_and_sampling_controls, test_stream_failure_emits_terminal_responses_event, test_spend_analytics.py::test_exact_output_without_prompt_usage_is_explicitly_unavailable, and the already-documented test_psychometric_routing.py::test_fast_mlsirm_fit_uses_judge_acceptance_item_for_context_score (missing optional fast_mlsirm dependency in this sandbox).

Verified the other 4 are pre-existing on main itself, unrelated to this PR: cloned a fresh checkout of exact current main@4d143601 (no involvement of this branch's diff at all) and ran those 4 tests directly — all 4 fail identically there too. This PR's own diff (contextual_orchestrator/orchestrator.py, tests/test_passthrough_provider_failover.py) doesn't touch spend analytics, the Responses-API streaming path, or psychometric routing, and test_passthrough_provider_failover.py itself still passes clean (59/59).

Full suite after merge: 5 failed, 3301 passed, 2 skipped. This PR's own test file: 59/59 passed.


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 0 new potential issues.

Devin Review

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

자동 정리: base 대비 실제 변경(diff)이 0건이라 이 PR을 닫습니다. 변경을 추가한 뒤 reopen하세요.

@github-actions github-actions Bot closed this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants