From e984d19d7da2c2526a71fdc16afc2edddba15dcb Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Tue, 23 Jun 2026 16:10:32 -0500 Subject: [PATCH] refactor(core): consolidate backend raw responses onto mot.raw namespace Replace the per-backend bag of provider-coupled `mot._meta` keys (`chat_response`, `oai_chat_response`, `litellm_chat_response`, `hf_output`, etc.) with a typed `mot.raw: RawProviderResponse` namespace, a sibling to `mot.generation`. - Add `RawProviderResponse` dataclass (provider/response/streamed_chunks) to mellea/core/base.py; wire into ModelOutputThunk `__init__`, `__copy__`, `__deepcopy__`, `_copy_from`; export from mellea.core. - Migrate all five backends (ollama, openai, watsonx, litellm, huggingface) on both the chat path and `_generate_from_raw`. - Drop the redundant `oai_chat_response_choice` key; tool extraction reads the response directly per its per-path shape. - Record streaming token usage straight to `mot.generation.usage` instead of a transient `_meta` key. - Switch `chat.py:_parse` and `cli/serve/utils.py:extract_finish_reason` to dispatch on `mot.raw.provider`. No deprecation shim: the `_meta` keys were private and undocumented, so following project precedent only public surface gets a deprecation cycle. Closes #1215. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- cli/serve/utils.py | 35 ++-- mellea/backends/huggingface.py | 22 ++- mellea/backends/litellm.py | 66 ++++--- mellea/backends/ollama.py | 22 ++- mellea/backends/openai.py | 58 +++--- mellea/backends/watsonx.py | 57 +++--- mellea/core/__init__.py | 2 + mellea/core/base.py | 28 +++ mellea/stdlib/components/chat.py | 86 +++------ test/backends/test_huggingface_unit.py | 2 +- test/backends/test_ollama.py | 5 +- test/backends/test_ollama_unit.py | 10 +- test/cli/test_serve_utils.py | 248 +++++++++++-------------- test/core/test_base.py | 76 +++++++- test/stdlib/components/test_chat.py | 82 ++++++-- 15 files changed, 432 insertions(+), 367 deletions(-) diff --git a/cli/serve/utils.py b/cli/serve/utils.py index 7990ff808..56a3a84ec 100644 --- a/cli/serve/utils.py +++ b/cli/serve/utils.py @@ -35,31 +35,30 @@ def extract_finish_reason(output: Any) -> FinishReason: "function_call", } - # Try to get finish_reason from the response metadata - # Different backends store this in different places - if hasattr(output, "_meta") and output._meta: - # Ollama backend stores response in chat_response with done_reason field - # (ollama.ChatResponse object with done_reason attribute) - chat_response = output._meta.get("chat_response") - if chat_response and hasattr(chat_response, "done_reason"): - done_reason = chat_response.done_reason + # Try to get finish_reason from the backend-native response on mot.raw. + # Different backends store this in different places; switch on mot.raw.provider. + raw = getattr(output, "raw", None) + if raw is not None: + provider = raw.provider + response = raw.response + + if provider == "ollama" and response is not None: + # ollama.ChatResponse object with done_reason attribute. + done_reason = getattr(response, "done_reason", None) if done_reason in valid_reasons: return done_reason - # OpenAI backend stores full response dict in oai_chat_response - # (from chunk.model_dump() which includes choices array) - oai_response = output._meta.get("oai_chat_response") - if oai_response and isinstance(oai_response, dict): - choices = oai_response.get("choices", []) + elif provider in ("openai", "watsonx", "litellm") and isinstance( + response, dict + ): + # Chat path: full response dict, finish_reason nested under choices[0]. + choices = response.get("choices", []) if choices and len(choices) > 0: finish_reason = choices[0].get("finish_reason") if finish_reason in valid_reasons: return finish_reason - - # LiteLLM backend stores response dict in litellm_chat_response - litellm_response = output._meta.get("litellm_chat_response") - if litellm_response and isinstance(litellm_response, dict): - finish_reason = litellm_response.get("finish_reason") + # Raw-completion path: single choice dict, finish_reason at top level. + finish_reason = response.get("finish_reason") if finish_reason in valid_reasons: return finish_reason diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 0f43673de..d1ec63363 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -1233,7 +1233,7 @@ async def processing( mot._underlying_value += chunk elif isinstance(chunk, GenerateDecoderOnlyOutput): # Otherwise, it's a non-streaming request. Decode it here. - mot._meta["hf_output"] = chunk + mot.raw.response = chunk mot._underlying_value += cast( str, self._tokenizer.decode( @@ -1269,18 +1269,18 @@ class used during generation, if any. input_ids: The prompt token IDs; used to compute token counts and for KV cache bookkeeping. """ - if mot._meta.get("hf_output", None) is None: + if mot.raw.response is None: if mot._generate_extra is not None: full_output = await mot._generate_extra assert isinstance(full_output, GenerateDecoderOnlyOutput) - mot._meta["hf_output"] = full_output + mot.raw.response = full_output # The ModelOutputThunk must be computed by this point. assert mot.value is not None - # Store KV cache in LRU separately (not in mot._meta) to enable proper cleanup on eviction. + # Store KV cache in LRU separately (not on the MOT) to enable proper cleanup on eviction. # This prevents GPU memory from being held by ModelOutputThunk references. - hf_output = mot._meta.get("hf_output", None) + hf_output = mot.raw.response if ( self._use_caches and isinstance(hf_output, GenerateDecoderOnlyOutput) @@ -1320,7 +1320,7 @@ class used during generation, if any. ) # Derive token counts from the output sequences (HF models have no usage object). - hf_output = mot._meta.get("hf_output") + hf_output = mot.raw.response n_prompt, n_completion = None, None if isinstance(hf_output, GenerateDecoderOnlyOutput): try: @@ -1375,20 +1375,21 @@ class used during generation, if any. # Populate model and provider metadata mot.generation.model = self._model_id mot.generation.provider = self._provider + mot.raw.provider = self._provider - # When caching is disabled, clear hf_output from meta to free GPU memory. + # When caching is disabled, clear hf_output from raw to free GPU memory. # The sequences tensor is on GPU and accumulates if not cleared. if not self._use_caches and isinstance( - mot._meta.get("hf_output"), GenerateDecoderOnlyOutput + mot.raw.response, GenerateDecoderOnlyOutput ): import gc - hf_out = mot._meta["hf_output"] + hf_out = mot.raw.response if hasattr(hf_out, "sequences") and hf_out.sequences is not None: del hf_out.sequences if hasattr(hf_out, "scores") and hf_out.scores is not None: del hf_out.scores - del mot._meta["hf_output"] + mot.raw.response = None # Force Python GC and return CUDA memory to device gc.collect() @@ -1521,6 +1522,7 @@ async def _generate_from_raw( result.generation.usage = per_mot_usage result.generation.model = self._model_id result.generation.provider = self._provider + result.raw.provider = self._provider action = actions[i] result.parsed_repr = ( diff --git a/mellea/backends/litellm.py b/mellea/backends/litellm.py index 8aa64b808..0f0a81b7d 100644 --- a/mellea/backends/litellm.py +++ b/mellea/backends/litellm.py @@ -29,6 +29,7 @@ MelleaLogger, ModelOutputThunk, ModelToolCall, + RawProviderResponse, ) from ..core.base import AbstractMelleaTool from ..formatters import ChatFormatter, TemplateFormatter @@ -503,10 +504,8 @@ async def processing( if content_chunk is not None: mot._underlying_value += content_chunk - # Store the full response (includes usage) as a dict - mot._meta["litellm_full_response"] = chunk.model_dump() - # Also store just the choice for backward compatibility - mot._meta["litellm_chat_response"] = chunk.choices[0].model_dump() + # Store the full response (includes usage) as a dict. + mot.raw.response = chunk.model_dump() elif isinstance(chunk, litellm.ModelResponseStream): # type: ignore message_delta = chunk.choices[0].delta @@ -522,15 +521,13 @@ async def processing( if content_chunk is not None: mot._underlying_value += content_chunk - if mot._meta.get("litellm_chat_response_streamed", None) is None: - mot._meta["litellm_chat_response_streamed"] = [] - mot._meta["litellm_chat_response_streamed"].append( - chunk.choices[0].model_dump() - ) + if mot.raw.streamed_chunks is None: + mot.raw.streamed_chunks = [] + mot.raw.streamed_chunks.append(chunk.choices[0].model_dump()) - # Store usage information from the chunk if available (typically in the last chunk) + # Usage arrives on its own chunk (typically the last); record it now. if hasattr(chunk, "usage") and chunk.usage is not None: - mot._meta["litellm_streaming_usage"] = chunk.usage.model_dump() + mot.generation.usage = chunk.usage.model_dump() async def post_processing( self, @@ -555,16 +552,13 @@ async def post_processing( `None` if reasoning mode was not enabled. _format: The structured output format class used during generation, if any. """ - # Reconstruct the chat_response from chunks if streamed. - streamed_chunks = mot._meta.get("litellm_chat_response_streamed", None) - if streamed_chunks is not None: + # Reconstruct the top-level response from chunks if streamed. + if mot.raw.streamed_chunks is not None: # Must handle ollama differently due to: https://github.com/BerriAI/litellm/issues/14579. # Check that we are targeting ollama with the model_id prefix litellm uses. - separate_tools = False - if "ollama" in self._model_id.split("/")[0]: - separate_tools = True - mot._meta["litellm_chat_response"] = chat_completion_delta_merge( - streamed_chunks, force_all_tool_calls_separate=separate_tools + separate_tools = "ollama" in self._model_id.split("/")[0] + mot.raw.response = chat_completion_delta_merge( + mot.raw.streamed_chunks, force_all_tool_calls_separate=separate_tools ) assert mot._action is not None, ( @@ -577,9 +571,16 @@ async def post_processing( # OpenAI-like streamed responses potentially give you chunks of tool calls. # As a result, we have to store data between calls and only then # check for complete tool calls in the post_processing step. - tool_chunk = extract_model_tool_requests( - tools, mot._meta["litellm_chat_response"] + # Non-streaming stores a top-level response (index into choices); streaming + # stores the already-merged choice dict (use directly). + response = mot.raw.response + assert response is not None + choice_response = ( + response["choices"][0] + if isinstance(response, dict) and "choices" in response + else response ) + tool_chunk = extract_model_tool_requests(tools, choice_response) if tool_chunk is not None: if mot.tool_calls is None: mot.tool_calls = {} @@ -593,7 +594,7 @@ async def post_processing( generate_log.backend = f"litellm::{self.model_id!s}" generate_log.model_options = mot._model_options generate_log.date = datetime.datetime.now() - generate_log.model_output = mot._meta["litellm_chat_response"] + generate_log.model_output = response generate_log.extra = { "format": _format, "tools_available": tools, @@ -604,25 +605,18 @@ async def post_processing( generate_log.result = mot mot._generate_log = generate_log - # Extract token usage from full response dict or streaming usage - full_response = mot._meta.get("litellm_full_response") - usage = full_response.get("usage") if isinstance(full_response, dict) else None - - # For streaming responses, usage is stored separately - if usage is None: - usage = mot._meta.get("litellm_streaming_usage") - - # Populate standardized usage field (LiteLLM uses OpenAI format) - if usage: + # Non-streaming carries usage on the response; streaming already set it. + if usage := response.get("usage"): mot.generation.usage = usage # Populate model and provider metadata mot.generation.model = self._model_id mot.generation.provider = self._provider + mot.raw.provider = self._provider # Populate response-side metadata for telemetry - if isinstance(full_response, dict): - populate_response_metadata_openai_shape(mot, full_response) + if isinstance(response, dict): + populate_response_metadata_openai_shape(mot, response) @staticmethod def _extract_tools( @@ -730,7 +724,9 @@ async def _generate_from_raw( output._context = None # There is no context for generate_from_raw for now output._action = action output._model_options = model_opts - output._meta = {"litellm_chat_response": res.model_dump()} + output.raw = RawProviderResponse( + provider=self._provider, response=res.model_dump() + ) output.generation.model = self._model_id output.generation.provider = self._provider diff --git a/mellea/backends/ollama.py b/mellea/backends/ollama.py index be2e13b56..2ca1e9437 100644 --- a/mellea/backends/ollama.py +++ b/mellea/backends/ollama.py @@ -23,6 +23,7 @@ MelleaLogger, ModelOutputThunk, ModelToolCall, + RawProviderResponse, ) from ..core.base import AbstractMelleaTool from ..formatters import ChatFormatter, TemplateFormatter @@ -608,9 +609,9 @@ async def _generate_from_raw( "completion_tokens": n_out, "total_tokens": n_in + n_out, } - result = ModelOutputThunk( - value=response.response, - meta={"generate_response": response.model_dump()}, + result = ModelOutputThunk(value=response.response) + result.raw = RawProviderResponse( + provider=self._provider, response=response.model_dump() ) result.generation.usage = per_mot_usage result.generation.model = self._model_id @@ -684,9 +685,9 @@ async def processing( ): """Accumulate text and tool calls from a single Ollama ChatResponse chunk. - Called for each streaming or non-streaming ``ollama.ChatResponse``. Also + Called for each streaming or non-streaming `ollama.ChatResponse`. Also extracts tool call requests inline and merges the chunk into the running - aggregated response stored in ``mot._meta["chat_response"]``. + aggregated response stored in `mot.raw.response`. Args: mot (ModelOutputThunk): The output thunk being populated. @@ -751,7 +752,7 @@ async def post_processing( generate_log.backend = f"ollama::{self._model_id}" generate_log.model_options = mot._model_options generate_log.date = datetime.datetime.now() - generate_log.model_output = mot._meta["chat_response"] + generate_log.model_output = mot.raw.response generate_log.extra = { "format": _format, "thinking": mot._model_options.get(ModelOption.THINKING, None), @@ -766,7 +767,7 @@ async def post_processing( mot._generate = None # Extract token counts from response - response = mot._meta.get("chat_response") + response = mot.raw.response prompt_tokens = ( getattr(response, "prompt_eval_count", None) if response else None ) @@ -783,6 +784,7 @@ async def post_processing( # Populate model and provider metadata mot.generation.model = self._model_id mot.generation.provider = self._provider + mot.raw.provider = self._provider # Populate response-side metadata for telemetry if response is not None: @@ -798,11 +800,11 @@ def chat_response_delta_merge(mot: ModelOutputThunk, delta: ollama.ChatResponse) mot: the ModelOutputThunk that the deltas are being used to populated. delta: the most recent ollama ChatResponse. """ - if mot._meta.get("chat_response", None) is None: - mot._meta["chat_response"] = delta + if mot.raw.response is None: + mot.raw.response = delta return # Return early, no need to merge. - merged: ollama.ChatResponse = mot._meta["chat_response"] + merged: ollama.ChatResponse = mot.raw.response if not merged.done: merged.done = delta.done if merged.done_reason is None: diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 05527340c..4717e0cb1 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -26,6 +26,7 @@ GenerateType, MelleaLogger, ModelOutputThunk, + RawProviderResponse, Requirement, ) from ..core.base import AbstractMelleaTool @@ -1041,15 +1042,13 @@ async def processing( if content_chunk is not None: mot._underlying_value += content_chunk - # Store the full response (includes usage) as a dict - mot._meta["oai_chat_response"] = chunk.model_dump() - # Also store just the choice for backward compatibility - mot._meta["oai_chat_response_choice"] = chunk.choices[0].model_dump() + # Store the full response (includes usage) as a dict. + mot.raw.response = chunk.model_dump() elif isinstance(chunk, ChatCompletionChunk): - # Store usage information from the chunk if available (typically in the last chunk) + # Usage arrives on its own chunk (typically the last); record it now. if hasattr(chunk, "usage") and chunk.usage is not None: - mot._meta["oai_streaming_usage"] = chunk.usage.model_dump() + mot.generation.usage = chunk.usage.model_dump() # Some chunks (like the final usage chunk) may not have choices if len(chunk.choices) == 0: @@ -1066,11 +1065,9 @@ async def processing( if content_chunk is not None: mot._underlying_value += content_chunk - if mot._meta.get("oai_chat_response_streamed", None) is None: - mot._meta["oai_chat_response_streamed"] = [] - mot._meta["oai_chat_response_streamed"].append( - chunk.choices[0].model_dump() - ) + if mot.raw.streamed_chunks is None: + mot.raw.streamed_chunks = [] + mot.raw.streamed_chunks.append(chunk.choices[0].model_dump()) async def post_processing( self, @@ -1099,12 +1096,9 @@ async def post_processing( seed: The random seed used during generation, or `None`. _format: The structured output format class used during generation, if any. """ - # Reconstruct the chat_response from chunks if streamed. - streamed_chunks = mot._meta.get("oai_chat_response_streamed", None) - if streamed_chunks is not None: - mot._meta["oai_chat_response"] = chat_completion_delta_merge( - streamed_chunks - ) + # Reconstruct the top-level response from chunks if streamed. + if mot.raw.streamed_chunks is not None: + mot.raw.response = chat_completion_delta_merge(mot.raw.streamed_chunks) assert mot._action is not None, ( "ModelOutputThunks should have their action assigned during generation" @@ -1116,9 +1110,14 @@ async def post_processing( # OpenAI streamed responses give you chunks of tool calls. # As a result, we have to store data between calls and only then # check for complete tool calls in the post_processing step. - # Use the choice format for tool extraction (backward compatibility) - choice_response = mot._meta.get( - "oai_chat_response_choice", mot._meta["oai_chat_response"] + # Non-streaming stores a top-level response (index into choices); streaming + # stores the already-merged choice dict (use directly). + response = mot.raw.response + assert response is not None + choice_response = ( + response["choices"][0] + if isinstance(response, dict) and "choices" in response + else response ) tool_chunk = extract_model_tool_requests(tools, choice_response) if tool_chunk is not None: @@ -1135,7 +1134,7 @@ async def post_processing( generate_log.model_options = mot._model_options generate_log.date = datetime.datetime.now() # Store the full response (includes usage info) - generate_log.model_output = mot._meta["oai_chat_response"] + generate_log.model_output = response generate_log.extra = { "format": _format, "thinking": thinking, @@ -1147,21 +1146,14 @@ async def post_processing( generate_log.result = mot mot._generate_log = generate_log - # Extract token usage from response or streaming usage - response = mot._meta["oai_chat_response"] - usage = response.get("usage") if isinstance(response, dict) else None - - # For streaming responses, usage is stored separately - if usage is None: - usage = mot._meta.get("oai_streaming_usage") - - # Populate standardized usage field (OpenAI format already matches) - if usage: + # Non-streaming carries usage on the response; streaming already set it. + if usage := response.get("usage"): mot.generation.usage = usage # Populate model and provider metadata mot.generation.model = self._model_id mot.generation.provider = self._provider + mot.raw.provider = self._provider # Populate response-side metadata for telemetry if isinstance(response, dict): @@ -1259,7 +1251,9 @@ async def _generate_from_raw( output._context = None # There is no context for generate_from_raw for now output._action = action output._model_options = model_opts - output._meta = {"oai_completion_response": response.model_dump()} + output.raw = RawProviderResponse( + provider=self._provider, response=response.model_dump() + ) output.generation.model = self._model_id output.generation.provider = self._provider diff --git a/mellea/backends/watsonx.py b/mellea/backends/watsonx.py index 8af28d231..edc68e22c 100644 --- a/mellea/backends/watsonx.py +++ b/mellea/backends/watsonx.py @@ -32,6 +32,7 @@ MelleaLogger, ModelOutputThunk, ModelToolCall, + RawProviderResponse, ) from ..core.base import AbstractMelleaTool from ..formatters import ChatFormatter, TemplateFormatter @@ -512,10 +513,8 @@ async def processing(self, mot: ModelOutputThunk, chunk: dict): if content_chunk is not None: mot._underlying_value += content_chunk - # Store full chunk (includes usage information) - mot._meta["oai_chat_response"] = chunk - # Store choice separately for tool extraction - mot._meta["oai_chat_response_choice"] = chunk["choices"][0] + # Store full chunk (includes usage information). + mot.raw.response = chunk else: # Streaming. message_delta: dict = chunk["choices"][0].get("delta", dict()) @@ -528,9 +527,9 @@ async def processing(self, mot: ModelOutputThunk, chunk: dict): if content_chunk is not None: mot._underlying_value += content_chunk - if mot._meta.get("oai_chat_response_streamed", None) is None: - mot._meta["oai_chat_response_streamed"] = [] - mot._meta["oai_chat_response_streamed"].append(chunk["choices"][0]) + if mot.raw.streamed_chunks is None: + mot.raw.streamed_chunks = [] + mot.raw.streamed_chunks.append(chunk["choices"][0]) async def post_processing( self, @@ -554,12 +553,9 @@ async def post_processing( seed: The random seed used during generation, or `None`. _format: The structured output format class used during generation, if any. """ - # Reconstruct the chat_response from chunks if streamed. - streamed_chunks = mot._meta.get("oai_chat_response_streamed", None) - if streamed_chunks is not None: - mot._meta["oai_chat_response"] = chat_completion_delta_merge( - streamed_chunks - ) + # Reconstruct the top-level response from chunks if streamed. + if mot.raw.streamed_chunks is not None: + mot.raw.response = chat_completion_delta_merge(mot.raw.streamed_chunks) assert mot._action is not None, ( "ModelOutputThunks should have their action assigned during generation" @@ -571,9 +567,14 @@ async def post_processing( # OpenAI streamed responses give you chunks of tool calls. # As a result, we have to store data between calls and only then # check for complete tool calls in the post_processing step. - # Use choice for tool extraction (streaming returns choice, not full response) - choice_response = mot._meta.get( - "oai_chat_response_choice", mot._meta["oai_chat_response"] + # Non-streaming stores a top-level response (index into choices); streaming + # stores the already-merged choice dict (use directly). + response = mot.raw.response + assert response is not None + choice_response = ( + response["choices"][0] + if isinstance(response, dict) and "choices" in response + else response ) tool_chunk = extract_model_tool_requests(tools, choice_response) if tool_chunk is not None: @@ -583,24 +584,14 @@ async def post_processing( for key, val in tool_chunk.items(): mot.tool_calls[key] = val - # Extract token usage from response - response = mot._meta.get("oai_chat_response") - usage = None - if response is not None: - # Watsonx responses may have usage information - usage = ( - response.get("usage") - if isinstance(response, dict) - else getattr(response, "usage", None) - ) - - # Populate standardized usage field (WatsonX uses OpenAI format) - if usage: + # Populate usage when the response carries it (WatsonX uses OpenAI format). + if usage := response.get("usage"): mot.generation.usage = usage # Populate model and provider metadata mot.generation.model = self._model_id mot.generation.provider = self._provider + mot.raw.provider = self._provider # Populate response-side metadata for telemetry populate_response_metadata_openai_shape(mot, response) @@ -611,7 +602,7 @@ async def post_processing( generate_log.backend = f"watsonx::{self.model_id!s}" generate_log.model_options = mot._model_options generate_log.date = datetime.datetime.now() - generate_log.model_output = mot._meta["oai_chat_response"] + generate_log.model_output = response generate_log.extra = { "format": _format, "tools_available": tools, @@ -688,9 +679,9 @@ async def _generate_from_raw( } else: per_mot_usage = None - result = ModelOutputThunk( - value=output["generated_text"], - meta={"oai_completion_response": response["results"][0]}, + result = ModelOutputThunk(value=output["generated_text"]) + result.raw = RawProviderResponse( + provider=self._provider, response=response["results"][0] ) result.generation.usage = per_mot_usage result.generation.model = self._model_id diff --git a/mellea/core/__init__.py b/mellea/core/__init__.py index 4dd44f564..58c7cde5a 100644 --- a/mellea/core/__init__.py +++ b/mellea/core/__init__.py @@ -25,6 +25,7 @@ ImageUrlBlock, ModelOutputThunk, ModelToolCall, + RawProviderResponse, S, TemplateRepresentation, blockify, @@ -74,6 +75,7 @@ def __getattr__(name: str) -> object: "ModelOutputThunk", "ModelToolCall", "PartialValidationResult", + "RawProviderResponse", "Requirement", "S", "SamplingResult", diff --git a/mellea/core/base.py b/mellea/core/base.py index ff2202559..8c201b309 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -364,6 +364,28 @@ class GenerationMetadata: """ +@dataclass +class RawProviderResponse: + """Backend-native response payload from the provider's SDK. + + Reading these fields couples your code to a specific provider's response + shape. For portable access prefer `mot.value`, `mot.parsed_repr`, + `mot.tool_calls`, or `mot.generation`. + + Args: + provider: Name of the provider that produced `response`; the same value + as `mot.generation.provider`. Read it to know how to interpret + `response`. + response: Full SDK response object. Shape depends on `provider`. + streamed_chunks: Per-chunk SDK objects from streaming responses. + `None` for non-streaming requests. + """ + + provider: str | None = None + response: Any | None = None + streamed_chunks: list[Any] | None = None + + class ModelOutputThunk(CBlock, Generic[S]): """A `ModelOutputThunk` is a special type of `CBlock` that we know came from a model's output. It is possible to instantiate one without the output being computed yet. @@ -398,6 +420,9 @@ def __init__( self.generation: GenerationMetadata = GenerationMetadata() """Backend execution metadata populated during generation.""" + self.raw: RawProviderResponse = RawProviderResponse() + """Backend-native provider response populated during generation.""" + # Used for tracking generation. self._context: list[Component | CBlock] | None = None self._action: Component | CBlock | None = None @@ -596,6 +621,7 @@ def _copy_from(self, other: ModelOutputThunk) -> None: self.tool_calls = other.tool_calls self._thinking = other._thinking self.generation = other.generation + self.raw = other.raw self._generate_log = other._generate_log self._cancelled = other._cancelled self._error = other._error @@ -835,6 +861,7 @@ def __copy__(self) -> ModelOutputThunk: copied._generate_log = self._generate_log copied._model_options = self._model_options copied.generation = copy(self.generation) + copied.raw = copy(self.raw) return copied def __deepcopy__(self, memo: dict) -> ModelOutputThunk: @@ -870,6 +897,7 @@ def __deepcopy__(self, memo: dict) -> ModelOutputThunk: deepcopied._generate_log = copy(self._generate_log) deepcopied._model_options = copy(self._model_options) deepcopied.generation = deepcopy(self.generation) + deepcopied.raw = deepcopy(self.raw) return deepcopied diff --git a/mellea/stdlib/components/chat.py b/mellea/stdlib/components/chat.py index 9a486769c..7c323438e 100644 --- a/mellea/stdlib/components/chat.py +++ b/mellea/stdlib/components/chat.py @@ -119,72 +119,44 @@ def _parse(self, computed: ModelOutputThunk) -> "Message": """Parse the model output into a Message.""" # TODO: There's some specific logic for tool calls. Storing that here for now. # We may eventually need some generic parsing logic that gets run for all Component types... + provider = computed.raw.provider + response = computed.raw.response + if computed.tool_calls is not None: # A tool was successfully requested. # Assistant responses for tool calling differ by backend. For the default formatter, # we put all of the function data into the content field in the same format we received it. - - # Chat backends should provide an openai-like object in the _meta chat response, which we can use to properly format this output. - if "chat_response" in computed._meta: - # Ollama. - return Message( - role=computed._meta["chat_response"].message.role, - content=str(computed._meta["chat_response"].message.tool_calls), - ) - elif "oai_chat_response" in computed._meta: - # OpenAI and Watsonx. + if provider == "ollama" and response is not None: return Message( - role=computed._meta["oai_chat_response"]["choices"][0]["message"][ - "role" - ], - content=str( - computed._meta["oai_chat_response"]["choices"][0][ - "message" - ].get("tool_calls", []) - ), - ) - else: - # HuggingFace (or others). There are no guarantees on how the model represented the function calls. - # Output it in the same format we received the tool call request. - assert computed.value is not None - return Message(role="assistant", content=computed.value) - - if "chat_response" in computed._meta: - # Chat backends should provide an openai-like object in the _meta chat response, which we can use to properly format this output. - return Message( - role=computed._meta["chat_response"].message.role, - content=computed._meta["chat_response"].message.content, - ) - elif "oai_chat_response" in computed._meta: - role = ( - computed._meta["oai_chat_response"] - .get("choices", [{}])[0] - .get("message", {}) - .get("role", "") - ) - if role == "": - role = ( - computed._meta["oai_chat_response"] - .get("message", {}) - .get("role", "") + role=response.message.role, content=str(response.message.tool_calls) ) + if provider in ("openai", "watsonx", "litellm") and isinstance( + response, dict + ): + choice = response["choices"][0] if "choices" in response else response + msg = choice["message"] + return Message(role=msg["role"], content=str(msg.get("tool_calls", []))) + # HuggingFace (or others). There are no guarantees on how the model represented the function calls. + # Output it in the same format we received the tool call request. + assert computed.value is not None + return Message(role="assistant", content=computed.value) - content = ( - computed._meta["oai_chat_response"] - .get("choices", [{}])[0] - .get("message", {}) - .get("content", "") + if provider == "ollama" and response is not None: + # Ollama can return role="tool"; preserve role recovery from the response. + return Message(role=response.message.role, content=response.message.content) + if provider in ("openai", "watsonx", "litellm") and isinstance(response, dict): + choices = response.get("choices") or [{}] + msg = choices[0].get("message", {}) + role = msg.get("role") or response.get("message", {}).get("role", "") + content = msg.get("content") or response.get("message", {}).get( + "content", "" ) - if content == "": - content = ( - computed._meta["oai_chat_response"] - .get("message", {}) - .get("content", "") - ) return Message(role=role, content=content) - else: - assert computed.value is not None - return Message(role="assistant", content=computed.value) + + # HuggingFace: raw.response is token tensors with no role/content to parse. + # Unknown provider: nothing to switch on. Both fall back to the decoded text. + assert computed.value is not None + return Message(role="assistant", content=computed.value) class ToolMessage(Message): diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 7c16a617e..e9e6c999d 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -82,7 +82,7 @@ async def test_finish_reasons_derivation( mot = ModelOutputThunk(value=value) mot._action = Message("user", "noop") mot._model_options = model_options - mot._meta["hf_output"] = GenerateDecoderOnlyOutput( + mot.raw.response = GenerateDecoderOnlyOutput( sequences=sequences, scores=None, logits=None, diff --git a/test/backends/test_ollama.py b/test/backends/test_ollama.py index b2aa7f249..e3776ce06 100644 --- a/test/backends/test_ollama.py +++ b/test/backends/test_ollama.py @@ -56,8 +56,9 @@ def test_simple_instruct(session) -> None: "Write an email to Hendrik trying to sell him self-sealing stembolts." ) assert result.value.startswith("Subject") - assert "chat_response" in result._meta - assert result._meta["chat_response"].message.role == "assistant" + assert result.raw.provider == "ollama" + assert result.raw.response is not None + assert result.raw.response.message.role == "assistant" assert isinstance(result.parsed_repr, str) diff --git a/test/backends/test_ollama_unit.py b/test/backends/test_ollama_unit.py index 7d0c4b03f..9b27f783a 100644 --- a/test/backends/test_ollama_unit.py +++ b/test/backends/test_ollama_unit.py @@ -173,35 +173,35 @@ def test_delta_merge_first_sets_chat_response(): mot = ModelOutputThunk(value=None) delta = _make_delta("Hello") chat_response_delta_merge(mot, delta) - assert mot._meta["chat_response"] is delta + assert mot.raw.response is delta def test_delta_merge_second_appends_content(): mot = ModelOutputThunk(value=None) chat_response_delta_merge(mot, _make_delta("Hello")) chat_response_delta_merge(mot, _make_delta(" world")) - assert mot._meta["chat_response"].message.content == "Hello world" + assert mot.raw.response.message.content == "Hello world" def test_delta_merge_done_propagated(): mot = ModelOutputThunk(value=None) chat_response_delta_merge(mot, _make_delta("partial", done=False)) chat_response_delta_merge(mot, _make_delta("", done=True)) - assert mot._meta["chat_response"].done is True + assert mot.raw.response.done is True def test_delta_merge_role_set_from_first_delta(): mot = ModelOutputThunk(value=None) chat_response_delta_merge(mot, _make_delta("hi", role="assistant")) chat_response_delta_merge(mot, _make_delta(" there", role="")) - assert mot._meta["chat_response"].message.role == "assistant" + assert mot.raw.response.message.role == "assistant" def test_delta_merge_thinking_concatenated(): mot = ModelOutputThunk(value=None) chat_response_delta_merge(mot, _make_delta("reply", thinking="step 1")) chat_response_delta_merge(mot, _make_delta("", thinking=" step 2")) - assert mot._meta["chat_response"].message.thinking == "step 1 step 2" + assert mot.raw.response.message.thinking == "step 1 step 2" # --- timeout wiring --- diff --git a/test/cli/test_serve_utils.py b/test/cli/test_serve_utils.py index f2e83d0ac..a3dc14f0b 100644 --- a/test/cli/test_serve_utils.py +++ b/test/cli/test_serve_utils.py @@ -3,44 +3,36 @@ from unittest.mock import Mock from cli.serve.utils import extract_finish_reason -from mellea.core.base import ModelOutputThunk +from mellea.core.base import ModelOutputThunk, RawProviderResponse class TestExtractFinishReason: """Tests for extract_finish_reason function.""" - def test_default_finish_reason_when_no_meta(self): - """Test that 'stop' is returned when output has no _meta attribute.""" - output = ModelOutputThunk("test response") - # Don't set _meta attribute - assert extract_finish_reason(output) == "stop" - - def test_default_finish_reason_when_meta_is_none(self): - """Test that 'stop' is returned when _meta is None.""" - output = ModelOutputThunk("test response") - output._meta = None + def test_default_finish_reason_when_no_raw(self): + """Test that 'stop' is returned when output has no raw attribute.""" + output = Mock(spec=[]) assert extract_finish_reason(output) == "stop" - def test_default_finish_reason_when_meta_is_empty(self): - """Test that 'stop' is returned when _meta is empty dict.""" + def test_default_finish_reason_when_raw_unset(self): + """Test that 'stop' is returned when raw fields are unset.""" output = ModelOutputThunk("test response") - output._meta = {} assert extract_finish_reason(output) == "stop" def test_ollama_done_reason_stop(self): - """Test extraction of 'stop' from Ollama chat_response.done_reason.""" + """Test extraction of 'stop' from Ollama response.done_reason.""" output = ModelOutputThunk("test response") chat_response = Mock() chat_response.done_reason = "stop" - output._meta = {"chat_response": chat_response} + output.raw = RawProviderResponse(provider="ollama", response=chat_response) assert extract_finish_reason(output) == "stop" def test_ollama_done_reason_length(self): - """Test extraction of 'length' from Ollama chat_response.done_reason.""" + """Test extraction of 'length' from Ollama response.done_reason.""" output = ModelOutputThunk("test response") chat_response = Mock() chat_response.done_reason = "length" - output._meta = {"chat_response": chat_response} + output.raw = RawProviderResponse(provider="ollama", response=chat_response) assert extract_finish_reason(output) == "length" def test_ollama_done_reason_none(self): @@ -48,215 +40,193 @@ def test_ollama_done_reason_none(self): output = ModelOutputThunk("test response") chat_response = Mock() chat_response.done_reason = None - output._meta = {"chat_response": chat_response} + output.raw = RawProviderResponse(provider="ollama", response=chat_response) assert extract_finish_reason(output) == "stop" - def test_ollama_chat_response_without_done_reason(self): - """Test that default 'stop' is returned when chat_response lacks done_reason.""" + def test_ollama_response_without_done_reason(self): + """Test that default 'stop' is returned when response lacks done_reason.""" output = ModelOutputThunk("test response") - chat_response = Mock(spec=[]) # Mock without done_reason attribute - output._meta = {"chat_response": chat_response} + chat_response = Mock(spec=[]) + output.raw = RawProviderResponse(provider="ollama", response=chat_response) assert extract_finish_reason(output) == "stop" def test_openai_finish_reason_stop(self): - """Test extraction of 'stop' from OpenAI oai_chat_response.""" + """Test extraction of 'stop' from OpenAI response.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": {"choices": [{"finish_reason": "stop", "index": 0}]} - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "stop", "index": 0}]}, + ) assert extract_finish_reason(output) == "stop" def test_openai_finish_reason_length(self): - """Test extraction of 'length' from OpenAI oai_chat_response.""" + """Test extraction of 'length' from OpenAI response.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": {"choices": [{"finish_reason": "length", "index": 0}]} - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "length", "index": 0}]}, + ) assert extract_finish_reason(output) == "length" def test_openai_finish_reason_content_filter(self): - """Test extraction of 'content_filter' from OpenAI oai_chat_response.""" + """Test extraction of 'content_filter' from OpenAI response.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { - "choices": [{"finish_reason": "content_filter", "index": 0}] - } - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "content_filter", "index": 0}]}, + ) assert extract_finish_reason(output) == "content_filter" def test_openai_finish_reason_tool_calls(self): - """Test extraction of 'tool_calls' from OpenAI oai_chat_response.""" + """Test extraction of 'tool_calls' from OpenAI response.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { - "choices": [{"finish_reason": "tool_calls", "index": 0}] - } - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "tool_calls", "index": 0}]}, + ) assert extract_finish_reason(output) == "tool_calls" def test_openai_finish_reason_function_call(self): - """Test extraction of 'function_call' from OpenAI oai_chat_response.""" + """Test extraction of 'function_call' from OpenAI response.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { - "choices": [{"finish_reason": "function_call", "index": 0}] - } - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "function_call", "index": 0}]}, + ) assert extract_finish_reason(output) == "function_call" def test_openai_empty_choices_array(self): """Test that default 'stop' is returned when choices array is empty.""" output = ModelOutputThunk("test response") - output._meta = {"oai_chat_response": {"choices": []}} + output.raw = RawProviderResponse(provider="openai", response={"choices": []}) assert extract_finish_reason(output) == "stop" def test_openai_missing_choices_key(self): """Test that default 'stop' is returned when choices key is missing.""" output = ModelOutputThunk("test response") - output._meta = {"oai_chat_response": {}} + output.raw = RawProviderResponse(provider="openai", response={}) assert extract_finish_reason(output) == "stop" def test_openai_finish_reason_none(self): """Test that default 'stop' is returned when finish_reason is None.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": {"choices": [{"finish_reason": None, "index": 0}]} - } + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": None, "index": 0}]}, + ) assert extract_finish_reason(output) == "stop" def test_openai_non_dict_response(self): - """Test that default 'stop' is returned when oai_chat_response is not a dict.""" + """Test that default 'stop' is returned when response is not a dict.""" output = ModelOutputThunk("test response") - output._meta = {"oai_chat_response": "not a dict"} + output.raw = RawProviderResponse(provider="openai", response="not a dict") assert extract_finish_reason(output) == "stop" - def test_ollama_takes_precedence_over_openai(self): - """Test that Ollama done_reason is checked before OpenAI finish_reason.""" - output = ModelOutputThunk("test response") - chat_response = Mock() - chat_response.done_reason = "length" - output._meta = { - "chat_response": chat_response, - "oai_chat_response": {"choices": [{"finish_reason": "stop", "index": 0}]}, - } - # Should return Ollama's done_reason, not OpenAI's finish_reason - assert extract_finish_reason(output) == "length" - - def test_openai_used_when_ollama_missing(self): - """Test that OpenAI finish_reason is used when Ollama data is missing.""" - output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { - "choices": [{"finish_reason": "content_filter", "index": 0}] - } - } - assert extract_finish_reason(output) == "content_filter" - def test_multiple_choices_uses_first(self): """Test that first choice is used when multiple choices exist.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { + output.raw = RawProviderResponse( + provider="openai", + response={ "choices": [ {"finish_reason": "stop", "index": 0}, {"finish_reason": "length", "index": 1}, ] - } - } - assert extract_finish_reason(output) == "stop" - - def test_other_meta_keys_ignored(self): - """Test that unrelated _meta keys don't interfere.""" - output = ModelOutputThunk("test response") - output._meta = { - "model": "gpt-4", - "provider": "openai", - "usage": {"total_tokens": 100}, - "random_key": "random_value", - } - assert extract_finish_reason(output) == "stop" - - def test_output_without_meta_attribute(self): - """Test handling of output objects that don't have _meta attribute at all.""" - # Create a simple object without _meta - output = Mock(spec=[]) + }, + ) assert extract_finish_reason(output) == "stop" - def test_litellm_finish_reason_stop(self): - """Test extraction of 'stop' from LiteLLM litellm_chat_response.""" + def test_litellm_finish_reason_top_level_choices(self): + """Test extraction of 'stop' from a LiteLLM top-level response with choices.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "stop"}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": "stop", "index": 0}]}, + ) assert extract_finish_reason(output) == "stop" def test_litellm_finish_reason_length(self): - """Test extraction of 'length' from LiteLLM litellm_chat_response.""" + """Test extraction of 'length' from LiteLLM response.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "length"}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": "length", "index": 0}]}, + ) assert extract_finish_reason(output) == "length" def test_litellm_finish_reason_tool_calls(self): - """Test extraction of 'tool_calls' from LiteLLM litellm_chat_response.""" + """Test extraction of 'tool_calls' from LiteLLM response.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "tool_calls"}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": "tool_calls", "index": 0}]}, + ) assert extract_finish_reason(output) == "tool_calls" def test_litellm_finish_reason_content_filter(self): - """Test extraction of 'content_filter' from LiteLLM litellm_chat_response.""" + """Test extraction of 'content_filter' from LiteLLM response.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "content_filter"}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": "content_filter", "index": 0}]}, + ) assert extract_finish_reason(output) == "content_filter" def test_litellm_finish_reason_function_call(self): - """Test extraction of 'function_call' from LiteLLM litellm_chat_response.""" + """Test extraction of 'function_call' from LiteLLM response.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "function_call"}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": "function_call", "index": 0}]}, + ) assert extract_finish_reason(output) == "function_call" def test_litellm_finish_reason_none(self): """Test that default 'stop' is returned when LiteLLM finish_reason is None.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": None}} + output.raw = RawProviderResponse( + provider="litellm", + response={"choices": [{"finish_reason": None, "index": 0}]}, + ) assert extract_finish_reason(output) == "stop" def test_litellm_missing_finish_reason_key(self): """Test that default 'stop' is returned when finish_reason key is missing.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {}} + output.raw = RawProviderResponse(provider="litellm", response={}) assert extract_finish_reason(output) == "stop" def test_litellm_non_dict_response(self): - """Test that default 'stop' is returned when litellm_chat_response is not a dict.""" + """Test that default 'stop' is returned when response is not a dict.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": "not a dict"} + output.raw = RawProviderResponse(provider="litellm", response="not a dict") assert extract_finish_reason(output) == "stop" - def test_backend_precedence_ollama_openai_litellm(self): - """Test that backends are checked in order: Ollama, OpenAI, LiteLLM.""" + def test_litellm_per_choice_dict_fallback(self): + """Test that a LiteLLM per-choice dict (no choices key) uses top-level finish_reason.""" output = ModelOutputThunk("test response") - chat_response = Mock() - chat_response.done_reason = "length" - output._meta = { - "chat_response": chat_response, - "oai_chat_response": {"choices": [{"finish_reason": "stop", "index": 0}]}, - "litellm_chat_response": {"finish_reason": "content_filter"}, - } - # Should return Ollama's done_reason (checked first) - assert extract_finish_reason(output) == "length" + output.raw = RawProviderResponse( + provider="litellm", response={"finish_reason": "tool_calls"} + ) + assert extract_finish_reason(output) == "tool_calls" - def test_litellm_used_when_ollama_and_openai_missing(self): - """Test that LiteLLM finish_reason is used when Ollama and OpenAI data missing.""" + def test_huggingface_falls_through_to_default(self): + """Test that HuggingFace (no finish_reason on response) returns default 'stop'.""" output = ModelOutputThunk("test response") - output._meta = {"litellm_chat_response": {"finish_reason": "tool_calls"}} - assert extract_finish_reason(output) == "tool_calls" + output.raw = RawProviderResponse(provider="huggingface", response=Mock(spec=[])) + assert extract_finish_reason(output) == "stop" - def test_openai_takes_precedence_over_litellm(self): - """Test that OpenAI finish_reason is checked before LiteLLM.""" + def test_unknown_provider_falls_through(self): + """Test that an unknown provider returns default 'stop'.""" output = ModelOutputThunk("test response") - output._meta = { - "oai_chat_response": { - "choices": [{"finish_reason": "content_filter", "index": 0}] - }, - "litellm_chat_response": {"finish_reason": "stop"}, - } - # Should return OpenAI's finish_reason (checked before LiteLLM) - assert extract_finish_reason(output) == "content_filter" + output.raw = RawProviderResponse(provider="experimental", response={}) + assert extract_finish_reason(output) == "stop" + + def test_tool_calls_attribute_short_circuits(self): + """Test that a set tool_calls attribute returns 'tool_calls' regardless of raw.""" + output = ModelOutputThunk("test response", tool_calls={"fn": None}) + output.raw = RawProviderResponse( + provider="openai", + response={"choices": [{"finish_reason": "stop", "index": 0}]}, + ) + assert extract_finish_reason(output) == "tool_calls" diff --git a/test/core/test_base.py b/test/core/test_base.py index a3424d0f4..a7016245b 100644 --- a/test/core/test_base.py +++ b/test/core/test_base.py @@ -6,7 +6,14 @@ import pytest from PIL import Image as PILImage -from mellea.core import CBlock, Component, ImageBlock, ImageUrlBlock, ModelOutputThunk +from mellea.core import ( + CBlock, + Component, + ImageBlock, + ImageUrlBlock, + ModelOutputThunk, + RawProviderResponse, +) from mellea.stdlib.components import Message @@ -44,18 +51,15 @@ def __init__(self, msg: Message) -> None: self.message = msg source = Message(role="user", content="source message") - result = ModelOutputThunk( - value="result value", - meta={ - "chat_response": _ChatResponse( - Message(role="assistant", content="assistant reply") - ) - }, + result = ModelOutputThunk(value="result value") + result.raw = RawProviderResponse( + provider="ollama", + response=_ChatResponse(Message(role="assistant", content="assistant reply")), ) result.parsed_repr = source.parse(result) assert isinstance(result.parsed_repr, Message), ( - "result's parsed repr should be a message when meta includes a chat_response" + "result's parsed repr should be a message when raw provider is set" ) assert result.parsed_repr.role == "assistant", ( "result's parsed repr role should be assistant" @@ -230,6 +234,60 @@ def test_mot_deep_copy_clones_generation(): assert deepcopied.generation.ttfb_ms == 42.0 +# --- RawProviderResponse default + copy semantics --- + + +def _make_mot_with_raw() -> ModelOutputThunk: + mot = ModelOutputThunk(value="x") + mot.raw.provider = "openai" + mot.raw.response = {"choices": [{"message": {"role": "assistant", "content": "v"}}]} + mot.raw.streamed_chunks = [{"delta": {"content": "v"}}] + return mot + + +def test_raw_provider_response_default(): + mot = ModelOutputThunk(value=None) + assert mot.raw == RawProviderResponse() + assert mot.raw.provider is None + assert mot.raw.response is None + assert mot.raw.streamed_chunks is None + + +def test_raw_propagates_on_copy(): + original = _make_mot_with_raw() + copied = copy.copy(original) + assert copied.raw.provider == "openai" + # Shallow copy: the response dict is shared. + assert copied.raw.response is original.raw.response + assert copied.raw.streamed_chunks is original.raw.streamed_chunks + + +def test_raw_shallow_copy_provider_mutation_does_not_bleed(): + original = _make_mot_with_raw() + copied = copy.copy(original) + copied.raw.provider = "litellm" + assert original.raw.provider == "openai" + + +def test_raw_propagates_on_deepcopy(): + original = _make_mot_with_raw() + deepcopied = copy.deepcopy(original) + assert deepcopied.raw is not original.raw + assert deepcopied.raw.provider == "openai" + assert deepcopied.raw.response == original.raw.response + assert deepcopied.raw.response is not original.raw.response + assert deepcopied.raw.streamed_chunks == original.raw.streamed_chunks + assert deepcopied.raw.streamed_chunks is not original.raw.streamed_chunks + + +def test_raw_propagates_on_copy_from(): + a = ModelOutputThunk(value=None) + b = _make_mot_with_raw() + a._copy_from(b) + # _copy_from is reference assignment for raw, matching .generation semantics. + assert a.raw is b.raw + + # --- Public error / generate_log surface --- diff --git a/test/stdlib/components/test_chat.py b/test/stdlib/components/test_chat.py index e460e33ae..b15aeb16f 100644 --- a/test/stdlib/components/test_chat.py +++ b/test/stdlib/components/test_chat.py @@ -2,7 +2,12 @@ import pytest -from mellea.core import CBlock, ModelOutputThunk, TemplateRepresentation +from mellea.core import ( + CBlock, + ModelOutputThunk, + RawProviderResponse, + TemplateRepresentation, +) from mellea.formatters.template_formatter import TemplateFormatter from mellea.helpers import message_to_openai_message, messages_to_docs from mellea.stdlib.components import Document, Message @@ -134,7 +139,7 @@ def test_parse_ollama_chat_response(): )() }, )() - mot._meta["chat_response"] = fake_response + mot.raw = RawProviderResponse(provider="ollama", response=fake_response) result = msg._parse(mot) assert result.role == "assistant" assert result.content == "ollama answer" @@ -143,14 +148,33 @@ def test_parse_ollama_chat_response(): def test_parse_openai_chat_response(): msg = Message("user", "q") mot = ModelOutputThunk(value="v") - mot._meta["oai_chat_response"] = { - "choices": [{"message": {"role": "assistant", "content": "openai answer"}}] - } + mot.raw = RawProviderResponse( + provider="openai", + response={ + "choices": [{"message": {"role": "assistant", "content": "openai answer"}}] + }, + ) result = msg._parse(mot) assert result.role == "assistant" assert result.content == "openai answer" +def test_parse_openai_streamed_choice_shape(): + """Streaming stores the merged choice dict (no top-level `choices` wrapper).""" + msg = Message("user", "q") + mot = ModelOutputThunk(value="v") + mot.raw = RawProviderResponse( + provider="openai", + response={ + "finish_reason": "stop", + "message": {"role": "assistant", "content": "streamed answer"}, + }, + ) + result = msg._parse(mot) + assert result.role == "assistant" + assert result.content == "streamed answer" + + # --- Message._parse — with tool calls --- @@ -163,7 +187,7 @@ def test_parse_tool_calls_ollama(): (), {"message": type("Msg", (), {"role": "assistant", "tool_calls": fake_calls})()}, )() - mot._meta["chat_response"] = fake_response + mot.raw = RawProviderResponse(provider="ollama", response=fake_response) result = msg._parse(mot) assert result.role == "assistant" assert "some_fn" in result.content @@ -172,22 +196,48 @@ def test_parse_tool_calls_ollama(): def test_parse_tool_calls_openai(): msg = Message("user", "q") mot = ModelOutputThunk(value="v", tool_calls={"fn": None}) - mot._meta["oai_chat_response"] = { - "choices": [ - { - "message": { - "role": "assistant", - "tool_calls": [{"function": {"name": "fn"}}], + mot.raw = RawProviderResponse( + provider="openai", + response={ + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [{"function": {"name": "fn"}}], + } } - } - ] - } + ] + }, + ) + result = msg._parse(mot) + assert result.role == "assistant" + + +def test_parse_tool_calls_openai_streamed_choice_shape(): + """Streamed tool calls store the merged choice dict, not a top-level envelope. + + Regression test: the tool branch previously indexed `response["choices"][0]` + unconditionally and raised `KeyError` on the streaming choice-level shape. + """ + msg = Message("user", "q") + mot = ModelOutputThunk(value="v", tool_calls={"fn": None}) + mot.raw = RawProviderResponse( + provider="openai", + response={ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [{"function": {"name": "fn"}}], + }, + }, + ) result = msg._parse(mot) assert result.role == "assistant" + assert "fn" in result.content def test_parse_tool_calls_fallback_uses_value(): - """No chat_response or oai_chat_response — falls back to computed.value.""" + """No raw provider info — falls back to computed.value.""" msg = Message("user", "q") mot = ModelOutputThunk(value="fn()", tool_calls={"fn": None}) result = msg._parse(mot)