Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/backend/core/chat/tool_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def build_thinking_event(chunk: dict, chat_id: str) -> Dict[str, Any]:
yields it as SSE or pushes it via ``_emit``.
"""
evt: Dict[str, Any] = {"type": "thinking", "chat_id": chat_id}
if chunk.get("structured_reasoning") is True:
evt["structured_reasoning"] = True
if "delta" in chunk:
evt["delta"] = chunk.get("delta", "")
else:
Expand Down
12 changes: 11 additions & 1 deletion src/backend/core/llm/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def __init__(
context_size: int,
extra_body: dict | None = None,
azure: dict | None = None,
structured_reasoning: bool = False,
) -> None:
super().__init__(
credential=credential,
Expand All @@ -101,6 +102,9 @@ def __init__(
self._http_client = http_client
self._extra_body = extra_body or {}
self._azure = azure # when {"api_version": ...} is non-empty, use AsyncAzureOpenAI
# The SSE layer uses this to distinguish structured reasoning_content from
# legacy models that embed reasoning in content as <think>...</think>.
self.structured_reasoning = structured_reasoning

def _build_client(self):
import openai
Expand Down Expand Up @@ -208,12 +212,17 @@ def _make_openai_compatible(
azure = {"api_version": provider_extra.get("api_version", "")}
actual_model = provider_extra.get("deployment") or model

extra_body = {
extra_body: dict[str, Any] = {
"chat_template_kwargs": _build_chat_template_kwargs(
disable_thinking=disable_thinking,
reasoning_effort=reasoning_effort,
)
}
if spec.reasoning_effort_top_level and reasoning_effort is not None:
# OpenAI Responses-backed Chat Completions gateways expose reasoning
# only when the effort is sent at the request root. Keep the nested
# switch as well so OpenAI-compatible template controls still work.
extra_body["reasoning_effort"] = reasoning_effort
parameters = OpenAIChatModel.Parameters(
temperature=temperature,
max_tokens=max_tokens,
Expand All @@ -230,6 +239,7 @@ def _make_openai_compatible(
context_size=context_size,
extra_body=extra_body,
azure=azure,
structured_reasoning=spec.structured_reasoning,
)


Expand Down
12 changes: 12 additions & 0 deletions src/backend/core/llm/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ class ProviderSpec:
# placeholders, or vendors without a fixed URL stay False.
autofill_base_url: bool = False
api_key_required: bool = True
# OpenAI/Codex-style gateways expect reasoning_effort at the request root.
# Generic compatible vendors keep using chat_template_kwargs only.
reasoning_effort_top_level: bool = False
# Reasoning arrives separately as reasoning_content instead of being embedded in content.
structured_reasoning: bool = False
fields: tuple[ProviderField, ...] = () # vendor-specific extra fields (stored in extra_config)

@property
Expand Down Expand Up @@ -74,6 +79,13 @@ def extra_field_keys(self) -> tuple[str, ...]:
supports_types=("chat", "embedding", "reranker"),
base_url_template="https://api.openai.com/v1",
),
"openai": ProviderSpec(
id="openai", label="OpenAI / Codex", engine="openai",
supports_types=("chat", "embedding"),
base_url_template="https://api.openai.com/v1",
reasoning_effort_top_level=True,
structured_reasoning=True,
),
"deepseek": ProviderSpec(
id="deepseek", label="DeepSeek", engine="openai",
base_url_template="https://api.deepseek.com/v1", autofill_base_url=True,
Expand Down
3 changes: 3 additions & 0 deletions src/backend/orchestration/autonomous_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ async def _run_worker_iteration(
text += payload
if emit and not hold_output:
await emit({"type": "content", "event": "ai_message", "delta": payload})
elif et == "reasoning_protocol":
if emit:
await emit({"type": "thinking", **payload})
elif et == "thinking_delta":
if emit:
await emit({"type": "thinking", "delta": payload})
Expand Down
19 changes: 19 additions & 0 deletions src/backend/orchestration/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ def __init__(
self._raw_text = ""
self._emitted_answer = ""
self._in_thinking = False
self._reasoning_protocol_emitted = False

def _take_reasoning_protocol(self) -> Optional[Dict[str, bool]]:
"""Return the structured-reasoning marker once the active model is known.

