Skip to content

fix(proxy): dispatch Model Groups across passthrough endpoints (#471) - #472

Merged
nic-6443 merged 3 commits into
mainfrom
fix/issue-471-messages-model-group
Jun 2, 2026
Merged

fix(proxy): dispatch Model Groups across passthrough endpoints (#471)#472
nic-6443 merged 3 commits into
mainfrom
fix/issue-471-messages-model-group

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Problem

A Model Group (routing model) accessed through the Anthropic / OpenAI passthrough endpoints failed instead of dispatching:

  • /v1/messages400 model "<MG>" has no provider_key_id (routing models can't be dispatched directly)
  • /v1/responses400 model "<MG>" is not an OpenAI provider
  • /v1/messages/count_tokens400 model "<MG>" is not an Anthropic provider

All three resolved the requested Model and dispatched it directly (or applied a provider gate) without walking routing.targets. A routing model has no provider/provider_key_id, so it never reached target resolution. The same group worked on /v1/chat/completions, which already walks targets. Reported by a customer whose group mixed an Anthropic and an OpenAI provider.

Fix

  • Extract the routing-target resolution chat.rs already had (pick_targets + health/cooldown filter) into routing::resolve_attempt_models, shared by all handlers.
  • /v1/messages: resolve the attempt list and dispatch each target by its provider — Anthropic passthrough or cross-provider translation. A group mixing Anthropic and OpenAI targets now serves correctly.
  • /v1/responses (OpenAI-only) and /v1/messages/count_tokens (Anthropic-only): resolve the attempt list, then attempt the targets whose provider matches the endpoint, in order. A group with no matching-provider target still returns the endpoint's 400.
  • Non-streaming requests fail over across matching targets on retryable upstream failures (retry_on_429 honored); streaming attempts the first matching target only (matching chat completions).
  • Health is recorded against the resolved target's display_name (the served target), not the group alias — the id-keyed runtime status was already correct.

Behavior change

  • Routing-model names now dispatch on /v1/messages, /v1/responses, and /v1/messages/count_tokens instead of returning 400. Direct (non-routing) models are unchanged — their dispatch paths and error messages are preserved.

Tests

  • New DP E2E (messages-model-group-e2e.test.ts): mixed-provider group over /v1/messages (Anthropic target, OpenAI target via cross-provider, cross-protocol failover), an Anthropic group over /v1/messages/count_tokens, and an OpenAI group over /v1/responses. Each case fails before the change (400) and passes after.
  • Existing aisix-proxy unit tests (356) and the full DP e2e suite (60 files / 121 tests) pass.

Docs

  • routing-and-failover.md: routing aliases work across all four passthrough endpoints, including mixed-provider groups, the provider-restricted behavior of /v1/responses + /v1/messages/count_tokens, and the streaming-first-target caveat.

Summary by CodeRabbit

  • New Features

    • Model-group routing expanded: /v1/messages, /v1/chat, /v1/responses and token-count endpoints now resolve ordered attempt targets with consistent failover behavior (streaming uses primary only; non-streaming can fail over).
    • Anthropic targets support byte-for-byte passthrough; other providers use cross-provider translation.
  • Bug Fixes / Behavior

    • Consistent request handling: handlers treat incoming bodies immutably and attribute health/usage to resolved targets.
    • Token-count now requires an Anthropic-backed target.
  • Documentation

    • Clarified routing, failover, and header emission behavior for /v1/messages.
  • Tests

    • Added end-to-end coverage for model-group routing and failover.

The Anthropic Messages handler resolved the requested Model and called
resolve_provider_key on it directly. For a routing model (Model Group)
that has no provider_key_id, so any /v1/messages request targeting a
group failed with 400 "has no provider_key_id (routing models can't be
dispatched directly)" — while the same group worked on
/v1/chat/completions.

Extract the routing-target resolution chat.rs already had
(pick_targets + health filter) into routing::resolve_attempt_models and
reuse it in messages.rs. The Messages handler now walks routing.targets,
dispatching each target through the right path by its provider —
Anthropic passthrough or cross-provider translation — so a group mixing
Anthropic and OpenAI providers serves correctly over /v1/messages.
Streaming attempts the first target only (matching chat completions);
non-streaming fails over across targets on retryable upstream failures.

Adds DP E2E coverage for a mixed-provider group over /v1/messages
(Anthropic target, OpenAI target, and cross-protocol failover); all
three fail before the change and pass after.
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3907bf65-d3bc-4c5b-871f-713e333bb44d

📥 Commits

Reviewing files that changed from the base of the PR and between fb43af6 and 70cea79.

📒 Files selected for processing (4)
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/responses.rs
  • docs/configuration/routing-and-failover.md
  • tests/e2e/src/cases/messages-model-group-e2e.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/configuration/routing-and-failover.md

📝 Walkthrough

Walkthrough

This PR extends the routing and failover system to support the /v1/messages endpoint by extracting routing-candidate filtering logic into reusable helpers in crate::routing, refactoring chat.rs to use shared infrastructure, implementing multi-target dispatch with streaming/non-streaming branching in messages.rs, and validating the feature with comprehensive E2E tests.

Changes

Model Group Routing and Failover for /v1/Messages

Layer / File(s) Summary
Routing infrastructure: filtering and resolution
crates/aisix-proxy/src/routing.rs
Introduces AttemptModel and FilterOutcome types to represent concrete routing targets and filtering outcomes. filter_attempt_models classifies candidates by runtime health status (healthy/cooldown/unhealthy) and applies selection policy logic; resolve_attempt_models orchestrates virtual-to-concrete target resolution with snapshot validation. Unit tests cover healthy-only selection, cooldown preference, all-unhealthy fallback with configurable retry-after, and original-order policies.
Messages endpoint routing dispatch
crates/aisix-proxy/src/messages.rs
Implements /v1/messages routing by resolving attempt models, branching on streaming mode (first-target-only for streaming, retry loop for non-streaming with retry_on_429 gating). Introduces dispatch_to_target helper that branches by provider: Anthropic uses passthrough path with provider-key request overrides (param_renames, param_constraints, default_body_fields) and default headers; other providers use cross-provider translation. Cooldown/health tracking keyed by resolved model_id/model_name, streaming usage callbacks capture updated identifiers, non-streaming responses rewrite model field and set provider_key_id.
Chat endpoint migration to shared routing
crates/aisix-proxy/src/chat.rs
Refactors to import and use resolve_attempt_models and AttemptModel from crate::routing. Replaces inline routing-target resolution and filtering loop with single resolve_attempt_models call in dispatch handler. Removes duplicate local implementations of AttemptModel, FilterOutcome, filter_attempt_models, and associated unit tests.
Count tokens route: immutable body and Anthropic-only routing
crates/aisix-proxy/src/count_tokens.rs
Handler treats incoming JSON as immutable; dispatch resolves routing-aware attempt models but serves count_tokens only from Anthropic targets (skips non-Anthropic in mixed groups), applies provider-key request.* overrides on a cloned outbound body, and records cooldown/health using the resolved model_id. Returns 400 if no Anthropic target exists.
Responses route: immutable body and OpenAI-only routing
crates/aisix-proxy/src/responses.rs
Handler treats incoming JSON as immutable; dispatch resolves routing-aware OpenAI attempt targets (streaming: first-target-only; non-streaming: iterate with optional retry_on_429). Upstream requests use .json(&body) and telemetry/cooldown/success attribution use the resolved model_id/display_name consistently.
Routing and failover documentation updates
docs/configuration/routing-and-failover.md
Clarifies that routing aliases dispatch across both /v1/chat/completions and /v1/messages with mixed-provider groups. Specifies streaming requests attempt only first target while non-streaming requests can failover across targets. Notes x-aisix-served-by header is not currently emitted on /v1/messages.
End-to-end tests for model group routing on /v1/messages
tests/e2e/src/cases/messages-model-group-e2e.test.ts
Validates model group routing via /v1/messages with mock Anthropic and OpenAI upstreams: tests Anthropic group target reachability, OpenAI cross-provider target via message translation, cross-protocol failover (OpenAI 502 → Anthropic serves response), plus count_tokens and responses group coverage.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • api7/ai-gateway#363: Modifies /v1/messages streaming SSE translation and telemetry paths that overlap with the streaming dispatch refactoring in this PR.
  • api7/ai-gateway#378: Adds per-target routing attempt telemetry into chat routing dispatch, directly connected through the shared resolve_attempt_models infrastructure introduced here.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: enabling Model Groups (routing models) to be dispatched across passthrough endpoints like /v1/messages. The scope is specific and directly reflects the primary intent of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/aisix-proxy/src/messages.rs (1)

548-580: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep streaming UsageEvent.model_id aligned with non-streaming.

These callbacks capture the concrete target id, while the non-streaming and error paths emit the requested model's id from messages(). For a model group, the same alias will land under different model_ids depending on stream, which makes /v1/messages usage attribution inconsistent. Thread the outer virtual model id into these callbacks and keep only provider_key_id / upstream_model target-specific.

Suggested fix
 async fn dispatch(
     state: &ProxyState,
     auth: &AuthenticatedKey,
     body: &mut Value,
     request_id: &str,
     started: Instant,
 ) -> Result<DispatchOutcome, ProxyError> {
@@
     if is_stream {
         return dispatch_to_target(
             state,
             &snapshot,
             body,
             &attempt_models[0],
+            &model_entry.id,
             &model_name,
             request_id,
             started,
             &auth.entry.id,
             auth.key().team_id.clone(),
@@
 async fn dispatch_to_target(
     state: &ProxyState,
     snapshot: &aisix_core::AisixSnapshot,
     body: &Value,
     target: &crate::routing::AttemptModel,
+    virtual_model_id: &str,
     model_name: &str,
     request_id: &str,
     started: Instant,
     api_key_id: &str,
     team_id: Option<String>,
@@
-        let model_id_c = model_id.to_string();
+        let model_id_c = virtual_model_id.to_string();
@@
-        let model_id_for_telem = model_id.to_string();
+        let model_id_for_telem = virtual_model_id.to_string();

Also applies to: 800-828

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/messages.rs` around lines 548 - 580, The streaming
callback currently captures the concrete target model id (model_id_c) which
makes UsageEvent.model_id diverge from the non-streaming/messages() path; update
the closure passed to build_anthropic_passthrough_stream so it captures and uses
the outer virtual model id (e.g., create virtual_model_id_c from the outer
requested model id) and pass that virtual_model_id_c into
emit_anthropic_usage_event instead of model_id_c, while still leaving
provider_key_id_c and upstream_model_c as the target-specific values; apply the
same change to the other streaming callbacks in the same file (the block around
the 800-828 range) so streaming and non-streaming usage attribution use the same
model_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/messages.rs`:
- Around line 343-373: The health tracking is using the caller-facing model_name
instead of the concrete backend target; update the dispatch calls
(cross_provider_dispatch and anthropic_passthrough_dispatch) to pass
model.display_name (or a dedicated resolved_target_name parameter) in place of
model_name for health/health-update purposes, while keeping model_name unchanged
for response rewriting/telemetry; adjust the parameter list and any downstream
health-update usage to read the new resolved name (e.g., resolved_target_name or
model.display_name) so health is recorded against the actual backend target that
served the request.

In `@crates/aisix-proxy/src/routing.rs`:
- Around line 186-222: The code decides "cooldown beats unhealthy" using
runtime_status.status_with_stale but then re-reads runtime_status inside the
closure passed to .filter, which can race and yield an empty selection; fix by
reading runtime status once and reusing those results when building the filtered
list: before the if unhealthy_count < attempts.len() &&
!cooldown_only.is_empty() compute a local map (or set) of attempt.id -> status
(using runtime_status.status_with_stale or
runtime_status.should_skip_for_routing once per attempt) or simply filter from
the already-collected cooldown_only/healthy vectors, then use that precomputed
status map (refer to runtime_status.status_with_stale,
runtime_status.should_skip_for_routing, attempts, unhealthy_count,
cooldown_only) to build the filtered Vec<AttemptModel> so no further
runtime_status queries occur inside the filter closure.

In `@tests/e2e/src/cases/messages-model-group-e2e.test.ts`:
- Around line 292-306: The test currently assumes exact request-count deltas for
OpenAI and Anthropic in the mg-failover scenario which can flake; update the
assertion logic in tests/e2e/src/cases/messages-model-group-e2e.test.ts (around
waitUntilGroupRoutable, callMessages and the openaiDown/anthropicGood baselines)
to be more robust by either retrying callMessages until the expected response is
observed or relaxing the checks from strict equality to a minimum (e.g.,
expect(... - baseline).toBeGreaterThanOrEqual(1)) so transient extra probes
won't fail the test.

---

Outside diff comments:
In `@crates/aisix-proxy/src/messages.rs`:
- Around line 548-580: The streaming callback currently captures the concrete
target model id (model_id_c) which makes UsageEvent.model_id diverge from the
non-streaming/messages() path; update the closure passed to
build_anthropic_passthrough_stream so it captures and uses the outer virtual
model id (e.g., create virtual_model_id_c from the outer requested model id) and
pass that virtual_model_id_c into emit_anthropic_usage_event instead of
model_id_c, while still leaving provider_key_id_c and upstream_model_c as the
target-specific values; apply the same change to the other streaming callbacks
in the same file (the block around the 800-828 range) so streaming and
non-streaming usage attribution use the same model_id.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 64787718-0ba8-40c1-8666-7c03556d82cb

📥 Commits

Reviewing files that changed from the base of the PR and between 71bb76b and 1c6c7c6.

📒 Files selected for processing (5)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/routing.rs
  • docs/configuration/routing-and-failover.md
  • tests/e2e/src/cases/messages-model-group-e2e.test.ts

Comment thread crates/aisix-proxy/src/messages.rs
Comment thread crates/aisix-proxy/src/routing.rs Outdated
Comment thread tests/e2e/src/cases/messages-model-group-e2e.test.ts
…re-read

Address review on #472:

- /v1/messages dispatch recorded display-name-keyed health against the
  caller-facing model name. For a routing group that is the alias, not
  the target that served the request, so the served target's health was
  never updated. Record against the resolved target's display_name (the
  id-keyed runtime_status was already correct), matching chat.rs.
- filter_attempt_models re-read runtime_status inside the cooldown
  branch's filter closure after classifying from an earlier read. A
  state flip between the two reads could yield an empty Selected list,
  which streaming callers turn into a panic by indexing
  attempt_models[0]. Reuse the single classification read — with no
  healthy candidates, the non-unhealthy set is exactly cooldown_only.
@nic-6443 nic-6443 linked an issue Jun 2, 2026 that may be closed by this pull request
…471)

The same routing gap fixed for /v1/messages also affected the other two
provider-restricted passthrough endpoints. A routing model has no
`provider`, so the OpenAI-only / Anthropic-only gate in these handlers
rejected any Model Group with a 400 before walking `routing.targets`:

- /v1/responses        → "model … is not an OpenAI provider"
- /v1/messages/count_tokens → "model … is not an Anthropic provider"

Make both routing-aware via the shared routing::resolve_attempt_models:
resolve the attempt list, then attempt the targets whose provider
matches the endpoint, in order. Non-streaming responses fail over across
matching targets on retryable upstream failures; streaming /v1/responses
attempts the first matching target only. A group with no matching-provider
target still returns the endpoint's 400 (e.g. a count_tokens group with
no Anthropic target).

E2E: extend the model-group suite with an Anthropic group over
/v1/messages/count_tokens and an OpenAI group over /v1/responses; both
fail before this change (400) and pass after.
@jarvis9443 jarvis9443 changed the title fix(proxy): dispatch Model Groups via /v1/messages (#471) fix(proxy): dispatch Model Groups across passthrough endpoints (#471) Jun 2, 2026
@nic-6443
nic-6443 merged commit 817400c into main Jun 2, 2026
8 checks passed
@nic-6443
nic-6443 deleted the fix/issue-471-messages-model-group branch June 2, 2026 03:18
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.

bug:Report error when accessing Model Group

2 participants