Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]

### Added
- `audio-input-prefer` routing strategy: when `metadata.requires_audio`, `metadata.audio_input`, or `metadata.audio` is truthy, prefers healthy domain-eligible candidates whose capability set includes `audio` (from `metadata.audio_models`, a per-request `metadata.model_capabilities` override, the built-in known-model map, or an `audio` / `realtime` / `gpt-4o-audio` / `gemini` name heuristic), ranking by audio support then quality then cost; otherwise stays quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 traffic. Inspired by OpenRouter / LiteLLM / Portkey audio-input capability routing. See `docs/guides/AUDIO_INPUT_PREFER_GUIDE.md`.
- `long-context-prefer` routing strategy: when `metadata.min_context_tokens` is a positive int (or `metadata.long_context` is truthy with default threshold `100000`), prefers healthy domain-eligible candidates whose context window meets the threshold (from `metadata.model_context_windows`, the built-in known-model map / catalog `context_window`, or a `gpt-5` / `claude` / `gemini` / `kimi` name heuristic defaulting to `200000`), ranking by context then quality then cost; otherwise stays quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 traffic. Inspired by OpenRouter / LiteLLM / Portkey long-context capability routing. See `docs/guides/LONG_CONTEXT_PREFER_GUIDE.md`.
- `streaming-prefer` routing strategy: when `metadata.stream` or `metadata.requires_streaming` is truthy, prefers healthy domain-eligible candidates whose capability set includes `streaming` (from `metadata.streaming_models`, a per-request `metadata.model_capabilities` override, the built-in known-model map, or a `gpt-5` / `claude` / `gemini` / `kimi` name heuristic), ranking by streaming support then quality then cost; otherwise stays quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 traffic. Inspired by OpenRouter / LiteLLM / Portkey streaming capability routing. See `docs/guides/STREAMING_PREFER_GUIDE.md`.
- `multimodal-input-prefer` routing strategy: when `metadata.has_images` or `metadata.has_audio` is truthy, prefers healthy domain-eligible candidates whose capability set includes `vision` / `audio` / `multimodal` (from `metadata.multimodal_models`, a per-request `metadata.model_capabilities` override, the built-in known-model map, or a `gpt-5` / `claude` / `gemini` / `vision` name heuristic), ranking by multimodal support then quality then cost; otherwise stays quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 traffic. Inspired by OpenRouter multimodal routing. See `docs/guides/MULTIMODAL_INPUT_PREFER_GUIDE.md`.
Expand Down
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2317,3 +2317,5 @@ models when a request declares `metadata.stream` or
The `long-context-prefer` strategy biases selection toward models whose
context window meets a requested threshold when a request declares
`metadata.min_context_tokens` or `metadata.long_context`.

The `audio-input-prefer` strategy biases selection toward audio-capable models when a request declares `metadata.requires_audio`, `metadata.audio_input`, or `metadata.audio`.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# nexus-llm-router