DynamicModelMiddleware may replace ``agent.model`` at reply start, so this is
evaluated immediately before mapping the first AgentScope event rather than in
``__init__``.
"""
if self._reasoning_protocol_emitted:
return None
model = getattr(self.agent, "model", None)
if not bool(getattr(model, "structured_reasoning", False)):
return None
self._reasoning_protocol_emitted = True
return {"structured_reasoning": True}

def get_usage(self) -> Dict[str, int]:
total_prompt = sum(r.get("prompt_tokens", 0) for r in self._usage_records)
Expand Down Expand Up @@ -243,6 +259,9 @@ async def _produce():
yield ("subagent_event", payload)
continue
# kind == "ev"
reasoning_protocol = self._take_reasoning_protocol()
if reasoning_protocol is not None:
yield ("reasoning_protocol", reasoning_protocol)
async for out in self._map_event(payload):
if not _first_event_logged:
_ttfe = (time.monotonic() - _stream_start) * 1000
Expand Down
6 changes: 6 additions & 0 deletions src/backend/orchestration/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,9 @@ async def _finish_direct_log(
full_response += payload
yield {"type": "content", "event": "ai_message", "delta": payload}

elif event_type == "reasoning_protocol":
yield {"type": "thinking", **payload}

elif event_type == "thinking_delta":
yield {"type": "thinking", "delta": payload}

Expand Down Expand Up @@ -2100,6 +2103,9 @@ async def astream_chat_workflow(
full_response += payload
yield {"type": "content", "event": "ai_message", "delta": payload}

elif event_type == "reasoning_protocol":
yield {"type": "thinking", **payload}

elif event_type == "thinking_delta":
yield {"type": "thinking", "delta": payload}

Expand Down
50 changes: 50 additions & 0 deletions src/backend/tests/llm/test_openai_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Tests for the dedicated OpenAI/Codex provider preset."""

from core.llm.chat_models import make_chat_model
from core.llm.providers.registry import get_spec, to_frontend_schema


def _make_model(provider: str, reasoning_effort: str | None):
return make_chat_model(
model="test-model",
temperature=0.0,
max_tokens=32,
timeout=10,
base_url="http://model.test/api/v1",
api_key="test-key",
provider=provider,
reasoning_effort=reasoning_effort,
stream=True,
context_size=4096,
)


def test_openai_provider_is_exposed_to_dynamic_forms():
spec = get_spec("openai")

assert spec.label == "OpenAI / Codex"
assert spec.reasoning_effort_top_level is True
assert spec.structured_reasoning is True
assert any(row["id"] == "openai" for row in to_frontend_schema())


def test_openai_provider_sends_top_level_reasoning_effort():
model = _make_model("openai", "high")

assert model._extra_body["reasoning_effort"] == "high"
assert model._extra_body["chat_template_kwargs"] == {
"thinking": True,
"reasoning_effort": "high",
}
assert model.structured_reasoning is True


def test_generic_compatible_provider_keeps_existing_reasoning_transport():
model = _make_model("openai_compatible", "high")

assert "reasoning_effort" not in model._extra_body
assert model._extra_body["chat_template_kwargs"] == {
"thinking": True,
"reasoning_effort": "high",
}
assert model.structured_reasoning is False
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Regression tests for structured reasoning SSE protocol markers."""

from types import SimpleNamespace

from core.chat.tool_log import build_thinking_event
from orchestration.streaming import StreamingAgent


def test_streaming_agent_emits_structured_reasoning_protocol_once():
agent = SimpleNamespace(model=SimpleNamespace(structured_reasoning=True))
streaming_agent = StreamingAgent(agent, mcp_clients=[])

assert streaming_agent._take_reasoning_protocol() == {"structured_reasoning": True}
assert streaming_agent._take_reasoning_protocol() is None


def test_streaming_agent_skips_protocol_for_inline_reasoning_models():
agent = SimpleNamespace(model=SimpleNamespace(structured_reasoning=False))
streaming_agent = StreamingAgent(agent, mcp_clients=[])

assert streaming_agent._take_reasoning_protocol() is None


def test_thinking_event_preserves_structured_reasoning_marker():
event = build_thinking_event(
{"type": "thinking", "structured_reasoning": True},
"chat_test",
)

assert event == {
"type": "thinking",
"chat_id": "chat_test",
"structured_reasoning": True,
"message": "正在思考...",
}
11 changes: 11 additions & 0 deletions src/frontend/src/hooks/chatStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,17 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions)
// event, not embedded in `content` as <think>...</think>.
// Disable the embed-tag parser so subsequent content chunks
// are not treated as buffered thinking.
if (eventObj.structured_reasoning === true) {
structuredReasoning = true;
if (parseBuffer) {
// An explicit protocol marker means content is always the answer body.
// This also repairs replay streams where the marker arrives after a
// buffered content frame.
appendTextSeg(parseBuffer);
parseBuffer = '';
}
thinkingPhaseActive = false;
}
if (obj.delta) {
structuredReasoning = true;
if (thinkingPhaseActive && parseBuffer) {
Expand Down
Loading