diff --git a/app/openai_compat.py b/app/openai_compat.py index 87aa012..5789ce2 100644 --- a/app/openai_compat.py +++ b/app/openai_compat.py @@ -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}) @@ -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] @@ -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({ @@ -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 { @@ -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 @@ -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): diff --git a/tests/conftest.py b/tests/conftest.py index aacfe68..1e344f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 26ba0cb..547142e 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -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) +