Skip to content

Commit 4d6c7f3

Browse files
refactor: extract shared reasoning util and clean up provider implementations
Remove duplicated reasoning logic from vLLM, Ollama, and Bedrock providers into a shared reasoning.py utility. Remove dead _prepare_reasoning_params stubs, simplify return types to streaming-only, eliminate type ignores in streaming.py, and add multi-turn reasoning + tool call integration test. Signed-off-by: robinnarsinghranabhat <robinnarsingha123@gmail.com>
1 parent 95fba5b commit 4d6c7f3

16 files changed

Lines changed: 17794 additions & 153 deletions

src/llama_stack/core/routers/inference.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
OpenAIChatCompletionResponseMessage,
4343
OpenAIChatCompletionToolCall,
4444
OpenAIChatCompletionToolCallFunction,
45-
OpenAIChatCompletionWithReasoning,
4645
OpenAIChoice,
4746
OpenAIChoiceLogprobs,
4847
OpenAICompletion,
@@ -272,7 +271,7 @@ async def openai_chat_completion(
272271
async def openai_chat_completions_with_reasoning(
273272
self,
274273
params: OpenAIChatCompletionRequestWithExtraBody,
275-
) -> OpenAIChatCompletionWithReasoning | AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
274+
) -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
276275
"""Called by the Responses layer when a user requests reasoning.
277276
278277
Routes to the provider's reasoning-aware CC implementation, which

src/llama_stack/providers/inline/responses/builtin/responses/streaming.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
OpenAIChatCompletionToolChoiceAllowedTools,
3838
OpenAIChatCompletionToolChoiceCustomTool,
3939
OpenAIChatCompletionToolChoiceFunctionTool,
40-
OpenAIChatCompletionWithReasoning,
4140
OpenAIChoice,
4241
OpenAIChoiceLogprobs,
4342
OpenAIFinishReason,
@@ -547,7 +546,6 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
547546
completion_result: (
548547
OpenAIChatCompletion
549548
| AsyncIterator[OpenAIChatCompletionChunk]
550-
| OpenAIChatCompletionWithReasoning
551549
| AsyncIterator[OpenAIChatCompletionChunkWithReasoning]
552550
)
553551
if self.reasoning and self.reasoning.effort and self.reasoning.effort != "none":
@@ -730,20 +728,18 @@ def _separate_tool_calls(
730728

731729
for choice in current_response.choices:
732730
# Convert response message to input message format for multi-turn.
733-
# Use AssistantMessageWithReasoning if reasoning was present in the
734-
# CC response. Providers will be check for this AssistantMessageWithReasoning
735-
# message
731+
# Assign base type first, then narrow to AssistantMessageWithReasoning
732+
# if reasoning was present in the CC response.
733+
message: OpenAIAssistantMessageParam | AssistantMessageWithReasoning = OpenAIAssistantMessageParam(
734+
content=choice.message.content,
735+
tool_calls=choice.message.tool_calls,
736+
)
736737
if reasoning_content:
737738
message = AssistantMessageWithReasoning(
738739
content=choice.message.content,
739740
tool_calls=choice.message.tool_calls,
740741
reasoning_content=reasoning_content,
741742
)
742-
else:
743-
message = OpenAIAssistantMessageParam( # type: ignore[assignment]
744-
content=choice.message.content,
745-
tool_calls=choice.message.tool_calls,
746-
)
747743
next_turn_messages.append(message)
748744
logger.debug("Choice message content", content=choice.message.content)
749745
logger.debug("Choice message tool_calls", tool_calls=choice.message.tool_calls)
@@ -1123,7 +1119,7 @@ async def _process_streaming_chunks(
11231119
# chunk: OpenAIChatCompletionChunk annotation above.
11241120
if chunk_choice.delta.tool_calls:
11251121
for tool_call in chunk_choice.delta.tool_calls:
1126-
response_tool_call = chat_response_tool_calls.get(tool_call.index, None) # type: ignore[arg-type]
1122+
response_tool_call = chat_response_tool_calls.get(tool_call.index, None)
11271123
# Create new tool call entry if this is the first chunk for this index
11281124
is_new_tool_call = response_tool_call is None
11291125
if is_new_tool_call:
@@ -1133,16 +1129,16 @@ async def _process_streaming_chunks(
11331129
if tool_call_dict.get("function") and tool_call_dict["function"].get("arguments") is None:
11341130
tool_call_dict["function"]["arguments"] = "{}"
11351131
response_tool_call = OpenAIChatCompletionToolCall(**tool_call_dict)
1136-
chat_response_tool_calls[tool_call.index] = response_tool_call # type: ignore[index]
1132+
chat_response_tool_calls[tool_call.index] = response_tool_call
11371133

11381134
# Create item ID for this tool call for streaming events
11391135
tool_call_item_id = f"fc_{uuid.uuid4()}"
1140-
tool_call_item_ids[tool_call.index] = tool_call_item_id # type: ignore[index]
1136+
tool_call_item_ids[tool_call.index] = tool_call_item_id
11411137

11421138
# Emit output_item.added event for the new function call
11431139
self.sequence_number += 1
1144-
is_mcp_tool = tool_call.function.name and tool_call.function.name in self.mcp_tool_to_server # type: ignore[union-attr]
1145-
if not is_mcp_tool and tool_call.function.name not in _SERVER_SIDE_BUILTIN_TOOL_NAMES: # type: ignore[union-attr]
1140+
is_mcp_tool = tool_call.function.name and tool_call.function.name in self.mcp_tool_to_server
1141+
if not is_mcp_tool and tool_call.function.name not in _SERVER_SIDE_BUILTIN_TOOL_NAMES:
11461142
# for MCP tools (and even other non-function tools) we emit an output message item later
11471143
function_call_item = OpenAIResponseOutputMessageFunctionToolCall(
11481144
arguments="", # Will be filled incrementally via delta events

src/llama_stack/providers/remote/inference/bedrock/bedrock.py

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@
55
# the root directory of this source tree.
66

77
from collections.abc import AsyncIterator
8+
from typing import cast
89

910
from openai import AuthenticationError
1011

1112
from llama_stack.log import get_logger
12-
from llama_stack.providers.inline.responses.builtin.responses.types import (
13-
AssistantMessageWithReasoning,
14-
)
1513
from llama_stack.providers.utils.inference.openai_mixin import OpenAIMixin
14+
from llama_stack.providers.utils.inference.reasoning import map_reasoning_messages, wrap_chunks_with_reasoning
1615
from llama_stack_api import (
1716
OpenAIChatCompletion,
1817
OpenAIChatCompletionChunk,
1918
OpenAIChatCompletionChunkWithReasoning,
2019
OpenAIChatCompletionRequestWithExtraBody,
21-
OpenAIChatCompletionWithReasoning,
2220
OpenAICompletion,
2321
OpenAICompletionRequestWithExtraBody,
2422
OpenAIEmbeddingsRequestWithExtraBody,
@@ -65,56 +63,24 @@ async def openai_completion(
6563
"See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions.html"
6664
)
6765

68-
def _prepare_reasoning_params(self, params: OpenAIChatCompletionRequestWithExtraBody) -> None:
69-
"""Adapt CC request params to match what Bedrock expects for reasoning.
70-
71-
No-op for now. Override if Bedrock needs specific param adjustments.
72-
"""
73-
pass
74-
7566
async def openai_chat_completions_with_reasoning(
7667
self,
7768
params: OpenAIChatCompletionRequestWithExtraBody,
78-
) -> OpenAIChatCompletionWithReasoning | AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
69+
) -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
7970
"""Chat completion with reasoning support for Bedrock.
8071
8172
Extracts reasoning from Bedrock's response and wraps it in internal
82-
types so the Responses layer can read reasoning as a typed field.
73+
OpenAIChatCompletionChunkWithReasoning so the Responses layer can
74+
read reasoning as a typed field.
8375
"""
8476
if not params.stream:
8577
raise NotImplementedError("Non-streaming reasoning is not yet supported for Bedrock")
8678

8779
params = params.model_copy()
88-
self._prepare_reasoning_params(params)
89-
90-
# Bedrock's CC endpoint expects 'reasoning' on assistant messages, but
91-
# that field isn't part of the official CC spec. Convert to dicts so we
92-
# can rename reasoning_content → reasoning.
93-
mapped_messages: list = []
94-
for msg in params.messages:
95-
if isinstance(msg, AssistantMessageWithReasoning) and msg.reasoning_content:
96-
msg_dict = msg.model_dump(exclude_none=True)
97-
msg_dict["reasoning"] = msg_dict.pop("reasoning_content")
98-
mapped_messages.append(msg_dict)
99-
else:
100-
mapped_messages.append(msg)
101-
params.messages = mapped_messages
102-
80+
params.messages = map_reasoning_messages(params.messages, reasoning_field="reasoning")
10381
result = await self.openai_chat_completion(params)
104-
105-
async def _wrap_chunks() -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
106-
async for chunk in result:
107-
reasoning = None
108-
for choice in chunk.choices or []:
109-
reasoning = getattr(choice.delta, "reasoning", None) or getattr(
110-
choice.delta, "reasoning_content", None
111-
)
112-
yield OpenAIChatCompletionChunkWithReasoning(
113-
chunk=chunk,
114-
reasoning_content=reasoning,
115-
)
116-
117-
return _wrap_chunks()
82+
result = cast(AsyncIterator[OpenAIChatCompletionChunk], result)
83+
return wrap_chunks_with_reasoning(result, reasoning_fields=("reasoning", "reasoning_content"))
11884

11985
async def openai_chat_completion(
12086
self,

src/llama_stack/providers/remote/inference/ollama/ollama.py

Lines changed: 11 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,21 @@
77

88
import asyncio
99
from collections.abc import AsyncIterator
10+
from typing import cast
1011

1112
from ollama import AsyncClient as AsyncOllamaClient
1213

1314
from llama_stack.log import get_logger
14-
from llama_stack.providers.inline.responses.builtin.responses.types import (
15-
AssistantMessageWithReasoning,
16-
)
1715
from llama_stack.providers.remote.inference.ollama.config import OllamaImplConfig
1816
from llama_stack.providers.utils.inference.openai_mixin import OpenAIMixin
17+
from llama_stack.providers.utils.inference.reasoning import map_reasoning_messages, wrap_chunks_with_reasoning
1918
from llama_stack_api import (
2019
HealthResponse,
2120
HealthStatus,
2221
Model,
22+
OpenAIChatCompletionChunk,
2323
OpenAIChatCompletionChunkWithReasoning,
2424
OpenAIChatCompletionRequestWithExtraBody,
25-
OpenAIChatCompletionWithReasoning,
2625
UnsupportedModelError,
2726
)
2827

@@ -79,62 +78,26 @@ def get_api_key(self):
7978
def get_base_url(self):
8079
return str(self.config.base_url)
8180

82-
def _prepare_reasoning_params(self, params: OpenAIChatCompletionRequestWithExtraBody) -> None:
83-
"""Adapt CC request params to match what Ollama expects for reasoning.
84-
85-
Each provider may need different param adjustments. For Ollama:
86-
- If reasoning_effort is not set, default to "none" so Ollama
87-
doesn't apply its own default (medium).
88-
89-
Override this in other providers if they need different mapping,
90-
e.g. converting effort levels to boolean flags.
91-
"""
92-
if params.reasoning_effort is None:
93-
params.reasoning_effort = "none"
94-
9581
async def openai_chat_completions_with_reasoning(
9682
self,
9783
params: OpenAIChatCompletionRequestWithExtraBody,
98-
) -> OpenAIChatCompletionWithReasoning | AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
84+
) -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
9985
"""Chat completion with reasoning support for Ollama.
10086
10187
Extracts reasoning from Ollama's response and wraps it in internal
102-
types so the Responses layer can read reasoning as a typed field.
88+
OpenAIChatCompletionChunkWithReasoning so the Responses layer can
89+
read reasoning as a typed field.
10390
"""
10491
if not params.stream:
10592
raise NotImplementedError("Non-streaming reasoning is not yet supported for Ollama")
10693

10794
params = params.model_copy()
108-
self._prepare_reasoning_params(params)
109-
110-
# Ollama's CC endpoint expects 'reasoning' on assistant messages, but
111-
# that field isn't part of the official CC spec. Convert to dicts so we
112-
# can rename reasoning_content → reasoning.
113-
mapped_messages: list = []
114-
for msg in params.messages:
115-
if isinstance(msg, AssistantMessageWithReasoning) and msg.reasoning_content:
116-
msg_dict = msg.model_dump(exclude_none=True)
117-
msg_dict["reasoning"] = msg_dict.pop("reasoning_content")
118-
mapped_messages.append(msg_dict)
119-
else:
120-
mapped_messages.append(msg)
121-
params.messages = mapped_messages
122-
95+
if params.reasoning_effort is None:
96+
params.reasoning_effort = "none"
97+
params.messages = map_reasoning_messages(params.messages, reasoning_field="reasoning")
12398
result = await self.openai_chat_completion(params)
124-
125-
async def _wrap_chunks() -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
126-
async for chunk in result:
127-
reasoning = None
128-
for choice in chunk.choices or []:
129-
reasoning = getattr(choice.delta, "reasoning", None) or getattr(
130-
choice.delta, "reasoning_content", None
131-
)
132-
yield OpenAIChatCompletionChunkWithReasoning(
133-
chunk=chunk,
134-
reasoning_content=reasoning,
135-
)
136-
137-
return _wrap_chunks()
99+
result = cast(AsyncIterator[OpenAIChatCompletionChunk], result)
100+
return wrap_chunks_with_reasoning(result, reasoning_fields=("reasoning", "reasoning_content"))
138101

139102
async def initialize(self) -> None:
140103
logger.info("checking connectivity to Ollama", base_url=self.config.base_url)

src/llama_stack/providers/remote/inference/vllm/vllm.py

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@
44
# This source code is licensed under the terms described in the LICENSE file in
55
# the root directory of this source tree.
66
from collections.abc import AsyncIterator
7+
from typing import cast
78
from urllib.parse import urljoin
89

910
import httpx
1011
from pydantic import ConfigDict
1112

1213
from llama_stack.log import get_logger
13-
from llama_stack.providers.inline.responses.builtin.responses.types import (
14-
AssistantMessageWithReasoning,
15-
)
1614
from llama_stack.providers.utils.inference.http_client import _build_network_client_kwargs
1715
from llama_stack.providers.utils.inference.openai_mixin import OpenAIMixin
16+
from llama_stack.providers.utils.inference.reasoning import map_reasoning_messages, wrap_chunks_with_reasoning
1817
from llama_stack_api import (
1918
HealthResponse,
2019
HealthStatus,
@@ -26,7 +25,6 @@
2625
OpenAIChatCompletionContentPartImageParam,
2726
OpenAIChatCompletionContentPartTextParam,
2827
OpenAIChatCompletionRequestWithExtraBody,
29-
OpenAIChatCompletionWithReasoning,
3028
RerankData,
3129
RerankResponse,
3230
)
@@ -117,58 +115,24 @@ async def openai_chat_completion(
117115

118116
return await super().openai_chat_completion(params)
119117

120-
def _prepare_reasoning_params(self, params: OpenAIChatCompletionRequestWithExtraBody) -> None:
121-
"""Adapt CC request params to match what vLLM expects for reasoning.
122-
123-
No-op for now. Override if vLLM needs specific param adjustments,
124-
e.g. mapping effort levels or moving params to extra_body.
125-
"""
126-
pass
127-
128118
async def openai_chat_completions_with_reasoning(
129119
self,
130120
params: OpenAIChatCompletionRequestWithExtraBody,
131-
) -> OpenAIChatCompletionWithReasoning | AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
121+
) -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
132122
"""Chat completion with reasoning support for vLLM.
133123
134124
Extracts reasoning from vLLM's response and wraps it in internal
135-
types (OpenAIChatCompletionChunkWithReasoning / OpenAIChatCompletionWithReasoning)
136-
so the Responses layer can read reasoning as a typed field.
125+
OpenAIChatCompletionChunkWithReasoning so the Responses layer can
126+
read reasoning as a typed field.
137127
"""
138128
if not params.stream:
139129
raise NotImplementedError("Non-streaming reasoning is not yet supported for vLLM")
140130

141131
params = params.model_copy()
142-
self._prepare_reasoning_params(params)
143-
144-
# vLLM's CC endpoint expects 'reasoning' on assistant messages, but
145-
# that field isn't part of the official CC spec. Convert to dicts so we
146-
# can rename reasoning_content → reasoning.
147-
mapped_messages: list = []
148-
for msg in params.messages:
149-
if isinstance(msg, AssistantMessageWithReasoning) and msg.reasoning_content:
150-
msg_dict = msg.model_dump(exclude_none=True)
151-
msg_dict["reasoning"] = msg_dict.pop("reasoning_content")
152-
mapped_messages.append(msg_dict)
153-
else:
154-
mapped_messages.append(msg)
155-
params.messages = mapped_messages
156-
132+
params.messages = map_reasoning_messages(params.messages, reasoning_field="reasoning")
157133
result = await self.openai_chat_completion(params)
158-
159-
async def _wrap_chunks() -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
160-
async for chunk in result: # type: ignore[union-attr]
161-
reasoning = None
162-
for choice in chunk.choices or []:
163-
reasoning = getattr(choice.delta, "reasoning", None) or getattr(
164-
choice.delta, "reasoning_content", None
165-
)
166-
yield OpenAIChatCompletionChunkWithReasoning(
167-
chunk=chunk,
168-
reasoning_content=reasoning,
169-
)
170-
171-
return _wrap_chunks()
134+
result = cast(AsyncIterator[OpenAIChatCompletionChunk], result)
135+
return wrap_chunks_with_reasoning(result, reasoning_fields=("reasoning", "reasoning_content"))
172136

173137
def construct_model_from_identifier(self, identifier: str) -> Model:
174138
# vLLM's /v1/models response does not expose a model task/type field, so classify by name.

0 commit comments

Comments
 (0)