![Tests](https://img.shields.io/badge/tests-787%20passing-brightgreen) ![Python](https://img.shields.io/badge/python-3.11%2B-blue) ![CI](https://github.com/Francis1998/nexus-llm-router/actions/workflows/ci.yml/badge.svg)
![Tests](https://img.shields.io/badge/tests-799%20passing-brightgreen) ![Python](https://img.shields.io/badge/python-3.11%2B-blue) ![CI](https://github.com/Francis1998/nexus-llm-router/actions/workflows/ci.yml/badge.svg)


> Intelligent multi-LLM routing middleware with task-aware model selection, cost optimization, fallback safety, and a drop-in OpenAI-compatible API.
Expand Down Expand Up @@ -206,6 +206,7 @@ Select a strategy with `X-Router-Strategy`:
- `thinking-model-prefer`: when `signals.complexity_score` or `metadata.complexity_score` is at or above `NEXUS_THINKING_COMPLEXITY_THRESHOLD`, prefers thinking/reasoning models (from `metadata.thinking_models` or names containing `o1`/`o3`/`reasoning`/`thinking`/`sonnet`/`opus`), ranking by thinking membership then quality then cost; otherwise quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2
- `tool-calling-prefer`: when `metadata.requires_tools` is truthy or `metadata.tools` is non-empty, prefers candidates advertising tool/function calling (from `metadata.tool_capable_models`, `metadata.model_capabilities` / the known-model map, or a `gpt-5`/`claude`/`gemini`/`kimi` name heuristic), ranking by tool support then quality then cost; otherwise quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2
- `multimodal-input-prefer`: when `metadata.has_images` or `metadata.has_audio` is truthy, prefers candidates advertising vision/multimodal capability (from `metadata.multimodal_models`, `metadata.model_capabilities` / the known-model map, or a `gpt-5`/`claude`/`gemini`/`vision` name heuristic), ranking by multimodal support then quality then cost; otherwise quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2
- `audio-input-prefer`: when `metadata.requires_audio`, `metadata.audio_input`, or `metadata.audio` is truthy, prefers candidates advertising audio capability (from `metadata.audio_models`, `metadata.model_capabilities` / the known-model map, or an `audio`/`realtime`/`gpt-4o-audio`/`gemini` name heuristic), ranking by audio support then quality then cost; otherwise quality-first for GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2
- `ab`: deterministic request-id buckets across two model arms

## Documentation
Expand Down
Binary file added assets/audio-input-prefer.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions docs/guides/AUDIO_INPUT_PREFER_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Audio-Input-Prefer Routing Guide

Use `audio-input-prefer` to bias selection toward `audio`-capable
models when a request declares `metadata.requires_audio` / `metadata.audio_input` / `metadata.audio`, for GPT-5.5 / Claude Sonnet 4.6 /
Gemini 3.x / Kimi K2.

![audio-input-prefer demo](../../assets/audio-input-prefer.gif)

## When to use it

- Gateways that mirror OpenRouter / LiteLLM / Portkey audio-input capability routing and want
`audio`-capable models preferred when the signal is set.
- Workloads that set `requires_audio`, `audio_input`, `audio` upstream and still want quality-first
routing when those flags are absent.
- Fleets that maintain a per-request `metadata.audio_models` allowlist
or `metadata.model_capabilities` override for models whose
`audio` support is not yet reflected in the built-in map.

## How it works

1. Read `metadata.requires_audio` / `metadata.audio_input` / `metadata.audio`. Truthy values are `true` / `1` / `yes` / `on`, or any
other non-empty non-falsy token.
2. Filter domain-eligible candidates through provider circuit health
(emergency-retain the full eligible pool when every circuit is open).
3. When the signal is present, resolve capability from
`metadata.audio_models`, then `metadata.model_capabilities` /
the built-in known-model map (`audio`). When neither is
present for a model, treat names containing `audio`, `realtime`, `gpt-4o-audio`, `gemini` as capable.
4. Rank by `(supports_audio desc, quality desc, cost asc)`.
5. When the signal is absent, route quality-first among healthy
domain-eligible candidates.

## Quick start

```bash
export NEXUS_DEFAULT_STRATEGY=audio-input-prefer
```

Or select it per request:

```http
X-Router-Strategy: audio-input-prefer
```

```json
{
"metadata": {
"requires_audio": true,
"audio_models": ["gemini-3.5-flash"],
"model_capabilities": {
"gemini-3.5-flash": "audio"
}
}
}
```

No additional `NEXUS_*` environment variables are required — selection is
driven entirely by request metadata.
1 change: 1 addition & 0 deletions src/router/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class RoutingStrategyName(StrEnum):
MULTIMODAL_INPUT_PREFER = "multimodal-input-prefer"
STREAMING_PREFER = "streaming-prefer"
LONG_CONTEXT_PREFER = "long-context-prefer"
AUDIO_INPUT_PREFER = "audio-input-prefer"
AB_TEST = "ab"


Expand Down
199 changes: 196 additions & 3 deletions src/router/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -10436,12 +10436,14 @@ def choose(self, request: RouterRequest, signals: TaskSignals) -> RoutingDecisio


_KNOWN_MODEL_CAPABILITIES: dict[str, frozenset[str]] = {
OPENAI_FRONTIER_MODEL: frozenset({"vision", "tools", "long_context", "json", "streaming"}),
OPENAI_FRONTIER_MODEL: frozenset(
{"vision", "tools", "long_context", "json", "streaming", "audio"}
),
OPENAI_BALANCED_MODEL: frozenset({"tools", "streaming"}),
ANTHROPIC_SAFETY_MODEL: frozenset({"vision", "tools", "long_context", "json", "streaming"}),
ANTHROPIC_FAST_MODEL: frozenset({"tools", "streaming"}),
GEMINI_PRO_MODEL: frozenset({"vision", "tools", "long_context", "json", "streaming"}),
GEMINI_FLASH_MODEL: frozenset({"vision", "tools", "json", "streaming"}),
GEMINI_PRO_MODEL: frozenset({"vision", "tools", "long_context", "json", "streaming", "audio"}),
GEMINI_FLASH_MODEL: frozenset({"vision", "tools", "json", "streaming", "audio"}),
MOONSHOT_BALANCED_MODEL: frozenset({"tools", "long_context", "json", "streaming"}),
}

Expand Down Expand Up @@ -12164,6 +12166,192 @@ def choose(self, request: RouterRequest, signals: TaskSignals) -> RoutingDecisio
)


class AudioInputPreferStrategy(RoutingStrategy):
"""Prefer audio-capable models when requested.

When ``metadata.requires_audio``, ``metadata.audio_input``, or
``metadata.audio`` is truthy, rank healthy domain-eligible candidates by
whether they support ``audio``, then by quality (descending) and cost
(ascending). Capability is resolved from ``metadata.audio_models``,
``metadata.model_capabilities`` / the built-in known-model map
(``audio`` capability), or a name heuristic matching ``audio``,
``realtime``, ``gpt-4o-audio``, or ``gemini``. Requests that omit the
signal stay quality-first. Inspired by OpenRouter / LiteLLM / Portkey
audio-input capability routing for GPT-5.5 / Claude Sonnet 4.6 /
Gemini 3.x / Kimi K2.
"""

strategy_name = RoutingStrategyName.AUDIO_INPUT_PREFER

_TRUTHY_TOKENS = frozenset({"true", "1", "yes", "on"})
_FALSY_TOKENS = frozenset({"false", "0", "no", "off", ""})
_NAME_TOKENS = (
"audio",
"realtime",
"gpt-4o-audio",
"gemini",
)

def __init__(
self,
model_catalog: Mapping[str, ModelCandidate],
provider_health: ProviderHealth,
capability_map: Mapping[str, frozenset[str]] | None = None,
) -> None:
"""Initialize audio-input-prefer routing."""
super().__init__(model_catalog)
self._provider_health = provider_health
self._capability_map: Mapping[str, frozenset[str]] = (
_KNOWN_MODEL_CAPABILITIES if capability_map is None else capability_map
)

@classmethod
def _is_truthy(cls, value: object) -> bool:
"""Return whether a metadata value is treated as truthy."""
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value != 0
if isinstance(value, (list, tuple, set, dict)):
return len(value) > 0
text = str(value).strip().lower()
if text in cls._FALSY_TOKENS:
return False
return text in cls._TRUTHY_TOKENS or bool(text)

@classmethod
def _wants_audio(cls, request: RouterRequest) -> bool:
"""Return whether the request asks for audio support."""
return (
cls._is_truthy(request.metadata.get("requires_audio"))
or cls._is_truthy(request.metadata.get("audio_input"))
or cls._is_truthy(request.metadata.get("audio"))
)

@staticmethod
def _audio_allowlist(request: RouterRequest) -> frozenset[str] | None:
"""Parse an optional ``metadata.audio_models`` allowlist."""
raw = request.metadata.get("audio_models")
if raw is None:
return None
if isinstance(raw, str):
parts: Iterable[object] = raw.split(",")
elif isinstance(raw, Iterable) and not isinstance(raw, (bytes, bytearray)):
parts = raw
else:
return frozenset()
return frozenset(stripped.lower() for item in parts if (stripped := str(item).strip()))

def _capabilities_for(self, model: str, request: RouterRequest) -> frozenset[str] | None:
"""Resolve an explicit capability set, or ``None`` when absent."""
overrides = request.metadata.get("model_capabilities")
if isinstance(overrides, Mapping) and model in overrides:
override = overrides[model]
if isinstance(override, str):
return frozenset(
stripped.lower() for part in override.split(",") if (stripped := part.strip())
)
if isinstance(override, Iterable) and not isinstance(override, (bytes, bytearray)):
return frozenset(str(item).strip().lower() for item in override)
return frozenset()
if model in self._capability_map:
return self._capability_map[model]
return None

def _supports_audio(self, model: str, request: RouterRequest) -> bool:
"""Return whether a model is treated as audio capable."""
allowlist = self._audio_allowlist(request)
if allowlist is not None:
return model.lower() in allowlist
capabilities = self._capabilities_for(model, request)
if capabilities is not None:
return "audio" in capabilities
lower = model.lower()
return any(token in lower for token in self._NAME_TOKENS)

def choose(self, request: RouterRequest, signals: TaskSignals) -> RoutingDecision:
"""Prefer audio models when the capability is requested."""
wants = self._wants_audio(request)
eligible = [
candidate
for candidate in self._model_catalog.values()
if signals.domain_tag in candidate.supports_domains
] or list(self._model_catalog.values())
healthy = [
candidate
for candidate in eligible
if self._provider_health.is_available(candidate.provider)
]
active = healthy or eligible
costs = {
candidate.model: candidate.estimate_cost(
signals.prompt_tokens_estimate,
request.max_tokens,
)
for candidate in active
}
audio_flags = {
candidate.model: self._supports_audio(candidate.model, request) for candidate in active
}
availability_note = "healthy" if healthy else "circuit-open emergency"

if wants:
selected = max(
active,
key=lambda candidate: (
audio_flags[candidate.model],
candidate.quality_score,
-costs[candidate.model],
candidate.model,
),
)
fallback_candidates = sorted(
(candidate for candidate in active if candidate.model != selected.model),
key=lambda candidate: (
not audio_flags[candidate.model],
-candidate.quality_score,
costs[candidate.model],
candidate.model,
),
)
capable_note = "audio-capable" if audio_flags[selected.model] else "non-audio fallback"
rationale = (
f"audio-input-prefer requested; selected {availability_note} "
f"{capable_note} {selected.model} (quality {selected.quality_score:.2f})"
)
else:
selected = max(
active,
key=lambda candidate: (
candidate.quality_score,
-costs[candidate.model],
candidate.model,
),
)
fallback_candidates = sorted(
(candidate for candidate in active if candidate.model != selected.model),
key=lambda candidate: (
-candidate.quality_score,
costs[candidate.model],
candidate.model,
),
)
rationale = (
f"audio-input-prefer no requires_audio/audio_input/audio signal; "
f"selected {availability_note} quality-first {selected.model}"
)

return RoutingDecision(
chosen_model=selected.model,
provider=selected.provider,
routing_strategy=self.strategy_name,
rationale=rationale,
fallback_chain=[candidate.model for candidate in fallback_candidates[:3]],
)


def build_strategies(
model_catalog: Mapping[str, ModelCandidate],
latency_stats: LatencyStats,
Expand Down Expand Up @@ -12999,6 +13187,11 @@ def build_strategies(
model_catalog=model_catalog,
provider_health=provider_health,
),
RoutingStrategyName.AUDIO_INPUT_PREFER: AudioInputPreferStrategy(
model_catalog=model_catalog,
provider_health=provider_health,
capability_map=model_capability_map,
),
RoutingStrategyName.AB_TEST: ABRoutingStrategy(
model_catalog,
ab_model_a,
Expand Down
Loading
Loading