litellm/tests/integration/streaming/test_mid_stream_fallback_continuation.py
Ayush Shekhar 7930c0e434
feat(router): mid-stream fallback continuation for chat completions (#41127)
* feat(router): continue chat streams on fallback after a mid-stream break

A chat-completions stream that breaks after content has been delivered was
re-raised and the fallback deployment never ran (#40404), while the
Responses-API path already continues via a prefilled assistant turn. Add the
same for chat completions behind `enable_mid_stream_fallback_continuation`
(opt-in, async-only), so a post-content break re-enters the fallback chain with
the partial text as an assistant prefill instead of surfacing the error.

Continuation is only attempted when it is safe: the stream emitted plain
assistant text (no tool/function calls, thinking blocks, reasoning items,
audio or images), the request is not constrained output (response_format or a
forced tool_choice) and not merge-reasoning mode, and the fallback target's
model supports assistant prefill. The target check runs as a deployment
pre-call filter, so a chain with no prefill-capable deployment empties and the
original error is surfaced rather than a duplicated or rejected request being
sent. Plain reasoning_content is treated as out-of-band and does not block a
continuation, matching the Responses-API path; Anthropic thinking is excluded
because its signed thinking blocks cannot ride a text-only prefill.

CustomStreamWrapper now latches whether a disqualifying delta was streamed and
carries it on MidStreamFallbackError so the router can decide without
re-scanning chunks. Default behavior is unchanged.

* fix(router): harden mid-stream continuation eligibility and prefill folding

Address review of the mid-stream continuation:
- keep response_format={"type": "text"} eligible; only json_object / json_schema
  and other structured formats decline, since "text" is the unconstrained default
- fold a new partial into an existing trailing assistant prefill instead of
  appending a second one, so a nested mid-stream break stays a single prefill
  turn even on providers that do not merge consecutive assistant messages
- split the deployment prefill-capability lookup into two readable steps
- cover the merge_reasoning decline branch, the text response_format pass, and
  the prefill-folding path with tests

* fix(router): address review of mid-stream continuation

- register the continuation deployment filter as a process-global singleton
  instead of tracking it per router, so discarding one router no longer removes
  the type-deduplicated filter that other live routers still depend on
- disqualify reasoning_content: it reaches the caller as visible reasoning that
  a text-only prefill cannot carry, so a reasoning-capable fallback would
  re-derive it and produce an incoherent response
- move MID_STREAM_CONTINUATION_KWARG to constants and lazy-import the filter
  class in the router, breaking the module-level import cycle CodeQL flagged
- trim the added docstrings to the repository comment policy

* fix(router): keep mid-stream continuation within the type-discipline budget

The LIT* type-discipline gate counts mutable-collection use the ruff pass does
not see. Annotate the reads with read-only Sequence/Mapping views, compare
response_format against a frozen MappingProxyType sentinel, and mark the
genuinely mutable litellm message and deployment-list boundaries with
mutable-ok reasons, so LIT001 and LIT002 stay under their limits.

* fix(router): clear basedpyright operator error and the import-cycle alert

- narrow the accumulated stream delta content to str before appending, so the
  shared helper no longer trips reportOperatorIssue (str += object)
- keep the continuation marker in the pre-call-check module and lazy-import it
  at the two router call sites instead of routing it through constants, so the
  router has no module-level dependency that closes an import cycle

* fix(router): make the mid-stream continuation marker unforgeable

The deployment filter is process-global and read the continuation marker as a
truthy value from request_kwargs. Since the proxy can forward arbitrary
request-body fields into the router, a client could send
`"_mid_stream_continuation": true` on a normal request to filter a mixed model
group down to prefill-capable deployments and steer their prompt onto that
provider.

Set a private sentinel object internally and check it by type instead of
truthiness: a JSON request body cannot construct one, so a forged flag is
ignored. Adds a regression test that forged client values (true, "true", 1, a
dict) leave the deployment list untouched.

* feat(router): honor per-deployment model_info for continuation prefill capability

The deployment filter only read supports_assistant_prefill from the cost map, so
a model configured with `model_info: {supports_assistant_prefill: true}` on the
deployment (the usual way to declare a capability for a model that is not in the
cost map, or to override it) was wrongly skipped. Check the deployment's
model_info first (True or False both authoritative), then fall back to the cost
map. Adds tests for the override in both directions.

* fix(router): bound mid-stream continuation to the caller's output allowance

A continuation handed the fallback the full max_tokens / max_completion_tokens
even though part of the answer had already streamed, so the total could exceed
the caller's limit and repeated interruptions could re-grant the allowance on
every hop, amplifying spend. Reduce each configured ceiling by the tokens already
emitted before re-entry, and decline the continuation (surface the original
error) when the allowance is exhausted. The reductions compound across hops
because the trimmed ceiling is what the next hop sees.

Also add the missing explanatory comment on the model_info fall-through except.

* refactor(router): extract mid-stream decline to stay within the complexity budget

The output-ceiling branch pushed stream_with_fallbacks past the C901 limit. Both
decline paths (ineligible after content, and exhausted allowance) re-raised the
original provider error the same way, so pull that into
_raise_original_mid_stream_error. It removes the duplicated branching, drops the
function back under the complexity ceiling, and reads clearer.

* fix(cost-map): mark Claude Sonnet 4.6 as not supporting assistant prefill

The Anthropic API and Bedrock both reject an assistant-last message on
claude-sonnet-4-6 with "This model does not support assistant message
prefill", so the continuation filter must not route a mid-stream
continuation to it. Flip the flag on every claude-sonnet-4-6 entry and on
databricks' opus-4-6 entry, whose anthropic sibling already says false.

The prefill check tests now register synthetic cost-map entries instead
of pinning real models, which broke when claude-3-opus left the map.

* refactor(router): type the streaming fallback kwargs and drop narration comments

Removes an unused mutable-ok marker (LIT013) and the explanatory comments
Greptile flagged, and types initial_kwargs on both streaming fallback
iterators as dict[str, Any] like their Responses siblings so the new
continuation helpers take a typed mapping and the basedpyright budget
stays at or below main.

* fix(router): run post_call_rules over the joined text of a continued stream

Each provider stream wrapper validates only the fragment it produced, so a
rule that trips on the primary's prefix plus the continuation's text never
fired. The router now feeds litellm.post_call_rules the emitted text plus
every continuation chunk before yielding it. Also drops a narrating
test-helper docstring.

* refactor(router): read a deployment's declared prefill support without an empty except

* test(router): cover _raise_original_mid_stream_error directly for the router coverage gate

* fix(router): fold the continuation prefill into a trailing assistant turn

A request that already ends in an assistant message took the partial output as a second assistant turn, which prefill providers reject. The partial now extends that turn in place, with prefix set, so the continuation request never ends in two assistant messages.

* fix(router): keep content blocks on a trailing assistant turn when extending it with the partial output

A trailing assistant message whose content is a list of blocks was stringified before the partial output was appended, so the fallback received the Python repr of the blocks as its prefill. The partial now lands as one more text block, and a content shape that cannot be extended surfaces the original stream error instead of a garbled continuation.

* fix(router): continue the trailing text block in place when extending a prefill with content blocks

Anthropic strips trailing whitespace per content block, so appending the partial output as a new text block after an assistant turn that ends in "Sure, " would send the prefill as "Sure,here". The continuation now extends the last text block itself and only opens a new text block when the trailing block is not text (an image, say), so the prefill reads exactly as the text the caller already received

* test(router): type the prefill check's deployment helper

* fix(router): keep a continued stream on the primary's identity and widen what can continue

Continuation chunks now keep the primary stream's id and created time and
drop the fallback's second role delta. Requests with n > 1 no longer continue.
Prefill support reads model_info.base_model and litellm_params.base_model
before the deployment model. enable_mid_stream_fallback_continuation is
runtime-updatable through update_settings and registers the prefill filter.
The bridge rebuild of MidStreamFallbackError keeps the inner stream's
disqualifier. A fallback that repeats the prefill is not doubled in the
continuation input.

* fix(router): decline mid-stream continuation after a content filter, merged reasoning, or a plain trailing assistant turn

Also count the partial stream's completion tokens in the continued stream's usage, accept string
values for enable_mid_stream_fallback_continuation, and list the new router helpers in the
router coverage check

* fix(router): drop re-emitted whitespace at the continuation join and sum both legs' usage consistently

* fix(router): keep the full prefill so a nested break keeps the separator

* fix(router): bill both calls of a continued stream and fix Bedrock Converse, Databricks, and Vertex DeepSeek prefill routes

* test(router): integration cells for mid-stream fallback continuation

Covers the continued stream the caller gets, the prefill turn and reduced
max_tokens the fallback receives, the prefill-capability filter, the runtime
toggle, repeated requests through the response cache, and the spend row of a
continued stream

* fix(router): log a continued stream under the caller's id, price each cut on its own prompt, and keep prefill flags per deployment

* fix(router): price and log each cut leg before its continuation reuses the logging object

Price the cut stream's partial and let its failure log finish (bounded) before the fallback
overwrites a shared proxy logging object, and skip continuation when an output guardrail is
registered or a request fallback carries its own messages.

* test(router): integration cells for the output-guardrail and request-fallback declines and the cut call's failure log

Cover a registered post_call guardrail and a request fallback carrying its own messages keeping the original error, an input-only guardrail leaving continuation on, and the cut call's failure event naming its own deployment and price.

* fix(router): skip mid-stream continuation when a request fallback overrides request params

A request-body fallback entry that sets anything besides model (stream, messages, max_tokens) changes the
request the prefill continuation relies on, so a stream: false entry ended the answer with no error.

* fix(router): skip mid-stream continuation for a fallback group target that overrides request params

The override check read only top-level fallback entries, so a target inside a group's list, such as {"primary": [{"model": "backup", "stream": false}]} in the request, the config, or a key's router settings, still ended the answer with no error.

* fix(router): check only the request's own fallback chain for request param overrides

Scanning every group's chain turned continuation off for all requests when one unrelated group's target overrode a param. Resolve the chain the router will use for this request, the same way the fallback path does.

* fix(router): keep the original error when request fallbacks are malformed

The request-param override check resolved a standard-shape chain outside the router's own error handling, so an empty entry or a non-list chain raised StopIteration or TypeError and replaced the original mid-stream error. Skip empty entries and treat a non-list chain as no chain.

* fix(router): read deployment-configured constraints, decline deployment-id fallbacks without prefill, strip only the last Bedrock text block

* fix(router): read a deployment's tool_choice ahead of router defaults in the continuation check, and restore the renamed prefill test names

* fix(router): filter continuation deployments on every routing strategy, read extra_body and fallback deployments' own params

The deployment filter now also runs on the sync selection path that usage-based-routing v1 and custom strategies use,
one predicate reads n, response_format, tool_choice and merge_reasoning_content_in_choices with extra_body laid over
them, fallback deployments whose own config constrains the output are skipped, and continuation declines when the cut
deployment's id is not in the router (per-request credential deployments)

* fix(router): decline a cut mid-echo, reduce extra_body output limits, and check post_call_rules once on nested continuations

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-16 13:51:32 +00:00

749 lines
28 KiB
Python

from __future__ import annotations
import functools
import hashlib
import itertools
import json
import math
import operator
import threading
import uuid
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import openai
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, eventually, gateway_from_environment, object_value
from integration._support.database import read_rows, scratch_database
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
from openai.types.chat import ChatCompletionChunk
from pydantic import JsonValue, TypeAdapter
pytestmark: Final = pytest.mark.timeout(4 * graceful_stop_seconds() + 120)
SETTING: Final = "enable_mid_stream_fallback_continuation"
PARTIAL: Final = ("Alpha beta", " gamma")
JOINED_PARTIAL: Final = "".join(PARTIAL)
RELAY_TEXT: Final = " delta"
BACKUP_TEXT: Final = " omega."
PLAIN_TEXT: Final = "plain answer"
TOOL_PREAMBLE: Final = "Let me check"
BACKUP_USAGE: Final = (40, 6)
MAX_TOKENS: Final = 200
PRIMARY_PRICES: Final = (0.001, 0.002)
BACKUP_PRICES: Final = (0.003, 0.005)
SHARED_BACKEND: Final = "openai/gpt-4o-mini"
SEPARATE_BACKEND: Final = "openai/gpt-4o"
WEATHER_TOOL: Final[Mapping[str, JsonValue]] = {
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
TOOL_CALL_DELTA: Final[Mapping[str, JsonValue]] = {
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": ""},
}
NOTHING_SEEN: Final[tuple[ChatCompletionChunk, ...]] = ()
ALLOW_EVERYTHING: Final = "def apply_guardrail(inputs, request_data, input_type):\n return allow()\n"
EVENT_BATCH: Final = TypeAdapter(list[dict[str, JsonValue]])
@functools.cache
def cut_gate(marker: str, hop: str, attempt: int) -> threading.Event:
return threading.Event()
@functools.cache
def attempts(marker: str, hop: str) -> Iterator[int]:
return itertools.count()
def frame(identity: str, delta: Mapping[str, JsonValue], finish: str | None = None) -> bytes:
body: Final = {
"id": identity,
"object": "chat.completion.chunk",
"created": 1700000000,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish}],
}
return b"data: " + json.dumps(body).encode() + b"\n\n"
def complete_stream(identity: str, text: str, usage: tuple[int, int]) -> Reply:
usage_frame: Final[dict[str, JsonValue]] = {
"id": identity,
"object": "chat.completion.chunk",
"created": 1700000000,
"model": "gpt-4o-mini",
"choices": [],
"usage": {"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)},
}
return Reply(
content_type="text/event-stream",
chunks=(
frame(identity, {"role": "assistant", "content": text}),
frame(identity, {}, finish="stop"),
b"data: " + json.dumps(usage_frame).encode() + b"\n\n",
b"data: [DONE]\n\n",
),
)
def cut_after_caller_sees(marker: str, hop: str, *frames: bytes) -> Reply:
return Reply(
content_type="text/event-stream",
chunks=(b"".join(frames), b"data: never sent\n\n"),
abort_after=1,
gate_after_first=cut_gate(marker, hop, next(attempts(marker, hop))),
)
def first_user_text(request: Request) -> str:
messages: Final = JSON_OBJECT.validate_json(request.body)["messages"]
assert isinstance(messages, list), messages
first: Final = object_value(messages[0])["content"]
assert isinstance(first, str), first
return first
def marker_of(request: Request) -> str:
return first_user_text(request).split()[-1]
def is_chat(request: Request) -> bool:
return request.target.endswith("/chat/completions")
def model_list() -> Reply:
return Reply(body=b'{"object": "list", "data": []}')
def primary(request: Request) -> Reply:
if not is_chat(request):
return model_list()
scenario: Final = first_user_text(request).split()[0]
marker: Final = marker_of(request)
identity: Final = f"chatcmpl-{marker}-primary"
if scenario == "reject":
return Reply(status=500, body=b'{"error": {"message": "primary is down", "type": "server_error"}}')
if scenario == "tool-cut":
return cut_after_caller_sees(
marker,
"primary",
frame(identity, {"role": "assistant", "content": TOOL_PREAMBLE}),
frame(identity, {"tool_calls": [dict(TOOL_CALL_DELTA)]}),
)
return cut_after_caller_sees(
marker,
"primary",
frame(identity, {"role": "assistant", "content": PARTIAL[0]}),
frame(identity, {"content": PARTIAL[1]}),
)
def relay(request: Request) -> Reply:
if not is_chat(request):
return model_list()
marker: Final = marker_of(request)
return cut_after_caller_sees(
marker, "relay", frame(f"chatcmpl-{marker}-relay", {"role": "assistant", "content": RELAY_TEXT})
)
def backup(request: Request) -> Reply:
if not is_chat(request):
return model_list()
return complete_stream(f"chatcmpl-{marker_of(request)}-backup", BACKUP_TEXT, BACKUP_USAGE)
def no_prefill(request: Request) -> Reply:
if not is_chat(request):
return model_list()
return complete_stream(f"chatcmpl-{marker_of(request)}-plain", PLAIN_TEXT, (9, 2))
def accept_events(request: Request) -> Reply:
return Reply()
def registered_guardrail(name: str, mode: str) -> dict[str, JsonValue]:
return {
"guardrail_name": name,
"litellm_params": {
"guardrail": "custom_code",
"mode": mode,
"default_on": False,
"custom_code": ALLOW_EVERYTHING,
},
}
@dataclass(frozen=True, slots=True)
class Rig:
gateway: Gateway
database_url: str
primary: Wire
relay: Wire
backup: Wire
no_prefill: Wire
events: Wire
def joined_text(chunks: Iterable[ChatCompletionChunk]) -> str:
return "".join(choice.delta.content or "" for chunk in chunks for choice in chunk.choices)
def has_tool_call(chunks: Iterable[ChatCompletionChunk]) -> bool:
return any(choice.delta.tool_calls for chunk in chunks for choice in chunk.choices)
@dataclass(frozen=True, slots=True)
class Release:
gate: threading.Event
ready: Callable[[tuple[ChatCompletionChunk, ...]], bool]
def after_text(marker: str, hop: str, text: str, attempt: int = 0) -> Release:
return Release(cut_gate(marker, hop, attempt), lambda seen: joined_text(seen) == text)
@dataclass(frozen=True, slots=True)
class Streamed:
chunks: tuple[ChatCompletionChunk, ...]
error: openai.APIError | None
@property
def text(self) -> str:
return joined_text(self.chunks)
@property
def ids(self) -> frozenset[str]:
return frozenset(chunk.id for chunk in self.chunks)
@property
def role_deltas(self) -> int:
return sum(1 for chunk in self.chunks for choice in chunk.choices if choice.delta.role is not None)
@property
def finish_reasons(self) -> tuple[str, ...]:
return tuple(choice.finish_reason for chunk in self.chunks for choice in chunk.choices if choice.finish_reason)
@property
def usage(self) -> tuple[int, int]:
reported: Final = tuple(chunk.usage for chunk in self.chunks if chunk.usage is not None)
assert reported, self.chunks
return (reported[-1].prompt_tokens, reported[-1].completion_tokens)
def deployment(
name: str,
wire: Wire,
prices: tuple[float, float],
*,
backend: str = SHARED_BACKEND,
prefill: bool | None = None,
) -> dict[str, JsonValue]:
return {
"model_name": name,
"litellm_params": {
"model": backend,
"api_base": wire.url + "/v1",
"api_key": "synthetic-continuation-key",
"input_cost_per_token": prices[0],
"output_cost_per_token": prices[1],
},
**({"model_info": {"supports_assistant_prefill": prefill}} if prefill is not None else {}),
}
def write_config(
directory: Path,
model_entries: Sequence[Mapping[str, JsonValue]],
fallbacks: Sequence[Mapping[str, JsonValue]],
router_settings: Mapping[str, JsonValue],
guardrails: Sequence[Mapping[str, JsonValue]],
) -> Path:
base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
config: Final = {
**base,
"model_list": [dict(entry) for entry in model_entries],
"litellm_settings": {
**object_value(base["litellm_settings"]),
"callbacks": ["generic_api"],
"DEFAULT_FLUSH_INTERVAL_SECONDS": 1,
},
"guardrails": [dict(entry) for entry in guardrails],
"router_settings": {
**object_value(base["router_settings"]),
"num_retries": 0,
"fallbacks": [dict(entry) for entry in fallbacks],
**router_settings,
},
}
path: Final = directory / f"continuation-{uuid.uuid4().hex[:8]}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@contextmanager
def booted_rig(
directory: Path,
fallbacks: Sequence[Mapping[str, JsonValue]],
router_settings: Mapping[str, JsonValue],
workers: int,
guardrails: Sequence[Mapping[str, JsonValue]] = (),
) -> Generator[Rig]:
with ExitStack() as stack:
shared: Final = stack.enter_context(gateway_from_environment())
database_url: Final = stack.enter_context(scratch_database())
wires: Final = tuple(
stack.enter_context(wire_server(respond)) for respond in (primary, relay, backup, no_prefill, accept_events)
)
primary_wire, relay_wire, backup_wire, no_prefill_wire, events_wire = wires
config: Final = write_config(
directory,
(
*(
deployment(name, primary_wire, PRIMARY_PRICES)
for name in ("primary", "primary-skip", "primary-none", "primary-relay", "primary-shared")
),
deployment("relay", relay_wire, PRIMARY_PRICES, prefill=True),
deployment("backup", backup_wire, BACKUP_PRICES, prefill=True),
deployment("no-prefill", no_prefill_wire, PRIMARY_PRICES, backend=SEPARATE_BACKEND),
deployment("no-prefill-shared", no_prefill_wire, PRIMARY_PRICES),
),
fallbacks,
router_settings,
guardrails,
)
gateway: Final = stack.enter_context(
owned_proxy(
shared,
directory,
{"DATABASE_URL": database_url, "GENERIC_LOGGER_ENDPOINT": events_wire.url},
config=config,
remove_environment=("DATABASE_URL_READ_REPLICA",),
workers=workers,
)
)
yield Rig(gateway, database_url, primary_wire, relay_wire, backup_wire, no_prefill_wire, events_wire)
@pytest.fixture(scope="module")
def configured(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
fallbacks: Final[tuple[Mapping[str, JsonValue], ...]] = (
{"primary": ["backup"]},
{"primary-skip": ["no-prefill", "backup"]},
{"primary-none": ["no-prefill"]},
{"primary-relay": ["relay"]},
{"relay": ["backup"]},
{"primary-shared": ["no-prefill-shared"]},
)
with booted_rig(
tmp_path_factory.mktemp("continuation-configured"),
fallbacks,
{SETTING: True},
2,
(registered_guardrail("input-check", "pre_call"),),
) as rig:
yield rig
@pytest.fixture(scope="module")
def output_guarded(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
with booted_rig(
tmp_path_factory.mktemp("continuation-output-guarded"),
({"primary": ["backup"]},),
{SETTING: True},
2,
(registered_guardrail("output-check", "post_call"),),
) as rig:
yield rig
@pytest.fixture(scope="module")
def unset(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
with booted_rig(
tmp_path_factory.mktemp("continuation-unset"), ({"primary": ["no-prefill", "backup"]},), {}, 1
) as rig:
yield rig
def new_key(rig: Rig) -> str:
key: Final = rig.gateway.post("/key/generate", {})["key"]
assert isinstance(key, str), key
return key
def appended(seen: tuple[ChatCompletionChunk, ...], chunk: ChatCompletionChunk) -> tuple[ChatCompletionChunk, ...]:
return (*seen, chunk)
def observed(
response: Iterable[ChatCompletionChunk], releases: Sequence[Release]
) -> Iterator[ChatCompletionChunk | openai.APIError]:
try:
for seen in itertools.accumulate(response, appended, initial=NOTHING_SEEN):
for release in releases:
if release.ready(seen):
release.gate.set()
yield from seen[-1:]
except openai.APIError as error:
yield error
def stream(
rig: Rig,
key: str,
model: str,
prompt: str,
releases: Sequence[Release] = (),
extra: Mapping[str, JsonValue] | None = None,
) -> Streamed:
base_url: Final = f"{rig.gateway.client.base_url}".rstrip("/") + "/v1"
try:
with openai.OpenAI(base_url=base_url, api_key=key, max_retries=0, timeout=60) as client:
response: Final = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
stream_options={"include_usage": True},
max_tokens=MAX_TOKENS,
extra_body=dict(extra or {}),
)
outcome: Final = tuple(observed(response, releases))
finally:
for release in releases:
release.gate.set()
errors: Final = tuple(item for item in outcome if isinstance(item, openai.APIError))
return Streamed(
tuple(item for item in outcome if isinstance(item, ChatCompletionChunk)), errors[0] if errors else None
)
def chat_bodies(wire: Wire, marker: str) -> tuple[dict[str, JsonValue], ...]:
return tuple(
JSON_OBJECT.validate_json(request.body)
for request in wire.drain()
if is_chat(request) and marker_of(request) == marker
)
def spend_rows(rig: Rig, key: str) -> list[dict[str, JsonValue]]:
return eventually(
lambda: read_rows(
'SELECT request_id, status, spend, prompt_tokens, completion_tokens, metadata FROM "LiteLLM_SpendLogs" '
"WHERE api_key=%s",
(hashlib.sha256(key.encode()).hexdigest(),),
database_url=rig.database_url,
),
lambda rows: len(rows) >= 1,
seconds=70,
)
def amount(value: JsonValue) -> float:
assert isinstance(value, (int, float)), value
return float(value)
def key_spend(rig: Rig, key: str) -> float:
return amount(object_value(rig.gateway.get("/key/info", {"key": key})["info"])["spend"])
def close(observed_value: float, expected: float) -> bool:
return math.isclose(observed_value, expected, rel_tol=1e-9, abs_tol=1e-12)
def cost(tokens: tuple[int, int], prices: tuple[float, float]) -> float:
return tokens[0] * prices[0] + tokens[1] * prices[1]
def prefill_turn(text: str) -> dict[str, JsonValue]:
return {"role": "assistant", "content": text, "prefix": True}
@dataclass(frozen=True, slots=True)
class Billed:
rows: list[dict[str, JsonValue]]
partial: tuple[int, int]
def billed_once(rig: Rig, key: str, received: Streamed) -> Billed:
rows: Final = spend_rows(rig, key)
assert [row["status"] for row in rows] == ["success"], rows
row_tokens: Final = (rows[0]["prompt_tokens"], rows[0]["completion_tokens"])
assert isinstance(row_tokens[0], int) and isinstance(row_tokens[1], int), rows
assert received.usage == row_tokens, (received.usage, row_tokens)
partial: Final = (row_tokens[0] - BACKUP_USAGE[0], row_tokens[1] - BACKUP_USAGE[1])
assert partial[0] > 0 and partial[1] > 0, (row_tokens, BACKUP_USAGE)
return Billed(rows, partial)
def assert_spend_prices_every_token(rig: Rig, key: str, billed: Billed) -> None:
partial_cost: Final = cost(billed.partial, PRIMARY_PRICES)
breakdown: Final = object_value(object_value(billed.rows[0]["metadata"])["cost_breakdown"])
spend: Final = amount(billed.rows[0]["spend"])
reported_partial_cost: Final = amount(object_value(breakdown["additional_costs"])["mid_stream_partial_cost"])
assert close(spend, cost(BACKUP_USAGE, BACKUP_PRICES) + partial_cost) and close(
reported_partial_cost, partial_cost
), f"partial={billed.partial} spend={spend} breakdown={json.dumps(breakdown)}"
eventually(lambda: key_spend(rig, key), lambda value: close(value, spend), seconds=70)
assert spend_rows(rig, key) == billed.rows
def test_text_cut_continues_on_the_prefill_fallback_and_bills_both_calls_once(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
key: Final = new_key(configured)
prompt: Final = f"text-cut {marker}"
received: Final = stream(configured, key, "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL),))
assert received.error is None, received.error
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
assert received.ids == {f"chatcmpl-{marker}-primary"}, received.ids
assert received.role_deltas == 1, received.chunks
assert received.finish_reasons == ("stop",), received.chunks
assert len(chat_bodies(configured.primary, marker)) == 1
continued: Final = chat_bodies(configured.backup, marker)
assert len(continued) == 1, continued
assert continued[0]["messages"] == [{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
assert "_mid_stream_continuation" not in continued[0], continued[0]
billed: Final = billed_once(configured, key, received)
assert continued[0]["max_tokens"] == MAX_TOKENS - billed.partial[1], (continued[0], billed.partial)
assert_spend_prices_every_token(configured, key, billed)
def test_spend_row_of_a_continued_stream_carries_the_id_the_caller_received(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
key: Final = new_key(configured)
received: Final = stream(
configured, key, "primary", f"text-cut {marker}", (after_text(marker, "primary", JOINED_PARTIAL),)
)
assert received.error is None, received.error
assert [row["request_id"] for row in spend_rows(configured, key)] == [received.chunks[0].id]
def test_tool_call_before_the_cut_keeps_the_original_error(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
configured,
new_key(configured),
"primary",
f"tool-cut {marker}",
(Release(cut_gate(marker, "primary", 0), has_tool_call),),
{"tools": [dict(WEATHER_TOOL)]},
)
assert received.error is not None, received.chunks
assert (received.text, has_tool_call(received.chunks)) == (TOOL_PREAMBLE, True), received.chunks
assert len(chat_bodies(configured.primary, marker)) == 1
assert chat_bodies(configured.backup, marker) == ()
def test_continuation_skips_a_fallback_without_prefill_support(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
prompt: Final = f"text-cut {marker}"
received: Final = stream(
configured, new_key(configured), "primary-skip", prompt, (after_text(marker, "primary", JOINED_PARTIAL),)
)
assert received.error is None, received.error
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
assert chat_bodies(configured.no_prefill, marker) == ()
assert [body["messages"] for body in chat_bodies(configured.backup, marker)] == [
[{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
]
def test_no_prefill_capable_fallback_keeps_the_original_error(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
configured,
new_key(configured),
"primary-none",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL),),
)
assert received.error is not None, received.chunks
assert "No deployments available" not in str(received.error), received.error
assert received.text == JOINED_PARTIAL, received.chunks
assert len(chat_bodies(configured.primary, marker)) == 1
assert chat_bodies(configured.no_prefill, marker) == ()
def test_undeclared_fallback_sharing_a_backend_with_a_prefill_deployment_keeps_the_original_error(
configured: Rig,
) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
configured,
new_key(configured),
"primary-shared",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL),),
)
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
assert chat_bodies(configured.no_prefill, marker) == ()
def test_failure_before_content_still_falls_back_to_a_fallback_without_prefill_support(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
prompt: Final = f"reject {marker}"
received: Final = stream(configured, new_key(configured), "primary-none", prompt)
assert received.error is None, received.error
assert received.text == PLAIN_TEXT, received.chunks
assert [body["messages"] for body in chat_bodies(configured.no_prefill, marker)] == [
[{"role": "user", "content": prompt}]
]
def stream_with_two_cuts(rig: Rig, key: str, marker: str) -> Streamed:
return stream(
rig,
key,
"primary-relay",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL), after_text(marker, "relay", JOINED_PARTIAL + RELAY_TEXT)),
)
def test_second_cut_continues_with_both_partials_in_one_prefill(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
key: Final = new_key(configured)
received: Final = stream_with_two_cuts(configured, key, marker)
assert received.error is None, received.error
assert received.text == JOINED_PARTIAL + RELAY_TEXT + BACKUP_TEXT, received.chunks
assert received.role_deltas == 1, received.chunks
user_turn: Final = {"role": "user", "content": f"text-cut {marker}"}
relayed: Final = chat_bodies(configured.relay, marker)
continued: Final = chat_bodies(configured.backup, marker)
assert [body["messages"] for body in relayed] == [[user_turn, prefill_turn(JOINED_PARTIAL)]]
assert [body["messages"] for body in continued] == [[user_turn, prefill_turn(JOINED_PARTIAL + RELAY_TEXT)]]
partial: Final = billed_once(configured, key, received).partial
relay_ceiling: Final = relayed[0]["max_tokens"]
assert isinstance(relay_ceiling, int), relayed[0]
assert 0 < MAX_TOKENS - relay_ceiling < partial[1], (relayed[0], partial)
assert continued[0]["max_tokens"] == MAX_TOKENS - partial[1], (continued[0], partial)
def test_second_cut_bills_every_call_on_one_row(configured: Rig) -> None:
key: Final = new_key(configured)
received: Final = stream_with_two_cuts(configured, key, uuid.uuid4().hex)
assert received.error is None, received.error
assert_spend_prices_every_token(configured, key, billed_once(configured, key, received))
def test_repeating_a_continued_request_never_returns_only_the_continuation(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
key: Final = new_key(configured)
prompt: Final = f"text-cut {marker}"
answers: Final = tuple(
stream(configured, key, "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL, attempt),))
for attempt in range(2)
)
assert tuple((answer.error, answer.text) for answer in answers) == ((None, JOINED_PARTIAL + BACKUP_TEXT),) * 2
def test_without_the_setting_a_text_cut_keeps_the_original_error(unset: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
unset, new_key(unset), "primary", f"text-cut {marker}", (after_text(marker, "primary", JOINED_PARTIAL),)
)
assert received.error is not None, received.chunks
assert received.text == JOINED_PARTIAL, received.chunks
assert len(chat_bodies(unset.primary, marker)) == 1
assert chat_bodies(unset.no_prefill, marker) == ()
assert chat_bodies(unset.backup, marker) == ()
def router_setting(rig: Rig) -> JsonValue:
return object_value(rig.gateway.get("/router/settings")["current_values"]).get(SETTING)
def test_turning_the_setting_on_at_runtime_continues_on_a_prefill_fallback(unset: Rig) -> None:
rig: Final = unset
rig.gateway.post("/config/update", {"router_settings": {SETTING: True}})
try:
assert router_setting(rig) is True
marker: Final = uuid.uuid4().hex
prompt: Final = f"text-cut {marker}"
received: Final = stream(rig, new_key(rig), "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL),))
assert received.error is None, received.error
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
assert chat_bodies(rig.no_prefill, marker) == ()
assert [body["messages"] for body in chat_bodies(rig.backup, marker)] == [
[{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
]
finally:
rig.gateway.post("/config/update", {"router_settings": {SETTING: False}})
def test_a_registered_output_guardrail_keeps_the_original_error(output_guarded: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
output_guarded,
new_key(output_guarded),
"primary",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL),),
)
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
assert len(chat_bodies(output_guarded.primary, marker)) == 1
assert chat_bodies(output_guarded.backup, marker) == ()
def test_a_request_fallback_bringing_its_own_messages_keeps_the_original_error(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
configured,
new_key(configured),
"primary",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL),),
{"fallbacks": [{"model": "backup", "messages": [{"role": "user", "content": f"start over {marker}"}]}]},
)
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
assert len(chat_bodies(configured.primary, marker)) == 1
assert chat_bodies(configured.backup, marker) == ()
def carries_tag(event: Mapping[str, JsonValue], marker: str) -> bool:
tags: Final = event.get("request_tags")
return isinstance(tags, list) and marker in tags
def tagged_events(batches: Iterable[Request], marker: str) -> tuple[dict[str, JsonValue], ...]:
events: Final = itertools.chain.from_iterable(EVENT_BATCH.validate_json(batch.body) for batch in batches)
return tuple(event for event in events if carries_tag(event, marker))
def logged_events(sink: Wire, marker: str) -> Iterator[tuple[dict[str, JsonValue], ...]]:
return itertools.accumulate((tagged_events(sink.drain(), marker) for _ in itertools.count()), operator.add)
def test_the_cut_call_logs_its_failure_under_its_own_deployment_and_price(configured: Rig) -> None:
marker: Final = uuid.uuid4().hex
received: Final = stream(
configured,
new_key(configured),
"primary",
f"text-cut {marker}",
(after_text(marker, "primary", JOINED_PARTIAL),),
{"metadata": {"tags": [marker]}},
)
assert received.error is None, received.error
polls: Final = logged_events(configured.events, marker)
events: Final = eventually(lambda: next(polls), lambda logged: len(logged) >= 2, seconds=30)
assert sorted(str(event["status"]) for event in events) == ["failure", "success"], events
failure: Final = next(event for event in events if event["status"] == "failure")
assert failure["model_group"] == "primary", failure
assert str(failure["api_base"]).startswith(configured.primary.url), failure
tokens: Final = (failure["prompt_tokens"], failure["completion_tokens"])
assert isinstance(tokens[0], int) and isinstance(tokens[1], int) and tokens[0] > 0 and tokens[1] > 0, failure
assert close(amount(failure["response_cost"]), cost((tokens[0], tokens[1]), PRIMARY_PRICES)), failure