From 472918f577c8f2007f9e1266ece02d603aea7072 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 11:51:13 +0100 Subject: [PATCH 1/7] feat(v1): support response_format / response_schema on /v1/chat/completions Make the OpenAI-compatible Chat Completions endpoint honor per-request Structured Outputs and keep it OpenAI-compatible. - translate_request now forwards the request's `response_format` (json_schema) or a `response_schema` convenience field to the agent as its json_schema, overriding the agent-configured schema for that request. - Honor `response_format.json_schema.strict` (default true); strict:false passes the schema through without forcing additionalProperties:false / all-required (OpenAI's lenient mode). - Support `response_format {"type":"json_object"}` via the provider's native JSON mode. - Keep `content` clean: some models echo their reasoning into content as stringified `{'type': 'thought', ...}` reprs when response_format is set. Strip those from content and reroute them to `reasoning_content` (OpenAI never puts reasoning in content), for both streaming and non-streaming. Flow: translate_request -> StreamProcessor._configure_agent -> Agent.json_schema / json_schema_strict / json_object -> _llm_gen -> prepare_structured_output_format(schema, strict). --- application/agents/base.py | 17 ++- .../api/answer/services/stream_processor.py | 14 ++ application/api/v1/translator.py | 121 ++++++++++++++++-- application/llm/google_ai.py | 8 +- application/llm/openai.py | 11 +- 5 files changed, 154 insertions(+), 17 deletions(-) diff --git a/application/agents/base.py b/application/agents/base.py index e887c22d..b9d41109 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -33,6 +33,8 @@ class BaseAgent(ABC): decoded_token: Optional[Dict] = None, attachments: Optional[List[Dict]] = None, json_schema: Optional[Dict] = None, + json_schema_strict: bool = True, + json_object: bool = False, limited_token_mode: Optional[bool] = False, token_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["token_limit"], limited_request_mode: Optional[bool] = False, @@ -108,6 +110,11 @@ class BaseAgent(ABC): self.json_schema = normalize_json_schema_payload(json_schema) except JsonSchemaValidationError as exc: logger.warning("Ignoring invalid JSON schema payload: %s", exc) + # Per-request structured-output controls (OpenAI-compatible): + # ``json_schema_strict`` mirrors response_format.json_schema.strict; + # ``json_object`` mirrors response_format {"type":"json_object"}. + self.json_schema_strict = json_schema_strict + self.json_object = json_object self.limited_token_mode = limited_token_mode self.token_limit = token_limit self.limited_request_mode = limited_request_mode @@ -572,13 +579,21 @@ class BaseAgent(ABC): and self.llm._supports_structured_output() ): structured_format = self.llm.prepare_structured_output_format( - self.json_schema + self.json_schema, strict=getattr(self, "json_schema_strict", True) ) if structured_format: if self.llm_name == "openai": gen_kwargs["response_format"] = structured_format elif self.llm_name == "google": gen_kwargs["response_schema"] = structured_format + elif ( + getattr(self, "json_object", False) + and self.llm_name == "openai" + and hasattr(self.llm, "_supports_structured_output") + and self.llm._supports_structured_output() + ): + # OpenAI json_object mode: guarantee valid JSON, no schema enforcement. + gen_kwargs["response_format"] = {"type": "json_object"} if ( settings.OPENAI_RESPONSES_STORE and hasattr(self.llm, "_uses_responses_api") diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index e923b26a..dfaf5b9d 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -647,6 +647,18 @@ class StreamProcessor: } ) + # Per-request structured output: a ``response_format`` / ``response_schema`` + # in the request (surfaced by the v1 translator as ``json_schema``) overrides + # the agent's configured schema for this call. Invalid schemas are ignored + # downstream by the agent (normalize_json_schema_payload). + request_json_schema = self.data.get("json_schema") + if request_json_schema is not None: + self.agent_config["json_schema"] = request_json_schema + if self.data.get("json_schema_strict") is not None: + self.agent_config["json_schema_strict"] = self.data.get("json_schema_strict") + if self.data.get("json_object"): + self.agent_config["json_object"] = True + def _configure_retriever(self): """Assemble retriever config; agent's values are authoritative when bound.""" # BYOM scope: owner for shared-agent BYOM, caller for own BYOM, @@ -1254,6 +1266,8 @@ class StreamProcessor: "decoded_token": self.decoded_token, "attachments": self.attachments, "json_schema": self.agent_config.get("json_schema"), + "json_schema_strict": self.agent_config.get("json_schema_strict", True), + "json_object": self.agent_config.get("json_object", False), "compressed_summary": self.compressed_summary, "llm": llm, "llm_handler": llm_handler, diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index a4d48f0c..d7c91898 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -7,9 +7,49 @@ This module handles: """ import json +import re import time from typing import Any, Dict, List, Optional +# Some upstream models/proxies echo their reasoning into ``content`` as +# stringified ``{'type': 'thought', 'thought': '...'}`` event reprs (instead of +# using the separate reasoning channel) — most visibly when ``response_format`` +# is set. OpenAI's API never puts reasoning in ``content``, so for the +# OpenAI-compatible endpoint we strip these and reroute them to +# ``reasoning_content`` to keep ``content`` clean and compatible. +# The thought value is a Python string repr: single-quoted, or double-quoted when +# the token contains an apostrophe (e.g. "'ll"). Match the full quoted value +# (honoring escapes) so tokens containing ``}`` or newlines don't truncate the +# match and leave stray ``'}`` tails in the content. +_LEAKED_THOUGHT_RE = re.compile( + r"""\{'type': 'thought', 'thought': ('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\}""", + re.DOTALL, +) + + +def _strip_repr_quotes(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]: + return value[1:-1] + return value + + +def _split_leaked_reasoning(content: Optional[str]) -> tuple: + """Return ``(clean_content, leaked_reasoning)``. + + ``clean_content`` has any stringified thought-event reprs removed; + ``leaked_reasoning`` is the concatenated reasoning text that was extracted. + A no-op (returns the input unchanged) when no leak markers are present. + """ + if not content or "'type': 'thought'" not in content: + return content, "" + extracted: List[str] = [] + cleaned = _LEAKED_THOUGHT_RE.sub( + lambda m: (extracted.append(_strip_repr_quotes(m.group(1))) or ""), content + ) + return cleaned, "".join(extracted) + + def _get_client_tool_name(tc: Dict) -> str: """Return the original tool name for client-facing responses. @@ -121,6 +161,37 @@ def convert_history(messages: List[Dict]) -> List[Dict]: return history +def extract_response_schema(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Extract a JSON schema for structured output from a chat-completions request. + + Supports two request shapes: + - OpenAI ``response_format``: + ``{"type": "json_schema", "json_schema": {"name": ..., "schema": {...}}}`` + (a bare schema under ``json_schema`` is also tolerated). + - ``response_schema`` convenience field: a raw JSON Schema object, or a + ``{"schema": {...}}`` wrapper. + + Returns a raw JSON Schema object, or None. ``response_format`` + ``{"type": "json_object"}`` carries no schema to enforce and yields None + (the model is still steered by the system prompt). + """ + response_schema = data.get("response_schema") + if isinstance(response_schema, dict) and response_schema: + inner = response_schema.get("schema") + return inner if isinstance(inner, dict) else response_schema + + response_format = data.get("response_format") + if isinstance(response_format, dict) and response_format.get("type") == "json_schema": + json_schema = response_format.get("json_schema") + if isinstance(json_schema, dict): + schema = json_schema.get("schema") + if isinstance(schema, dict): + return schema + if "type" in json_schema: + return json_schema + return None + + def translate_request( data: Dict[str, Any], api_key: str ) -> Dict[str, Any]: @@ -134,6 +205,12 @@ def translate_request( Dict suitable for passing to ``StreamProcessor``. """ messages = data.get("messages", []) + response_schema = extract_response_schema(data) + _rf = data.get("response_format") + _rf = _rf if isinstance(_rf, dict) else {} + # OpenAI Structured Outputs default to strict; honor an explicit strict:false. + json_schema_strict = bool((_rf.get("json_schema") or {}).get("strict", True)) + json_object_mode = _rf.get("type") == "json_object" # Check for continuation (tool results after assistant tool_calls) if is_continuation(messages): @@ -155,6 +232,11 @@ def translate_request( # Carry tools forward for next iteration if data.get("tools"): result["client_tools"] = data["tools"] + if response_schema is not None: + result["json_schema"] = response_schema + result["json_schema_strict"] = json_schema_strict + if json_object_mode: + result["json_object"] = True return result # Normal request — extract question from last user message @@ -189,6 +271,12 @@ def translate_request( if docsgpt.get("attachments"): result["attachments"] = docsgpt["attachments"] + if response_schema is not None: + result["json_schema"] = response_schema + result["json_schema_strict"] = json_schema_strict + if json_object_mode: + result["json_object"] = True + return result @@ -246,9 +334,11 @@ def translate_response( ] finish_reason = "tool_calls" else: - message["content"] = answer - if thought: - message["reasoning_content"] = thought + clean_answer, leaked_reasoning = _split_leaked_reasoning(answer) + message["content"] = clean_answer + combined_reasoning = (thought or "") + leaked_reasoning + if combined_reasoning: + message["reasoning_content"] = combined_reasoning finish_reason = "stop" result: Dict[str, Any] = { @@ -341,9 +431,15 @@ def translate_stream_event( chunks: List[str] = [] if event_type == "answer": - chunks.append( - _make_chunk(completion_id, model_name, {"content": event_data.get("answer", "")}) - ) + clean, leaked = _split_leaked_reasoning(event_data.get("answer", "")) + if leaked: + chunks.append( + _make_chunk(completion_id, model_name, {"reasoning_content": leaked}) + ) + if clean: + chunks.append( + _make_chunk(completion_id, model_name, {"content": clean}) + ) elif event_type == "thought": chunks.append( @@ -427,12 +523,15 @@ def translate_stream_event( chunks.append(f"data: {json.dumps(error_data)}\n\n") elif event_type == "structured_answer": - chunks.append( - _make_chunk( - completion_id, model_name, - {"content": event_data.get("answer", "")}, + clean, leaked = _split_leaked_reasoning(event_data.get("answer", "")) + if leaked: + chunks.append( + _make_chunk(completion_id, model_name, {"reasoning_content": leaked}) + ) + if clean: + chunks.append( + _make_chunk(completion_id, model_name, {"content": clean}) ) - ) # Skip: tool_calls (redundant), research_plan, research_progress diff --git a/application/llm/google_ai.py b/application/llm/google_ai.py index aca099c5..e1fcf7bc 100644 --- a/application/llm/google_ai.py +++ b/application/llm/google_ai.py @@ -653,8 +653,12 @@ class GoogleLLM(BaseLLM): """Return whether this LLM supports structured JSON output.""" return True - def prepare_structured_output_format(self, json_schema): - """Convert JSON schema to Google AI structured output format.""" + def prepare_structured_output_format(self, json_schema, strict=True): + """Convert JSON schema to Google AI structured output format. + + ``strict`` is accepted for signature parity with the OpenAI provider; + Google enforces the schema natively via ``response_schema``. + """ if not json_schema: return None type_map = { diff --git a/application/llm/openai.py b/application/llm/openai.py index e3b9000e..1ae59632 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -879,7 +879,7 @@ class OpenAILLM(BaseLLM): if effort: kwargs["reasoning_effort"] = effort - def prepare_structured_output_format(self, json_schema): + def prepare_structured_output_format(self, json_schema, strict=True): if not json_schema: return None try: @@ -914,7 +914,12 @@ class OpenAILLM(BaseLLM): return schema_copy return schema_obj - processed_schema = add_additional_properties_false(json_schema) + # Strict mode requires additionalProperties:false + all-required on every + # object (OpenAI Structured Outputs). When strict is false (OpenAI's + # lenient json_schema), pass the schema through unchanged. + processed_schema = ( + add_additional_properties_false(json_schema) if strict else json_schema + ) result = { "type": "json_schema", @@ -924,7 +929,7 @@ class OpenAILLM(BaseLLM): "description", "Structured response" ), "schema": processed_schema, - "strict": True, + "strict": strict, }, } From b1f220ec052277bfd4fb2e3df9da87f27df306d4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 12:07:30 +0100 Subject: [PATCH 2/7] fix(v1): make docsgpt extension SSE frames valid chat.completion.chunks Strict OpenAI clients (e.g. opencode / the Vercel AI SDK) validate every streaming `data:` frame as a chat.completion.chunk. The id / source / tool_call / tool_calls_pending events were emitted as bare `{"docsgpt": ...}` objects with no `choices`, which those clients reject ("expected array, received undefined" on `choices`). Wrap the extension in an otherwise-empty chunk (choices:[{index:0,delta:{},finish_reason:null}]) with a top-level `docsgpt` field that OpenAI clients ignore, and skip the placeholder "None" conversation_id frame emitted when the call is not persisted. --- application/api/v1/translator.py | 60 ++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index d7c91898..97e90ed6 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -402,9 +402,24 @@ def _make_chunk( return f"data: {json.dumps(chunk)}\n\n" -def _make_docsgpt_chunk(data: Dict[str, Any]) -> str: - """Build a DocsGPT extension SSE chunk.""" - return f"data: {json.dumps({'docsgpt': data})}\n\n" +def _make_docsgpt_chunk(data: Dict[str, Any], completion_id: str, model_name: str) -> str: + """Build a DocsGPT extension chunk that is ALSO a valid ``chat.completion.chunk``. + + Strict OpenAI clients (e.g. the Vercel AI SDK used by opencode) validate every + SSE ``data:`` frame as a chat.completion.chunk, so the DocsGPT extension is + attached to an otherwise-empty (no-op) chunk rather than sent as a bare + ``{"docsgpt": ...}`` object — which has no ``choices`` and fails validation. + OpenAI clients ignore the extra top-level ``docsgpt`` field. + """ + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name, + "choices": [{"index": 0, "delta": {}, "finish_reason": None}], + "docsgpt": data, + } + return f"data: {json.dumps(chunk)}\n\n" def translate_stream_event( @@ -451,10 +466,10 @@ def translate_stream_event( elif event_type == "source": chunks.append( - _make_docsgpt_chunk({ - "type": "source", - "sources": event_data.get("source", []), - }) + _make_docsgpt_chunk( + {"type": "source", "sources": event_data.get("source", [])}, + completion_id, model_name, + ) ) elif event_type == "tool_call": @@ -480,10 +495,10 @@ def translate_stream_event( ) elif status == "awaiting_approval": # Extension: approval needed - chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data})) + chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data}, completion_id, model_name)) elif status in ("completed", "pending", "error", "denied", "skipped"): # Extension: tool call progress - chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data})) + chunks.append(_make_docsgpt_chunk({"type": "tool_call", "data": tc_data}, completion_id, model_name)) elif event_type == "tool_calls_pending": # Standard: finish_reason = tool_calls @@ -492,10 +507,13 @@ def translate_stream_event( ) # Also emit as docsgpt extension chunks.append( - _make_docsgpt_chunk({ - "type": "tool_calls_pending", - "pending_tool_calls": event_data.get("data", {}).get("pending_tool_calls", []), - }) + _make_docsgpt_chunk( + { + "type": "tool_calls_pending", + "pending_tool_calls": event_data.get("data", {}).get("pending_tool_calls", []), + }, + completion_id, model_name, + ) ) elif event_type == "end": @@ -505,12 +523,16 @@ def translate_stream_event( chunks.append("data: [DONE]\n\n") elif event_type == "id": - chunks.append( - _make_docsgpt_chunk({ - "type": "id", - "conversation_id": event_data.get("id", ""), - }) - ) + # Skip the "None" placeholder conversation_id emitted when the call is + # not persisted (save_conversation=false) — nothing useful to surface. + conv_id = event_data.get("id", "") + if conv_id and conv_id != "None": + chunks.append( + _make_docsgpt_chunk( + {"type": "id", "conversation_id": conv_id}, + completion_id, model_name, + ) + ) elif event_type == "error": # Emit as standard error (non-standard but widely supported) From 534ffc4e5b6a945f112c55be22927c7bbef8bfa9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 12:17:27 +0100 Subject: [PATCH 3/7] feat(v1): stateless tool continuation + sampling-param passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stateless tool continuation. OpenAI-compatible clients (opencode, etc.) resend the full messages array — system, user, assistant(tool_calls), tool(results) — but no conversation_id, so the prior "conversation_id required for tool continuation" 400 broke every tool call. When no conversation_id is present, rebuild the agent + pending tool calls + tool results directly from the resent messages (StreamProcessor.build_continuation_from_messages) instead of loading server-side pending_tool_state, and call gen_continuation. - Forward OpenAI sampling params (temperature, max_tokens, max_completion_tokens, top_p, frequency_penalty, presence_penalty, stop, seed) from the request to the LLM gen call; the agent otherwise uses its configured defaults. --- application/agents/base.py | 8 +++ .../api/answer/services/stream_processor.py | 58 +++++++++++++++++++ application/api/v1/routes.py | 39 ++++++++----- application/api/v1/translator.py | 16 +++++ 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/application/agents/base.py b/application/agents/base.py index b9d41109..f84bd38d 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -35,6 +35,7 @@ class BaseAgent(ABC): json_schema: Optional[Dict] = None, json_schema_strict: bool = True, json_object: bool = False, + llm_params: Optional[Dict] = None, limited_token_mode: Optional[bool] = False, token_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["token_limit"], limited_request_mode: Optional[bool] = False, @@ -115,6 +116,9 @@ class BaseAgent(ABC): # ``json_object`` mirrors response_format {"type":"json_object"}. self.json_schema_strict = json_schema_strict self.json_object = json_object + # OpenAI sampling params forwarded from the request (temperature, + # max_tokens, top_p, ...). Empty when the caller sent none. + self.llm_params = llm_params or {} self.limited_token_mode = limited_token_mode self.token_limit = token_limit self.limited_request_mode = limited_request_mode @@ -602,6 +606,10 @@ class BaseAgent(ABC): previous_response_id = self._previous_response_id() if previous_response_id: gen_kwargs["previous_response_id"] = previous_response_id + + # Forward OpenAI sampling params (temperature, max_tokens, top_p, ...). + if self.llm_params: + gen_kwargs.update(self.llm_params) resp = self.llm.gen_stream(**gen_kwargs) if log_context: diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index dfaf5b9d..ab1d94e6 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -172,6 +172,63 @@ class StreamProcessor: tools_data=tools_data, ) + def build_continuation_from_messages(self, messages, tool_actions): + """Rebuild a tool continuation from the request messages (STATELESS). + + OpenAI-compatible clients (opencode, etc.) resend the full conversation + -- system, user, assistant(tool_calls), tool(results) -- but carry no + conversation_id, so there is no server-side ``pending_tool_state`` to + load. Reconstruct the agent + continuation context directly from the + resent messages and return the same tuple as ``resume_from_tool_actions``: + (agent, messages, tools_dict, pending_tool_calls, tool_actions, + reasoning_content). + """ + # Locate the last assistant message that issued tool calls. + pending_idx = None + for i in range(len(messages) - 1, -1, -1): + m = messages[i] + if m.get("role") == "assistant" and m.get("tool_calls"): + pending_idx = i + break + if pending_idx is None: + raise ValueError( + "No assistant message with tool_calls found for continuation" + ) + + pending_tool_calls = [] + for tc in messages[pending_idx].get("tool_calls") or []: + fn = tc.get("function") or {} + raw_args = fn.get("arguments") + try: + args = ( + json.loads(raw_args) + if isinstance(raw_args, str) + else (raw_args or {}) + ) + except (json.JSONDecodeError, TypeError): + args = {} + name = fn.get("name", "") + pending_tool_calls.append( + { + "call_id": tc.get("id", ""), + "name": name, + "tool_name": name, + "action_name": name, + "llm_name": name, + "arguments": args, + } + ) + + # The conversation up to (but not including) the assistant tool_calls; + # gen_continuation re-appends the assistant message + tool results. + prior_messages = [dict(m) for m in messages[:pending_idx]] + + # Build a normal agent (config / LLM / client tools), no new question. + agent = self.build_agent("") + tools_dict = agent.tool_executor.get_tools() + + return agent, prior_messages, tools_dict, pending_tool_calls, tool_actions, "" + def _load_conversation_history(self): """Load conversation history either from DB or request""" if self.conversation_id and self.initial_user_id: @@ -1268,6 +1325,7 @@ class StreamProcessor: "json_schema": self.agent_config.get("json_schema"), "json_schema_strict": self.agent_config.get("json_schema_strict", True), "json_object": self.agent_config.get("json_object", False), + "llm_params": self.data.get("llm_params") or {}, "compressed_summary": self.compressed_summary, "llm": llm, "llm_handler": llm_handler, diff --git a/application/api/v1/routes.py b/application/api/v1/routes.py index 268aa12b..336bc9bc 100644 --- a/application/api/v1/routes.py +++ b/application/api/v1/routes.py @@ -106,21 +106,32 @@ def chat_completions(): if internal_data.get("tool_actions"): # Continuation mode conversation_id = internal_data.get("conversation_id") - if not conversation_id: - return make_response( - jsonify({"error": {"message": "conversation_id required for tool continuation", "type": "invalid_request"}}), - 400, + if conversation_id: + ( + agent, + messages, + tools_dict, + pending_tool_calls, + tool_actions, + reasoning_content, + ) = processor.resume_from_tool_actions( + internal_data["tool_actions"], conversation_id + ) + else: + # Stateless continuation: OpenAI-compatible clients (opencode, + # etc.) resend the full messages array but no conversation_id, + # so rebuild the agent + pending calls from the request itself. + ( + agent, + messages, + tools_dict, + pending_tool_calls, + tool_actions, + reasoning_content, + ) = processor.build_continuation_from_messages( + internal_data.get("messages", []), + internal_data["tool_actions"], ) - ( - agent, - messages, - tools_dict, - pending_tool_calls, - tool_actions, - reasoning_content, - ) = processor.resume_from_tool_actions( - internal_data["tool_actions"], conversation_id - ) continuation = { "messages": messages, "tools_dict": tools_dict, diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index 97e90ed6..ccb4b23a 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -212,6 +212,16 @@ def translate_request( json_schema_strict = bool((_rf.get("json_schema") or {}).get("strict", True)) json_object_mode = _rf.get("type") == "json_object" + # OpenAI sampling params, forwarded to the LLM gen call (the agent otherwise + # uses its configured defaults). + sampling_params = {} + for _k in ( + "temperature", "max_tokens", "max_completion_tokens", + "top_p", "frequency_penalty", "presence_penalty", "stop", "seed", + ): + if data.get(_k) is not None: + sampling_params[_k] = data[_k] + # Check for continuation (tool results after assistant tool_calls) if is_continuation(messages): tool_actions = extract_tool_results(messages) @@ -222,6 +232,10 @@ def translate_request( "conversation_id": conversation_id, "tool_actions": tool_actions, "api_key": api_key, + # Full messages array for STATELESS continuation: OpenAI clients + # (opencode, etc.) don't carry a conversation_id, so the agent is + # rebuilt from the resent messages instead of server-side state. + "messages": messages, } # A continuation only exists if turn 1 was saved, so default to True — # otherwise the final turn and its WAL row are never persisted. An @@ -276,6 +290,8 @@ def translate_request( result["json_schema_strict"] = json_schema_strict if json_object_mode: result["json_object"] = True + if sampling_params: + result["llm_params"] = sampling_params return result From 9eb7878ad376552ad5a9349f1a4e791cb44c1b5e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 12:23:08 +0100 Subject: [PATCH 4/7] feat(v1): support multimodal content arrays (text + image_url) OpenAI-compatible clients send multimodal user turns as a `content` array of typed parts. translate_request previously assigned the array straight to the question, breaking the string-only retrieval / token-budgeting / history paths (HTTP 500). Now: - content_to_text() extracts text from content arrays for the question, history and system prompt, so the string paths work unchanged. - The full content array (text + image_url parts) is preserved as `multimodal_content`, threaded to the agent and emitted as the final user message so images reach the model. Token budgeting uses the text only. The content array (incl. image_url) now reaches the LLM call intact; images render for vision-capable models. A text-only upstream model will reject the image_url variant, as expected. --- application/agents/base.py | 14 ++++++- .../api/answer/services/stream_processor.py | 1 + application/api/v1/translator.py | 40 ++++++++++++++++--- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/application/agents/base.py b/application/agents/base.py index f84bd38d..8001f53a 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -36,6 +36,7 @@ class BaseAgent(ABC): json_schema_strict: bool = True, json_object: bool = False, llm_params: Optional[Dict] = None, + multimodal_content: Optional[List] = None, limited_token_mode: Optional[bool] = False, token_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["token_limit"], limited_request_mode: Optional[bool] = False, @@ -119,6 +120,9 @@ class BaseAgent(ABC): # OpenAI sampling params forwarded from the request (temperature, # max_tokens, top_p, ...). Empty when the caller sent none. self.llm_params = llm_params or {} + # Full OpenAI content array (text + image_url parts) for the current + # user turn, when the request was multimodal; None otherwise. + self.multimodal_content = multimodal_content self.limited_token_mode = limited_token_mode self.token_limit = token_limit self.limited_request_mode = limited_request_mode @@ -512,7 +516,15 @@ class BaseAgent(ABC): "tool_call_id": call_id, "content": result_str, }) - messages.append({"role": "user", "content": query}) + # When the request was multimodal, send the full content array (text + + # image_url parts) so images reach the model; the text-only `query` above + # is used only for token budgeting / retrieval. + user_content = ( + self.multimodal_content + if getattr(self, "multimodal_content", None) + else query + ) + messages.append({"role": "user", "content": user_content}) return messages def _truncate_history_to_fit( diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index ab1d94e6..e7ebec6d 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -1326,6 +1326,7 @@ class StreamProcessor: "json_schema_strict": self.agent_config.get("json_schema_strict", True), "json_object": self.agent_config.get("json_object", False), "llm_params": self.data.get("llm_params") or {}, + "multimodal_content": self.data.get("multimodal_content"), "compressed_summary": self.compressed_summary, "llm": llm, "llm_handler": llm_handler, diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index ccb4b23a..f55f7c3c 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -120,6 +120,28 @@ def extract_conversation_id(messages: List[Dict]) -> Optional[str]: return None +def content_to_text(content: Any) -> str: + """Flatten an OpenAI message ``content`` to plain text. + + ``content`` may be a string or a list of typed parts + (``{"type":"text",...}`` / ``{"type":"image_url",...}`` / ...). Only text + parts contribute; image/other parts are dropped here. The full content + array is preserved separately (see ``multimodal_content``) so images still + reach the model in the final user message. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + out = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + out.append(part.get("text", "") or "") + elif isinstance(part, str): + out.append(part) + return "\n".join(out) + return "" if content is None else str(content) + + def extract_system_prompt(messages: List[Dict]) -> Optional[str]: """Extract the first system message content from the messages array. @@ -127,7 +149,7 @@ def extract_system_prompt(messages: List[Dict]) -> Optional[str]: """ for msg in messages: if msg.get("role") == "system": - return msg.get("content", "") + return content_to_text(msg.get("content", "")) return None @@ -147,9 +169,9 @@ def convert_history(messages: List[Dict]) -> List[Dict]: if msg.get("role") == "user": # Look ahead for assistant response if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant": - content = messages[i + 1].get("content") or "" + content = content_to_text(messages[i + 1].get("content") or "") history.append({ - "prompt": msg.get("content", ""), + "prompt": content_to_text(msg.get("content", "")), "response": content, }) i += 2 @@ -253,12 +275,16 @@ def translate_request( result["json_object"] = True return result - # Normal request — extract question from last user message - question = "" + # Normal request — extract the question (text) from the last user message, + # and keep its full content array (text + image_url parts) when multimodal so + # images still reach the model in the final user message. + last_user_content = None for msg in reversed(messages): if msg.get("role") == "user": - question = msg.get("content", "") + last_user_content = msg.get("content") break + question = content_to_text(last_user_content) + multimodal_content = last_user_content if isinstance(last_user_content, list) else None history = convert_history(messages) system_prompt_override = extract_system_prompt(messages) @@ -292,6 +318,8 @@ def translate_request( result["json_object"] = True if sampling_params: result["llm_params"] = sampling_params + if multimodal_content is not None: + result["multimodal_content"] = multimodal_content return result From 37417f99c8ddb077dd0f8309f3bd5ce406f04c5b Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 12:55:47 +0100 Subject: [PATCH 5/7] fix(v1): address review feedback (multimodal provider gate, save default, leak scope, continuation sampling) - Drop multimodal content for non-OpenAI-family providers (Google/Anthropic raised on OpenAI image_url parts) so multimodal requests degrade to text instead of returning 500. - Stateless continuations (no conversation_id) default save_conversation to False, avoiding an orphan conversation with an empty question on every tool round (stateful continuations still default True). - Only strip leaked reasoning from content for structured requests (response_format / json_schema / json_object); legitimate answers that mention the marker text are no longer corrupted. - Forward sampling params (temperature, max_tokens, ...) on continuation turns. - An explicit json_object request clears an agent-configured json_schema so it isn't silently overridden. - Drop max_tokens when max_completion_tokens is also sent (OpenAI rejects both). - Don't build a chatcmpl-None completion id from the placeholder "None" id event. --- .../api/answer/services/stream_processor.py | 17 +++++++++- application/api/v1/routes.py | 17 ++++++++-- application/api/v1/translator.py | 33 +++++++++++++++---- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index e7ebec6d..e537d30e 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -715,6 +715,9 @@ class StreamProcessor: self.agent_config["json_schema_strict"] = self.data.get("json_schema_strict") if self.data.get("json_object"): self.agent_config["json_object"] = True + # An explicit json_object request beats an agent-configured schema + # (otherwise the configured json_schema would silently override it). + self.agent_config["json_schema"] = None def _configure_retriever(self): """Assemble retriever config; agent's values are authoritative when bound.""" @@ -1309,6 +1312,18 @@ class StreamProcessor: if client_tools: tool_executor.client_tools = client_tools + # OpenAI-style image_url content parts are only understood by the + # OpenAI-family providers; drop multimodal content for others (Google, + # Anthropic, ...) so a multimodal request degrades to text rather than + # erroring upstream. + from application.llm.openai import OpenAILLM + + request_multimodal = ( + self.data.get("multimodal_content") + if isinstance(llm, OpenAILLM) + else None + ) + agent_kwargs = { "endpoint": "stream", "llm_name": provider or settings.LLM_PROVIDER, @@ -1326,7 +1341,7 @@ class StreamProcessor: "json_schema_strict": self.agent_config.get("json_schema_strict", True), "json_object": self.agent_config.get("json_object", False), "llm_params": self.data.get("llm_params") or {}, - "multimodal_content": self.data.get("multimodal_content"), + "multimodal_content": request_multimodal, "compressed_summary": self.compressed_summary, "llm": llm, "llm_handler": llm_handler, diff --git a/application/api/v1/routes.py b/application/api/v1/routes.py index 336bc9bc..1b4bfb72 100644 --- a/application/api/v1/routes.py +++ b/application/api/v1/routes.py @@ -158,6 +158,12 @@ def chat_completions(): return usage_error should_save_conversation = bool(internal_data.get("save_conversation", False)) + # Only strip leaked reasoning from content for structured requests -- the + # only path where models echo reasoning into content -- so legitimate + # answers that mention the marker text are never corrupted. + strip_reasoning_leak = bool( + internal_data.get("json_schema") or internal_data.get("json_object") + ) if is_stream: return Response( @@ -169,6 +175,7 @@ def chat_completions(): model_name, continuation, should_save_conversation, + strip_reasoning_leak, ), mimetype="text/event-stream", headers={ @@ -185,6 +192,7 @@ def chat_completions(): model_name, continuation, should_save_conversation, + strip_reasoning_leak, ) except ValueError as e: @@ -215,6 +223,7 @@ def _stream_response( model_name: str, continuation: Optional[Dict], should_save_conversation: bool, + strip_reasoning_leak: bool = False, ) -> Generator[str, None, None]: """Generate translated SSE chunks for streaming response.""" completion_id = f"chatcmpl-{int(time.time())}" @@ -258,11 +267,13 @@ def _stream_response( # Update completion_id when we get the conversation id if event_data.get("type") == "id": conv_id = event_data.get("id", "") - if conv_id: + if conv_id and conv_id != "None": completion_id = f"chatcmpl-{conv_id}" # Translate to standard format - translated = translate_stream_event(event_data, completion_id, model_name) + translated = translate_stream_event( + event_data, completion_id, model_name, strip_reasoning_leak + ) for chunk in translated: yield chunk @@ -275,6 +286,7 @@ def _non_stream_response( model_name: str, continuation: Optional[Dict], should_save_conversation: bool, + strip_reasoning_leak: bool = False, ) -> Response: """Collect full response and return as single JSON.""" stream = helper.complete_stream( @@ -309,6 +321,7 @@ def _non_stream_response( thought=result["thought"] or "", model_name=model_name, pending_tool_calls=pending, + strip_reasoning_leak=strip_reasoning_leak, ) return make_response(jsonify(response), 200) diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index f55f7c3c..94b4f9e7 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -243,6 +243,10 @@ def translate_request( ): if data.get(_k) is not None: sampling_params[_k] = data[_k] + # OpenAI rejects sending both; the provider maps max_tokens -> + # max_completion_tokens, so drop the alias when the canonical key is present. + if "max_completion_tokens" in sampling_params: + sampling_params.pop("max_tokens", None) # Check for continuation (tool results after assistant tool_calls) if is_continuation(messages): @@ -259,11 +263,13 @@ def translate_request( # rebuilt from the resent messages instead of server-side state. "messages": messages, } - # A continuation only exists if turn 1 was saved, so default to True — - # otherwise the final turn and its WAL row are never persisted. An - # explicit override is honoured if the client resends it. + # Stateful continuations (with a conversation_id) default to True so the + # final turn / WAL row is persisted. Stateless continuations (no + # conversation_id, e.g. opencode) default to False — otherwise every + # tool round persists an orphan conversation with an empty question. An + # explicit ``docsgpt.save_conversation`` override is honoured either way. result["save_conversation"] = bool( - data.get("docsgpt", {}).get("save_conversation", True) + data.get("docsgpt", {}).get("save_conversation", bool(conversation_id)) ) # Carry tools forward for next iteration if data.get("tools"): @@ -273,6 +279,8 @@ def translate_request( result["json_schema_strict"] = json_schema_strict if json_object_mode: result["json_object"] = True + if sampling_params: + result["llm_params"] = sampling_params return result # Normal request — extract the question (text) from the last user message, @@ -337,6 +345,7 @@ def translate_response( thought: str, model_name: str, pending_tool_calls: Optional[List[Dict]] = None, + strip_reasoning_leak: bool = False, ) -> Dict[str, Any]: """Translate DocsGPT response to chat completions format. @@ -378,7 +387,10 @@ def translate_response( ] finish_reason = "tool_calls" else: - clean_answer, leaked_reasoning = _split_leaked_reasoning(answer) + if strip_reasoning_leak: + clean_answer, leaked_reasoning = _split_leaked_reasoning(answer) + else: + clean_answer, leaked_reasoning = answer, "" message["content"] = clean_answer combined_reasoning = (thought or "") + leaked_reasoning if combined_reasoning: @@ -470,6 +482,7 @@ def translate_stream_event( event_data: Dict[str, Any], completion_id: str, model_name: str, + strip_reasoning_leak: bool = False, ) -> List[str]: """Translate a DocsGPT SSE event dict to standard streaming chunks. @@ -490,7 +503,10 @@ def translate_stream_event( chunks: List[str] = [] if event_type == "answer": - clean, leaked = _split_leaked_reasoning(event_data.get("answer", "")) + raw = event_data.get("answer", "") + clean, leaked = ( + _split_leaked_reasoning(raw) if strip_reasoning_leak else (raw, "") + ) if leaked: chunks.append( _make_chunk(completion_id, model_name, {"reasoning_content": leaked}) @@ -589,7 +605,10 @@ def translate_stream_event( chunks.append(f"data: {json.dumps(error_data)}\n\n") elif event_type == "structured_answer": - clean, leaked = _split_leaked_reasoning(event_data.get("answer", "")) + raw = event_data.get("answer", "") + clean, leaked = ( + _split_leaked_reasoning(raw) if strip_reasoning_leak else (raw, "") + ) if leaked: chunks.append( _make_chunk(completion_id, model_name, {"reasoning_content": leaked}) From b212c5ceddea51efdf413d416f02502a386f407f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 13:03:04 +0100 Subject: [PATCH 6/7] test(v1): align tests with valid-chunk envelope and leak-strip signature - test_standard_clients_can_ignore_docsgpt: docsgpt-only events now ride on a valid empty chat.completion.chunk (choices[0].delta == {}) so strict OpenAI clients accept the frame; assert the envelope plus the docsgpt extension instead of the absence of choices. - split continuation persistence into stateful (conversation_id present -> save) and stateless (no conversation_id -> no save) cases. - _fake_translate_stream_event mock accepts strip_reasoning_leak kwarg. --- tests/api/v1/test_routes_extended.py | 4 ++- tests/test_v1_translator.py | 41 +++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/tests/api/v1/test_routes_extended.py b/tests/api/v1/test_routes_extended.py index c7a46eae..17095840 100644 --- a/tests/api/v1/test_routes_extended.py +++ b/tests/api/v1/test_routes_extended.py @@ -541,7 +541,9 @@ class TestChatCompletionsHappyPath: translated_chunks: list = [] - def _fake_translate_stream_event(event_data, completion_id, model_name): + def _fake_translate_stream_event( + event_data, completion_id, model_name, strip_reasoning_leak=False + ): translated_chunks.append(event_data) return ['data: x\n\n'] diff --git a/tests/test_v1_translator.py b/tests/test_v1_translator.py index e8de8d8f..7ec2717a 100644 --- a/tests/test_v1_translator.py +++ b/tests/test_v1_translator.py @@ -245,10 +245,12 @@ class TestTranslateRequest: assert len(result["tool_actions"]) == 1 assert result["tool_actions"][0]["call_id"] == "c1" - def test_continuation_persists_by_default(self): - """A continuation implies the first turn was saved, so the resumed turn - must persist too (otherwise the final answer + WAL row are lost).""" + def test_stateful_continuation_persists_by_default(self): + """A stateful continuation (conversation_id present) implies the first + turn was saved, so the resumed turn must persist too (otherwise the + final answer + WAL row are lost).""" data = { + "conversation_id": "conv-1", "messages": [ {"role": "user", "content": "Search for X"}, { @@ -261,6 +263,23 @@ class TestTranslateRequest: result = translate_request(data, "key") assert result["save_conversation"] is True + def test_stateless_continuation_does_not_persist_by_default(self): + """A stateless continuation (no conversation_id, e.g. an OpenAI client + such as opencode) never persisted turn 1, so it must NOT default to + saving -- otherwise every tool round leaks an orphan conversation.""" + data = { + "messages": [ + {"role": "user", "content": "Search for X"}, + { + "role": "assistant", + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "done"}, + ], + } + result = translate_request(data, "key") + assert result["save_conversation"] is False + def test_continuation_honours_explicit_save_conversation_override(self): """An explicit docsgpt.save_conversation=false on the continuation wins.""" data = { @@ -650,15 +669,23 @@ class TestTranslateStreamEvent: assert parsed["docsgpt"]["type"] == "tool_call" def test_standard_clients_can_ignore_docsgpt(self): - """Standard clients parse only 'choices' — docsgpt namespace is ignored.""" + """The docsgpt namespace rides on a valid empty chat.completion.chunk. + + Standard OpenAI clients validate every streamed frame as a + ``chat.completion.chunk``; a frame without ``choices`` is rejected. + So docsgpt-only events (sources, ids, tool_calls) are emitted as a + valid chunk with an empty delta plus a ``docsgpt`` extension that + standard parsers simply ignore. + """ chunks = translate_stream_event( {"type": "source", "source": [{"title": "t"}]}, "chatcmpl-1", "agent", ) parsed = json.loads(chunks[0].replace("data: ", "").strip()) - # No "choices" key — standard parsers skip this chunk entirely - assert "choices" not in parsed - # docsgpt key is present + # Valid chunk envelope: standard parsers read choices[0].delta (empty) + assert isinstance(parsed.get("choices"), list) + assert parsed["choices"][0]["delta"] == {} + # docsgpt extension is present for clients that want it assert "docsgpt" in parsed From 45cae8f6b81df782fe876ce7dece55ca451d081c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Jun 2026 13:39:48 +0100 Subject: [PATCH 7/7] test(v1): cover structured-output, reasoning-leak, multimodal and stateless continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise patch coverage on the new v1 code paths: - translator: content_to_text (text/image/string parts), extract_response_schema (response_format json_schema/json_object + response_schema shapes), _split_leaked_reasoning (single/multi/double-quoted/brace-in-value), translate_request structured-output + sampling + multimodal surfacing (normal and continuation branches), and translate_response / translate_stream_event reasoning-leak gating (answer + structured_answer). - stream_processor: build_continuation_from_messages — pending-call rebuild, invalid/dict argument handling, last-tool-call selection, prior-message slice, and the no-tool-calls error. translator patch coverage 54% -> ~100% of changed lines; stream_processor continuation path fully covered. --- .../answer/services/test_stream_processor.py | 137 +++++++ tests/test_v1_translator.py | 385 ++++++++++++++++++ 2 files changed, 522 insertions(+) diff --git a/tests/api/answer/services/test_stream_processor.py b/tests/api/answer/services/test_stream_processor.py index 899ed65a..4863e380 100644 --- a/tests/api/answer/services/test_stream_processor.py +++ b/tests/api/answer/services/test_stream_processor.py @@ -177,5 +177,142 @@ class TestToolPreFetch: """Tests for tool pre-fetching with saved parameter values.""" +@pytest.mark.unit +class TestBuildContinuationFromMessages: + """Stateless tool continuation rebuilt from the resent messages array. + + OpenAI-compatible clients (opencode, etc.) resend the full conversation but + carry no conversation_id, so the agent + pending tool calls are reconstructed + directly from the request instead of from server-side ``pending_tool_state``. + """ + + @staticmethod + def _make_processor(): + from unittest.mock import MagicMock + + from application.api.answer.services.stream_processor import StreamProcessor + + processor = StreamProcessor({"question": ""}, {"sub": "user_123"}) + fake_agent = MagicMock() + fake_agent.tool_executor.get_tools.return_value = {"search": object()} + # build_agent touches the DB / config; stub it for this unit test. + processor.build_agent = MagicMock(return_value=fake_agent) + return processor, fake_agent + + def _messages_with_tool_call(self, arguments): + return [ + {"role": "system", "content": "You are a bot"}, + {"role": "user", "content": "search the docs"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": arguments}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "found it"}, + ] + + def test_rebuilds_pending_tool_calls_and_prior_messages(self): + processor, fake_agent = self._make_processor() + messages = self._messages_with_tool_call('{"q": "docs"}') + tool_actions = [{"call_id": "call_1", "result": "found it"}] + + ( + agent, + prior_messages, + tools_dict, + pending_tool_calls, + returned_actions, + reasoning, + ) = processor.build_continuation_from_messages(messages, tool_actions) + + assert agent is fake_agent + assert tools_dict == fake_agent.tool_executor.get_tools.return_value + assert returned_actions is tool_actions + assert reasoning == "" + # prior_messages stop before the assistant-with-tool_calls message. + assert prior_messages == messages[:2] + assert len(pending_tool_calls) == 1 + call = pending_tool_calls[0] + assert call["call_id"] == "call_1" + assert call["name"] == "search" + assert call["tool_name"] == "search" + assert call["action_name"] == "search" + assert call["arguments"] == {"q": "docs"} + processor.build_agent.assert_called_once_with("") + + def test_invalid_json_arguments_default_to_empty_dict(self): + processor, _ = self._make_processor() + messages = self._messages_with_tool_call("not-json") + + _, _, _, pending_tool_calls, _, _ = ( + processor.build_continuation_from_messages(messages, []) + ) + + assert pending_tool_calls[0]["arguments"] == {} + + def test_dict_arguments_passed_through(self): + processor, _ = self._make_processor() + messages = self._messages_with_tool_call({"already": "parsed"}) + + _, _, _, pending_tool_calls, _, _ = ( + processor.build_continuation_from_messages(messages, []) + ) + + assert pending_tool_calls[0]["arguments"] == {"already": "parsed"} + + def test_uses_last_assistant_tool_call_message(self): + processor, _ = self._make_processor() + messages = [ + {"role": "user", "content": "first"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "old", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "old", "content": "r1"}, + {"role": "user", "content": "second"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "new", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "new", "content": "r2"}, + ] + + _, prior_messages, _, pending_tool_calls, _, _ = ( + processor.build_continuation_from_messages(messages, []) + ) + + assert pending_tool_calls[0]["call_id"] == "new" + # Everything up to (not including) the last assistant tool_calls message. + assert prior_messages == messages[:4] + + def test_raises_when_no_assistant_tool_calls(self): + processor, _ = self._make_processor() + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "no tools here"}, + ] + + with pytest.raises(ValueError, match="No assistant message with tool_calls"): + processor.build_continuation_from_messages(messages, []) diff --git a/tests/test_v1_translator.py b/tests/test_v1_translator.py index 7ec2717a..1249ff27 100644 --- a/tests/test_v1_translator.py +++ b/tests/test_v1_translator.py @@ -10,7 +10,11 @@ import pytest from application.api.v1.translator import ( _get_client_tool_name, + _split_leaked_reasoning, + _strip_repr_quotes, + content_to_text, convert_history, + extract_response_schema, extract_system_prompt, extract_tool_results, is_continuation, @@ -708,3 +712,384 @@ class TestGetClientToolName: def test_returns_empty_when_no_fields(self): assert _get_client_tool_name({}) == "" + + +# --------------------------------------------------------------------------- +# content_to_text +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestContentToText: + + def test_plain_string_passthrough(self): + assert content_to_text("hello") == "hello" + + def test_none_returns_empty(self): + assert content_to_text(None) == "" + + def test_text_parts_joined(self): + content = [ + {"type": "text", "text": "line one"}, + {"type": "text", "text": "line two"}, + ] + assert content_to_text(content) == "line one\nline two" + + def test_image_parts_dropped(self): + content = [ + {"type": "text", "text": "describe this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}}, + ] + # Only the text part contributes; the image is dropped from the flattened text. + assert content_to_text(content) == "describe this" + + def test_bare_string_parts_included(self): + assert content_to_text(["a", "b"]) == "a\nb" + + def test_missing_text_field_becomes_empty(self): + assert content_to_text([{"type": "text"}]) == "" + + def test_non_string_non_list_coerced(self): + assert content_to_text(123) == "123" + + +# --------------------------------------------------------------------------- +# extract_response_schema +# --------------------------------------------------------------------------- + + +_SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], +} + + +@pytest.mark.unit +class TestExtractResponseSchema: + + def test_none_when_absent(self): + assert extract_response_schema({}) is None + + def test_response_format_json_schema_wrapper(self): + data = { + "response_format": { + "type": "json_schema", + "json_schema": {"name": "ans", "schema": _SCHEMA}, + } + } + assert extract_response_schema(data) == _SCHEMA + + def test_response_format_bare_schema_under_json_schema(self): + # A bare schema (no "schema" wrapper) but with a top-level "type" is tolerated. + data = { + "response_format": { + "type": "json_schema", + "json_schema": _SCHEMA, + } + } + assert extract_response_schema(data) == _SCHEMA + + def test_response_format_json_object_carries_no_schema(self): + data = {"response_format": {"type": "json_object"}} + assert extract_response_schema(data) is None + + def test_response_schema_raw_object(self): + assert extract_response_schema({"response_schema": _SCHEMA}) == _SCHEMA + + def test_response_schema_wrapper(self): + data = {"response_schema": {"schema": _SCHEMA}} + assert extract_response_schema(data) == _SCHEMA + + def test_response_schema_takes_precedence_over_response_format(self): + other = {"type": "object", "properties": {}} + data = { + "response_schema": _SCHEMA, + "response_format": { + "type": "json_schema", + "json_schema": {"schema": other}, + }, + } + assert extract_response_schema(data) == _SCHEMA + + +# --------------------------------------------------------------------------- +# _split_leaked_reasoning +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSplitLeakedReasoning: + + def test_no_marker_is_noop(self): + assert _split_leaked_reasoning("just an answer") == ("just an answer", "") + + def test_none_is_noop(self): + assert _split_leaked_reasoning(None) == (None, "") + + def test_extracts_single_thought(self): + content = "{'type': 'thought', 'thought': 'let me think'}The answer is 42." + clean, leaked = _split_leaked_reasoning(content) + assert clean == "The answer is 42." + assert leaked == "let me think" + + def test_extracts_multiple_thoughts(self): + content = ( + "{'type': 'thought', 'thought': 'first'}" + "partial" + "{'type': 'thought', 'thought': 'second'}done" + ) + clean, leaked = _split_leaked_reasoning(content) + assert clean == "partialdone" + assert leaked == "firstsecond" + + def test_double_quoted_thought_value(self): + # When the token contains an apostrophe the repr uses double quotes. + content = "{'type': 'thought', 'thought': \"I'll check\"}answer" + clean, leaked = _split_leaked_reasoning(content) + assert clean == "answer" + assert leaked == "I'll check" + + def test_thought_value_with_brace_not_truncated(self): + content = "{'type': 'thought', 'thought': 'use {json} here'}final" + clean, leaked = _split_leaked_reasoning(content) + assert clean == "final" + assert leaked == "use {json} here" + + def test_strip_repr_quotes_unquoted_passthrough(self): + # Defensive branch: an unquoted value is returned unchanged. + assert _strip_repr_quotes("plain") == "plain" + assert _strip_repr_quotes("'quoted'") == "quoted" + + +# --------------------------------------------------------------------------- +# translate_request — structured outputs / sampling / multimodal +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestTranslateRequestStructuredOutputs: + + def test_response_format_surfaces_json_schema_strict_default(self): + data = { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": { + "type": "json_schema", + "json_schema": {"name": "ans", "schema": _SCHEMA}, + }, + } + result = translate_request(data, "key") + assert result["json_schema"] == _SCHEMA + assert result["json_schema_strict"] is True + + def test_response_format_honours_explicit_strict_false(self): + data = { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": { + "type": "json_schema", + "json_schema": {"name": "ans", "schema": _SCHEMA, "strict": False}, + }, + } + result = translate_request(data, "key") + assert result["json_schema_strict"] is False + + def test_json_object_mode_flag(self): + data = { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": {"type": "json_object"}, + } + result = translate_request(data, "key") + assert result["json_object"] is True + assert "json_schema" not in result + + def test_sampling_params_forwarded(self): + data = { + "messages": [{"role": "user", "content": "Hi"}], + "temperature": 0.2, + "top_p": 0.9, + "seed": 7, + } + result = translate_request(data, "key") + assert result["llm_params"] == {"temperature": 0.2, "top_p": 0.9, "seed": 7} + + def test_max_tokens_alias_dropped_when_canonical_present(self): + data = { + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "max_completion_tokens": 200, + } + result = translate_request(data, "key") + assert result["llm_params"]["max_completion_tokens"] == 200 + assert "max_tokens" not in result["llm_params"] + + def test_multimodal_content_preserved_and_question_flattened(self): + content = [ + {"type": "text", "text": "what is this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}}, + ] + data = {"messages": [{"role": "user", "content": content}]} + result = translate_request(data, "key") + assert result["question"] == "what is this" + assert result["multimodal_content"] == content + + def test_plain_text_request_has_no_multimodal_content(self): + data = {"messages": [{"role": "user", "content": "plain"}]} + result = translate_request(data, "key") + assert "multimodal_content" not in result + + def test_continuation_forwards_schema_and_sampling(self): + data = { + "messages": [ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ], + "response_format": { + "type": "json_schema", + "json_schema": {"name": "ans", "schema": _SCHEMA}, + }, + "temperature": 0.5, + } + result = translate_request(data, "key") + assert result["tool_actions"] + assert result["json_schema"] == _SCHEMA + assert result["json_schema_strict"] is True + assert result["llm_params"] == {"temperature": 0.5} + + def test_continuation_forwards_tools_and_json_object(self): + data = { + "messages": [ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ], + "tools": [{"type": "function", "function": {"name": "search"}}], + "response_format": {"type": "json_object"}, + } + result = translate_request(data, "key") + assert result["client_tools"] == data["tools"] + assert result["json_object"] is True + + +# --------------------------------------------------------------------------- +# translate_response / translate_stream_event — reasoning-leak handling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestReasoningLeakHandling: + + def test_response_strips_leak_only_when_enabled(self): + answer = "{'type': 'thought', 'thought': 'hmm'}Final answer." + result = translate_response( + conversation_id="", + answer=answer, + sources=None, + tool_calls=None, + thought="", + model_name="agent", + strip_reasoning_leak=True, + ) + msg = result["choices"][0]["message"] + assert msg["content"] == "Final answer." + assert msg["reasoning_content"] == "hmm" + + def test_response_preserves_content_when_strip_disabled(self): + answer = "{'type': 'thought', 'thought': 'hmm'}Final answer." + result = translate_response( + conversation_id="", + answer=answer, + sources=None, + tool_calls=None, + thought="", + model_name="agent", + strip_reasoning_leak=False, + ) + msg = result["choices"][0]["message"] + # Untouched: the leak marker is left in content verbatim. + assert msg["content"] == answer + assert "reasoning_content" not in msg + + def test_response_combines_thought_and_leaked_reasoning(self): + answer = "{'type': 'thought', 'thought': 'leaked'}Answer." + result = translate_response( + conversation_id="", + answer=answer, + sources=None, + tool_calls=None, + thought="explicit ", + model_name="agent", + strip_reasoning_leak=True, + ) + msg = result["choices"][0]["message"] + assert msg["reasoning_content"] == "explicit leaked" + + def test_stream_answer_splits_leak_when_enabled(self): + chunks = translate_stream_event( + {"type": "answer", "answer": "{'type': 'thought', 'thought': 'r'}hi"}, + "chatcmpl-1", "agent", True, + ) + deltas = [ + json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"] + for c in chunks + ] + assert {"reasoning_content": "r"} in deltas + assert {"content": "hi"} in deltas + + def test_stream_answer_preserves_content_when_disabled(self): + raw = "{'type': 'thought', 'thought': 'r'}hi" + chunks = translate_stream_event( + {"type": "answer", "answer": raw}, "chatcmpl-1", "agent", False, + ) + deltas = [ + json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"] + for c in chunks + ] + assert {"content": raw} in deltas + assert all("reasoning_content" not in d for d in deltas) + + def test_stream_structured_answer_event(self): + chunks = translate_stream_event( + {"type": "structured_answer", "answer": '{"answer": "42"}'}, + "chatcmpl-1", "agent", True, + ) + deltas = [ + json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"] + for c in chunks + ] + assert {"content": '{"answer": "42"}'} in deltas + + def test_stream_structured_answer_splits_leak(self): + chunks = translate_stream_event( + { + "type": "structured_answer", + "answer": "{'type': 'thought', 'thought': 'why'}{\"answer\": \"42\"}", + }, + "chatcmpl-1", "agent", True, + ) + deltas = [ + json.loads(c.replace("data: ", "").strip())["choices"][0]["delta"] + for c in chunks + ] + assert {"reasoning_content": "why"} in deltas + assert {"content": '{"answer": "42"}'} in deltas