Skip to content

feat(failover): skip incompatible fallbacks before dispatch - #515

Open
yansigit wants to merge 2 commits into
pleaseai:mainfrom
yansigit:codex/capability-aware-fallback
Open

yansigit wants to merge 2 commits into
pleaseai:mainfrom
yansigit:codex/capability-aware-fallback

Conversation

@yansigit

@yansigit yansigit commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Independent of #509/#510. After the ordered chain is resolved, later fallbacks are dropped when the request needs a feature that adapter is known not to serve:

  • tools
  • images (base64 / URL)
  • structured output
  • explicit reasoning effort
  • a [1m]/[1M] context hint

The configured primary is always kept. A [1m]/[1M] hint currently excludes every fallback because no adapter advertises a verified 1M path. Exclusions are logged and counted as capability_excluded; they are not remembered as best failures.

This is a small, known-behavior matrix — not a general capability registry.

Test plan

  • cargo fmt --all --check
  • RUSTFLAGS=-D warnings cargo clippy --all-targets -- -D warnings
  • cargo test --lib proxy::capability
  • cargo test --test failover -- capability_filter

(--all-features clippy needs the admin SPA bundle from #503; default features pass.)


Summary by cubic

Filters incompatible fallbacks before dispatch so requests only reach upstreams that can serve the requested features. Previously all fallbacks were attempted regardless of capability; now later chain elements are dropped when the request needs tools, images, structured output, explicit reasoning effort, or a [1m]/[1M] context hint, while the configured primary is always kept. Native antigravity fallbacks stay in the chain when the request asks for explicit reasoning effort.

Notes

  • Image blocks nested inside tool results are also detected as image requirements.
  • Exclusions are logged and counted as capability_excluded; they are not remembered as best failures.
  • A [1m]/[1M] hint currently excludes every fallback because no adapter advertises a verified 1M path.
  • This is a small, known-behavior matrix, not a general capability registry.

Written for commit 3951475. Summary will update on new commits.

Keep the configured primary and drop later chain elements when the
request needs a feature that adapter is known not to serve: tools,
images, structured output, explicit reasoning effort, or a [1m]/[1M]
context hint. A 1M hint currently excludes every fallback. Exclusions
are logged and counted as capability_excluded; they are not remembered
as best failures.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 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-10T21:44:19.929528Z 3951475 New commits
ℹ️ 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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements capability-aware fallback filtering for heterogeneous upstream chains, skipping fallback adapters that do not support requested features like tools, images, structured output, reasoning effort, or 1M context hints. The changes include a new capability module, failover integration, metrics, updated multi-language documentation, and integration tests. The review feedback suggests refactoring the manual index loop in filter_fallbacks to use Rust's idiomatic drain method, which improves readability and avoids the performance overhead of repeated remove operations.

Comment thread src/proxy/capability.rs Outdated
Comment on lines +122 to +137
let mut index = 1;
while index < routes.len() {
let reasons = requirements.incompatibilities(&routes[index].adapter);
if reasons.is_empty() {
index += 1;
continue;
}
let route = routes.remove(index);
tracing::warn!(
provider = %route.provider,
model = %route.upstream_model,
reasons = %reasons.join(","),
"fallback excluded by request capabilities"
);
crate::metrics::record_failover(&route.provider, "capability_excluded");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

[MEDIUM] Refactor manual index loop to use idiomatic vector draining

Symptom: The filter_fallbacks function uses a manual while loop with index manipulation and routes.remove(index) to filter incompatible fallback routes in-place.
Source: McConnell — Code Complete (Ch. 12: General Loop Guidelines - simplifying loop controls and avoiding manual index manipulation).
Consequence: Manual index tracking and in-place vector shifting via remove increases cognitive load, is more prone to off-by-one errors during future maintenance, and incurs unnecessary O(N) element shifting overhead on each removal.
Remedy: Use routes.drain(1..).collect() to cleanly separate the fallbacks from the primary route, then iterate over them and push the compatible ones back. This is more idiomatic, safer, and more efficient.

let fallbacks: Vec<Route> = routes.drain(1..).collect();
    for route in fallbacks {
        let reasons = requirements.incompatibilities(&route.adapter);
        if reasons.is_empty() {
            routes.push(route);
        } else {
            tracing::warn!(
                provider = %route.provider,
                model = %route.upstream_model,
                reasons = %reasons.join(","),
                "fallback excluded by request capabilities"
            );
            crate::metrics::record_failover(&route.provider, "capability_excluded");
        }
    }

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because explicit-effort requests can lose a compatible native Antigravity fallback.

Fix All in Claude CodeFindings

  1. P1 Compatible Antigravity fallback excluded
Fix with agent prompt
### Issue 1
src/proxy/capability.rs:94-95
Native Antigravity routes use `AdapterKind::Gemini`, but they read `output_config.effort` and translate it into the model tier and `thinkingLevel`. This blanket check therefore removes a compatible Antigravity fallback whenever explicit effort is requested. If the primary fails, Shunt skips that viable route and may return an avoidable failure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

  • Extracts tool, image, structured-output, effort, and 1M-context requirements from each request.
  • Removes incompatible non-primary routes before entering the failover loop.
  • Adds focused unit and integration coverage for structured-output and 1M-context filtering.
  • The adapter-level matrix incorrectly excludes native Antigravity routes that support explicit reasoning effort.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Parsed request] --> B[Resolve ordered route chain]
    B --> C[Extract capability requirements]
    C --> D{Primary route?}
    D -->|Yes| E[Always retain]
    D -->|No| F{Adapter marked compatible?}
    F -->|Yes| G[Retain fallback]
    F -->|No| H[Exclude and record capability_excluded]
    E --> I[Dispatch surviving chain]
    G --> I
    H --> I
    I --> J[Normal failover classification]
Loading

Reviews (1) · Last reviewed commit: "feat(failover): skip incompatible fallba..."

Comment thread src/proxy/capability.rs
Comment on lines +94 to +95
if self.explicit_effort {
reasons.push("reasoning-effort");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Compatible Antigravity fallback excluded

Native Antigravity routes use AdapterKind::Gemini, but they read output_config.effort and translate it into the model tier and thinkingLevel. This blanket check therefore removes a compatible Antigravity fallback whenever explicit effort is requested. If the primary fails, Shunt skips that viable route and may return an avoidable failure.

Knowledge Base Used: Protocol and model translation

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/proxy/capability.rs
Line: 94-95

Comment:
**Compatible Antigravity fallback excluded**

Native Antigravity routes use `AdapterKind::Gemini`, but they read `output_config.effort` and translate it into the model tier and `thinkingLevel`. This blanket check therefore removes a compatible Antigravity fallback whenever explicit effort is requested. If the primary fails, Shunt skips that viable route and may return an avoidable failure.

**Knowledge Base Used:** [Protocol and model translation](https://app.greptile.com/passionfactory/-/custom-context/knowledge-base/pleaseai/shunt/-/docs/protocol-translation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 14 files

Architecture diagram
sequenceDiagram
    participant Client as API Client
    participant Proxy as Proxy Layer
    participant Cap as Capability Filter
    participant Route as Route Resolution
    participant Metrics as Metrics Service
    participant Adapter as Adapter Layer
    participant Upstream as Upstream Provider

    Note over Client,Upstream: Request Flow with Capability-Aware Failover

    Client->>Proxy: POST /v1/messages (model, tools, images, output_config)
    Proxy->>Route: Resolve ordered chain (primary + fallbacks)
    Route-->>Proxy: Routes list + requested_model
    
    Proxy->>Proxy: Parse request body
    Proxy->>Cap: filter_fallbacks(routes, body, requested_model)
    
    Note over Cap: Extract requirement flags from request
    Cap->>Cap: Check for tools, base64/url images, structured output, effort, [1m]/[1M] hint
    
    alt Request has capability requirements
        Cap->>Cap: Evaluate each fallback adapter against requirement matrix
        loop Each fallback in chain (index >= 1)
            alt Adapter incompatible with requirement
                Cap->>Metrics: record_failover(provider, "capability_excluded")
                Cap->>Cap: Remove route from chain
                Note over Cap: Log warning with provider, model, reasons
            else Adapter compatible
                Cap->>Cap: Keep route in chain
            end
        end
    end
    
    Note over Cap: Primary route is always preserved even if incompatible
    
    Cap-->>Proxy: Filtered routes list
    
    loop Each remaining route in chain order
        Proxy->>Adapter: Dispatch request to adapter
        Adapter->>Upstream: Forward request
        Upstream-->>Adapter: Response (status + body)
        
        alt Success (2xx)
            Adapter-->>Proxy: Return response
            Proxy-->>Client: Stream response
        else Failure (4xx/5xx)
            alt More routes remaining
                Adapter-->>Proxy: Error status
                Proxy->>Proxy: Advance to next route
            else Chain exhausted
                Adapter-->>Proxy: Best failure relayed
                Proxy-->>Client: Return best error response
            end
        end
    end
    
    Note over Client,Upstream: [1m]/[1M] hint excludes all fallbacks since no adapter advertises verified 1M path
Loading

Re-trigger cubic

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dffd16e63d

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/proxy/capability.rs
Comment on lines +94 to +95
if self.explicit_effort {
reasons.push("reasoning-effort");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep native Antigravity fallbacks for explicit effort

When a later kind = "antigravity" upstream is configured, routing maps it to AdapterKind::Gemini, so this branch excludes it whenever Claude Code sends output_config.effort. The native Antigravity path explicitly consumes that field in antigravity_upstream_model_with (src/adapters/gemini/mod.rs:218-228), meaning a compatible fallback is skipped and the primary failure is returned instead. Distinguish native Antigravity from ordinary Gemini through adapter-owned capability logic.

AGENTS.md reference: src/AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

Comment thread src/proxy/capability.rs Outdated
Comment on lines +87 to +90
AdapterKind::Gemini | AdapterKind::Cursor => {
if self.url_images {
reasons.push("url-images");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude Gemini for base64 images inside tool results

For a request containing a base64 image nested under tool_result.content, extraction sets base64_images, but the Gemini branch checks only url_images and leaves the fallback eligible. extract_tool_result_content then rejects that request as unsupported rich media (src/model/gemini_request.rs:291-294); because this is a gateway-local 400, the failover loop returns immediately and never reaches any later compatible Anthropic fallback. Track nested rich media separately and exclude Gemini for that case.

AGENTS.md reference: src/AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

Comment thread src/proxy/failover.rs Outdated
response: Box::new(error.into_response()),
}
})?;
super::capability::filter_fallbacks(&mut routes, body.json(), &requested_model);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass fallback filtering for count-tokens requests

When /v1/messages/count_tokens carries tools or images and the configured model has an incompatible later route, this call logs and increments capability_excluded before lines 74-79 truncate the chain to its primary. Count-token requests never attempt fallbacks, so repeated token estimation can produce false failover telemetry and warnings for routes that were never candidates; skip capability filtering on this endpoint or run it only after the count-token early path.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/proxy/capability.rs">

<violation number="1" location="src/proxy/capability.rs:137">
P2: Custom provider names are allowed, but `Route` erases whether `AdapterKind::Gemini` came from `ProviderKind::Antigravity`; this name-prefix check can drop valid custom native-Antigravity fallbacks for explicit-effort requests and retain ordinary Gemini fallbacks with an `antigravity-` name. Carry the provider kind or auth identity into this check instead of inferring it from `route.provider`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/proxy/capability.rs
}
}

fn is_native_antigravity(route: &Route) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Custom provider names are allowed, but Route erases whether AdapterKind::Gemini came from ProviderKind::Antigravity; this name-prefix check can drop valid custom native-Antigravity fallbacks for explicit-effort requests and retain ordinary Gemini fallbacks with an antigravity- name. Carry the provider kind or auth identity into this check instead of inferring it from route.provider.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/proxy/capability.rs, line 137:

<comment>Custom provider names are allowed, but `Route` erases whether `AdapterKind::Gemini` came from `ProviderKind::Antigravity`; this name-prefix check can drop valid custom native-Antigravity fallbacks for explicit-effort requests and retain ordinary Gemini fallbacks with an `antigravity-` name. Carry the provider kind or auth identity into this check instead of inferring it from `route.provider`.</comment>

<file context>
@@ -114,26 +134,30 @@ impl Requirements {
     }
 }
 
