Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
65583ae
feat: add reasoning output support for OpenAI Responses API
rranabha Mar 19, 2026
929c5a8
chore: regenerate openai coverage after adding ReasoningItem variant
robinnarsinghranabhat Mar 20, 2026
395e48f
test: add integration tests for reasoning output in Responses API
robinnarsinghranabhat Mar 20, 2026
c720a44
fix: use openai_client in reasoning integration tests
robinnarsinghranabhat Mar 20, 2026
66df6d6
feat: provider-level reasoning support via openai_chat_completions_wi…
robinnarsinghranabhat Mar 25, 2026
d3ebff2
refactor: use internal AssistantMessageWithReasoning instead of modif…
robinnarsinghranabhat Mar 27, 2026
e401547
test: add reasoning effort param to integration tests
robinnarsinghranabhat Mar 27, 2026
41ba0a0
feat: graceful fallback for reasoning unsupported providers
robinnarsinghranabhat Mar 27, 2026
ad81e02
fix: minor cleanups in reasoning tests
rranabha Mar 27, 2026
15a3eaf
fix: make integration tests compatible with current LlamaStack client
robinnarsinghranabhat Mar 27, 2026
d911ba7
Recordings update from CI
github-actions[bot] Mar 27, 2026
fae02a5
test: re-record ollama-reasoning and vllm-reasoning integration test …
robinnarsinghranabhat Mar 27, 2026
ad15e40
fix: pass params copy to reasoning method to prevent router mutation …
robinnarsinghranabhat Mar 27, 2026
86deda6
test: re-record gpt-reasoning integration test recordings
robinnarsinghranabhat Mar 27, 2026
f7cae37
chore: pre-commit formatting fixes
robinnarsinghranabhat Mar 28, 2026
b36f910
chore: regenerate docs after rebase
robinnarsinghranabhat Mar 28, 2026
61b72fa
fix: clear reasoning_effort on fallback to prevent provider rejection
robinnarsinghranabhat Mar 28, 2026
d3477d9
fix: remove flawed unsupported-provider fallback test
robinnarsinghranabhat Mar 28, 2026
a06ff17
chore: restore recordings from main to clean state before re-recording
robinnarsinghranabhat Mar 30, 2026
f8a0c56
ollama-reasoning and gpt-reasoning integration test recordings
robinnarsinghranabhat Mar 30, 2026
4a25d84
ci: add --reasoning-parser to vLLM CI setup for reasoning test support
robinnarsinghranabhat Mar 30, 2026
85ca2a8
missing updates from rebase
robinnarsinghranabhat Mar 31, 2026
4a0a0b7
updated recordings for ollama-reasoning, vllm-reasoning, gpt-reasoning
robinnarsinghranabhat Mar 31, 2026
ccf2756
spec changes after pre-commit
robinnarsinghranabhat Mar 31, 2026
8433a63
fix: resolve mypy errors for reasoning method across providers
robinnarsinghranabhat Mar 31, 2026
8c6bc42
fix: resolve mypy type errors in reasoning implementation
robinnarsinghranabhat Mar 31, 2026
9154fae
unused recordings detected
robinnarsinghranabhat Mar 31, 2026
570667e
updated after local pre-commit
robinnarsinghranabhat Mar 31, 2026
5ff28f2
refactor: use wrapper dataclass for reasoning return types per mattf …
robinnarsinghranabhat Apr 1, 2026
e6b8493
record missing
robinnarsinghranabhat Apr 1, 2026
b4565e1
chore: regenerate conformance docs after rebase
robinnarsinghranabhat Apr 1, 2026
ef74218
recorded a failing integration test -- Integration Tests (server, oll…
robinnarsinghranabhat Apr 1, 2026
db5367e
Merge branch 'main' into feat/reasoning-output-responses-api
cdoern Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/setup-vllm/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ runs:
--port 8000 \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--reasoning-parser deepseek_r1 \
--model /root/.cache/Qwen3-0.6B \
--served-model-name Qwen/Qwen3-0.6B \
--max-model-len 8192
Expand Down
300 changes: 152 additions & 148 deletions docs/docs/api-openai/provider_matrix.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions src/llama_stack/core/routers/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
from llama_stack.core.datatypes import ModelWithOwner
from llama_stack.core.request_headers import get_authenticated_user
from llama_stack.log import get_logger
from llama_stack.providers.inline.responses.builtin.responses.types import (
OpenAIChatCompletionChunkWithReasoning,
OpenAIChatCompletionWithReasoning,
)
from llama_stack.providers.utils.inference.inference_store import InferenceStore
from llama_stack.telemetry.inference_metrics import (
create_inference_metric_attributes,
Expand Down Expand Up @@ -267,6 +271,23 @@ async def openai_chat_completion(

return response

async def openai_chat_completions_with_reasoning(
self,
params: OpenAIChatCompletionRequestWithExtraBody,
) -> OpenAIChatCompletionWithReasoning | AsyncIterator[OpenAIChatCompletionChunkWithReasoning]:
"""Called by the Responses layer when a user requests reasoning.

Routes to the provider's reasoning-aware CC implementation, which
handles mapping reasoning fields to/from the provider's format.
If the provider doesn't support reasoning, raises an error.
"""
provider, provider_resource_id = await self._get_model_provider(params.model, ModelType.llm)
params.model = provider_resource_id
# Not all providers implement openai_chat_completions_with_reasoning.
# the Responses layer catches them and falls back to regular CC
# with a warning.
return await provider.openai_chat_completions_with_reasoning(params)

async def openai_embeddings(
self,
params: Annotated[OpenAIEmbeddingsRequestWithExtraBody, Body(...)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class SentenceTransformersInferenceImpl(
def __init__(self, config: SentenceTransformersInferenceConfig) -> None:
self.config = config

async def openai_chat_completions_with_reasoning(self, params: OpenAIChatCompletionRequestWithExtraBody) -> None:
raise NotImplementedError("SentenceTransformers provider does not support reasoning in chat completions")

async def initialize(self) -> None:
pass

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ class TransformersInferenceImpl(
def __init__(self, config: TransformersInferenceConfig) -> None:
self.config = config

async def openai_chat_completions_with_reasoning(self, params: OpenAIChatCompletionRequestWithExtraBody) -> None:
raise NotImplementedError("Transformers provider does not support reasoning in chat completions")

async def initialize(self) -> None:
pass

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,10 @@ async def create_openai_response(
if background and store is False:
raise ValueError("Cannot use 'background' with 'store=False'. Background responses must be stored.")

# Validate: reasoning.encrypted_content is not supported
if include and any(str(item) == "reasoning.encrypted_content" for item in include):
raise ValueError("reasoning.encrypted_content is not supported by Llama Stack.")

# Validate MCP tools: ensure Authorization header is not passed via headers dict
if tools:
from llama_stack_api.openai_responses import OpenAIResponseInputToolMCP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
OpenAIResponseOutputMessageFunctionToolCall,
OpenAIResponseOutputMessageMCPCall,
OpenAIResponseOutputMessageMCPListTools,
OpenAIResponseOutputMessageReasoningContent,
OpenAIResponseOutputMessageReasoningItem,
OpenAIResponseOutputMessageWebSearchToolCall,
OpenAIResponsePrompt,
OpenAIResponseReasoning,
Expand All @@ -102,7 +104,12 @@
)
from llama_stack_api.inference import ServiceTier

from .types import ChatCompletionContext, ChatCompletionResult
from .types import (
AssistantMessageWithReasoning,
ChatCompletionContext,
ChatCompletionResult,
OpenAIChatCompletionChunkWithReasoning,
)
from .utils import (
convert_chat_choice_to_response_message,
convert_mcp_tool_choice,
Expand Down Expand Up @@ -535,7 +542,23 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
presence_penalty=self.presence_penalty,
**(self.extra_body or {}),
)
completion_result = await self.inference_api.openai_chat_completion(params)
# Use reasoning-aware method when reasoning is explicitly requested
if self.reasoning and self.reasoning.effort and self.reasoning.effort != "none":
try:
# Pass a copy — the router mutates params.model (strips provider prefix).
Comment thread
robinnarsinghranabhat marked this conversation as resolved.
# Keep original params intact in-case of fallback to regular CC.
# NOTE : Is a deep-copy necessary ?
completion_result = await self.inference_api.openai_chat_completions_with_reasoning(
params.model_copy()
)
except (NotImplementedError, AttributeError, ValueError):
logger.critical(
"Provider does not support reasoning in chat completions. "
"Falling back to regular chat completion."
)
completion_result = await self.inference_api.openai_chat_completion(params)
else:
completion_result = await self.inference_api.openai_chat_completion(params)

# Process streaming chunks and build complete response
completion_result_data = None
Expand All @@ -544,7 +567,6 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
completion_result_data = stream_event_or_result
else:
yield stream_event_or_result

# If violation detected, skip the rest of processing since we already sent refusal
if self.violation_detected:
return
Expand All @@ -563,24 +585,37 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
# Update service_tier with actual value from provider response
# This is especially important when "auto" was used as input
self.service_tier = completion_result_data.service_tier

current_response = self._build_chat_completion(completion_result_data)

(
function_tool_calls,
non_function_tool_calls,
approvals,
next_turn_messages,
) = self._separate_tool_calls(current_response, messages)

) = self._separate_tool_calls(current_response, messages, completion_result_data.reasoning_content)
# add any approval requests required
for tool_call in approvals:
async for evt in self._add_mcp_approval_request(
tool_call.function.name, tool_call.function.arguments, output_messages
):
yield evt

# Handle choices with no tool calls
# Reasoning is independent of the response type — whether the assistant
# response is a tool call or a content message, reasoning (if present)
# is added to output_messages before processing choices.
# The reasoning_content field is populated by the provider via
# openai_chat_completions_with_reasoning, which maps provider-specific
# reasoning fields to the standard reasoning_content attribute.
if completion_result_data.reasoning_content:
reasoning_item = OpenAIResponseOutputMessageReasoningItem(
id=f"rs_{uuid.uuid4().hex}",
summary=[],
content=[
OpenAIResponseOutputMessageReasoningContent(text=completion_result_data.reasoning_content)
],
status="completed",
)
output_messages.append(reasoning_item)

for choice in current_response.choices:
has_tool_calls = choice.message.tool_calls and self.ctx.response_tools
if not has_tool_calls:
Expand All @@ -591,7 +626,6 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
message_id=completion_result_data.message_item_id,
)
)

# Execute tool calls and coordinate results
async for stream_event in self._coordinate_tool_execution(
function_tool_calls,
Expand All @@ -601,9 +635,7 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
next_turn_messages,
):
yield stream_event

messages = next_turn_messages

if not function_tool_calls and not non_function_tool_calls:
break

Expand Down Expand Up @@ -680,21 +712,32 @@ async def create_response(self) -> AsyncIterator[OpenAIResponseObjectStream]:
response=final_response, sequence_number=self.sequence_number
)

def _separate_tool_calls(self, current_response, messages) -> tuple[list, list, list, list]:
def _separate_tool_calls(
self, current_response, messages, reasoning_content: str | None = None
) -> tuple[list, list, list, list]:
"""Separate tool calls into function and non-function categories."""
function_tool_calls = []
non_function_tool_calls = []
approvals = []
next_turn_messages = messages.copy()

for choice in current_response.choices:
# Convert response message to input message format for multi-turn
next_turn_messages.append(
OpenAIAssistantMessageParam(
# Convert response message to input message format for multi-turn.
# Use AssistantMessageWithReasoning if reasoning was present in the
# CC response. Providers will be check for this AssistantMessageWithReasoning
# message
if reasoning_content:
message = AssistantMessageWithReasoning(
content=choice.message.content,
tool_calls=choice.message.tool_calls,
reasoning_content=reasoning_content,
)
)
else:
message = OpenAIAssistantMessageParam( # type: ignore[assignment]
content=choice.message.content,
tool_calls=choice.message.tool_calls,
)
next_turn_messages.append(message)
logger.debug("Choice message content", content=choice.message.content)
logger.debug("Choice message tool_calls", tool_calls=choice.message.tool_calls)

Expand Down Expand Up @@ -950,7 +993,16 @@ async def _process_streaming_chunks(
reasoning_text_accumulated = []
refusal_text_accumulated = []

async for chunk in completion_result:
async for raw_chunk in completion_result:
# Providers returning OpenAIChatCompletionChunkWithReasoning wrap
# the chunk with a typed reasoning_content field. Unwrap here.
if isinstance(raw_chunk, OpenAIChatCompletionChunkWithReasoning):
chunk = raw_chunk.chunk
reasoning_content = raw_chunk.reasoning_content
else:
chunk = raw_chunk
reasoning_content = None

chat_response_id = chunk.id
chunk_created = chunk.created
chunk_model = chunk.model
Expand Down Expand Up @@ -1026,10 +1078,12 @@ async def _process_streaming_chunks(
if chunk_choice.finish_reason:
chunk_finish_reason = chunk_choice.finish_reason

# Handle reasoning content if present (non-standard field for o1/o3 models)
if hasattr(chunk_choice.delta, "reasoning") and chunk_choice.delta.reasoning:
# Handle reasoning content if present.
# reasoning_content comes from the typed wrapper
# (OpenAIChatCompletionChunkWithReasoning) unwrapped above.
if reasoning_content:
async for event in self._handle_reasoning_content_chunk(
reasoning_content=chunk_choice.delta.reasoning,
reasoning_content=reasoning_content,
reasoning_part_emitted=reasoning_part_emitted,
reasoning_content_index=reasoning_content_index,
message_item_id=message_item_id,
Expand All @@ -1041,7 +1095,7 @@ async def _process_streaming_chunks(
else:
yield event
reasoning_part_emitted = True
reasoning_text_accumulated.append(chunk_choice.delta.reasoning)
reasoning_text_accumulated.append(reasoning_content)

# Handle refusal content if present
if chunk_choice.delta.refusal:
Expand All @@ -1057,9 +1111,12 @@ async def _process_streaming_chunks(
refusal_text_accumulated.append(chunk_choice.delta.refusal)

# Aggregate tool call arguments across chunks
# Note: The type: ignore comments below suppress pre-existing mypy
# issues that surfaced when we added the
# chunk: OpenAIChatCompletionChunk annotation above.
if chunk_choice.delta.tool_calls:
for tool_call in chunk_choice.delta.tool_calls:
response_tool_call = chat_response_tool_calls.get(tool_call.index, None)
response_tool_call = chat_response_tool_calls.get(tool_call.index, None) # type: ignore[arg-type]
# Create new tool call entry if this is the first chunk for this index
is_new_tool_call = response_tool_call is None
if is_new_tool_call:
Expand All @@ -1069,16 +1126,16 @@ async def _process_streaming_chunks(
if tool_call_dict.get("function") and tool_call_dict["function"].get("arguments") is None:
tool_call_dict["function"]["arguments"] = "{}"
response_tool_call = OpenAIChatCompletionToolCall(**tool_call_dict)
chat_response_tool_calls[tool_call.index] = response_tool_call
chat_response_tool_calls[tool_call.index] = response_tool_call # type: ignore[index]

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

# Emit output_item.added event for the new function call
self.sequence_number += 1
is_mcp_tool = tool_call.function.name and tool_call.function.name in self.mcp_tool_to_server
if not is_mcp_tool and tool_call.function.name not in _SERVER_SIDE_BUILTIN_TOOL_NAMES:
is_mcp_tool = tool_call.function.name and tool_call.function.name in self.mcp_tool_to_server # type: ignore[union-attr]
if not is_mcp_tool and tool_call.function.name not in _SERVER_SIDE_BUILTIN_TOOL_NAMES: # type: ignore[union-attr]
# for MCP tools (and even other non-function tools) we emit an output message item later
function_call_item = OpenAIResponseOutputMessageFunctionToolCall(
arguments="", # Will be filled incrementally via delta events
Expand All @@ -1096,7 +1153,7 @@ async def _process_streaming_chunks(

# Stream tool call arguments as they arrive (differentiate between MCP and function calls)
if tool_call.function and tool_call.function.arguments:
tool_call_item_id = tool_call_item_ids[tool_call.index]
tool_call_item_id = tool_call_item_ids[tool_call.index] # type: ignore[index]
self.sequence_number += 1

# Check if this is an MCP tool call
Expand Down Expand Up @@ -1249,6 +1306,7 @@ async def _process_streaming_chunks(
content_part_emitted=content_part_emitted,
logprobs=chat_response_logprobs if chat_response_logprobs else None,
service_tier=chunk_service_tier,
reasoning_content="".join(reasoning_text_accumulated) if reasoning_text_accumulated else None,
)

def _build_chat_completion(self, result: ChatCompletionResult) -> OpenAIChatCompletion:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from llama_stack_api import (
OpenAIAssistantMessageParam,
OpenAIChatCompletion,
OpenAIChatCompletionChunk,
OpenAIChatCompletionToolCall,
OpenAIFinishReason,
OpenAIMessageParam,
Expand Down Expand Up @@ -48,6 +50,30 @@ class AssistantMessageWithReasoning(OpenAIAssistantMessageParam):
reasoning_content: str | None = None


@dataclass
class OpenAIChatCompletionWithReasoning:
"""Internal wrapper: a CC response with extracted reasoning content.

Returned by openai_chat_completions_with_reasoning for non-streaming.
The Responses layer unwraps .completion and reads .reasoning_content.
"""

completion: OpenAIChatCompletion
reasoning_content: str | None = None


@dataclass
class OpenAIChatCompletionChunkWithReasoning:
"""Internal wrapper: a CC streaming chunk with extracted reasoning content.

Yielded by openai_chat_completions_with_reasoning for streaming.
The Responses layer unwraps .chunk and reads .reasoning_content.
"""

chunk: OpenAIChatCompletionChunk
reasoning_content: str | None = None


def _json_equal(a: str, b: str) -> bool:
"""Compare two JSON strings by value, falling back to string comparison."""
try:
Expand Down
Loading
Loading