Skip to content

Commit 68ba51c

Browse files
authored
fix(llm): enhance fallback logic to handle empty completions and impr… (#5491)
* fix(llm): enhance fallback logic to handle empty completions and improve metadata handling * fix(llm): stream tool call deltas immediately
1 parent 37a831b commit 68ba51c

2 files changed

Lines changed: 191 additions & 15 deletions

File tree

src/llm_core.py

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2769,8 +2769,9 @@ async def stream_llm_with_fallback(candidates, messages, **kwargs):
27692769
"""Wrap stream_llm with an ordered fallback chain.
27702770
27712771
`candidates` is a list of (url, model, headers). Each is tried in order,
2772-
but only retried on a *pre-content* failure — i.e. an ``event: error``
2773-
that arrives before any assistant text / tool-call data has been yielded.
2772+
but only retried on a *pre-content* failure — an ``event: error`` or an
2773+
empty completion before any assistant text / completed tool call is yielded.
2774+
Metadata is held until substantive output commits the candidate.
27742775
Once a candidate has emitted real output we never switch (that would
27752776
duplicate streamed tokens); a later error from that candidate passes
27762777
through unchanged. The dead-host cooldown in stream_llm makes repeat
@@ -2789,6 +2790,7 @@ async def stream_llm_with_fallback(candidates, messages, **kwargs):
27892790
is_last = (i == len(cands) - 1)
27902791
emitted = False
27912792
retried = False
2793+
pending_metadata = []
27922794
async for chunk in stream_llm(url, model, messages, headers=headers, **kwargs):
27932795
if chunk.startswith("event: error"):
27942796
if not emitted and not is_last:
@@ -2801,34 +2803,67 @@ async def stream_llm_with_fallback(candidates, messages, **kwargs):
28012803
else:
28022804
logger.warning(f"[fallback] candidate {model} failed; trying next")
28032805
break
2806+
if not emitted:
2807+
# A last-candidate error is already the clearest terminal
2808+
# result; do not append an empty-completion error as well.
2809+
yield chunk
2810+
return
28042811
yield chunk
28052812
continue
2806-
# Any data chunk other than the terminal [DONE] means real output.
2807-
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
2813+
2814+
event_data = {}
2815+
is_done = chunk.startswith("data: [DONE]")
2816+
if chunk.startswith("data: ") and not is_done:
28082817
try:
28092818
event_data = json.loads(chunk[6:])
28102819
except Exception:
2811-
event_data = {}
2812-
if event_data.get("type") == "model_actual":
2813-
yield chunk
2814-
continue
2820+
pass
2821+
2822+
delta = event_data.get("delta")
2823+
event_type = event_data.get("type")
2824+
substantive = (
2825+
isinstance(delta, str) and bool(delta)
2826+
) or (
2827+
event_type == "tool_call_delta"
2828+
) or (
2829+
event_type == "tool_calls"
2830+
and bool(event_data.get("calls"))
2831+
)
2832+
2833+
if substantive and not emitted:
28152834
# First real output from a NON-primary candidate: tell the client
28162835
# the selected model failed and another answered. Without this the
28172836
# fallback is invisible — a misconfigured provider looks like it
28182837
# works because the reply is shown under the originally selected
28192838
# model's name (e.g. a Bedrock/Claude endpoint that 400s every
28202839
# request but appears fine because another model silently answered).
2821-
if not emitted and i > 0:
2840+
if i > 0:
28222841
yield ('data: ' + json.dumps({
28232842
"type": "fallback",
28242843
"selected_model": primary_model,
28252844
"answered_by": model,
28262845
"reason": _summarize_stream_error(last_error),
28272846
}) + '\n\n')
2847+
# Metadata must not commit a candidate. Once real output arrives,
2848+
# flush it after any fallback notice and before the output itself.
2849+
for metadata_chunk in pending_metadata:
2850+
yield metadata_chunk
2851+
pending_metadata.clear()
28282852
emitted = True
2829-
yield chunk
2830-
if not retried:
2831-
return # candidate finished (success, or terminal error already sent)
2832-
# Every candidate failed pre-content — surface the last error.
2833-
if last_error:
2834-
yield last_error
2853+
2854+
if substantive or emitted:
2855+
yield chunk
2856+
elif not is_done:
2857+
pending_metadata.append(chunk)
2858+
2859+
if emitted:
2860+
return
2861+
if retried:
2862+
continue
2863+
if not is_last:
2864+
last_error = f'event: error\ndata: {json.dumps({"error": f"Model {model} returned no substantive output", "status": 502})}\n\n'
2865+
tag = "primary" if i == 0 else "candidate"
2866+
logger.warning(f"[fallback] {tag} {model} returned no substantive output; trying next")
2867+
continue
2868+
yield f'event: error\ndata: {json.dumps({"error": "All model candidates returned no substantive output", "status": 502})}\n\n'
2869+
return

tests/test_llm_core_fallback.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import json
99
import asyncio
1010

11+
import pytest
12+
1113
from src import llm_core
1214

1315

@@ -55,6 +57,145 @@ def per_model(model):
5557
assert not any('"fallback"' in c for c in chunks)
5658

5759

60+
def test_done_only_primary_invokes_fallback(monkeypatch):
61+
calls = []
62+
63+
def per_model(model):
64+
calls.append(model)
65+
if model == "primary":
66+
return ["data: [DONE]\n\n"]
67+
return [
68+
'data: {"type": "model_actual", "requested_model": "backup", "model": "backup-v2"}\n\n',
69+
'data: {"delta": "backup answer"}\n\n',
70+
"data: [DONE]\n\n",
71+
]
72+
73+
chunks = _run_fallback(monkeypatch, per_model)
74+
assert calls == ["primary", "backup"]
75+
assert any('"delta": "backup answer"' in c for c in chunks)
76+
model_idx = next(i for i, c in enumerate(chunks) if '"model_actual"' in c)
77+
fallback_idx = next(i for i, c in enumerate(chunks) if '"fallback"' in c)
78+
answer_idx = next(i for i, c in enumerate(chunks) if '"delta": "backup answer"' in c)
79+
assert fallback_idx < model_idx < answer_idx
80+
81+
82+
def test_usage_then_done_primary_invokes_fallback_and_discards_usage(monkeypatch):
83+
calls = []
84+
85+
def per_model(model):
86+
calls.append(model)
87+
if model == "primary":
88+
return [
89+
'data: {"type": "usage", "data": {"input_tokens": 4, "output_tokens": 0}}\n\n',
90+
"data: [DONE]\n\n",
91+
]
92+
return ['data: {"delta": "backup answer"}\n\n', "data: [DONE]\n\n"]
93+
94+
chunks = _run_fallback(monkeypatch, per_model)
95+
assert calls == ["primary", "backup"]
96+
assert not any('"type": "usage"' in c for c in chunks)
97+
98+
99+
@pytest.mark.parametrize(
100+
"output_chunk",
101+
[
102+
'data: {"delta": "visible text"}\n\n',
103+
'data: {"delta": "reasoning", "thinking": true}\n\n',
104+
],
105+
)
106+
def test_text_or_reasoning_output_prevents_fallback(monkeypatch, output_chunk):
107+
calls = []
108+
109+
def per_model(model):
110+
calls.append(model)
111+
return [output_chunk, "data: [DONE]\n\n"]
112+
113+
chunks = _run_fallback(monkeypatch, per_model)
114+
assert calls == ["primary"]
115+
assert output_chunk in chunks
116+
assert not any('"fallback"' in c for c in chunks)
117+
118+
119+
def test_whitespace_only_delta_prevents_fallback(monkeypatch):
120+
calls = []
121+
whitespace = 'data: {"delta": " "}\n\n'
122+
123+
def per_model(model):
124+
calls.append(model)
125+
return [whitespace, "data: [DONE]\n\n"]
126+
127+
chunks = _run_fallback(monkeypatch, per_model)
128+
assert calls == ["primary"]
129+
assert whitespace in chunks
130+
assert not any('"fallback"' in c for c in chunks)
131+
132+
133+
def test_completed_tool_call_output_prevents_fallback(monkeypatch):
134+
calls = []
135+
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "bash", "arguments": "{}"}]}\n\n'
136+
137+
def per_model(model):
138+
calls.append(model)
139+
return [tool_calls, "data: [DONE]\n\n"]
140+
141+
chunks = _run_fallback(monkeypatch, per_model)
142+
assert calls == ["primary"]
143+
assert tool_calls in chunks
144+
assert not any('"fallback"' in c for c in chunks)
145+
146+
147+
def test_tool_call_delta_is_forwarded_immediately_and_prevents_fallback(monkeypatch):
148+
calls = []
149+
advanced_past_delta = False
150+
tool_delta = 'data: {"type": "tool_call_delta", "index": 0, "arg_delta": "{\\"path\\":"}\n\n'
151+
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "write_file", "arguments": "{\\"path\\":\\"x\\"}"}]}\n\n'
152+
153+
async def fake_stream(url, model, messages, **kw):
154+
nonlocal advanced_past_delta
155+
calls.append(model)
156+
yield tool_delta
157+
advanced_past_delta = True
158+
yield tool_calls
159+
yield "data: [DONE]\n\n"
160+
161+
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
162+
163+
async def run():
164+
stream = llm_core.stream_llm_with_fallback(
165+
[("u1", "primary", {}), ("u2", "backup", {})],
166+
[{"role": "user", "content": "hi"}],
167+
)
168+
first = await anext(stream)
169+
assert first == tool_delta
170+
assert not advanced_past_delta
171+
chunks = [first]
172+
async for chunk in stream:
173+
chunks.append(chunk)
174+
return chunks
175+
176+
chunks = asyncio.run(run())
177+
assert calls == ["primary"]
178+
assert tool_calls in chunks
179+
assert not any('"type": "fallback"' in c for c in chunks)
180+
181+
182+
def test_empty_final_candidate_surfaces_terminal_error(monkeypatch):
183+
calls = []
184+
185+
def per_model(model):
186+
calls.append(model)
187+
if model == "primary":
188+
return [] # clean EOF without substantive output
189+
return ["data: [DONE]\n\n"]
190+
191+
chunks = _run_fallback(monkeypatch, per_model)
192+
assert calls == ["primary", "backup"]
193+
errors = [c for c in chunks if c.startswith("event: error")]
194+
assert len(errors) == 1
195+
assert "All model candidates returned no substantive output" in errors[0]
196+
assert '"status": 502' in errors[0]
197+
198+
58199
def test_dedupe_candidates_keeps_first_of_each_route():
59200
"""(url, model) is the route key; later repeats are dropped, order preserved,
60201
the first tuple (with its headers) kept, malformed entries filtered."""

0 commit comments

Comments
 (0)