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})