Skip to content
Open
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
24 changes: 23 additions & 1 deletion app/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
})
elif role == "assistant":
blocks: list[dict] = []
# 多轮重放:reasoning_content(历史轮思考文本)→ Anthropic thinking 块。
# 上游对 thinking 块 signature 不校验(实测伪造值可通过),空串亦可。
rc = msg.get("reasoning_content") or msg.get("reasoning") or msg.get("reasoning_text")
if isinstance(rc, str) and rc.strip():
blocks.append({"type": "thinking", "thinking": rc, "signature": ""})
text = _text_from_content(content)
if text:
blocks.append({"type": "text", "text": text})
Expand Down Expand Up @@ -148,6 +153,12 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
body["top_p"] = float(payload["top_p"])
except (TypeError, ValueError):
pass
# 推理强度:上游 GLM-5.3 系原生接受 reasoning_effort(low/high/max)。
# 其余值(含 off/none)不带该字段——上游不支持关闭思考,交给默认档。
effort = payload.get("reasoning_effort")
if isinstance(effort, str) and effort.strip().lower() in ("low", "high", "max"):
body["reasoning_effort"] = effort.strip().lower()

stop = payload.get("stop")
if isinstance(stop, str) and stop:
body["stop_sequences"] = [stop]
Expand Down Expand Up @@ -186,11 +197,14 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
def anthropic_to_openai(data: dict, model: str) -> dict:
"""Anthropic message 响应 → OpenAI chat.completion。"""
text_parts: list[str] = []
thinking_parts: list[str] = []
tool_calls: list[dict] = []
for block in data.get("content") or []:
if not isinstance(block, dict):
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
if block.get("type") == "thinking" and isinstance(block.get("thinking"), str):
thinking_parts.append(block["thinking"])
elif block.get("type") == "text" and isinstance(block.get("text"), str):
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
Expand All @@ -205,6 +219,10 @@ def anthropic_to_openai(data: dict, model: str) -> dict:
in_tok = _as_int(usage.get("input_tokens")) or 0
out_tok = _as_int(usage.get("output_tokens")) or 0
message: dict = {"role": "assistant", "content": "".join(text_parts) or None}
# OpenAI 兼容侧通用约定:思考文本放 reasoning_content(pi-ai/DeepSeek 风格均可识别)
reasoning = "\n".join(p for p in thinking_parts if p)
if reasoning:
message["reasoning_content"] = reasoning
if tool_calls:
message["tool_calls"] = tool_calls
return {
Expand Down Expand Up @@ -261,6 +279,8 @@ def feed(self, evt: dict) -> list[str]:
return []
if etype == "content_block_start":
block = evt.get("content_block") or {}
if block.get("type") == "thinking":
return [self._chunk({"reasoning_content": ""})]
if block.get("type") == "tool_use":
idx = self._tool_seq
self._tool_seq += 1
Expand All @@ -273,6 +293,8 @@ def feed(self, evt: dict) -> list[str]:
return []
if etype == "content_block_delta":
delta = evt.get("delta") or {}
if delta.get("type") == "thinking_delta" and isinstance(delta.get("thinking"), str):
return [self._chunk({"reasoning_content": delta["thinking"]})]
if delta.get("type") == "text_delta" and isinstance(delta.get("text"), str):
return [self._chunk({"content": delta["text"]})]
if delta.get("type") == "input_json_delta" and isinstance(delta.get("partial_json"), str):
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def fresh_app(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "DATA_DIR", data_dir)
monkeypatch.setattr(settings, "DB_PATH", data_dir / "accounts.db")
monkeypatch.setattr(settings, "COOLING_SECONDS", 300)
# 本机 .env 的自定义 ZCODE_ADMIN_KEY 不得漏进夹具:admin 接口测试依赖默认密钥 "zcode"
monkeypatch.setattr(settings, "DEFAULT_ADMIN_KEY", "zcode")

from app import store as store_module
from app.store import Store
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/test_openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,128 @@ def test_unknown_events_ignored(self):
conv.start()
assert conv.feed({"type": "ping"}) == []
assert conv.feed({"type": "content_block_stop", "index": 0}) == []

class TestReasoningMapping:
"""reasoning 双向映射:reasoning_effort 入站透传 + thinking 块 ↔ reasoning_content 出站。"""

def test_reasoning_effort_passthrough(self):
body, err = openai_to_anthropic({
"model": "glm-5.3",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "high",
})
assert err is None
assert body["reasoning_effort"] == "high"

def test_reasoning_effort_normalizes_case(self):
body, _ = openai_to_anthropic({
"model": "glm-5.3",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "MAX",
})
assert body["reasoning_effort"] == "max"

def test_reasoning_effort_invalid_omitted(self):
# 上游不支持关闭思考,off/none/未知值一律不带该字段
body, _ = openai_to_anthropic({
"model": "glm-5.3",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "off",
})
assert "reasoning_effort" not in body

def test_reasoning_effort_absent(self):
body, _ = openai_to_anthropic({
"model": "glm-5.3",
"messages": [{"role": "user", "content": "hi"}],
})
assert "reasoning_effort" not in body

def test_assistant_reasoning_content_replays_as_thinking(self):
body, err = openai_to_anthropic({
"model": "glm-5.3",
"messages": [
{"role": "user", "content": "1+1"},
{"role": "assistant", "content": "2", "reasoning_content": "用户问 1+1,答案是 2"},
{"role": "user", "content": "再加 1"},
],
})
assert err is None
assistant = body["messages"][1]
assert [b["type"] for b in assistant["content"]] == ["thinking", "text"]
assert assistant["content"][0]["thinking"] == "用户问 1+1,答案是 2"

def test_assistant_reasoning_alias_fields(self):
for alias in ("reasoning", "reasoning_text"):
body, _ = openai_to_anthropic({
"model": "glm-5.3",
"messages": [
{"role": "assistant", "content": "ok", alias: "思考"},
],
})
assert body["messages"][0]["content"][0]["type"] == "thinking"

def test_assistant_blank_reasoning_skipped(self):
body, _ = openai_to_anthropic({
"model": "glm-5.3",
"messages": [{"role": "assistant", "content": "ok", "reasoning_content": " "}],
})
# 空白思考不产生 thinking 块,也不丢 assistant 消息
assert body["messages"][0]["content"] == [{"type": "text", "text": "ok"}]

def test_nonstream_thinking_to_reasoning_content(self):
data = {
"id": "msg_1", "type": "message", "role": "assistant", "model": "glm-5.3",
"content": [
{"type": "thinking", "thinking": "先想一下", "signature": "sig"},
{"type": "text", "text": "答案"},
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 5, "output_tokens": 9},
}
out = anthropic_to_openai(data, "glm-5.3")
msg = out["choices"][0]["message"]
assert msg["content"] == "答案"
assert msg["reasoning_content"] == "先想一下"

def test_nonstream_no_thinking_no_field(self):
data = {
"id": "msg_1", "type": "message", "role": "assistant", "model": "glm-5.3",
"content": [{"type": "text", "text": "答案"}],
"stop_reason": "end_turn", "usage": {"input_tokens": 5, "output_tokens": 1},
}
out = anthropic_to_openai(data, "glm-5.3")
assert "reasoning_content" not in out["choices"][0]["message"]

def test_stream_thinking_to_reasoning_content(self):
conv = StreamConverter("GLM-5.3")
outs = [conv.start()]
events = [
{"type": "message_start", "message": {"id": "msg_r", "usage": {"input_tokens": 3}}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "想"}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "一下"}},
{"type": "content_block_stop", "index": 0},
{"type": "content_block_start", "index": 1, "content_block": {"type": "text"}},
{"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "答案"}},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 4}},
]
for evt in events:
outs.extend(conv.feed(evt))
outs.append(conv.done())

chunks = _parse_sse("".join(outs))
reasoning = "".join(c["choices"][0]["delta"].get("reasoning_content") or "" for c in chunks)
text = "".join(c["choices"][0]["delta"].get("content") or "" for c in chunks)
assert reasoning == "想一下"
assert text == "答案"

def test_stream_no_thinking_keeps_shape(self):
# 原有纯文本流不应引入 reasoning_content 字段(与既有客户端行为兼容)
conv = StreamConverter("GLM-5.3")
conv.start()
outs = conv.feed({"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "你好"}})
chunks = _parse_sse("".join(outs))
assert all("reasoning_content" not in c["choices"][0]["delta"] for c in chunks)