mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(router): implement native fusion deliberation
This commit is contained in:
parent
c72e4b8b43
commit
568b6c910e
18 changed files with 2133 additions and 929 deletions
55
cookbook/fusion_models.md
Normal file
55
cookbook/fusion_models.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Fusion models
|
||||
|
||||
Fusion models are virtual LiteLLM model groups that give one **outer model** a private deliberation tool. They remain compatible with normal chat, Responses API, Anthropic Messages, streaming, and client tool loops.
|
||||
|
||||
Deliberation is an optional private tool inside an otherwise normal model call, rather than an always-on panel in front of every request.
|
||||
|
||||
The request path is:
|
||||
|
||||
1. LiteLLM adds a private `litellm_fusion` server tool alongside any client tools and calls the outer model normally.
|
||||
2. If requested, 1–8 panel models answer a self-contained question in parallel.
|
||||
3. The analyst compares consensus, contradictions, partial coverage, unique insights, and blind spots. It does not choose a winner or write the final response.
|
||||
4. The outer model receives the structured analysis and bounded raw responses, then returns the only client-visible answer or tool call.
|
||||
|
||||
If the outer model answers directly or selects a client tool, LiteLLM returns that first response without running a second outer-model completion. Panel and analyst models never receive client tools. If they use an optional LiteLLM Search Tool, the search is executed server-side and its results remain advisory. A failed panel is reported to the outer model; one successful panel is enough to continue. If the analyst fails or returns invalid JSON, the outer model still receives the raw panel responses. If every panel fails, the outer model receives a typed error and can answer without them.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
# These are user-defined model-group names for regular deployments that
|
||||
# already exist on the proxy.
|
||||
- model_name: fusion/general
|
||||
litellm_params:
|
||||
model: fusion_router
|
||||
fusion_router_config:
|
||||
outer_model: production-outer
|
||||
panel_models: [research-fast, research-reasoning]
|
||||
analyst_model: production-outer # optional; defaults to outer_model
|
||||
invocation: auto # auto or required
|
||||
reasoning_effort: none
|
||||
temperature: 0
|
||||
max_completion_tokens: 16000
|
||||
panel_timeout_seconds: 120
|
||||
max_candidate_chars: 12000
|
||||
# Optional existing LiteLLM Search Tool:
|
||||
# search_tool_name: web-search
|
||||
# max_tool_calls: 4
|
||||
```
|
||||
|
||||
Call `fusion/general` exactly like any other model. `invocation: auto` lets the outer model skip the panel for routine requests. `required` forces deliberation and is useful for evaluations or workloads where every request should receive the same treatment.
|
||||
|
||||
`reasoning_effort: none` makes deliberation replace private extended reasoning where a provider supports that parameter. LiteLLM drops it for providers that do not support it. The optional Search Tool supplies search results and bounded page content through LiteLLM's Search API. This first version does not expose a separate URL-fetch tool.
|
||||
|
||||
The outer model must support function calling. Panel and analyst models only need function calling when a Search Tool is configured. Granting access to the Fusion model lets the request use its administrator-configured model and search dependencies; the panel query and private research are sent to those deployments under their normal provider data policies.
|
||||
|
||||
## Operational behavior
|
||||
|
||||
- The outer model is the only hard health dependency. Panel failures degrade into tool-result data, and analyst failure degrades to raw responses.
|
||||
- Initial outer, panel, analyst, continuation, and search calls are marked separately in spend logs. They inherit the caller identity and remain part of one logical Fusion request.
|
||||
- Admission control reserves the worst-case model-call cost. Hidden calls accumulate against that shared reservation, and the direct initial response or final continuation reconciles it once. This keeps concurrent requests from spending the same remaining budget while Fusion is still running.
|
||||
- Chat-completion streaming is buffered until LiteLLM knows whether the private tool was invoked. A direct response is replayed as a normal stream; a Fusion invocation suppresses the private tool-call stream and exposes only the final outer-model stream.
|
||||
- A request-level `tool_choice: required` is considered satisfied when Fusion runs. The continuation changes it to `auto` when client tools exist, or removes it when they do not, so the outer model can finish instead of being forced into a second tool call.
|
||||
- A client tool named `litellm_fusion` is rejected because that name is reserved for the private server tool.
|
||||
- A Fusion model cannot use another Fusion model as its outer, panel, or analyst model. The router's existing recursion guard enforces this at runtime.
|
||||
- Fusion runs at most once per top-level model request. The harness still owns the multi-turn tool loop, so a later tool result creates a new model request and a new independent Fusion decision.
|
||||
|
|
@ -1456,6 +1456,10 @@ CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
|
|||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
|
||||
SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted"
|
||||
FUSION_BUDGET_ACCUMULATED_COST_KEY: Final = "_fusion_accumulated_actual_cost"
|
||||
FUSION_BUDGET_ACCUMULATED_CALL_IDS_KEY: Final = "_fusion_accumulated_call_ids"
|
||||
FUSION_BUDGET_ACTIVE_KEY: Final = "_fusion_logical_request"
|
||||
FUSION_BUDGET_CONTINUATION_STARTED_KEY: Final = "_fusion_continuation_started"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
|
||||
"Truncation is a DB storage safeguard. "
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -343,26 +343,15 @@ def _strategy_router_dependency_error(
|
|||
if not isinstance(raw_config, Mapping):
|
||||
return "Fusion model has no fusion_router_config"
|
||||
try:
|
||||
config: Final = FusionRouterConfig.model_validate(raw_config)
|
||||
FusionRouterConfig.model_validate(raw_config)
|
||||
except ValidationError:
|
||||
return "Fusion model has an invalid fusion_router_config"
|
||||
dependencies: Final = fusion_router_dependencies(params)
|
||||
aggregator: Final = next(dependency for dependency in dependencies if dependency.role == "aggregator")
|
||||
aggregator_failure: Final = _dependency_failure(aggregator, router, unhealthy_ids)
|
||||
if aggregator_failure is not None:
|
||||
return aggregator_failure
|
||||
if config.on_quorum_failure == "aggregator_only":
|
||||
return None
|
||||
panel_dependencies: Final = tuple(dependency for dependency in dependencies if dependency.role == "panel")
|
||||
usable_panel_count: Final = sum(
|
||||
_dependency_failure(dependency, router, unhealthy_ids) is None for dependency in panel_dependencies
|
||||
)
|
||||
if usable_panel_count < config.min_successful_panelists:
|
||||
return (
|
||||
f"panel quorum cannot be met: {usable_panel_count} of "
|
||||
f"{config.min_successful_panelists} required panel models are healthy"
|
||||
)
|
||||
return None
|
||||
# The outer model is the only hard dependency. Panel failures become a
|
||||
# typed Fusion tool error that the outer model can recover from, and an
|
||||
# analyst failure degrades to raw panel responses.
|
||||
outer: Final = next(dependency for dependency in dependencies if dependency.role == "outer")
|
||||
return _dependency_failure(outer, router, unhealthy_ids)
|
||||
return next(
|
||||
(
|
||||
failure
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import BACKGROUND_INTERACTION_COST_POLLING_ENABLED
|
||||
from litellm.constants import (
|
||||
BACKGROUND_INTERACTION_COST_POLLING_ENABLED,
|
||||
FUSION_BUDGET_ACCUMULATED_CALL_IDS_KEY,
|
||||
FUSION_BUDGET_ACCUMULATED_COST_KEY,
|
||||
FUSION_BUDGET_ACTIVE_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
_get_parent_otel_span_from_kwargs,
|
||||
|
|
@ -67,6 +73,83 @@ _CAPTURED_IDENTITY_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
|||
)
|
||||
)
|
||||
|
||||
_FUSION_TOOL_NAME: Final = "litellm_fusion"
|
||||
_FUSION_ALWAYS_DEFERRED_ORIGINS: Final[frozenset[str]] = frozenset(
|
||||
{"fusion_panel", "fusion_analyst", "fusion_research"}
|
||||
)
|
||||
|
||||
|
||||
def _mapping_or_attribute(value: object, key: str) -> object:
|
||||
if isinstance(value, dict):
|
||||
return value.get(key)
|
||||
return getattr(value, key, None)
|
||||
|
||||
|
||||
def _response_invoked_fusion(response: object) -> bool:
|
||||
choices = _mapping_or_attribute(response, "choices")
|
||||
if not isinstance(choices, Sequence) or isinstance(choices, (str, bytes)) or not choices:
|
||||
return False
|
||||
message = _mapping_or_attribute(choices[0], "message")
|
||||
tool_calls = _mapping_or_attribute(message, "tool_calls")
|
||||
if not isinstance(tool_calls, Sequence) or isinstance(tool_calls, (str, bytes)):
|
||||
return False
|
||||
return any(
|
||||
_mapping_or_attribute(_mapping_or_attribute(tool_call, "function"), "name") == _FUSION_TOOL_NAME
|
||||
for tool_call in tool_calls
|
||||
)
|
||||
|
||||
|
||||
def _should_defer_fusion_budget_reconciliation(
|
||||
metadata: dict,
|
||||
completion_response: object,
|
||||
kwargs: dict,
|
||||
) -> bool:
|
||||
origin = metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY)
|
||||
if origin in _FUSION_ALWAYS_DEFERRED_ORIGINS:
|
||||
return True
|
||||
if origin != "fusion_initial":
|
||||
return False
|
||||
complete_stream = kwargs.get("complete_streaming_response")
|
||||
return _response_invoked_fusion(completion_response) or _response_invoked_fusion(complete_stream)
|
||||
|
||||
|
||||
def _accumulate_fusion_cost(
|
||||
budget_reservation: dict,
|
||||
response_cost: float,
|
||||
kwargs: dict,
|
||||
) -> None:
|
||||
"""Add one hidden call exactly once before its asynchronous DB write."""
|
||||
call_id = kwargs.get("litellm_call_id") or kwargs.get("id")
|
||||
seen_call_ids = budget_reservation.setdefault(FUSION_BUDGET_ACCUMULATED_CALL_IDS_KEY, [])
|
||||
if isinstance(seen_call_ids, list) and call_id is not None:
|
||||
normalized_call_id = str(call_id)
|
||||
if normalized_call_id in seen_call_ids:
|
||||
return
|
||||
seen_call_ids.append(normalized_call_id)
|
||||
budget_reservation[FUSION_BUDGET_ACCUMULATED_COST_KEY] = float(
|
||||
budget_reservation.get(FUSION_BUDGET_ACCUMULATED_COST_KEY) or 0.0
|
||||
) + max(response_cost, 0.0)
|
||||
|
||||
|
||||
def _failure_should_leave_fusion_reservation_open(request_data: dict) -> bool:
|
||||
buckets: tuple[object, ...] = (
|
||||
request_data.get("metadata"),
|
||||
request_data.get("litellm_metadata"),
|
||||
(request_data.get("litellm_params") or {}).get("metadata")
|
||||
if isinstance(request_data.get("litellm_params"), dict)
|
||||
else None,
|
||||
(request_data.get("litellm_params") or {}).get("litellm_metadata")
|
||||
if isinstance(request_data.get("litellm_params"), dict)
|
||||
else None,
|
||||
)
|
||||
return any(
|
||||
isinstance(bucket, dict)
|
||||
and bucket.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) in _FUSION_ALWAYS_DEFERRED_ORIGINS
|
||||
and isinstance(bucket.get("user_api_key_budget_reservation"), dict)
|
||||
and bucket["user_api_key_budget_reservation"].get(FUSION_BUDGET_ACTIVE_KEY) is True
|
||||
for bucket in buckets
|
||||
)
|
||||
|
||||
|
||||
class _ProxyDBLogger(CustomLogger):
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
|
|
@ -80,7 +163,8 @@ class _ProxyDBLogger(CustomLogger):
|
|||
traceback_str: str | None = None,
|
||||
):
|
||||
try:
|
||||
await _release_budget_reservation(budget_reservation=user_api_key_dict.budget_reservation)
|
||||
if not _failure_should_leave_fusion_reservation_open(request_data):
|
||||
await _release_budget_reservation(budget_reservation=user_api_key_dict.budget_reservation)
|
||||
except Exception:
|
||||
verbose_proxy_logger.exception("Failed to release budget reservation during failure handling")
|
||||
try:
|
||||
|
|
@ -266,12 +350,31 @@ class _ProxyDBLogger(CustomLogger):
|
|||
served_model_id=sl_object.get("model_id") if sl_object is not None else None,
|
||||
router=get_llm_router(),
|
||||
)
|
||||
if response_cost is not None and kwargs.get("cache_hit", False) is True:
|
||||
response_cost = 0.0
|
||||
verbose_proxy_logger.debug("Cache Hit: response_cost %s, for user_id %s", response_cost, user_id)
|
||||
defer_fusion_reconciliation: Final = (
|
||||
budget_reservation is not None
|
||||
and budget_reservation.get(FUSION_BUDGET_ACTIVE_KEY) is True
|
||||
and _should_defer_fusion_budget_reconciliation(metadata, completion_response, kwargs)
|
||||
)
|
||||
|
||||
if response_cost is not None:
|
||||
if defer_fusion_reconciliation and budget_reservation is not None:
|
||||
_accumulate_fusion_cost(
|
||||
budget_reservation=budget_reservation,
|
||||
response_cost=float(response_cost),
|
||||
kwargs=kwargs,
|
||||
)
|
||||
budget_counter_response_cost: Final = (
|
||||
float(response_cost)
|
||||
+ float(budget_reservation.get(FUSION_BUDGET_ACCUMULATED_COST_KEY) or 0.0)
|
||||
if budget_reservation is not None
|
||||
and budget_reservation.get(FUSION_BUDGET_ACTIVE_KEY) is True
|
||||
and metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == "fusion_continuation"
|
||||
else float(response_cost)
|
||||
)
|
||||
user_api_key: Final = metadata.get("user_api_key", None)
|
||||
if kwargs.get("cache_hit", False) is True:
|
||||
response_cost = 0.0
|
||||
verbose_proxy_logger.debug("Cache Hit: response_cost %s, for user_id %s", response_cost, user_id)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"user_api_key %s, user_id %s, team_id %s, end_user_id %s",
|
||||
|
|
@ -303,6 +406,8 @@ class _ProxyDBLogger(CustomLogger):
|
|||
end_time=end_time,
|
||||
response_cost=response_cost,
|
||||
budget_reservation=budget_reservation,
|
||||
budget_counter_response_cost=budget_counter_response_cost,
|
||||
defer_budget_counter_update=defer_fusion_reconciliation,
|
||||
request_tags=tags,
|
||||
model_access_groups=model_access_groups,
|
||||
)
|
||||
|
|
@ -328,7 +433,7 @@ class _ProxyDBLogger(CustomLogger):
|
|||
response_cost=response_cost,
|
||||
max_budget=end_user_max_budget,
|
||||
)
|
||||
elif budget_reservation is not None:
|
||||
elif budget_reservation is not None and not defer_fusion_reconciliation:
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
else:
|
||||
if _is_unbilled_interaction_response(completion_response):
|
||||
|
|
@ -340,13 +445,15 @@ class _ProxyDBLogger(CustomLogger):
|
|||
"the budget reservation stays open until the poll task logs the final usage"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
verbose_proxy_logger.debug(
|
||||
"Released the budget reservation for an interaction create with no usage "
|
||||
"that no poll task will settle"
|
||||
)
|
||||
if not defer_fusion_reconciliation:
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
verbose_proxy_logger.debug(
|
||||
"Released the budget reservation for an interaction create with no usage "
|
||||
"that no poll task will settle"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
if not defer_fusion_reconciliation:
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
# Non-model call types (health checks, afile_delete) have no model or standard_logging_object.
|
||||
# Use .get() for "stream" to avoid KeyError on health checks.
|
||||
# WS session wrappers (_aresponses_websocket, _arealtime) also reach here with
|
||||
|
|
@ -580,6 +687,8 @@ async def _update_database_and_spend_counters(
|
|||
end_time: Any,
|
||||
response_cost: float,
|
||||
budget_reservation: dict | None,
|
||||
budget_counter_response_cost: float | None = None,
|
||||
defer_budget_counter_update: bool = False,
|
||||
request_tags: list[str] | None = None,
|
||||
model_access_groups: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
|
|
@ -610,12 +719,17 @@ async def _update_database_and_spend_counters(
|
|||
)
|
||||
raise
|
||||
|
||||
if defer_budget_counter_update:
|
||||
return
|
||||
|
||||
try:
|
||||
await increment_spend_counters(
|
||||
token=user_api_key,
|
||||
team_id=team_id,
|
||||
user_id=user_id,
|
||||
response_cost=response_cost,
|
||||
response_cost=(
|
||||
budget_counter_response_cost if budget_counter_response_cost is not None else response_cost
|
||||
),
|
||||
org_id=org_id,
|
||||
budget_reservation=budget_reservation,
|
||||
end_user_id=end_user_id,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from fastapi import HTTPException, status
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
FUSION_BUDGET_ACCUMULATED_CALL_IDS_KEY,
|
||||
FUSION_BUDGET_ACCUMULATED_COST_KEY,
|
||||
FUSION_BUDGET_CONTINUATION_STARTED_KEY,
|
||||
)
|
||||
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
||||
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -324,7 +329,10 @@ async def reconcile_budget_reservation(
|
|||
async def release_budget_reservation(budget_reservation: dict | None) -> None:
|
||||
await reconcile_budget_reservation(
|
||||
budget_reservation=budget_reservation,
|
||||
actual_cost=0.0,
|
||||
# A Fusion request may have completed hidden provider calls before a
|
||||
# later panel/continuation failure. Preserve that known billed floor
|
||||
# instead of refunding the whole logical request to zero.
|
||||
actual_cost=(budget_reservation or {}).get(FUSION_BUDGET_ACCUMULATED_COST_KEY, 0.0),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -352,7 +360,21 @@ async def release_budget_reservation_on_cancel(
|
|||
"""
|
||||
if not budget_reservation or budget_reservation.get("finalized") is True:
|
||||
return
|
||||
incurred_cost: Final = float(budget_reservation.get("input_cost") or 0.0)
|
||||
accumulated_cost: Final = float(budget_reservation.get(FUSION_BUDGET_ACCUMULATED_COST_KEY) or 0.0)
|
||||
# Before the initial outer call finishes, its input is the only known
|
||||
# provider charge. After it finishes, its actual cost is already in the
|
||||
# accumulator. Add another input floor only once the final continuation
|
||||
# has been dispatched; otherwise cancellation during the panel would count
|
||||
# the initial input twice.
|
||||
hidden_call_finished = bool(budget_reservation.get(FUSION_BUDGET_ACCUMULATED_CALL_IDS_KEY)) or (
|
||||
accumulated_cost > 0.0
|
||||
)
|
||||
add_in_flight_input = not hidden_call_finished or (
|
||||
budget_reservation.get(FUSION_BUDGET_CONTINUATION_STARTED_KEY) is True
|
||||
)
|
||||
incurred_cost: Final = accumulated_cost + (
|
||||
float(budget_reservation.get("input_cost") or 0.0) if add_in_flight_input else 0.0
|
||||
)
|
||||
try:
|
||||
await asyncio.shield(
|
||||
reconcile_budget_reservation(budget_reservation=budget_reservation, actual_cost=incurred_cost)
|
||||
|
|
@ -1050,37 +1072,90 @@ def _estimate_request_model_max_cost(
|
|||
input_tokens=input_tokens,
|
||||
)
|
||||
|
||||
initial_outer_estimate: Final = _estimate_request_max_cost_for_model(
|
||||
request_body=request_body,
|
||||
route=route,
|
||||
model=fusion_router.config.outer_model,
|
||||
llm_router=llm_router,
|
||||
input_tokens=input_tokens,
|
||||
)
|
||||
internal_call_multiplier: Final = (
|
||||
fusion_router.config.max_tool_calls + 1 if fusion_router.config.search_tool_name is not None else 1
|
||||
)
|
||||
internal_request_body: Final = {
|
||||
**request_body,
|
||||
# Panel and analyst output is controlled by the Fusion config, not by
|
||||
# the caller's cap on the outward response.
|
||||
"max_completion_tokens": fusion_router.config.max_completion_tokens,
|
||||
}
|
||||
query_token_ceiling: Final = (4 * fusion_router.config.max_candidate_chars) + 1024
|
||||
search_context_token_ceiling: Final = (
|
||||
4 * fusion_router.config.max_candidate_chars * fusion_router.config.max_tool_calls
|
||||
if fusion_router.config.search_tool_name is not None
|
||||
else 0
|
||||
)
|
||||
panel_input_token_ceiling: Final = query_token_ceiling + search_context_token_ceiling
|
||||
panel_estimates: Final = tuple(
|
||||
_estimate_request_max_cost_for_model(
|
||||
request_body=request_body,
|
||||
route=route,
|
||||
model=panel_model,
|
||||
llm_router=llm_router,
|
||||
(
|
||||
estimate * internal_call_multiplier
|
||||
if (
|
||||
estimate := _estimate_request_max_cost_for_model(
|
||||
request_body=internal_request_body,
|
||||
route=route,
|
||||
model=panel_model,
|
||||
llm_router=llm_router,
|
||||
input_tokens=panel_input_token_ceiling,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
else None
|
||||
)
|
||||
for panel_model in fusion_router.config.panel_models
|
||||
)
|
||||
original_aggregator_tokens: Final = _count_input_tokens(
|
||||
original_outer_tokens: Final = _count_input_tokens(
|
||||
request_body=request_body,
|
||||
model=fusion_router.config.aggregator_model,
|
||||
model=fusion_router.config.outer_model,
|
||||
)
|
||||
# Four tokens per bounded character plus fixed protocol headroom safely covers
|
||||
# candidate serialization without materializing a synthetic prompt at admission.
|
||||
# query/candidate serialization without materializing synthetic prompts at admission.
|
||||
candidate_token_ceiling: Final = (
|
||||
4 * fusion_router.config.max_candidate_chars * len(fusion_router.config.panel_models)
|
||||
) + 1024
|
||||
aggregator_input_tokens: Final = (
|
||||
original_aggregator_tokens + candidate_token_ceiling if original_aggregator_tokens is not None else None
|
||||
analyst_input_tokens: Final = candidate_token_ceiling + query_token_ceiling + search_context_token_ceiling
|
||||
final_outer_input_tokens: Final = (
|
||||
original_outer_tokens
|
||||
+ candidate_token_ceiling
|
||||
+ query_token_ceiling
|
||||
+ fusion_router.config.max_completion_tokens
|
||||
if original_outer_tokens is not None
|
||||
else None
|
||||
)
|
||||
aggregator_estimate: Final = _estimate_request_max_cost_for_model(
|
||||
analyst_estimate = _estimate_request_max_cost_for_model(
|
||||
request_body=internal_request_body,
|
||||
route=route,
|
||||
model=fusion_router.config.resolved_analyst_model,
|
||||
llm_router=llm_router,
|
||||
input_tokens=analyst_input_tokens,
|
||||
)
|
||||
if analyst_estimate is not None:
|
||||
analyst_estimate *= internal_call_multiplier
|
||||
final_outer_estimate: Final = _estimate_request_max_cost_for_model(
|
||||
request_body=request_body,
|
||||
route=route,
|
||||
model=fusion_router.config.aggregator_model,
|
||||
model=fusion_router.config.outer_model,
|
||||
llm_router=llm_router,
|
||||
input_tokens=aggregator_input_tokens,
|
||||
input_tokens=final_outer_input_tokens,
|
||||
)
|
||||
child_estimates: Final = (*panel_estimates, aggregator_estimate)
|
||||
known_estimates: Final = tuple(estimate for estimate in child_estimates if estimate is not None)
|
||||
return sum(known_estimates) if known_estimates else None
|
||||
# Reserve the worst case: the initial outer call, every panel call, the
|
||||
# analyst, and the outer-model continuation. If Fusion is skipped,
|
||||
# normal reconciliation releases the unused panel/analyst headroom.
|
||||
child_estimates: Final = (initial_outer_estimate, *panel_estimates, analyst_estimate, final_outer_estimate)
|
||||
if any(estimate is None for estimate in child_estimates):
|
||||
# Additive orchestration cannot safely reserve a partial total. This
|
||||
# matches the normal unknown-price behavior instead of presenting an
|
||||
# under-estimate as a valid worst case.
|
||||
return None
|
||||
return sum(cast("tuple[float, ...]", child_estimates))
|
||||
|
||||
|
||||
def estimate_request_input_cost(
|
||||
|
|
@ -1135,35 +1210,18 @@ def _estimate_request_model_input_cost(
|
|||
input_tokens=input_tokens,
|
||||
)
|
||||
|
||||
panel_estimates: Final = tuple(
|
||||
_estimate_request_input_cost_for_model(
|
||||
request_body=request_body,
|
||||
route=route,
|
||||
model=panel_model,
|
||||
llm_router=llm_router,
|
||||
)
|
||||
for panel_model in fusion_router.config.panel_models
|
||||
)
|
||||
original_aggregator_tokens: Final = _count_input_tokens(
|
||||
request_body=request_body,
|
||||
model=fusion_router.config.aggregator_model,
|
||||
)
|
||||
candidate_token_ceiling: Final = (
|
||||
4 * fusion_router.config.max_candidate_chars * len(fusion_router.config.panel_models)
|
||||
) + 1024
|
||||
aggregator_input_tokens: Final = (
|
||||
original_aggregator_tokens + candidate_token_ceiling if original_aggregator_tokens is not None else None
|
||||
)
|
||||
aggregator_estimate: Final = _estimate_request_input_cost_for_model(
|
||||
# Only the initial outer input is known at admission. Successful hidden
|
||||
# calls add their actual cost to the reservation as they finish, and the
|
||||
# cancellation path adds another input floor only when the continuation is
|
||||
# known to have started. Charging every possible child here would bill
|
||||
# skipped deliberation as though it ran.
|
||||
return _estimate_request_input_cost_for_model(
|
||||
request_body=request_body,
|
||||
route=route,
|
||||
model=fusion_router.config.aggregator_model,
|
||||
model=fusion_router.config.outer_model,
|
||||
llm_router=llm_router,
|
||||
input_tokens=aggregator_input_tokens,
|
||||
input_tokens=input_tokens,
|
||||
)
|
||||
child_estimates: Final = (*panel_estimates, aggregator_estimate)
|
||||
known_estimates: Final = tuple(estimate for estimate in child_estimates if estimate is not None)
|
||||
return sum(known_estimates) if known_estimates else None
|
||||
|
||||
|
||||
def _estimate_request_input_cost_for_model(
|
||||
|
|
|
|||
|
|
@ -2538,7 +2538,7 @@ class Router:
|
|||
if fusion_router is not None:
|
||||
if fusion_depth:
|
||||
raise litellm.BadRequestError(
|
||||
message="Fusion models cannot use another Fusion model as a panel or aggregator",
|
||||
message="Fusion models cannot use another Fusion model as an outer, panel, or analyst model",
|
||||
model=model,
|
||||
llm_provider="",
|
||||
)
|
||||
|
|
@ -9460,8 +9460,13 @@ class Router:
|
|||
model_name=deployment.model_name,
|
||||
raw_config=raw_config,
|
||||
completion=self.acompletion,
|
||||
search=self._fusion_asearch,
|
||||
)
|
||||
|
||||
async def _fusion_asearch(self, *, model: str, query: str, **kwargs: object) -> object:
|
||||
"""Late-bound Search API bridge; Fusion routers are registered before endpoint factories run."""
|
||||
return await self.asearch(model=model, query=query, **kwargs)
|
||||
|
||||
def deployment_is_active_for_environment(self, deployment: Deployment) -> bool:
|
||||
"""
|
||||
Function to check if a llm deployment is active for a given environment. Allows using the same config.yaml across multople environments
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/"
|
|||
|
||||
StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality"]
|
||||
|
||||
StrategyRouterDependencyRole: TypeAlias = Literal["tier", "default", "classifier", "embedding", "panel", "aggregator"]
|
||||
StrategyRouterDependencyRole: TypeAlias = Literal[
|
||||
"tier", "default", "classifier", "embedding", "panel", "analyst", "outer"
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -2902,7 +2902,11 @@ RoutingDecisionCause = Literal[
|
|||
|
||||
InternalCallOrigin = Literal[
|
||||
"autorouter_classifier",
|
||||
"fusion_initial",
|
||||
"fusion_panel",
|
||||
"fusion_analyst",
|
||||
"fusion_research",
|
||||
"fusion_continuation",
|
||||
"shadow_eval_router",
|
||||
"shadow_eval_judge",
|
||||
"background_response_cost_poll",
|
||||
|
|
@ -2911,7 +2915,11 @@ InternalCallOrigin = Literal[
|
|||
records that it is not traffic the caller sent."""
|
||||
|
||||
AUTOROUTER_CLASSIFIER_CALL_ORIGIN: Final[InternalCallOrigin] = "autorouter_classifier"
|
||||
FUSION_INITIAL_CALL_ORIGIN: Final[InternalCallOrigin] = "fusion_initial"
|
||||
FUSION_PANEL_CALL_ORIGIN: Final[InternalCallOrigin] = "fusion_panel"
|
||||
FUSION_ANALYST_CALL_ORIGIN: Final[InternalCallOrigin] = "fusion_analyst"
|
||||
FUSION_RESEARCH_CALL_ORIGIN: Final[InternalCallOrigin] = "fusion_research"
|
||||
FUSION_CONTINUATION_CALL_ORIGIN: Final[InternalCallOrigin] = "fusion_continuation"
|
||||
SHADOW_EVAL_ROUTER_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_router"
|
||||
SHADOW_EVAL_JUDGE_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_judge"
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN: Final[InternalCallOrigin] = "background_response_cost_poll"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.constants import FUSION_BUDGET_ACCUMULATED_COST_KEY, FUSION_BUDGET_ACTIVE_KEY
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import (
|
||||
_failure_should_leave_fusion_reservation_open,
|
||||
_get_budget_reservation_from_metadata,
|
||||
_ProxyDBLogger,
|
||||
_should_track_cost_callback,
|
||||
|
|
@ -605,6 +609,261 @@ async def test_update_database_and_spend_counters_updates_counters_after_db_upda
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_database_and_spend_counters_can_defer_fusion_reconciliation():
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.db_spend_update_writer.update_database = AsyncMock()
|
||||
increment_spend_counters = AsyncMock()
|
||||
budget_reservation = {"reserved_cost": 0.5, "entries": []}
|
||||
|
||||
await _update_database_and_spend_counters(
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
increment_spend_counters=increment_spend_counters,
|
||||
user_api_key="test_api_key",
|
||||
user_id="test_user_id",
|
||||
end_user_id=None,
|
||||
team_id="test_team_id",
|
||||
org_id=None,
|
||||
kwargs={},
|
||||
completion_response=None,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
response_cost=0.2,
|
||||
budget_reservation=budget_reservation,
|
||||
defer_budget_counter_update=True,
|
||||
)
|
||||
|
||||
proxy_logging_obj.db_spend_update_writer.update_database.assert_awaited_once()
|
||||
increment_spend_counters.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fusion_hidden_costs_accumulate_then_continuation_reconciles_once():
|
||||
logger = _ProxyDBLogger()
|
||||
reservation = {
|
||||
"reserved_cost": 1.0,
|
||||
"entries": [],
|
||||
"finalized": False,
|
||||
FUSION_BUDGET_ACTIVE_KEY: True,
|
||||
}
|
||||
initial_response = litellm.ModelResponse(
|
||||
choices=[
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "fusion-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "litellm_fusion",
|
||||
"arguments": '{"query":"investigate"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def kwargs_for(origin: str, response_cost: float, call_id: str) -> dict:
|
||||
return {
|
||||
"call_type": "acompletion",
|
||||
"model": "test-model",
|
||||
"litellm_call_id": call_id,
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"user_api_key": "hashed-key",
|
||||
"user_api_key_user_id": "user-1",
|
||||
"internal_call_origin": origin,
|
||||
"user_api_key_budget_reservation": reservation,
|
||||
}
|
||||
},
|
||||
"standard_logging_object": {
|
||||
"response_cost": response_cost,
|
||||
"request_tags": [],
|
||||
"metadata": {},
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.increment_spend_counters", new_callable=AsyncMock) as increment,
|
||||
patch("litellm.proxy.proxy_server.update_cache", new_callable=AsyncMock),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj") as proxy_logging,
|
||||
):
|
||||
proxy_logging.db_spend_update_writer.update_database = AsyncMock()
|
||||
proxy_logging.slack_alerting_instance.customer_spend_alert = AsyncMock()
|
||||
|
||||
initial_kwargs = kwargs_for("fusion_initial", 0.1, "initial-call")
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=initial_kwargs,
|
||||
completion_response=initial_response,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
# Replaying the same callback cannot double-add a provider call.
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=initial_kwargs,
|
||||
completion_response=initial_response,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
panel_kwargs = kwargs_for("fusion_panel", 0.2, "panel-call")
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=panel_kwargs,
|
||||
completion_response=litellm.ModelResponse(choices=[]),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert reservation[FUSION_BUDGET_ACCUMULATED_COST_KEY] == pytest.approx(0.3)
|
||||
increment.assert_not_awaited()
|
||||
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=kwargs_for("fusion_continuation", 0.4, "continuation-call"),
|
||||
completion_response=litellm.ModelResponse(choices=[]),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
increment.assert_awaited_once()
|
||||
assert increment.await_args.kwargs["response_cost"] == pytest.approx(0.7)
|
||||
assert increment.await_args.kwargs["budget_reservation"] is reservation
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_fusion_hidden_call_accumulates_zero_cost():
|
||||
logger = _ProxyDBLogger()
|
||||
reservation = {
|
||||
"reserved_cost": 1.0,
|
||||
"entries": [],
|
||||
"finalized": False,
|
||||
FUSION_BUDGET_ACTIVE_KEY: True,
|
||||
}
|
||||
kwargs = {
|
||||
"call_type": "acompletion",
|
||||
"model": "panel",
|
||||
"cache_hit": True,
|
||||
"litellm_call_id": "cached-panel-call",
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"user_api_key": "hashed-key",
|
||||
"user_api_key_user_id": "user-1",
|
||||
"internal_call_origin": "fusion_panel",
|
||||
"user_api_key_budget_reservation": reservation,
|
||||
}
|
||||
},
|
||||
"standard_logging_object": {
|
||||
"response_cost": 0.2,
|
||||
"request_tags": [],
|
||||
"metadata": {},
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.increment_spend_counters", new_callable=AsyncMock) as increment,
|
||||
patch("litellm.proxy.proxy_server.update_cache", new_callable=AsyncMock),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj") as proxy_logging,
|
||||
):
|
||||
proxy_logging.db_spend_update_writer.update_database = AsyncMock()
|
||||
proxy_logging.slack_alerting_instance.customer_spend_alert = AsyncMock()
|
||||
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=kwargs,
|
||||
completion_response=litellm.ModelResponse(choices=[]),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert reservation[FUSION_BUDGET_ACCUMULATED_COST_KEY] == 0.0
|
||||
assert proxy_logging.db_spend_update_writer.update_database.await_args.kwargs["response_cost"] == 0.0
|
||||
increment.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpriced_fusion_hidden_call_does_not_release_parent_reservation():
|
||||
logger = _ProxyDBLogger()
|
||||
reservation = {
|
||||
"reserved_cost": 1.0,
|
||||
"entries": [],
|
||||
"finalized": False,
|
||||
FUSION_BUDGET_ACTIVE_KEY: True,
|
||||
}
|
||||
kwargs = {
|
||||
"call_type": "acompletion",
|
||||
"model": "panel",
|
||||
"litellm_call_id": "unpriced-panel-call",
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"internal_call_origin": "fusion_panel",
|
||||
"user_api_key_budget_reservation": reservation,
|
||||
}
|
||||
},
|
||||
"standard_logging_object": {
|
||||
"response_cost": None,
|
||||
"response_cost_failure_debug_info": "missing custom price",
|
||||
"request_tags": [],
|
||||
"metadata": {},
|
||||
},
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj") as proxy_logging,
|
||||
patch(
|
||||
"litellm.proxy.spend_tracking.budget_reservation.release_budget_reservation",
|
||||
new_callable=AsyncMock,
|
||||
) as release_reservation,
|
||||
):
|
||||
proxy_logging.failed_tracking_alert = AsyncMock()
|
||||
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=kwargs,
|
||||
completion_response=litellm.ModelResponse(choices=[]),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
release_reservation.assert_not_awaited()
|
||||
assert reservation["finalized"] is False
|
||||
|
||||
|
||||
def test_only_hidden_fusion_failures_leave_parent_reservation_open():
|
||||
reservation = {FUSION_BUDGET_ACTIVE_KEY: True}
|
||||
assert _failure_should_leave_fusion_reservation_open(
|
||||
{
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"internal_call_origin": "fusion_panel",
|
||||
"user_api_key_budget_reservation": reservation,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert not _failure_should_leave_fusion_reservation_open(
|
||||
{
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"internal_call_origin": "fusion_continuation",
|
||||
"user_api_key_budget_reservation": reservation,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert not _failure_should_leave_fusion_reservation_open(
|
||||
{
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"internal_call_origin": "fusion_panel",
|
||||
"user_api_key_budget_reservation": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_database_and_spend_counters_invalidates_reservation_when_counter_update_fails():
|
||||
proxy_logging_obj = MagicMock()
|
||||
|
|
|
|||
|
|
@ -4022,7 +4022,7 @@ class TestStrategyRouterWriteValidation:
|
|||
violation = _strategy_router_write_violation(
|
||||
incoming_params=LiteLLM_Params(
|
||||
model="fusion_router",
|
||||
fusion_router_config={"panel_models": ["one"], "aggregator_model": "aggregator"},
|
||||
fusion_router_config={"outer_model": "outer", "panel_models": []},
|
||||
),
|
||||
existing_params=None,
|
||||
)
|
||||
|
|
@ -4037,15 +4037,15 @@ class TestStrategyRouterWriteValidation:
|
|||
|
||||
stored = LiteLLM_Params(
|
||||
model="fusion_router",
|
||||
fusion_router_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
fusion_router_config={"outer_model": "outer", "panel_models": ["panel-a", "panel-b"]},
|
||||
)
|
||||
assert (
|
||||
_strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(
|
||||
fusion_router_config={
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b", "panel-c"],
|
||||
"aggregator_model": "aggregator",
|
||||
"min_successful_panelists": 3,
|
||||
"invocation": "required",
|
||||
}
|
||||
),
|
||||
existing_params=stored,
|
||||
|
|
@ -4061,14 +4061,14 @@ class TestStrategyRouterWriteValidation:
|
|||
|
||||
stored = LiteLLM_Params(
|
||||
model="fusion_router",
|
||||
fusion_router_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
fusion_router_config={"outer_model": "outer", "panel_models": ["panel-a", "panel-b"]},
|
||||
)
|
||||
violation = _strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(fusion_router_config={}),
|
||||
existing_params=stored,
|
||||
)
|
||||
assert violation is not None
|
||||
assert "panel_models" in violation
|
||||
assert "outer_model" in violation
|
||||
|
||||
def test_fusion_config_on_regular_model_is_rejected(self):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
|
|
@ -4078,7 +4078,7 @@ class TestStrategyRouterWriteValidation:
|
|||
violation = _strategy_router_write_violation(
|
||||
incoming_params=LiteLLM_Params(
|
||||
model="openai/gpt-4o",
|
||||
fusion_router_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
fusion_router_config={"outer_model": "outer", "panel_models": ["panel-a", "panel-b"]},
|
||||
),
|
||||
existing_params=None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from fastapi import HTTPException
|
|||
|
||||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
|
||||
from litellm.constants import (
|
||||
FUSION_BUDGET_ACCUMULATED_COST_KEY,
|
||||
FUSION_BUDGET_CONTINUATION_STARTED_KEY,
|
||||
STREAM_SSE_KEEPALIVE_PING_BYTES,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
|
||||
AgenticAnthropicStreamingIterator,
|
||||
)
|
||||
|
|
@ -1049,7 +1053,7 @@ async def test_should_reserve_tiered_pricing_cost(spend_counter_state):
|
|||
await release_budget_reservation(reservation)
|
||||
|
||||
|
||||
def test_fusion_reservation_sums_panels_and_candidate_inflated_aggregator() -> None:
|
||||
def test_fusion_reservation_covers_initial_outer_panel_analyst_and_continuation() -> None:
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
@ -1061,16 +1065,21 @@ def test_fusion_reservation_sums_panels_and_candidate_inflated_aggregator() -> N
|
|||
"litellm_params": {"model": "openai/panel-b", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "aggregator",
|
||||
"litellm_params": {"model": "openai/aggregator", "api_key": "fake"},
|
||||
"model_name": "analyst",
|
||||
"litellm_params": {"model": "openai/analyst", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "outer",
|
||||
"litellm_params": {"model": "openai/outer", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "fusion/test",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"analyst_model": "analyst",
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
},
|
||||
|
|
@ -1083,11 +1092,18 @@ def test_fusion_reservation_sums_panels_and_candidate_inflated_aggregator() -> N
|
|||
"max_tokens": 10,
|
||||
}
|
||||
|
||||
def child_estimate(*, model: str, input_tokens: int | None = None, **_: object) -> float:
|
||||
if model == "aggregator":
|
||||
def child_estimate(
|
||||
*, model: str, request_body: dict, input_tokens: int | None = None, **_: object
|
||||
) -> float:
|
||||
if model == "analyst":
|
||||
assert input_tokens is not None
|
||||
assert input_tokens >= 9000
|
||||
assert request_body["max_completion_tokens"] == 16000
|
||||
return 3.0
|
||||
if model == "outer":
|
||||
assert request_body["max_tokens"] == 10
|
||||
return 4.0
|
||||
assert request_body["max_completion_tokens"] == 16000
|
||||
return {"panel-a": 1.0, "panel-b": 2.0}[model]
|
||||
|
||||
with patch( # test-quality-ok: isolates child pricing so this test measures Fusion aggregation, not registry prices
|
||||
|
|
@ -1100,10 +1116,10 @@ def test_fusion_reservation_sums_panels_and_candidate_inflated_aggregator() -> N
|
|||
llm_router=router,
|
||||
)
|
||||
|
||||
assert estimated == pytest.approx(6.0)
|
||||
assert estimated == pytest.approx(14.0)
|
||||
|
||||
|
||||
def test_fusion_cancel_floor_sums_child_input_costs() -> None:
|
||||
def test_fusion_cancel_floor_only_charges_the_guaranteed_initial_outer_input() -> None:
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
@ -1115,16 +1131,21 @@ def test_fusion_cancel_floor_sums_child_input_costs() -> None:
|
|||
"litellm_params": {"model": "openai/panel-b", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "aggregator",
|
||||
"litellm_params": {"model": "openai/aggregator", "api_key": "fake"},
|
||||
"model_name": "analyst",
|
||||
"litellm_params": {"model": "openai/analyst", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "outer",
|
||||
"litellm_params": {"model": "openai/outer", "api_key": "fake"},
|
||||
},
|
||||
{
|
||||
"model_name": "fusion/test",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"analyst_model": "analyst",
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
},
|
||||
|
|
@ -1138,11 +1159,7 @@ def test_fusion_cancel_floor_sums_child_input_costs() -> None:
|
|||
}
|
||||
|
||||
def child_input_estimate(*, model: str, input_tokens: int | None = None, **_: object) -> float:
|
||||
if model == "aggregator":
|
||||
assert input_tokens is not None
|
||||
assert input_tokens >= 9000
|
||||
return 3.0
|
||||
return {"panel-a": 1.0, "panel-b": 2.0}[model]
|
||||
return {"panel-a": 1.0, "panel-b": 2.0, "analyst": 3.0, "outer": 4.0}[model]
|
||||
|
||||
with patch( # test-quality-ok: isolates child pricing so this test measures Fusion aggregation, not registry prices
|
||||
"litellm.proxy.spend_tracking.budget_reservation._estimate_request_input_cost_for_model",
|
||||
|
|
@ -1154,7 +1171,87 @@ def test_fusion_cancel_floor_sums_child_input_costs() -> None:
|
|||
llm_router=router,
|
||||
)
|
||||
|
||||
assert estimated == pytest.approx(6.0)
|
||||
assert estimated == pytest.approx(4.0)
|
||||
|
||||
|
||||
def test_fusion_reservation_does_not_return_a_partial_additive_estimate() -> None:
|
||||
router = Router(
|
||||
model_list=[
|
||||
{"model_name": "panel", "litellm_params": {"model": "openai/panel", "api_key": "fake"}},
|
||||
{"model_name": "outer", "litellm_params": {"model": "openai/outer", "api_key": "fake"}},
|
||||
{
|
||||
"model_name": "fusion/test",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {"outer_model": "outer", "panel_models": ["panel"]},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def child_estimate(*, model: str, **_: object) -> float | None:
|
||||
return None if model == "panel" else 1.0
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.spend_tracking.budget_reservation._estimate_request_max_cost_for_model",
|
||||
side_effect=child_estimate,
|
||||
):
|
||||
estimated = estimate_request_max_cost(
|
||||
request_body={"model": "fusion/test", "messages": [{"role": "user", "content": "hello"}]},
|
||||
route="/chat/completions",
|
||||
llm_router=router,
|
||||
)
|
||||
|
||||
assert estimated is None
|
||||
|
||||
|
||||
def test_fusion_reservation_expands_private_search_loops_and_context() -> None:
|
||||
router = Router(
|
||||
model_list=[
|
||||
{"model_name": "panel", "litellm_params": {"model": "openai/panel", "api_key": "fake"}},
|
||||
{"model_name": "analyst", "litellm_params": {"model": "openai/analyst", "api_key": "fake"}},
|
||||
{"model_name": "outer", "litellm_params": {"model": "openai/outer", "api_key": "fake"}},
|
||||
{
|
||||
"model_name": "fusion/test",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel"],
|
||||
"analyst_model": "analyst",
|
||||
"search_tool_name": "web-search",
|
||||
"max_tool_calls": 2,
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
observed: list[tuple[str, int | None]] = []
|
||||
|
||||
def child_estimate(*, model: str, input_tokens: int | None = None, **_: object) -> float:
|
||||
observed.append((model, input_tokens))
|
||||
return 1.0
|
||||
|
||||
with patch( # test-quality-ok: isolates pricing to verify multiplicity and conservative context ceilings
|
||||
"litellm.proxy.spend_tracking.budget_reservation._estimate_request_max_cost_for_model",
|
||||
side_effect=child_estimate,
|
||||
):
|
||||
estimated = estimate_request_max_cost(
|
||||
request_body={
|
||||
"model": "fusion/test",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"max_tokens": 10,
|
||||
},
|
||||
route="/chat/completions",
|
||||
llm_router=router,
|
||||
)
|
||||
|
||||
assert estimated == pytest.approx(8.0)
|
||||
assert ("panel", 13024) in observed
|
||||
assert ("analyst", 18048) in observed
|
||||
final_outer_tokens = [tokens for model, tokens in observed if model == "outer"][-1]
|
||||
assert final_outer_tokens is not None and final_outer_tokens >= 26048
|
||||
|
||||
|
||||
def test_tiered_reservation_is_all_or_nothing_with_output_tier_from_input_length():
|
||||
|
|
@ -2804,6 +2901,30 @@ async def test_release_budget_reservation_on_cancel_swallows_release_errors():
|
|||
await release_budget_reservation_on_cancel(reservation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fusion_release_and_cancel_keep_already_billed_hidden_costs():
|
||||
reservation = {
|
||||
"reserved_cost": 3.0,
|
||||
"entries": [],
|
||||
"finalized": False,
|
||||
"input_cost": 0.5,
|
||||
FUSION_BUDGET_ACCUMULATED_COST_KEY: 0.3,
|
||||
}
|
||||
with patch(
|
||||
"litellm.proxy.spend_tracking.budget_reservation.reconcile_budget_reservation",
|
||||
new=AsyncMock(),
|
||||
) as reconcile:
|
||||
await release_budget_reservation(reservation)
|
||||
assert reconcile.await_args.kwargs["actual_cost"] == pytest.approx(0.3)
|
||||
|
||||
await release_budget_reservation_on_cancel(reservation)
|
||||
assert reconcile.await_args.kwargs["actual_cost"] == pytest.approx(0.3)
|
||||
|
||||
reservation[FUSION_BUDGET_CONTINUATION_STARTED_KEY] = True
|
||||
await release_budget_reservation_on_cancel(reservation)
|
||||
assert reconcile.await_args.kwargs["actual_cost"] == pytest.approx(0.8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_cancel_in_slow_path_before_yield_refunds(spend_counter_state):
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
|
|
|
|||
|
|
@ -693,8 +693,8 @@ async def test_run_model_health_check_skips_fusion_deployment():
|
|||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
},
|
||||
},
|
||||
"model_info": {},
|
||||
|
|
@ -709,7 +709,7 @@ async def test_run_model_health_check_skips_fusion_deployment():
|
|||
assert result == {}
|
||||
|
||||
|
||||
def _fusion_health_fixture(on_quorum_failure="fail"):
|
||||
def _fusion_health_fixture():
|
||||
return litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
@ -723,19 +723,17 @@ def _fusion_health_fixture(on_quorum_failure="fail"):
|
|||
"model_info": {"id": "panel-b-1"},
|
||||
},
|
||||
{
|
||||
"model_name": "aggregator",
|
||||
"model_name": "outer",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-x"},
|
||||
"model_info": {"id": "aggregator-1"},
|
||||
"model_info": {"id": "outer-1"},
|
||||
},
|
||||
{
|
||||
"model_name": "fusion/quality",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"min_successful_panelists": 2,
|
||||
"on_quorum_failure": on_quorum_failure,
|
||||
},
|
||||
},
|
||||
"model_info": {"id": "fusion-1"},
|
||||
|
|
@ -744,37 +742,36 @@ def _fusion_health_fixture(on_quorum_failure="fail"):
|
|||
)
|
||||
|
||||
|
||||
def test_fusion_health_uses_panel_quorum_and_aggregator_health():
|
||||
def test_fusion_health_requires_outer_but_treats_deliberation_dependencies_as_degradable():
|
||||
router = _fusion_health_fixture()
|
||||
healthy = [{"model_id": "fusion-1"}, {"model_id": "panel-a-1"}, {"model_id": "aggregator-1"}]
|
||||
healthy = [{"model_id": "fusion-1"}, {"model_id": "panel-a-1"}, {"model_id": "outer-1"}]
|
||||
unhealthy = [{"model_id": "panel-b-1", "error": "boom"}]
|
||||
|
||||
new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints(
|
||||
healthy, unhealthy, router.model_list, router, ()
|
||||
)
|
||||
|
||||
assert {endpoint["model_id"] for endpoint in new_healthy} == {"panel-a-1", "aggregator-1"}
|
||||
fusion_failure = next(endpoint for endpoint in new_unhealthy if endpoint["model_id"] == "fusion-1")
|
||||
assert "panel quorum cannot be met" in fusion_failure["error"]
|
||||
assert {endpoint["model_id"] for endpoint in new_healthy} == {"fusion-1", "panel-a-1", "outer-1"}
|
||||
assert {endpoint["model_id"] for endpoint in new_unhealthy} == {"panel-b-1"}
|
||||
|
||||
healthy = [{"model_id": "fusion-1"}, {"model_id": "panel-a-1"}, {"model_id": "panel-b-1"}]
|
||||
unhealthy = [{"model_id": "aggregator-1", "error": "boom"}]
|
||||
unhealthy = [{"model_id": "outer-1", "error": "boom"}]
|
||||
_, new_unhealthy = hc_module._finalize_strategy_router_endpoints(healthy, unhealthy, router.model_list, router, ())
|
||||
fusion_failure = next(endpoint for endpoint in new_unhealthy if endpoint["model_id"] == "fusion-1")
|
||||
assert fusion_failure["error"] == "aggregator model 'aggregator' has no healthy deployment"
|
||||
assert fusion_failure["error"] == "outer model 'outer' has no healthy deployment"
|
||||
|
||||
|
||||
def test_resilient_fusion_health_allows_panel_failure_and_dependency_probe_finds_all_members():
|
||||
router = _fusion_health_fixture(on_quorum_failure="aggregator_only")
|
||||
def test_fusion_dependency_probe_finds_all_members():
|
||||
router = _fusion_health_fixture()
|
||||
marker = next(deployment for deployment in router.model_list if deployment["model_info"]["id"] == "fusion-1")
|
||||
probes = hc_module._dependency_deployments_to_probe([marker], router.model_list, router)
|
||||
assert {deployment["model_info"]["id"] for deployment in probes} == {
|
||||
"panel-a-1",
|
||||
"panel-b-1",
|
||||
"aggregator-1",
|
||||
"outer-1",
|
||||
}
|
||||
|
||||
healthy = [{"model_id": "fusion-1"}, {"model_id": "aggregator-1"}]
|
||||
healthy = [{"model_id": "fusion-1"}, {"model_id": "outer-1"}]
|
||||
unhealthy = [
|
||||
{"model_id": "panel-a-1", "error": "boom"},
|
||||
{"model_id": "panel-b-1", "error": "boom"},
|
||||
|
|
@ -782,7 +779,7 @@ def test_resilient_fusion_health_allows_panel_failure_and_dependency_probe_finds
|
|||
new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints(
|
||||
healthy, unhealthy, router.model_list, router, ()
|
||||
)
|
||||
assert {endpoint["model_id"] for endpoint in new_healthy} == {"fusion-1", "aggregator-1"}
|
||||
assert {endpoint["model_id"] for endpoint in new_healthy} == {"fusion-1", "outer-1"}
|
||||
assert {endpoint["model_id"] for endpoint in new_unhealthy} == {"panel-a-1", "panel-b-1"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, cast
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.fusion_router import (
|
||||
FUSION_TOOL_NAME,
|
||||
FusionRouterConfig,
|
||||
build_fusion_router,
|
||||
fusion_router_dependencies,
|
||||
|
|
@ -29,9 +31,42 @@ def _response(content: str | None, tool_calls: list[dict[str, object]] | None =
|
|||
)
|
||||
|
||||
|
||||
def _fusion_call(query: str = "Investigate this") -> ModelResponse:
|
||||
return _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "fusion-call-1",
|
||||
"type": "function",
|
||||
"function": {"name": FUSION_TOOL_NAME, "arguments": json.dumps({"query": query})},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _analysis() -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"consensus": ["Both approaches agree on the root cause."],
|
||||
"contradictions": [
|
||||
{
|
||||
"topic": "rollout order",
|
||||
"stances": [
|
||||
{"model": "panel-a", "stance": "lock first"},
|
||||
{"model": "panel-b", "stance": "idempotency first"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"partial_coverage": [],
|
||||
"unique_insights": [{"model": "panel-b", "insight": "identified an edge case"}],
|
||||
"blind_spots": ["Neither response measured latency."],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RecordingCompletion:
|
||||
def __init__(self, responses: Mapping[str, ModelResponse | Exception | CustomStreamWrapper]) -> None:
|
||||
self.responses: Final = responses
|
||||
def __init__(self, responses: Mapping[str, Sequence[ModelResponse | Exception]]) -> None:
|
||||
self.responses: Final = {model: deque(values) for model, values in responses.items()}
|
||||
self.calls: Final[list[dict[str, object]]] = []
|
||||
self.active_panel_calls = 0
|
||||
self.max_active_panel_calls = 0
|
||||
|
|
@ -50,300 +85,363 @@ class RecordingCompletion:
|
|||
self.max_active_panel_calls = max(self.max_active_panel_calls, self.active_panel_calls)
|
||||
await asyncio.sleep(0.01)
|
||||
self.active_panel_calls -= 1
|
||||
response: Final = self.responses[model]
|
||||
response = self.responses[model].popleft()
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_panel_runs_in_parallel_and_aggregator_synthesizes_from_canonical_history() -> None:
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"panel-a": _response("First approach"),
|
||||
"panel-b": _response("Second approach"),
|
||||
"aggregator": _response("Synthesized answer"),
|
||||
}
|
||||
)
|
||||
router = build_fusion_router(
|
||||
model_name="fusion/coding",
|
||||
raw_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
def _router(
|
||||
completion: RecordingCompletion,
|
||||
search=None,
|
||||
**config: object,
|
||||
):
|
||||
return build_fusion_router(
|
||||
model_name="fusion/test",
|
||||
raw_config={
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"analyst_model": "analyst",
|
||||
**config,
|
||||
},
|
||||
completion=completion,
|
||||
search=search,
|
||||
)
|
||||
messages: list[AllMessageValues] = [
|
||||
{"role": "system", "content": "Be accurate"},
|
||||
{"role": "user", "content": "Fix the bug"},
|
||||
{"role": "assistant", "content": None, "tool_calls": []},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "traceback"},
|
||||
{"role": "user", "content": "Continue"},
|
||||
]
|
||||
|
||||
response = await router.acompletion(messages=messages, stream=False, request_kwargs={})
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Synthesized answer"
|
||||
assert completion.max_active_panel_calls == 2
|
||||
panel_calls = completion.calls[:2]
|
||||
assert {call["model"] for call in panel_calls} == {"panel-a", "panel-b"}
|
||||
assert all(call["messages"] == messages for call in panel_calls)
|
||||
aggregator_messages = completion.calls[-1]["messages"]
|
||||
assert isinstance(aggregator_messages, list)
|
||||
assert aggregator_messages[0] == messages[0]
|
||||
assert aggregator_messages[1]["role"] == "developer"
|
||||
assert aggregator_messages[2:] == messages[1:]
|
||||
candidate_payload = str(aggregator_messages[1]["content"]).split("Candidate responses:\n", 1)[1]
|
||||
candidates = json.loads(candidate_payload)
|
||||
assert [candidate["content"] for candidate in candidates] == ["First approach", "Second approach"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_panel_gets_only_function_schemas_and_aggregator_owns_tool_call() -> None:
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"panel-a": _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "panel-call-id",
|
||||
"type": "function",
|
||||
"function": {"name": "send_email", "arguments": '{"to":"a@example.com"}'},
|
||||
}
|
||||
],
|
||||
),
|
||||
"panel-b": _response("Ask before sending"),
|
||||
"aggregator": _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "authoritative-call-id",
|
||||
"type": "function",
|
||||
"function": {"name": "send_email", "arguments": '{"to":"a@example.com"}'},
|
||||
}
|
||||
],
|
||||
),
|
||||
}
|
||||
)
|
||||
router = build_fusion_router(
|
||||
model_name="fusion/actions",
|
||||
raw_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
completion=completion,
|
||||
)
|
||||
function_tool: Final = {
|
||||
async def test_outer_can_skip_fusion_and_answer_or_call_client_tools_directly() -> None:
|
||||
completion = RecordingCompletion({"outer": [_response("Hello!")]})
|
||||
router = _router(completion)
|
||||
client_tool = {
|
||||
"type": "function",
|
||||
"function": {"name": "send_email", "parameters": {"type": "object"}},
|
||||
}
|
||||
hosted_tool: Final = {"type": "web_search_preview"}
|
||||
hosted_tool_choice: Final = {"type": "web_search_preview"}
|
||||
|
||||
response = await router.acompletion(
|
||||
messages=[{"role": "user", "content": "Say hello"}],
|
||||
stream=False,
|
||||
request_kwargs={"tools": [client_tool], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Hello!"
|
||||
assert [call["model"] for call in completion.calls] == ["outer"]
|
||||
initial = completion.calls[0]
|
||||
assert [tool["function"]["name"] for tool in initial["tools"]] == ["send_email", FUSION_TOOL_NAME]
|
||||
assert initial["tool_choice"] == "auto"
|
||||
assert initial["messages"] == [{"role": "user", "content": "Say hello"}]
|
||||
assert response._hidden_params["fusion"]["invoked"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outer_client_tool_call_is_returned_without_running_panel_or_second_outer_call() -> None:
|
||||
client_call = {
|
||||
"id": "email-1",
|
||||
"type": "function",
|
||||
"function": {"name": "send_email", "arguments": '{"to":"user@example.com"}'},
|
||||
}
|
||||
completion = RecordingCompletion({"outer": [_response(None, [client_call])]})
|
||||
router = _router(completion)
|
||||
|
||||
response = await router.acompletion(
|
||||
messages=[{"role": "user", "content": "Send the update"}],
|
||||
stream=False,
|
||||
request_kwargs={
|
||||
"tools": [function_tool, hosted_tool],
|
||||
"tool_choice": hosted_tool_choice,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "send_email", "parameters": {"type": "object"}},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.tool_calls[0].function.name == "send_email"
|
||||
assert [call["model"] for call in completion.calls] == ["outer"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_fusion_runs_parallel_panel_then_analyst_then_outer() -> None:
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"outer": [_fusion_call("Find and fix the race"), _response("Final answer")],
|
||||
"panel-a": [_response("Use a lock")],
|
||||
"panel-b": [_response("Use idempotency")],
|
||||
"analyst": [_response(_analysis())],
|
||||
}
|
||||
)
|
||||
router = _router(completion, invocation="required")
|
||||
messages: list[AllMessageValues] = [
|
||||
{"role": "system", "content": "Be accurate"},
|
||||
{"role": "user", "content": "Fix the bug"},
|
||||
]
|
||||
client_tool = {
|
||||
"type": "function",
|
||||
"function": {"name": "apply_patch", "parameters": {"type": "object"}},
|
||||
}
|
||||
|
||||
response = await router.acompletion(
|
||||
messages=messages,
|
||||
stream=False,
|
||||
request_kwargs={
|
||||
"tools": [client_tool],
|
||||
"tool_choice": "required",
|
||||
"litellm_metadata": {
|
||||
"user_api_key_budget_reservation": {"id": "must-not-propagate"},
|
||||
"user_api_key_user_id": "u-1",
|
||||
"user_api_key_budget_reservation": {"id": "parent-only"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.tool_calls[0].id == "authoritative-call-id"
|
||||
for panel_call in completion.calls[:2]:
|
||||
assert panel_call["tools"] == [function_tool]
|
||||
assert "tool_choice" not in panel_call
|
||||
assert panel_call["metadata"]["internal_call_origin"] == "fusion_panel"
|
||||
assert "user_api_key_budget_reservation" not in panel_call["metadata"]
|
||||
aggregator_call = completion.calls[-1]
|
||||
assert aggregator_call["tools"] == [function_tool, hosted_tool]
|
||||
assert aggregator_call["tool_choice"] == hosted_tool_choice
|
||||
aggregator_metadata = cast(Mapping[str, object], aggregator_call["litellm_metadata"])
|
||||
assert aggregator_metadata["user_api_key_budget_reservation"] == {"id": "must-not-propagate"}
|
||||
aggregator_messages = aggregator_call["messages"]
|
||||
assert isinstance(aggregator_messages, list)
|
||||
instruction = str(aggregator_messages[0]["content"])
|
||||
assert "panel-call-id" not in instruction
|
||||
assert "send_email" in instruction
|
||||
assert response.choices[0].message.content == "Final answer"
|
||||
assert completion.max_active_panel_calls == 2
|
||||
assert [call["model"] for call in completion.calls] == ["outer", "panel-a", "panel-b", "analyst", "outer"]
|
||||
panel_calls = completion.calls[1:3]
|
||||
assert [tool["function"]["name"] for tool in completion.calls[0]["tools"]] == [
|
||||
"apply_patch",
|
||||
FUSION_TOOL_NAME,
|
||||
]
|
||||
assert completion.calls[0]["tool_choice"] == {
|
||||
"type": "function",
|
||||
"function": {"name": FUSION_TOOL_NAME},
|
||||
}
|
||||
assert all("tools" not in call for call in panel_calls)
|
||||
assert all(call["messages"][-1] == {"role": "user", "content": "Find and fix the race"} for call in panel_calls)
|
||||
assert all(call["reasoning_effort"] == "none" for call in panel_calls)
|
||||
assert all(call["metadata"]["internal_call_origin"] == "fusion_panel" for call in panel_calls)
|
||||
reservation = completion.calls[0]["metadata"]["user_api_key_budget_reservation"]
|
||||
assert completion.calls[0]["metadata"]["internal_call_origin"] == "fusion_initial"
|
||||
assert all(call["metadata"]["user_api_key_budget_reservation"] is reservation for call in panel_calls)
|
||||
analyst = completion.calls[3]
|
||||
assert analyst["temperature"] == 0
|
||||
assert analyst["response_format"] == {"type": "json_object"}
|
||||
assert analyst["metadata"]["internal_call_origin"] == "fusion_analyst"
|
||||
assert analyst["metadata"]["user_api_key_budget_reservation"] is reservation
|
||||
final = completion.calls[4]
|
||||
assert final["tools"] == [client_tool]
|
||||
assert final["tool_choice"] == "auto"
|
||||
continuation = final["messages"]
|
||||
assert continuation[0] == messages[0]
|
||||
assert continuation[1]["role"] == "developer"
|
||||
assert "untrusted evidence" in continuation[1]["content"]
|
||||
assert continuation[2] == messages[1]
|
||||
assert continuation[3]["tool_calls"][0]["function"]["name"] == FUSION_TOOL_NAME
|
||||
payload = json.loads(continuation[4]["content"])
|
||||
assert payload["analysis"]["consensus"][0].startswith("Both approaches")
|
||||
assert [item["content"] for item in payload["responses"]] == ["Use a lock", "Use idempotency"]
|
||||
assert final["metadata"]["internal_call_origin"] == "fusion_continuation"
|
||||
assert final["metadata"]["user_api_key_budget_reservation"] is reservation
|
||||
assert response._hidden_params["fusion"] == {
|
||||
"invoked": True,
|
||||
"protocol": "fusion-tool-v1",
|
||||
"panel_successes": 2,
|
||||
"panel_failures": 0,
|
||||
"analysis_available": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quorum_failure_modes_and_candidate_bound() -> None:
|
||||
failing_completion = RecordingCompletion(
|
||||
{
|
||||
"panel-a": _response("x" * 2000),
|
||||
"panel-b": RuntimeError("provider down"),
|
||||
"aggregator": _response("fallback"),
|
||||
}
|
||||
)
|
||||
fail_router = build_fusion_router(
|
||||
model_name="fusion/quality",
|
||||
raw_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
completion=failing_completion,
|
||||
)
|
||||
with pytest.raises(litellm.ServiceUnavailableError, match="quorum"):
|
||||
await fail_router.acompletion(messages=[{"role": "user", "content": "Answer"}], stream=False, request_kwargs={})
|
||||
assert [call["model"] for call in failing_completion.calls] == ["panel-a", "panel-b"]
|
||||
|
||||
resilient_completion = RecordingCompletion(failing_completion.responses)
|
||||
resilient_router = build_fusion_router(
|
||||
model_name="fusion/resilient",
|
||||
raw_config={
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"on_quorum_failure": "aggregator_only",
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
completion=resilient_completion,
|
||||
)
|
||||
response = await resilient_router.acompletion(
|
||||
messages=[{"role": "user", "content": "Answer"}], stream=False, request_kwargs={}
|
||||
)
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert resilient_completion.calls[-1]["messages"] == [{"role": "user", "content": "Answer"}]
|
||||
|
||||
bounded_completion = RecordingCompletion(
|
||||
{
|
||||
"panel-a": _response("x" * 2000),
|
||||
"panel-b": _response("second"),
|
||||
"aggregator": _response("bounded"),
|
||||
}
|
||||
)
|
||||
bounded_router = build_fusion_router(
|
||||
model_name="fusion/bounded",
|
||||
raw_config={
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
completion=bounded_completion,
|
||||
)
|
||||
await bounded_router.acompletion(messages=[{"role": "user", "content": "Answer"}], stream=False, request_kwargs={})
|
||||
instruction = str(bounded_completion.calls[-1]["messages"][0]["content"])
|
||||
payload = json.loads(instruction.split("Candidate responses:\n", 1)[1])
|
||||
assert len(json.dumps(payload[0], ensure_ascii=False, separators=(",", ":"))) <= 1000
|
||||
assert payload[0]["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_candidate_bound_includes_function_and_custom_tool_payloads() -> None:
|
||||
oversized_arguments = json.dumps({"patch": "x" * 4000})
|
||||
async def test_partial_panel_and_invalid_analyst_degrade_to_raw_responses() -> None:
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"panel-a": _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "function-call",
|
||||
"type": "function",
|
||||
"function": {"name": "apply_patch", "arguments": oversized_arguments},
|
||||
}
|
||||
],
|
||||
),
|
||||
"panel-b": _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "custom-call",
|
||||
"type": "custom",
|
||||
"custom": {"name": "research", "input": "漢" * 4000},
|
||||
}
|
||||
],
|
||||
),
|
||||
"aggregator": _response("bounded"),
|
||||
"outer": [_fusion_call(), _response("Recovered")],
|
||||
"panel-a": [_response("Useful evidence")],
|
||||
"panel-b": [RuntimeError("down")],
|
||||
"analyst": [_response("not-json")],
|
||||
}
|
||||
)
|
||||
router = build_fusion_router(
|
||||
model_name="fusion/bounded-tools",
|
||||
raw_config={
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
"max_candidate_chars": 1000,
|
||||
},
|
||||
completion=completion,
|
||||
|
||||
response = await _router(completion).acompletion(
|
||||
messages=[{"role": "user", "content": "Hard question"}],
|
||||
stream=False,
|
||||
request_kwargs={},
|
||||
)
|
||||
|
||||
await router.acompletion(messages=[{"role": "user", "content": "Act"}], stream=False, request_kwargs={})
|
||||
|
||||
instruction = str(completion.calls[-1]["messages"][0]["content"])
|
||||
payload = json.loads(instruction.split("Candidate responses:\n", 1)[1])
|
||||
assert len(payload) == 2
|
||||
for candidate in payload:
|
||||
assert len(json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))) <= 1000
|
||||
assert candidate["truncated"] is True
|
||||
assert candidate["tool_proposals"] == []
|
||||
assert isinstance(response, ModelResponse)
|
||||
payload = json.loads(completion.calls[-1]["messages"][-1]["content"])
|
||||
assert payload["status"] == "ok"
|
||||
assert "analysis" not in payload
|
||||
assert payload["responses"][0]["content"] == "Useful evidence"
|
||||
assert payload["failed_models"] == [
|
||||
{"model": "panel-b", "error_type": "RuntimeError", "failure_reason": "unexpected_error"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_n_greater_than_one_is_rejected_before_any_child_call() -> None:
|
||||
completion = RecordingCompletion({})
|
||||
router = build_fusion_router(
|
||||
model_name="fusion/test",
|
||||
raw_config={"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator"},
|
||||
completion=completion,
|
||||
async def test_all_panel_failures_are_a_typed_tool_result_the_outer_can_recover_from() -> None:
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"outer": [_fusion_call(), _response("Answered without the panel")],
|
||||
"panel-a": [litellm.RateLimitError("slow", "openai", "panel-a")],
|
||||
"panel-b": [litellm.RateLimitError("slow", "openai", "panel-b")],
|
||||
"analyst": [],
|
||||
}
|
||||
)
|
||||
|
||||
response = await _router(completion).acompletion(
|
||||
messages=[{"role": "user", "content": "Hard question"}],
|
||||
stream=False,
|
||||
request_kwargs={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Answered without the panel"
|
||||
assert [call["model"] for call in completion.calls] == ["outer", "panel-a", "panel-b", "outer"]
|
||||
assert completion.calls[0]["tool_choice"] == "required"
|
||||
assert "tool_choice" not in completion.calls[-1]
|
||||
payload = json.loads(completion.calls[-1]["messages"][-1]["content"])
|
||||
assert payload["status"] == "error"
|
||||
assert payload["failure_reason"] == "rate_limited"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_fusion_arguments_continue_with_typed_error_and_mark_invocation() -> None:
|
||||
invalid_fusion_call = _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "fusion-call-1",
|
||||
"type": "function",
|
||||
"function": {"name": FUSION_TOOL_NAME, "arguments": "not-json"},
|
||||
}
|
||||
],
|
||||
)
|
||||
completion = RecordingCompletion({"outer": [invalid_fusion_call, _response("Recovered")]})
|
||||
|
||||
response = await _router(completion).acompletion(
|
||||
messages=[{"role": "user", "content": "Hard question"}],
|
||||
stream=False,
|
||||
request_kwargs={},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert [call["model"] for call in completion.calls] == ["outer", "outer"]
|
||||
payload = json.loads(completion.calls[-1]["messages"][-1]["content"])
|
||||
assert payload["status"] == "error"
|
||||
assert payload["failure_reason"] == "invalid_tool_arguments"
|
||||
assert response._hidden_params["fusion"] == {
|
||||
"invoked": True,
|
||||
"protocol": "fusion-tool-v1",
|
||||
"panel_successes": 0,
|
||||
"panel_failures": 0,
|
||||
"analysis_available": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_search_tool_is_private_to_panel_and_analyst() -> None:
|
||||
search_calls: list[dict[str, object]] = []
|
||||
|
||||
async def search(**kwargs: object) -> object:
|
||||
search_calls.append(dict(kwargs))
|
||||
return {"results": [{"title": "Source", "url": "https://example.com", "snippet": "Evidence"}]}
|
||||
|
||||
research_call = _response(
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "search-1",
|
||||
"type": "function",
|
||||
"function": {"name": "litellm_fusion_search", "arguments": '{"query":"current evidence"}'},
|
||||
}
|
||||
],
|
||||
)
|
||||
completion = RecordingCompletion(
|
||||
{
|
||||
"outer": [_fusion_call(), _response("Final")],
|
||||
"panel-a": [research_call, _response("Evidence-backed answer")],
|
||||
"panel-b": [_response("Independent answer")],
|
||||
"analyst": [_response(_analysis())],
|
||||
}
|
||||
)
|
||||
|
||||
reservation = {"id": "shared-reservation"}
|
||||
response = await _router(completion, search=search, search_tool_name="web-search", max_tool_calls=4).acompletion(
|
||||
messages=[{"role": "user", "content": "Research this"}],
|
||||
stream=False,
|
||||
request_kwargs={"litellm_metadata": {"user_api_key_budget_reservation": reservation}},
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert search_calls[0]["model"] == "web-search"
|
||||
assert search_calls[0]["query"] == "current evidence"
|
||||
assert search_calls[0]["litellm_metadata"]["internal_call_origin"] == "fusion_research"
|
||||
assert search_calls[0]["litellm_metadata"]["user_api_key_budget_reservation"] is reservation
|
||||
second_panel_call = [call for call in completion.calls if call["model"] == "panel-a"][1]
|
||||
assert second_panel_call["messages"][-1]["role"] == "tool"
|
||||
assert completion.calls[-1].get("tools") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_tool_name_and_multiple_choices_are_rejected_before_calls() -> None:
|
||||
completion = RecordingCompletion({})
|
||||
router = _router(completion)
|
||||
with pytest.raises(litellm.BadRequestError, match="n=1"):
|
||||
await router.acompletion(
|
||||
messages=[{"role": "user", "content": "Answer"}], stream=False, request_kwargs={"n": 2}
|
||||
)
|
||||
with pytest.raises(litellm.BadRequestError, match="reserved"):
|
||||
await router.acompletion(
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
stream=False,
|
||||
request_kwargs={
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": FUSION_TOOL_NAME, "parameters": {"type": "object"}},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert completion.calls == []
|
||||
|
||||
|
||||
def test_config_write_validation_and_dependencies() -> None:
|
||||
assert validate_fusion_router_write("openai/gpt-4o", {"panel_models": [], "aggregator_model": "x"}) is not None
|
||||
def test_config_validation_and_dependencies() -> None:
|
||||
assert validate_fusion_router_write("fusion_router", None) is not None
|
||||
assert (
|
||||
validate_fusion_router_write(
|
||||
"fusion_router",
|
||||
{"panel_models": ["same", "same"], "aggregator_model": "aggregator"},
|
||||
)
|
||||
is not None
|
||||
assert validate_fusion_router_write(
|
||||
"fusion_router", {"outer_model": "outer", "panel_models": [f"p-{i}" for i in range(9)]}
|
||||
)
|
||||
params: Final = {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"panel_models": ["panel-a", "panel-b", "aggregator"],
|
||||
"aggregator_model": "aggregator",
|
||||
"outer_model": "outer",
|
||||
"panel_models": ["panel-a", "panel-b", "outer"],
|
||||
},
|
||||
}
|
||||
assert validate_fusion_router_write("fusion_router", params["fusion_router_config"]) is None
|
||||
assert [(dependency.model_name, dependency.role) for dependency in fusion_router_dependencies(params)] == [
|
||||
("panel-a", "panel"),
|
||||
("panel-b", "panel"),
|
||||
("aggregator", "panel"),
|
||||
("aggregator", "aggregator"),
|
||||
("outer", "panel"),
|
||||
("outer", "analyst"),
|
||||
("outer", "outer"),
|
||||
]
|
||||
|
||||
|
||||
def test_config_is_frozen_and_rejects_unknown_fields() -> None:
|
||||
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
|
||||
with pytest.raises(ValueError, match="Extra inputs"):
|
||||
FusionRouterConfig.model_validate(
|
||||
{"panel_models": ["panel-a", "panel-b"], "aggregator_model": "aggregator", "cadence": "automatic"}
|
||||
{"outer_model": "outer", "panel_models": ["panel"], "aggregator_model": "old-shape"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="outer_model must not be empty"):
|
||||
FusionRouterConfig.model_validate({"outer_model": " ", "panel_models": ["panel"]})
|
||||
|
||||
|
||||
def _router_model_list() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"model_name": "outer",
|
||||
"litellm_params": {"model": "openai/test", "api_key": "fake", "mock_response": "Final"},
|
||||
},
|
||||
{
|
||||
"model_name": "panel-a",
|
||||
"litellm_params": {"model": "openai/test", "api_key": "fake", "mock_response": "Panel A"},
|
||||
},
|
||||
{
|
||||
"model_name": "panel-b",
|
||||
"litellm_params": {"model": "openai/test", "api_key": "fake", "mock_response": "Panel B"},
|
||||
},
|
||||
{
|
||||
"model_name": "aggregator",
|
||||
"litellm_params": {"model": "openai/test", "api_key": "fake", "mock_response": "Final"},
|
||||
},
|
||||
{
|
||||
"model_name": "fusion/test",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "aggregator",
|
||||
},
|
||||
"fusion_router_config": {"outer_model": "outer", "panel_models": ["panel-a"]},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -352,145 +450,70 @@ def _router_model_list() -> list[dict[str, object]]:
|
|||
@pytest.mark.asyncio
|
||||
async def test_router_registers_and_executes_fusion_deployment() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
response = await router.acompletion(model="fusion/test", messages=[{"role": "user", "content": "Answer"}])
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Final"
|
||||
deployment = router.get_deployment(model_id=router.model_list[-1]["model_info"]["id"])
|
||||
assert deployment is not None
|
||||
|
||||
router._unregister_fusion_router_for_deployment( # pyright: ignore[reportPrivateUsage] # regression covers registry lifecycle
|
||||
deployment
|
||||
)
|
||||
router._unregister_fusion_router_for_deployment(deployment) # pyright: ignore[reportPrivateUsage]
|
||||
assert "fusion/test" not in router.fusion_routers
|
||||
router.init_fusion_router_deployment(deployment)
|
||||
assert "fusion/test" in router.fusion_routers
|
||||
|
||||
router.delete_deployment(id=deployment.model_info.id)
|
||||
assert "fusion/test" not in router.fusion_routers
|
||||
|
||||
|
||||
def test_router_upsert_replaces_fusion_config_and_restores_after_invalid_update() -> None:
|
||||
router = Router(model_list=_router_model_list(), ignore_invalid_deployments=True)
|
||||
model_id = router.model_list[-1]["model_info"]["id"]
|
||||
deployment = router.get_deployment(model_id=model_id)
|
||||
assert deployment is not None
|
||||
updated = deployment.model_copy(deep=True)
|
||||
updated.litellm_params.fusion_router_config = {
|
||||
"panel_models": ["panel-a", "panel-b"],
|
||||
"aggregator_model": "panel-a",
|
||||
}
|
||||
|
||||
assert router.upsert_deployment(updated) is not None
|
||||
assert router.fusion_routers["fusion/test"].config.aggregator_model == "panel-a"
|
||||
|
||||
stored = router.get_deployment(model_id=model_id)
|
||||
assert stored is not None
|
||||
invalid = stored.model_copy(deep=True)
|
||||
invalid.litellm_params.fusion_router_config = {
|
||||
"panel_models": ["panel-a"],
|
||||
"aggregator_model": "aggregator",
|
||||
}
|
||||
assert router.upsert_deployment(invalid) is None
|
||||
assert router.fusion_routers["fusion/test"].config.aggregator_model == "panel-a"
|
||||
assert router.get_deployment(model_id=model_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_responses_api_bridges_through_the_same_fusion_model() -> None:
|
||||
async def test_router_replays_direct_outer_response_as_an_async_stream() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
response = await router.aresponses(model="fusion/test", input="Answer")
|
||||
direct_response = await router._fusion_aware_aresponses( # pyright: ignore[reportPrivateUsage] # regression covers the Fusion bridge
|
||||
model="fusion/test", input="Answer"
|
||||
)
|
||||
|
||||
assert response.output[0].content[0].text == "Final"
|
||||
assert direct_response.output[0].content[0].text == "Final"
|
||||
with pytest.raises(litellm.BadRequestError, match="Background Responses"):
|
||||
await router.aresponses(model="fusion/test", input="Answer", background=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_anthropic_messages_bridges_through_the_same_fusion_model() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
assert inspect.iscoroutinefunction(router.aanthropic_messages)
|
||||
assert inspect.iscoroutinefunction(router.anthropic_messages)
|
||||
|
||||
response = await router.aanthropic_messages(
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
max_tokens=256,
|
||||
)
|
||||
alias_response = await router.anthropic_messages(
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
max_tokens=256,
|
||||
)
|
||||
direct_response = await router._fusion_aware_aanthropic_messages( # pyright: ignore[reportPrivateUsage] # regression covers the Fusion bridge
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
max_tokens=256,
|
||||
)
|
||||
|
||||
assert response["content"][0]["text"] == "Final"
|
||||
assert alias_response["content"][0]["text"] == "Final"
|
||||
assert direct_response["content"][0]["text"] == "Final"
|
||||
|
||||
|
||||
def test_sync_responses_api_supports_nonstreaming_fusion() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
response = router.responses(model="fusion/test", input="Answer")
|
||||
direct_response = router._fusion_aware_responses( # pyright: ignore[reportPrivateUsage] # regression covers the Fusion bridge
|
||||
model="fusion/test", input="Answer"
|
||||
)
|
||||
|
||||
assert response.output[0].content[0].text == "Final"
|
||||
assert direct_response.output[0].content[0].text == "Final"
|
||||
with pytest.raises(litellm.BadRequestError, match="Synchronous Responses streaming"):
|
||||
router.responses(model="fusion/test", input="Answer", stream=True)
|
||||
|
||||
|
||||
def test_sync_router_supports_nonstreaming_fusion_and_rejects_sync_streaming() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
response = router.completion(model="fusion/test", messages=[{"role": "user", "content": "Answer"}])
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Final"
|
||||
with pytest.raises(litellm.BadRequestError, match="Synchronous streaming"):
|
||||
router.completion(
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_rejects_recursive_fusion_members() -> None:
|
||||
model_list = _router_model_list()
|
||||
model_list.append(
|
||||
{
|
||||
"model_name": "fusion/recursive",
|
||||
"litellm_params": {
|
||||
"model": "fusion_router",
|
||||
"fusion_router_config": {
|
||||
"panel_models": ["fusion/test", "panel-a"],
|
||||
"aggregator_model": "aggregator",
|
||||
"on_quorum_failure": "aggregator_only",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
router = Router(model_list=model_list)
|
||||
|
||||
response = await router.acompletion(
|
||||
model="fusion/recursive",
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, CustomStreamWrapper)
|
||||
chunks = [chunk async for chunk in response]
|
||||
rebuilt = litellm.stream_chunk_builder(chunks=chunks)
|
||||
assert isinstance(rebuilt, ModelResponse)
|
||||
assert rebuilt.choices[0].message.content == "Final"
|
||||
assert response._hidden_params["fusion"]["invoked"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_responses_and_anthropic_adapters_use_same_fusion_model() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
responses_result = await router.aresponses(model="fusion/test", input="Answer")
|
||||
assert responses_result.output[0].content[0].text == "Final"
|
||||
assert inspect.iscoroutinefunction(router.aanthropic_messages)
|
||||
anthropic_result = await router.aanthropic_messages(
|
||||
model="fusion/test", messages=[{"role": "user", "content": "Answer"}], max_tokens=256
|
||||
)
|
||||
assert anthropic_result["content"][0]["text"] == "Final"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_responses_and_anthropic_adapters_stream_direct_outer_response() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
|
||||
responses_stream = await router.aresponses(model="fusion/test", input="Answer", stream=True)
|
||||
response_events = [event async for event in responses_stream]
|
||||
assert any(str(getattr(event, "type", "")).endswith("RESPONSE_COMPLETED") for event in response_events)
|
||||
|
||||
anthropic_stream = await router.aanthropic_messages(
|
||||
model="fusion/test",
|
||||
messages=[{"role": "user", "content": "Answer"}],
|
||||
max_tokens=256,
|
||||
stream=True,
|
||||
)
|
||||
anthropic_events = [event async for event in anthropic_stream]
|
||||
assert anthropic_events
|
||||
assert all(isinstance(event, bytes) for event in anthropic_events)
|
||||
|
||||
|
||||
def test_sync_router_and_responses_support_nonstreaming_fusion() -> None:
|
||||
router = Router(model_list=_router_model_list())
|
||||
response = router.completion(model="fusion/test", messages=[{"role": "user", "content": "Answer"}])
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "Final"
|
||||
responses_result = router.responses(model="fusion/test", input="Answer")
|
||||
assert responses_result.output[0].content[0].text == "Final"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ vi.mock("@/components/networking", () => ({
|
|||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useFusionRouters: () => useFusionRouters(),
|
||||
useInvalidateFusionRouters: () => invalidate,
|
||||
usePlainModelGroups: () => new Set(["panel-a", "panel-b", "aggregator"]),
|
||||
usePlainModelGroups: () => new Set(["panel-a", "panel-b", "outer", "analyst"]),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/MultiSelect", () => ({
|
||||
|
|
@ -31,6 +31,10 @@ vi.mock("@/components/shared/MultiSelect", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/search_tools/SearchToolSelector", () => ({
|
||||
default: () => <div data-testid="search-tool-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: ({ onChange }: { onChange: (teamID: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange("team-1")}>
|
||||
|
|
@ -49,12 +53,16 @@ const existingDeployment = {
|
|||
litellm_params: {
|
||||
model: "fusion_router",
|
||||
fusion_router_config: {
|
||||
outer_model: "outer",
|
||||
panel_models: ["panel-a", "panel-b"],
|
||||
aggregator_model: "aggregator",
|
||||
min_successful_panelists: 2,
|
||||
analyst_model: "analyst",
|
||||
invocation: "required",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "aggregator_only",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "none",
|
||||
max_tool_calls: 4,
|
||||
},
|
||||
},
|
||||
model_info: { id: "fusion-id", db_model: true },
|
||||
|
|
@ -80,15 +88,15 @@ describe("FusionModelsPanel", () => {
|
|||
modelPatchUpdateCall.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("creates a quality-first Fusion model with an ordinary model/new payload", async () => {
|
||||
it("creates an auto Fusion model with an ordinary model/new payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add Fusion Model" }));
|
||||
await user.type(screen.getByLabelText("Model name"), "fusion/coding");
|
||||
await user.click(screen.getByRole("button", { name: "Choose panel models" }));
|
||||
await user.click(screen.getByLabelText("Aggregator model"));
|
||||
await user.click(screen.getByRole("option", { name: "aggregator" }));
|
||||
await user.click(screen.getByLabelText("Outer model"));
|
||||
await user.click(screen.getByRole("option", { name: "outer" }));
|
||||
await user.click(screen.getByRole("button", { name: "Create Fusion Model" }));
|
||||
|
||||
await waitFor(() => expect(modelCreateCall).toHaveBeenCalledTimes(1));
|
||||
|
|
@ -97,33 +105,36 @@ describe("FusionModelsPanel", () => {
|
|||
litellm_params: {
|
||||
model: "fusion_router",
|
||||
fusion_router_config: {
|
||||
outer_model: "outer",
|
||||
panel_models: ["panel-a", "panel-b"],
|
||||
aggregator_model: "aggregator",
|
||||
min_successful_panelists: 2,
|
||||
invocation: "auto",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "fail",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "none",
|
||||
max_tool_calls: 4,
|
||||
},
|
||||
},
|
||||
model_info: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows readable, full-width behavior presets", async () => {
|
||||
it("shows readable, full-width deliberation presets", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add Fusion Model" }));
|
||||
const behaviorSelector = screen.getByLabelText("Behavior");
|
||||
expect(behaviorSelector).toHaveTextContent("Quality First");
|
||||
expect(behaviorSelector).toHaveTextContent("Auto");
|
||||
expect(behaviorSelector).toHaveClass("w-full");
|
||||
|
||||
await user.click(behaviorSelector);
|
||||
expect(screen.getByRole("option", { name: /Quality First/ })).toBeVisible();
|
||||
expect(screen.getByRole("option", { name: /High Availability/ })).toBeVisible();
|
||||
expect(screen.getByRole("option", { name: /^Auto/ })).toBeVisible();
|
||||
expect(screen.getByRole("option", { name: /Always deliberate/ })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("option", { name: /High Availability/ }));
|
||||
expect(behaviorSelector).toHaveTextContent("High Availability");
|
||||
await user.click(screen.getByRole("option", { name: /Always deliberate/ }));
|
||||
expect(behaviorSelector).toHaveTextContent("Always deliberate");
|
||||
});
|
||||
|
||||
it("requires and sends a team for team-admin creation", async () => {
|
||||
|
|
@ -132,8 +143,8 @@ describe("FusionModelsPanel", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Add Fusion Model" }));
|
||||
await user.type(screen.getByLabelText("Model name"), "fusion/team");
|
||||
await user.click(screen.getByRole("button", { name: "Choose panel models" }));
|
||||
await user.click(screen.getByLabelText("Aggregator model"));
|
||||
await user.click(screen.getByRole("option", { name: "aggregator" }));
|
||||
await user.click(screen.getByLabelText("Outer model"));
|
||||
await user.click(screen.getByRole("option", { name: "outer" }));
|
||||
await user.click(screen.getByRole("button", { name: "Create Fusion Model" }));
|
||||
expect(await screen.findByText("Select a team to continue.")).toBeInTheDocument();
|
||||
expect(modelCreateCall).not.toHaveBeenCalled();
|
||||
|
|
@ -149,7 +160,7 @@ describe("FusionModelsPanel", () => {
|
|||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("High Availability")).toBeInTheDocument();
|
||||
expect(screen.getByText("Always")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Configure fusion/existing" }));
|
||||
expect(screen.getByLabelText("Model name")).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
|
@ -160,7 +171,7 @@ describe("FusionModelsPanel", () => {
|
|||
{
|
||||
litellm_params: expect.objectContaining({
|
||||
model: "fusion_router",
|
||||
fusion_router_config: expect.objectContaining({ on_quorum_failure: "aggregator_only" }),
|
||||
fusion_router_config: expect.objectContaining({ outer_model: "outer", invocation: "required" }),
|
||||
}),
|
||||
},
|
||||
"fusion-id",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { type Model, type Team, modelCreateCall, modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
|
||||
import SearchToolSelector from "@/components/search_tools/SearchToolSelector";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -27,7 +28,7 @@ import {
|
|||
fusionConfigError,
|
||||
fusionModelPayload,
|
||||
parseFusionConfig,
|
||||
presetFailureMode,
|
||||
presetInvocation,
|
||||
} from "./fusionModelConfig";
|
||||
|
||||
interface FusionModelsPanelProps {
|
||||
|
|
@ -71,13 +72,16 @@ function FusionModelDialog({
|
|||
const [modelName, setModelName] = useState(deployment?.model_name ?? "");
|
||||
const [teamID, setTeamID] = useState("");
|
||||
const [panelModels, setPanelModels] = useState(initialConfig.panel_models);
|
||||
const [aggregatorModel, setAggregatorModel] = useState(initialConfig.aggregator_model);
|
||||
const [minSuccessful, setMinSuccessful] = useState(initialConfig.min_successful_panelists);
|
||||
const [outerModel, setOuterModel] = useState(initialConfig.outer_model);
|
||||
const [analystModel, setAnalystModel] = useState(initialConfig.analyst_model);
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState(initialConfig.panel_timeout_seconds);
|
||||
const [maxCandidateChars, setMaxCandidateChars] = useState(initialConfig.max_candidate_chars);
|
||||
const [preset, setPreset] = useState<FusionPreset>(
|
||||
initialConfig.on_quorum_failure === "aggregator_only" ? "resilient" : "quality",
|
||||
);
|
||||
const [maxCompletionTokens, setMaxCompletionTokens] = useState(initialConfig.max_completion_tokens);
|
||||
const [temperature, setTemperature] = useState(initialConfig.temperature);
|
||||
const [reasoningEffort, setReasoningEffort] = useState(initialConfig.reasoning_effort);
|
||||
const [searchToolName, setSearchToolName] = useState(initialConfig.search_tool_name);
|
||||
const [maxToolCalls, setMaxToolCalls] = useState(initialConfig.max_tool_calls);
|
||||
const [preset, setPreset] = useState<FusionPreset>(initialConfig.invocation === "required" ? "always" : "auto");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -85,14 +89,11 @@ function FusionModelDialog({
|
|||
const modelOptions = availableModels.map((model) => ({ label: model, value: model }));
|
||||
|
||||
const handlePanelsChanged = (models: string[]) => {
|
||||
const limited = models.slice(0, 6);
|
||||
setPanelModels(limited);
|
||||
setMinSuccessful((current) => Math.min(Math.max(1, current), Math.max(1, limited.length)));
|
||||
setPanelModels(models.slice(0, 8));
|
||||
};
|
||||
|
||||
const applyPreset = (nextPreset: FusionPreset) => {
|
||||
setPreset(nextPreset);
|
||||
setMinSuccessful(Math.min(2, Math.max(1, panelModels.length)));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
|
|
@ -100,12 +101,17 @@ function FusionModelDialog({
|
|||
const value: FusionFormValue = {
|
||||
model_name: modelName,
|
||||
team_id: teamID,
|
||||
outer_model: outerModel,
|
||||
panel_models: panelModels,
|
||||
aggregator_model: aggregatorModel,
|
||||
min_successful_panelists: minSuccessful,
|
||||
analyst_model: analystModel,
|
||||
invocation: presetInvocation(preset),
|
||||
panel_timeout_seconds: timeoutSeconds,
|
||||
max_candidate_chars: maxCandidateChars,
|
||||
on_quorum_failure: presetFailureMode(preset),
|
||||
max_completion_tokens: maxCompletionTokens,
|
||||
temperature,
|
||||
reasoning_effort: reasoningEffort,
|
||||
search_tool_name: searchToolName,
|
||||
max_tool_calls: maxToolCalls,
|
||||
};
|
||||
const validationError = fusionConfigError(value, requiresTeamScope);
|
||||
if (validationError) {
|
||||
|
|
@ -139,8 +145,8 @@ function FusionModelDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Configure Fusion Model" : "Add Fusion Model"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every request runs the panel in parallel, then the aggregator returns one normal model response or tool
|
||||
call.
|
||||
The outer model can privately ask a panel to deliberate, then uses their analysis to return the final
|
||||
response or tool call.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
|
|
@ -168,28 +174,26 @@ function FusionModelDialog({
|
|||
<Select
|
||||
value={preset}
|
||||
onValueChange={(value) => {
|
||||
if (value === "quality" || value === "resilient") applyPreset(value);
|
||||
if (value === "auto" || value === "always") applyPreset(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="fusion-preset" className="h-11 w-full px-3">
|
||||
<span className="flex-1 text-left font-medium">
|
||||
{preset === "quality" ? "Quality First" : "High Availability"}
|
||||
</span>
|
||||
<span className="flex-1 text-left font-medium">{preset === "auto" ? "Auto" : "Always deliberate"}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="quality" label="Quality First" className="items-start py-3">
|
||||
<div className="min-w-0 whitespace-normal pr-2">
|
||||
<div className="font-medium">Quality First</div>
|
||||
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)] min-w-[28rem]">
|
||||
<SelectItem value="auto" label="Auto" className="items-start py-3">
|
||||
<div className="min-w-0 whitespace-normal pr-4">
|
||||
<div className="font-medium">Auto</div>
|
||||
<div className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Fail the request when the panel quorum is missed.
|
||||
Let the outer model deliberate only when another perspective would help.
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="resilient" label="High Availability" className="items-start py-3">
|
||||
<div className="min-w-0 whitespace-normal pr-2">
|
||||
<div className="font-medium">High Availability</div>
|
||||
<SelectItem value="always" label="Always deliberate" className="items-start py-3">
|
||||
<div className="min-w-0 whitespace-normal pr-4">
|
||||
<div className="font-medium">Always deliberate</div>
|
||||
<div className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Let the aggregator answer alone when the panel quorum is missed.
|
||||
Force one panel deliberation on every request. Useful for evaluation and high-stakes workloads.
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -203,19 +207,19 @@ function FusionModelDialog({
|
|||
options={modelOptions}
|
||||
value={panelModels}
|
||||
onValueChange={handlePanelsChanged}
|
||||
placeholder="Select 2–6 independent models"
|
||||
emptyText="Add regular model deployments before creating a Fusion model."
|
||||
placeholder="Select 1–8 independent models"
|
||||
emptyText="Add a regular model deployment before creating a Fusion model."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Panel models see the full request and function schemas, but their tool proposals never execute.
|
||||
Panel models receive a self-contained deliberation question. They never receive or execute client tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-aggregator">Aggregator model</Label>
|
||||
<Select value={aggregatorModel} onValueChange={(value) => setAggregatorModel(value ?? "")}>
|
||||
<SelectTrigger id="fusion-aggregator" className="w-full">
|
||||
<SelectValue placeholder="Select the model that produces the final response" />
|
||||
<Label htmlFor="fusion-outer">Outer model</Label>
|
||||
<Select value={outerModel} onValueChange={(value) => setOuterModel(value ?? "")}>
|
||||
<SelectTrigger id="fusion-outer" className="w-full">
|
||||
<SelectValue placeholder="Select the model that talks to the client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((model) => (
|
||||
|
|
@ -226,7 +230,31 @@ function FusionModelDialog({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This model synthesizes the panel instead of choosing a winner. Only its response reaches the client.
|
||||
This model decides when to deliberate and is the only model that can return answers or client tool calls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-analyst">Analyst model</Label>
|
||||
<Select
|
||||
value={analystModel || "same-as-outer"}
|
||||
onValueChange={(value) => setAnalystModel(value === "same-as-outer" ? "" : value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="fusion-analyst" className="w-full">
|
||||
<SelectValue placeholder="Use the outer model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="same-as-outer">Same as outer model</SelectItem>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The analyst compares the panel's consensus, disagreements, gaps, and unique insights. It never writes
|
||||
the final answer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -240,18 +268,7 @@ function FusionModelDialog({
|
|||
<span className="text-xs text-muted-foreground">{advancedOpen ? "Hide" : "Show"}</span>
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-4 border-t p-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-quorum">Successful panelists</Label>
|
||||
<Input
|
||||
id="fusion-quorum"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, panelModels.length)}
|
||||
value={minSuccessful}
|
||||
onChange={(event) => setMinSuccessful(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 border-t p-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-timeout">Panel timeout (seconds)</Label>
|
||||
<Input
|
||||
|
|
@ -275,6 +292,75 @@ function FusionModelDialog({
|
|||
onChange={(event) => setMaxCandidateChars(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-output-tokens">Internal output tokens</Label>
|
||||
<Input
|
||||
id="fusion-output-tokens"
|
||||
type="number"
|
||||
min={1}
|
||||
max={128000}
|
||||
value={maxCompletionTokens}
|
||||
onChange={(event) => setMaxCompletionTokens(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fusion-temperature">Panel temperature</Label>
|
||||
<Input
|
||||
id="fusion-temperature"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="fusion-reasoning">Internal reasoning effort</Label>
|
||||
<Select
|
||||
value={reasoningEffort}
|
||||
onValueChange={(value) => setReasoningEffort(value as typeof reasoningEffort)}
|
||||
>
|
||||
<SelectTrigger id="fusion-reasoning" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["none", "minimal", "low", "medium", "high", "xhigh"] as const).map((effort) => (
|
||||
<SelectItem key={effort} value={effort}>
|
||||
{effort}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unsupported reasoning parameters are dropped for that provider.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Web research</Label>
|
||||
<SearchToolSelector
|
||||
accessToken={accessToken}
|
||||
value={searchToolName ? [searchToolName] : []}
|
||||
onChange={(tools) => setSearchToolName(tools.at(-1) ?? "")}
|
||||
placeholder="Select one Search Tool (optional)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When selected, panel and analyst models can search through this server-side LiteLLM Search Tool.
|
||||
</p>
|
||||
</div>
|
||||
{searchToolName && (
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="fusion-tool-calls">Tool calls per internal model</Label>
|
||||
<Input
|
||||
id="fusion-tool-calls"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={maxToolCalls}
|
||||
onChange={(event) => setMaxToolCalls(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -341,8 +427,8 @@ export function FusionModelsPanel({
|
|||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Fusion models</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
Run several models independently on every model turn, then have one aggregator synthesize the final answer
|
||||
or tool call. Your agent, coding harness, and tool loop stay unchanged.
|
||||
Give one model a private deliberation tool backed by an independent panel and analyst. The outer model still
|
||||
behaves like a normal model, so your agent, coding harness, and tool loop stay unchanged.
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
|
|
@ -357,9 +443,10 @@ export function FusionModelsPanel({
|
|||
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Outer model</th>
|
||||
<th className="px-4 py-3 font-medium">Panel</th>
|
||||
<th className="px-4 py-3 font-medium">Aggregator</th>
|
||||
<th className="px-4 py-3 font-medium">Policy</th>
|
||||
<th className="px-4 py-3 font-medium">Analyst</th>
|
||||
<th className="px-4 py-3 font-medium">Deliberation</th>
|
||||
<th className="w-24 px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -370,12 +457,11 @@ export function FusionModelsPanel({
|
|||
return (
|
||||
<tr key={deployment.model_info?.id ?? deployment.model_name}>
|
||||
<td className="px-4 py-3 font-medium">{deployment.model_name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{config.outer_model}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{config.panel_models.join(", ")}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{config.analyst_model || "Same as outer"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{config.panel_models.join(", ")} ({config.min_successful_panelists} required)
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{config.aggregator_model}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{config.on_quorum_failure === "fail" ? "Quality First" : "High Availability"}
|
||||
{config.invocation === "required" ? "Always" : "Auto"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -404,16 +490,16 @@ export function FusionModelsPanel({
|
|||
})}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-muted-foreground">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-muted-foreground">
|
||||
{canCreate
|
||||
? "No Fusion models yet. Create one after adding at least two regular model groups."
|
||||
? "No Fusion models yet. Create one after adding at least one regular model group."
|
||||
: "No Fusion models are available."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-muted-foreground">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-muted-foreground">
|
||||
Loading Fusion models…
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -5,70 +5,90 @@ import {
|
|||
fusionConfigError,
|
||||
fusionModelPayload,
|
||||
parseFusionConfig,
|
||||
presetFailureMode,
|
||||
presetInvocation,
|
||||
} from "./fusionModelConfig";
|
||||
|
||||
const validValue = (overrides: Partial<FusionFormValue> = {}): FusionFormValue => ({
|
||||
model_name: "fusion/coding",
|
||||
team_id: "",
|
||||
panel_models: ["claude", "gpt"],
|
||||
aggregator_model: "claude",
|
||||
min_successful_panelists: 2,
|
||||
outer_model: "outer",
|
||||
panel_models: ["panel-a", "panel-b"],
|
||||
analyst_model: "analyst",
|
||||
invocation: "auto",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "fail",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "none",
|
||||
search_tool_name: "",
|
||||
max_tool_calls: 4,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Fusion model configuration", () => {
|
||||
it("maps the two user presets to explicit runtime behavior", () => {
|
||||
expect(presetFailureMode("quality")).toBe("fail");
|
||||
expect(presetFailureMode("resilient")).toBe("aggregator_only");
|
||||
it("maps the simple presets to invocation behavior", () => {
|
||||
expect(presetInvocation("auto")).toBe("auto");
|
||||
expect(presetInvocation("always")).toBe("required");
|
||||
});
|
||||
|
||||
it("builds the model/new payload without harness or cross-turn state", () => {
|
||||
it("builds the virtual model payload", () => {
|
||||
expect(fusionModelPayload(validValue(), false)).toEqual({
|
||||
model_name: "fusion/coding",
|
||||
litellm_params: {
|
||||
model: "fusion_router",
|
||||
fusion_router_config: {
|
||||
panel_models: ["claude", "gpt"],
|
||||
aggregator_model: "claude",
|
||||
min_successful_panelists: 2,
|
||||
outer_model: "outer",
|
||||
panel_models: ["panel-a", "panel-b"],
|
||||
analyst_model: "analyst",
|
||||
invocation: "auto",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "fail",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "none",
|
||||
max_tool_calls: 4,
|
||||
},
|
||||
},
|
||||
model_info: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes team scope only when the caller is required to choose one", () => {
|
||||
it("omits an analyst to mean same as outer and validates the panel", () => {
|
||||
expect(
|
||||
fusionModelPayload(validValue({ analyst_model: "" }), false).litellm_params.fusion_router_config,
|
||||
).not.toHaveProperty("analyst_model");
|
||||
expect(fusionConfigError(validValue({ panel_models: [] }), false)).toMatch(/at least one/);
|
||||
expect(
|
||||
fusionConfigError(validValue({ panel_models: Array.from({ length: 9 }, (_, i) => `p-${i}`) }), false),
|
||||
).toMatch(/at most eight/);
|
||||
});
|
||||
|
||||
it("includes team scope only when required", () => {
|
||||
expect(fusionModelPayload(validValue({ team_id: "team-1" }), true).model_info).toEqual({ team_id: "team-1" });
|
||||
expect(fusionConfigError(validValue(), true)).toBe("Select a team to continue.");
|
||||
});
|
||||
|
||||
it("rejects undersized panels and impossible quorums", () => {
|
||||
expect(fusionConfigError(validValue({ panel_models: ["one"] }), false)).toMatch(/at least two/);
|
||||
expect(fusionConfigError(validValue({ min_successful_panelists: 3 }), false)).toMatch(/panel size/);
|
||||
});
|
||||
|
||||
it("parses stored configs defensively and supplies stable defaults", () => {
|
||||
it("parses stored configs defensively", () => {
|
||||
const storedConfig = {
|
||||
outer_model: "outer",
|
||||
panel_models: ["a", "a", "b", 4],
|
||||
invocation: "required",
|
||||
reasoning_effort: "low",
|
||||
};
|
||||
const expectedConfig = {
|
||||
panel_models: ["a", "b"],
|
||||
aggregator_model: "judge",
|
||||
min_successful_panelists: 2,
|
||||
outer_model: "outer",
|
||||
panel_models: ["a", "a", "b"],
|
||||
analyst_model: "",
|
||||
invocation: "required",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "aggregator_only",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "low",
|
||||
search_tool_name: "",
|
||||
max_tool_calls: 4,
|
||||
};
|
||||
expect(
|
||||
parseFusionConfig({
|
||||
panel_models: ["a", "a", "b", 4],
|
||||
aggregator_model: "judge",
|
||||
on_quorum_failure: "aggregator_only",
|
||||
}),
|
||||
).toEqual(expectedConfig);
|
||||
|
||||
expect(parseFusionConfig(storedConfig)).toEqual(expectedConfig);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
export type FusionFailureMode = "fail" | "aggregator_only";
|
||||
export type FusionPreset = "quality" | "resilient";
|
||||
export type FusionInvocation = "auto" | "required";
|
||||
export type FusionPreset = "auto" | "always";
|
||||
export type FusionReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export interface FusionRouterConfigValue {
|
||||
outer_model: string;
|
||||
panel_models: string[];
|
||||
aggregator_model: string;
|
||||
min_successful_panelists: number;
|
||||
analyst_model: string;
|
||||
invocation: FusionInvocation;
|
||||
panel_timeout_seconds: number;
|
||||
max_candidate_chars: number;
|
||||
on_quorum_failure: FusionFailureMode;
|
||||
max_completion_tokens: number;
|
||||
temperature: number;
|
||||
reasoning_effort: FusionReasoningEffort;
|
||||
search_tool_name: string;
|
||||
max_tool_calls: number;
|
||||
}
|
||||
|
||||
export interface FusionFormValue extends FusionRouterConfigValue {
|
||||
|
|
@ -16,16 +22,20 @@ export interface FusionFormValue extends FusionRouterConfigValue {
|
|||
}
|
||||
|
||||
export const DEFAULT_FUSION_CONFIG: FusionRouterConfigValue = {
|
||||
outer_model: "",
|
||||
panel_models: [],
|
||||
aggregator_model: "",
|
||||
min_successful_panelists: 2,
|
||||
analyst_model: "",
|
||||
invocation: "auto",
|
||||
panel_timeout_seconds: 120,
|
||||
max_candidate_chars: 12000,
|
||||
on_quorum_failure: "fail",
|
||||
max_completion_tokens: 16000,
|
||||
temperature: 0,
|
||||
reasoning_effort: "none",
|
||||
search_tool_name: "",
|
||||
max_tool_calls: 4,
|
||||
};
|
||||
|
||||
export const presetFailureMode = (preset: FusionPreset): FusionFailureMode =>
|
||||
preset === "quality" ? "fail" : "aggregator_only";
|
||||
export const presetInvocation = (preset: FusionPreset): FusionInvocation => (preset === "always" ? "required" : "auto");
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
|
|
@ -33,40 +43,56 @@ const asRecord = (value: unknown): Record<string, unknown> =>
|
|||
const numberOr = (value: unknown, fallback: number): number =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const REASONING_EFFORTS = new Set<FusionReasoningEffort>(["none", "minimal", "low", "medium", "high", "xhigh"]);
|
||||
|
||||
export const parseFusionConfig = (value: unknown): FusionRouterConfigValue => {
|
||||
const config = asRecord(value);
|
||||
const panelModels = Array.isArray(config.panel_models)
|
||||
? config.panel_models.filter((model): model is string => typeof model === "string" && model.length > 0)
|
||||
: [];
|
||||
const reasoningEffort =
|
||||
typeof config.reasoning_effort === "string" &&
|
||||
REASONING_EFFORTS.has(config.reasoning_effort as FusionReasoningEffort)
|
||||
? (config.reasoning_effort as FusionReasoningEffort)
|
||||
: "none";
|
||||
return {
|
||||
panel_models: Array.from(new Set(panelModels)),
|
||||
aggregator_model: typeof config.aggregator_model === "string" ? config.aggregator_model : "",
|
||||
min_successful_panelists: numberOr(config.min_successful_panelists, 2),
|
||||
outer_model: typeof config.outer_model === "string" ? config.outer_model : "",
|
||||
panel_models: panelModels,
|
||||
analyst_model: typeof config.analyst_model === "string" ? config.analyst_model : "",
|
||||
invocation: config.invocation === "required" ? "required" : "auto",
|
||||
panel_timeout_seconds: numberOr(config.panel_timeout_seconds, 120),
|
||||
max_candidate_chars: numberOr(config.max_candidate_chars, 12000),
|
||||
on_quorum_failure: config.on_quorum_failure === "aggregator_only" ? "aggregator_only" : "fail",
|
||||
max_completion_tokens: numberOr(config.max_completion_tokens, 16000),
|
||||
temperature: numberOr(config.temperature, 0),
|
||||
reasoning_effort: reasoningEffort,
|
||||
search_tool_name: typeof config.search_tool_name === "string" ? config.search_tool_name : "",
|
||||
max_tool_calls: numberOr(config.max_tool_calls, 4),
|
||||
};
|
||||
};
|
||||
|
||||
export const fusionConfigError = (value: FusionFormValue, requiresTeamScope: boolean): string | null => {
|
||||
if (!value.model_name.trim()) return "Fusion model name is required.";
|
||||
if (requiresTeamScope && !value.team_id) return "Select a team to continue.";
|
||||
if (!value.aggregator_model) return "Select an aggregator model.";
|
||||
if (value.panel_models.length < 2) return "Select at least two panel models.";
|
||||
if (value.panel_models.length > 6) return "A Fusion panel can contain at most six models.";
|
||||
if (
|
||||
!Number.isInteger(value.min_successful_panelists) ||
|
||||
value.min_successful_panelists < 1 ||
|
||||
value.min_successful_panelists > value.panel_models.length
|
||||
) {
|
||||
return "Successful panelists must be between 1 and the panel size.";
|
||||
}
|
||||
if (!value.outer_model) return "Select the outer model.";
|
||||
if (value.panel_models.length < 1) return "Select at least one panel model.";
|
||||
if (value.panel_models.length > 8) return "A Fusion panel can contain at most eight models.";
|
||||
if (value.panel_timeout_seconds <= 0 || value.panel_timeout_seconds > 600) {
|
||||
return "Panel timeout must be between 1 and 600 seconds.";
|
||||
}
|
||||
if (value.max_candidate_chars < 1000 || value.max_candidate_chars > 50000) {
|
||||
return "Candidate limit must be between 1,000 and 50,000 characters.";
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(value.max_completion_tokens) ||
|
||||
value.max_completion_tokens < 1 ||
|
||||
value.max_completion_tokens > 128000
|
||||
) {
|
||||
return "Internal output tokens must be between 1 and 128,000.";
|
||||
}
|
||||
if (value.temperature < 0 || value.temperature > 2) return "Panel temperature must be between 0 and 2.";
|
||||
if (!Number.isInteger(value.max_tool_calls) || value.max_tool_calls < 1 || value.max_tool_calls > 16) {
|
||||
return "Tool calls must be between 1 and 16.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -75,12 +101,17 @@ export const fusionModelPayload = (value: FusionFormValue, requiresTeamScope: bo
|
|||
litellm_params: {
|
||||
model: "fusion_router",
|
||||
fusion_router_config: {
|
||||
outer_model: value.outer_model,
|
||||
panel_models: value.panel_models,
|
||||
aggregator_model: value.aggregator_model,
|
||||
min_successful_panelists: value.min_successful_panelists,
|
||||
...(value.analyst_model ? { analyst_model: value.analyst_model } : {}),
|
||||
invocation: value.invocation,
|
||||
panel_timeout_seconds: value.panel_timeout_seconds,
|
||||
max_candidate_chars: value.max_candidate_chars,
|
||||
on_quorum_failure: value.on_quorum_failure,
|
||||
max_completion_tokens: value.max_completion_tokens,
|
||||
temperature: value.temperature,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
...(value.search_tool_name ? { search_tool_name: value.search_tool_name } : {}),
|
||||
max_tool_calls: value.max_tool_calls,
|
||||
},
|
||||
},
|
||||
model_info: requiresTeamScope ? { team_id: value.team_id } : {},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue