Skip to content

Commit 370337b

Browse files
authored
fix(inference): return requested model id in streamed completion chunks (#6320)
# What does this PR do? Re-opens #6314 (previously closed). @mattf confirmed this as a real bug and asked to continue it — reopening with the same fix. Fixes an inconsistency in `InferenceRouter.openai_completion`: streamed `/v1/completions` chunks report the provider-internal model id instead of the model id the client requested. The router rewrites `params.model` to the provider's `provider_resource_id` before calling the provider. For non-streaming requests it then restores the requested fully qualified id (`response.model = request_model_id`), and the chat-completions streaming path does the same per chunk in `stream_tokens_and_compute_metrics_openai_chat` (`chunk.model = fully_qualified_model_id`). But the `stream=True` branch of `openai_completion` returned the provider stream untouched, so every streamed chunk leaked the provider-internal id — e.g. a client requesting `openai/gpt-4o` gets `model="openai/gpt-4o"` when not streaming but `model="gpt-4o"` on every streamed chunk. Clients that correlate responses by the model they requested (or that multiplex providers behind aliases) see different model ids depending solely on whether they streamed. The fix wraps the provider stream in a small async generator that restores the requested model id on each chunk before yielding, mirroring the existing chat-streaming behavior — including skipping `None` chunks, exactly as `stream_tokens_and_compute_metrics_openai_chat` does. Provider exceptions and stream termination propagate through unchanged. The return type annotation is also corrected to `OpenAICompletion | AsyncIterator[OpenAICompletion]`, matching both the chat sibling and the `Inference` API definition in `src/ogx_api/inference/api.py`. ## Test Plan Added unit tests in `tests/unit/core/routers/test_inference_router.py`: - `test_openai_completion_streaming_rewrites_model_id` — the core case (requested fully qualified id differs from the provider's internal id, multi-chunk); fails on `main`, passes with this fix - `test_openai_completion_streaming_empty_stream` — a provider stream yielding zero chunks produces an empty stream without errors - `test_openai_completion_streaming_model_id_already_correct` — chunks already carrying the requested id pass through unchanged - `test_openai_completion_streaming_skips_none_chunks` — `None` chunks are skipped, matching the chat streaming path; fails on `main` - `test_openai_completion_streaming_propagates_provider_errors` — a mid-stream provider error propagates to the caller after earlier chunks are delivered - `test_openai_completion_non_streaming_rewrites_model_id` — regression guard for the already-correct non-streaming path ``` $ uv run pytest tests/unit/core/routers/ -q 87 passed, 1 warning in 1.21s ``` `uv run pre-commit run --files ...` passes on both touched files (ruff, ruff format, mypy, and all repo-local hooks). --------- Signed-off-by: Sohum Trivedi <trivsohum@gmail.com>
1 parent 405dba9 commit 370337b

2 files changed

Lines changed: 208 additions & 2 deletions

File tree

src/ogx/core/routers/inference.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ async def rerank(
189189
async def openai_completion(
190190
self,
191191
params: Annotated[OpenAICompletionRequestWithExtraBody, Body(...)],
192-
) -> OpenAICompletion:
192+
) -> OpenAICompletion | AsyncIterator[OpenAICompletion]:
193193
logger.debug(
194194
"InferenceRouter.openai_completion: model=, stream=, prompt",
195195
model=params.model,
@@ -204,12 +204,29 @@ async def openai_completion(
204204
params.model = provider_resource_id
205205

206206
if params.stream:
207-
return await provider.openai_completion(params)
207+
response_stream = await provider.openai_completion(params)
208+
# Providers respond with their internal model id, so rewrite each
209+
# chunk to carry the fully qualified model id the client requested
210+
# (mirrors the non-streaming path below and the chat-streaming path).
211+
return self._rewrite_completion_stream_model_id(response_stream, request_model_id)
208212

209213
response = await provider.openai_completion(params)
210214
response.model = request_model_id
211215
return response
212216

217+
async def _rewrite_completion_stream_model_id(
218+
self,
219+
response: AsyncIterator[OpenAICompletion],
220+
fully_qualified_model_id: str,
221+
) -> AsyncIterator[OpenAICompletion]:
222+
"""Yield streamed completion chunks with the requested model id restored."""
223+
async for chunk in response:
224+
# Skip None chunks, mirroring stream_tokens_and_compute_metrics_openai_chat
225+
if chunk is None:
226+
continue
227+
chunk.model = fully_qualified_model_id
228+
yield chunk
229+
213230
async def openai_chat_completion(
214231
self,
215232
params: Annotated[OpenAIChatCompletionRequestWithExtraBody, Body(...)],

tests/unit/core/routers/test_inference_router.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@
2323
from ogx.core.routers.inference import InferenceRouter
2424
from ogx_api import (
2525
ModelType,
26+
OpenAICompletion,
27+
OpenAICompletionRequestWithExtraBody,
2628
RerankData,
2729
RerankResponse,
2830
RoutingTable,
2931
)
3032
from ogx_api.inference import RerankRequest
33+
from ogx_api.inference.models import OpenAICompletionChoice
3134

3235

3336
@pytest.fixture
@@ -49,6 +52,192 @@ def mock_routing_table():
4952
return routing_table, mock_provider
5053

5154

55+
@pytest.fixture
56+
def mock_llm_routing_table():
57+
"""Create a mock routing table with an LLM model registered under a fully qualified id"""
58+
routing_table = MagicMock(spec=RoutingTable)
59+
60+
mock_model = MagicMock()
61+
mock_model.identifier = "test_provider/test-llm-model"
62+
mock_model.model_type = ModelType.llm
63+
mock_model.provider_resource_id = "test-llm-model"
64+
65+
mock_provider = MagicMock()
66+
mock_provider.__provider_id__ = "test_provider"
67+
68+
routing_table.get_object_by_identifier = AsyncMock(return_value=mock_model)
69+
routing_table.get_provider_impl = AsyncMock(return_value=mock_provider)
70+
71+
return routing_table, mock_provider
72+
73+
74+
def _make_completion_chunk(text: str, model: str) -> OpenAICompletion:
75+
return OpenAICompletion(
76+
id="cmpl-test",
77+
choices=[OpenAICompletionChoice(finish_reason="stop", text=text, index=0)],
78+
created=0,
79+
model=model,
80+
object="text_completion",
81+
)
82+
83+
84+
async def test_openai_completion_streaming_rewrites_model_id(mock_llm_routing_table):
85+
"""
86+
Test that streamed /v1/completions chunks report the fully qualified model id
87+
that the client requested, not the provider-internal resource id.
88+
89+
This mirrors the non-streaming path in openai_completion (which sets
90+
response.model = request_model_id) and the chat streaming path
91+
(stream_tokens_and_compute_metrics_openai_chat, which rewrites chunk.model).
92+
"""
93+
routing_table, mock_provider = mock_llm_routing_table
94+
router = InferenceRouter(routing_table=routing_table)
95+
96+
async def provider_stream():
97+
# Providers respond with their internal model id
98+
yield _make_completion_chunk("Hello", model="test-llm-model")
99+
yield _make_completion_chunk(" world", model="test-llm-model")
100+
101+
mock_provider.openai_completion = AsyncMock(return_value=provider_stream())
102+
103+
params = OpenAICompletionRequestWithExtraBody(
104+
model="test_provider/test-llm-model",
105+
prompt="Say hello",
106+
stream=True,
107+
)
108+
109+
stream = await router.openai_completion(params)
110+
chunks = [chunk async for chunk in stream]
111+
112+
assert len(chunks) == 2
113+
assert [chunk.model for chunk in chunks] == ["test_provider/test-llm-model", "test_provider/test-llm-model"], (
114+
"Streamed completion chunks should carry the requested model id, not the provider resource id"
115+
)
116+
assert [choice.text for chunk in chunks for choice in chunk.choices] == ["Hello", " world"]
117+
118+
# The provider itself should still be called with its own resource id
119+
called_params = mock_provider.openai_completion.call_args.args[0]
120+
assert called_params.model == "test-llm-model"
121+
122+
123+
async def test_openai_completion_streaming_empty_stream(mock_llm_routing_table):
124+
"""A provider stream that yields no chunks produces an empty stream without errors."""
125+
routing_table, mock_provider = mock_llm_routing_table
126+
router = InferenceRouter(routing_table=routing_table)
127+
128+
async def provider_stream():
129+
return
130+
yield # unreachable; makes this function an async generator
131+
132+
mock_provider.openai_completion = AsyncMock(return_value=provider_stream())
133+
134+
params = OpenAICompletionRequestWithExtraBody(
135+
model="test_provider/test-llm-model",
136+
prompt="Say hello",
137+
stream=True,
138+
)
139+
140+
stream = await router.openai_completion(params)
141+
chunks = [chunk async for chunk in stream]
142+
143+
assert chunks == []
144+
145+
146+
async def test_openai_completion_streaming_model_id_already_correct(mock_llm_routing_table):
147+
"""Chunks that already carry the fully qualified model id are passed through unchanged."""
148+
routing_table, mock_provider = mock_llm_routing_table
149+
router = InferenceRouter(routing_table=routing_table)
150+
151+
async def provider_stream():
152+
yield _make_completion_chunk("Hello", model="test_provider/test-llm-model")
153+
154+
mock_provider.openai_completion = AsyncMock(return_value=provider_stream())
155+
156+
params = OpenAICompletionRequestWithExtraBody(
157+
model="test_provider/test-llm-model",
158+
prompt="Say hello",
159+
stream=True,
160+
)
161+
162+
stream = await router.openai_completion(params)
163+
chunks = [chunk async for chunk in stream]
164+
165+
assert len(chunks) == 1
166+
assert chunks[0].model == "test_provider/test-llm-model"
167+
assert chunks[0].choices[0].text == "Hello"
168+
169+
170+
async def test_openai_completion_streaming_skips_none_chunks(mock_llm_routing_table):
171+
"""None chunks from a provider are skipped, mirroring the chat streaming path."""
172+
routing_table, mock_provider = mock_llm_routing_table
173+
router = InferenceRouter(routing_table=routing_table)
174+
175+
async def provider_stream():
176+
yield _make_completion_chunk("Hello", model="test-llm-model")
177+
yield None
178+
yield _make_completion_chunk(" world", model="test-llm-model")
179+
180+
mock_provider.openai_completion = AsyncMock(return_value=provider_stream())
181+
182+
params = OpenAICompletionRequestWithExtraBody(
183+
model="test_provider/test-llm-model",
184+
prompt="Say hello",
185+
stream=True,
186+
)
187+
188+
stream = await router.openai_completion(params)
189+
chunks = [chunk async for chunk in stream]
190+
191+
assert [chunk.model for chunk in chunks] == ["test_provider/test-llm-model", "test_provider/test-llm-model"]
192+
assert [choice.text for chunk in chunks for choice in chunk.choices] == ["Hello", " world"]
193+
194+
195+
async def test_openai_completion_streaming_propagates_provider_errors(mock_llm_routing_table):
196+
"""Errors raised by the provider mid-stream propagate to the caller after earlier chunks are delivered."""
197+
routing_table, mock_provider = mock_llm_routing_table
198+
router = InferenceRouter(routing_table=routing_table)
199+
200+
async def provider_stream():
201+
yield _make_completion_chunk("Hello", model="test-llm-model")
202+
raise RuntimeError("provider stream failed")
203+
204+
mock_provider.openai_completion = AsyncMock(return_value=provider_stream())
205+
206+
params = OpenAICompletionRequestWithExtraBody(
207+
model="test_provider/test-llm-model",
208+
prompt="Say hello",
209+
stream=True,
210+
)
211+
212+
stream = await router.openai_completion(params)
213+
chunks = []
214+
with pytest.raises(RuntimeError, match="provider stream failed"):
215+
async for chunk in stream:
216+
chunks.append(chunk)
217+
218+
assert len(chunks) == 1
219+
assert chunks[0].model == "test_provider/test-llm-model"
220+
221+
222+
async def test_openai_completion_non_streaming_rewrites_model_id(mock_llm_routing_table):
223+
"""Non-streaming /v1/completions responses report the requested model id (regression guard)."""
224+
routing_table, mock_provider = mock_llm_routing_table
225+
router = InferenceRouter(routing_table=routing_table)
226+
227+
mock_provider.openai_completion = AsyncMock(
228+
return_value=_make_completion_chunk("Hello world", model="test-llm-model")
229+
)
230+
231+
params = OpenAICompletionRequestWithExtraBody(
232+
model="test_provider/test-llm-model",
233+
prompt="Say hello",
234+
)
235+
236+
response = await router.openai_completion(params)
237+
238+
assert response.model == "test_provider/test-llm-model"
239+
240+
52241
async def test_rerank_calls_provider_correctly(mock_routing_table):
53242
"""
54243
Test that InferenceRouter.rerank() calls the provider's rerank method with the correct RerankRequest.

0 commit comments

Comments
 (0)