+fn is_native_antigravity(route: &Route) -> bool {
+    route.adapter == AdapterKind::Gemini
+        && (route.provider == "antigravity" || route.provider.starts_with("antigravity-"))
</file context>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39514753e7

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/proxy/capability.rs
Comment on lines +138 to +139
route.adapter == AdapterKind::Gemini
&& (route.provider == "antigravity" || route.provider.starts_with("antigravity-"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve native Antigravity from configured kind

With a valid [[upstreams]] entry such as name = "backup" and kind = "antigravity", routing collapses the kind to AdapterKind::Gemini, so this name heuristic returns false and removes the otherwise compatible fallback whenever output_config.effort is present. The chain contract explicitly permits same-kind upstreams under arbitrary distinct names (docs/upstreams-failover.md:106-107). Fresh evidence after the earlier review is that the new helper bases the exception solely on reserved-looking provider names; retain the configured kind on Route or query the provider config instead.

AGENTS.md reference: AGENTS.md:L43-L43

Useful? React with 👍 / 👎.

Comment thread src/proxy/capability.rs
if self.structured_output {
reasons.push("structured-output");
}
if self.explicit_effort && !is_native_antigravity(route) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict Antigravity effort compatibility to Gemini models

When a native Antigravity fallback maps to a supported non-Gemini ID such as claude-sonnet-4-6 or gpt-oss-120b-medium, this blanket exception keeps it for an explicit-effort request, but antigravity_upstream_model_with documents that those IDs are returned untouched (src/model/antigravity_request.rs:209-214), and the Gemini request translator does not map output_config.effort; a successful response therefore silently ignores the requested effort and prevents a later Responses-compatible fallback from being tried. Fresh evidence after the earlier review is that the newly added exception applies to every native Antigravity model rather than only the gemini-* IDs whose tier is actually resolved.

AGENTS.md reference: src/AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

@amondnet amondnet 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.

Thanks for this — the shape of the change is right, and I want to say what I think it gets correct before the two things I'd like changed.

What holds up well. Filtering before dispatch (rather than mid-loop) keeps this entirely clear of the post-2xx no-replay boundary in §3 of docs/upstreams-failover.md: excluded routes never enter the attempt loop, so the claim that capability_excluded is "not remembered as best failures" is true by construction rather than by discipline. The is_count_tokens guard is the right carve-out. And the AntigravityCli + tools exclusion is genuinely valuable — reject_caller_tools returns a hard 400, so this converts a guaranteed failure into a skip. I spot-checked the Cursor + url-images claim too and it's accurate (src/adapters/cursor/request.rs:57: "Only base64 source images are included. URL images are skipped."). Docs are updated across README + locales, docs/, and the site, and the branch merges cleanly with main.

Two changes requested.


1. The [1m]/[1M] rule excludes every fallback on a hint that never reaches an upstream

Requirements::extract sets million_context from the requested model id, and incompatibilities early-returns vec!["1m-context-unknown"] before the adapter match — so it fires for every adapter, including Anthropic, which the matrix otherwise treats as excluding nothing.

I don't think the premise holds. Per docs/running.md:1012-1016, [1m] is a client-side lever that raises Claude Code's own context-window / auto-compact threshold, and shunt "strips a trailing [1m] from the model id before route matching and before forwarding upstream (routing.rs), so … the provider never sees the suffix." It isn't a request feature an upstream has to serve — it's a display/compaction setting on the client.

Three consequences:

  • It silently disables failover for the project's canonical setup. An anthropicanthropic pool chain (the M8 multi-account case) loses its fallback entirely the moment a user types [1m]. The new test capability_filter_keeps_primary_but_suppresses_fallback_for_1m_requirement bakes that in as a 500.
  • It hits ids this repo's own docs recommend. kimi-k3[1m] appears at docs/running.md:237, :298, and :306 as the documented Kimi model id.
  • The protection is incoherent even on its own terms. The primary is always kept, so a small-window primary is fine while a small-window fallback is not — for a suffix that changes nothing about what either one receives.

The reason string "1m-context-unknown" is, I think, an honest admission of the problem: it fires on absence of knowledge, which contradicts the module's own stated scope — "only for request features whose current adapter behavior is already known." Every other rule in the matrix mirrors a verified adapter behavior; this one mirrors a gap.

My suggestion is to drop million_context entirely for now. If there's a real truncation concern behind it, it wants a context-window field on the route (and would apply to the primary too), not a suffix gate — and that's a bigger change than this PR is scoped for.

2. The AntigravityCli tools rule doesn't mirror reject_caller_tools

eligibility_matrix_matches_existing_adapter_fidelity names the contract, but tool_choice appears nowhere in capability.rs, while reject_caller_tools (src/adapters/antigravity/mod.rs:632-648) keys off both. That splits two ways:

  • tools present and tool_choice: {"type": "none"} → the adapter explicitly accepts (it returns Ok(()) before even looking at tools), but the filter excludes. A viable fallback is dropped.
  • no tools but tool_choice: {"type": "any"} or {"type": "tool"} → the adapter rejects with a 400, but the filter keeps it. This is the case the gate most wants to catch, and it misses it.

Mirroring it is small — read tool_choice.type, return early on none, and treat any/tool as requiring tools:

let choice_type = request.pointer("/tool_choice/type").and_then(Value::as_str);
let tools = choice_type != Some("none")
    && (matches!(choice_type, Some("any" | "tool"))
        || request.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty()));

Worth a test where tool_choice alone drives the verdict in each direction — otherwise the mirror can drift again without anything going red.


Neither of these touches the architecture, which I think is sound. Fix these two and I'm happy to land it.

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