From 7244c205cdc4e890be2198ebc9c52efa1ac78fcf Mon Sep 17 00:00:00 2001 From: wicm84266964 Date: Fri, 28 Aug 2026 00:52:44 +0800 Subject: [PATCH] release: Buddy2api 2.1.1 reasoning effort compatibility --- README.md | 24 +- README_EN.md | 17 +- docs/releases/v2.1.1.md | 31 ++ providers/qclaw/chat.py | 5 +- providers/qwenwork/chat.py | 7 +- proxy.py | 34 +- reasoning_controls.py | 366 +++++++++++++++++ responses.py | 257 ++++++++++-- router.py | 40 +- server.py | 34 +- tests/test_bind.py | 123 +++++- tests/test_core.py | 820 ++++++++++++++++++++++++++++++++++++- tests/test_qclaw.py | 24 +- tests/test_qwenwork.py | 23 +- version.py | 2 +- web/index.html | 2 +- 16 files changed, 1738 insertions(+), 71 deletions(-) create mode 100644 docs/releases/v2.1.1.md create mode 100644 reasoning_controls.py diff --git a/README.md b/README.md index b828b5b..5130c8e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > 把本机已经登录的消费级 AI 客户端,接成 OpenAI 兼容接口,给 Codex、OpenCode、Cherry Studio、NextChat 等用。默认打开 Work Buddy / CodeBuddy、QClaw、千问办公(QwenWork)、TraeWork 四个通道;管理页下拉选其中一个。一次请求只走一个通道。 -当前版本 **2.1.0**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 +当前版本 **2.1.1**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 ## 这是什么? @@ -155,6 +155,27 @@ python server.py 接口:`/v1/chat/completions`、`/v1/responses`、`/v1/models`。没加前缀的 `auto` 走这把 Key 绑定的通道。Codex 用 Responses 接口;管理页选 Codex 类型的 Key 会按 Codex 特征 prompt 做清洗(其它客户端借用这把 Key、但没有 Codex 特征时不改写)。 +### 思考强度 + +智能体可以在 Chat Completions 中发送顶层 `reasoning_effort`,在 Responses 中发送标准的 `reasoning: {"effort": "high"}`。网关也兼容 OpenCode、DSH、Cherry 和 Claude 风格的 `reasoning.effort`、`reasoningEffort`、`thinking.type`、`thinking.effort`、`output_config.effort`、`enable_thinking` 等写法。可用档位为 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra`;`off` 等同于 `none`。 + +```json +{ + "model": "deepseek-v4-pro", + "messages": [{"role": "user", "content": "分析这个问题"}], + "reasoning_effort": "high" +} +``` + +| 通道 | 实际能力 | +|---|---| +| WorkBuddy | DeepSeek V4 Pro/Flash 支持 `low` / `high` / `max`,标准档位会投影到这三档;未指定时默认 `high`,可用 `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` 关闭默认 | +| QClaw | 统一转换成 `reasoning_effort` 后透传;具体档位是否生效由所选上游模型决定,不额外注入默认值 | +| QwenWork | 协议只有 `is_reasoning` 开关;`none` 关闭,其它显式档位开启,无法区分多档强度 | +| TraeWork | 当前会话协议没有可验证的思考控制字段,因此暂不支持调档 | + +Chat 流会保留 `reasoning_content`。Responses 流会转换成标准的 `response.reasoning_summary_*` 事件,仅有推理、没有最终正文的有效响应也会正常完成。 + OpenCode 示例(WorkBuddy Key): ```json @@ -204,6 +225,7 @@ QwenWork、QClaw、TraeWork 各用自己那把 Key,不要混用。 | `CB_GATEWAY_PROVIDERS` | 启用哪些通道,逗号分隔。默认 `workbuddy,qclaw,qwenwork,traework`。只想留一家时再改 | | `CB_GATEWAY_AUTO_IMPORT` | 设 `1` 则启动时自动导入。默认 `0` | | `CB_GATEWAY_CHECKIN_GAP_MS` | 一键领取间隔,默认 `800` | +| `CB_GATEWAY_DEFAULT_REASONING_EFFORT` | WorkBuddy DeepSeek V4 Pro/Flash 的默认思考强度,支持 `low` / `high` / `max`,默认 `high`;设为 `off` 可关闭默认值。Responses 的 `reasoning.effort` 或 Chat Completions 的 `reasoning_effort` 会覆盖它 | | `CB_AUTH_DIR` | WorkBuddy 登录目录 | | `CB_QCLAW_AUTH_DIR` | QClaw 登录目录 | | `CB_QWENWORK_AUTH_DIR` | QwenWork 登录目录 | diff --git a/README_EN.md b/README_EN.md index 7bc18d9..0e0b69b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ > Local consumer AI clients → one OpenAI-compatible API for Codex, OpenCode, Cherry Studio, NextChat, and similar agents. Work Buddy / CodeBuddy, QClaw, QwenWork, and TraeWork are on by default; pick one in the UI dropdown. Each request stays on one channel. -Release **2.1.0**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. +Release **2.1.1**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. ## What is this? @@ -80,6 +80,19 @@ The database migrates on startup. Existing keys stay on `workbuddy`. Startup no Unprefixed `auto` follows the key’s channel. Use a separate key per channel. +### Reasoning effort + +Agent clients can send top-level `reasoning_effort` to Chat Completions and the standard `reasoning: {"effort": "high"}` object to Responses. Compatibility forms used by OpenCode, DSH, Cherry, and Claude-style clients are also accepted: `reasoning.effort`, `reasoningEffort`, `thinking.type`, `thinking.effort`, `output_config.effort`, and `enable_thinking`. Accepted levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`; `off` is an alias for `none`. + +| Channel | Effective capability | +|---|---| +| WorkBuddy | DeepSeek V4 Pro/Flash supports `low` / `high` / `max`; standard levels are projected onto those tiers. The default is `high` and can be disabled with `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` | +| QClaw | The control is normalized to `reasoning_effort` and forwarded. Whether a tier takes effect depends on the selected upstream model; no gateway default is injected | +| QwenWork | The protocol exposes only an `is_reasoning` switch. `none` disables it and any other explicit tier enables it; distinct effort levels are unavailable | +| TraeWork | The current session protocol has no verified reasoning control field, so effort selection is not supported | + +Chat streams preserve `reasoning_content`. Responses streams expose standard `response.reasoning_summary_*` events and accept valid reasoning-only completions. + ```bash curl http://127.0.0.1:8787/v1/chat/completions \ -H "Content-Type: application/json" \ @@ -91,6 +104,8 @@ curl http://127.0.0.1:8787/v1/chat/completions \ `CB_GATEWAY_PROVIDERS` (default `workbuddy,qclaw,qwenwork,traework`), `CB_GATEWAY_AUTO_IMPORT` (default `0`), `CB_AUTH_DIR` / `CB_QCLAW_AUTH_DIR` / `CB_QWENWORK_AUTH_DIR` / `CB_TRAEWORK_AUTH_DIR`, `CB_GATEWAY_ADMIN_TOKEN`, `CB_GATEWAY_MASTER_KEY`. +`CB_GATEWAY_DEFAULT_REASONING_EFFORT` controls the default reasoning effort for WorkBuddy DeepSeek V4 Pro/Flash. It accepts `low`, `high`, or `max`, defaults to `high`, and can be disabled with `off`. A Responses `reasoning.effort` or Chat Completions `reasoning_effort` value overrides the default. + Keep `--host 127.0.0.1`. Do not share the database, auth folders, or key screenshots. ## License diff --git a/docs/releases/v2.1.1.md b/docs/releases/v2.1.1.md new file mode 100644 index 0000000..8d01e3d --- /dev/null +++ b/docs/releases/v2.1.1.md @@ -0,0 +1,31 @@ +# Buddy2api v2.1.1 + +发布日期:2026-08-28 + +本版本补齐智能体思考强度兼容,并修复 Responses API 在多通道路由和推理输出上的行为。 + +## 思考强度 + +- Chat Completions 支持顶层 `reasoning_effort`,Responses 支持标准的 `reasoning: {"effort": "high"}`。 +- 同时兼容 `reasoning.effort`、`reasoningEffort`、`thinking.type`、`thinking.effort`、`output_config.effort`、`enable_thinking` 等常见智能体写法。 +- 接受 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra`;`off` 等同于 `none`。无效或冲突的控制返回 `400 invalid_reasoning_control`。 +- WorkBuddy DeepSeek V4 Pro/Flash 映射到 `low` / `high` / `max`,未指定时默认 `high`;可用 `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` 关闭默认值。 +- QClaw 规范化为 `reasoning_effort` 后透传;QwenWork 只能映射为开关;TraeWork 当前不支持调档。 + +## Responses 兼容 + +- `/v1/responses` 现在遵循 API Key 绑定的通道,不再固定走 WorkBuddy。 +- Chat 的 `reasoning_content` 会转换为标准 `response.reasoning_summary_*` 事件。 +- 支持只有推理内容、没有最终正文的有效完成,并避免兼容上游将相同推理文本复制到正文时重复显示。 +- 补齐 Responses 的请求回显、usage 明细、错误结构和流式事件收尾。 + +## 升级说明 + +- 无数据库迁移。 +- Docker 用户需要重新构建镜像并重启服务,才能加载新代码。 +- 思考强度选择器是否显示仍由客户端自己的模型能力配置决定。例如 DSH 自定义模型需要在其本地配置中声明 `reasoningEfforts`;Buddy2api 不会通过 `/v1/models` 强制客户端显示控件。 + +## 验证 + +- 完整测试集:`238 passed`。 +- 已实测 Chat 默认档位、`none`、`max`,以及 Responses `high` 的真实上游响应。 diff --git a/providers/qclaw/chat.py b/providers/qclaw/chat.py index 940bd18..97ee134 100644 --- a/providers/qclaw/chat.py +++ b/providers/qclaw/chat.py @@ -10,6 +10,7 @@ import auth_manager import database as db +from reasoning_controls import normalize_chat_reasoning from providers.qclaw.constants import AIZONE_BASE, ALIASES, CHANNEL_ID, RETRYABLE_STATUS from providers.qclaw.sign import aizone_headers @@ -44,6 +45,8 @@ def fill_empty_content(payload: dict) -> dict: for key in ("reasoning_content", "reasoning"): text = _alt_text(out.get(key)) if text: + if key == "reasoning": + out["reasoning_content"] = text out["content"] = text return out return out @@ -105,7 +108,7 @@ def _log(api_key_info, account, model_name, stream, prompt_t, completion_t, tota def _build_body(payload: dict) -> tuple[dict, str]: - body = dict(payload) + body = normalize_chat_reasoning(payload) body["model"] = translate_model(str(body.get("model") or "default")) raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")) return body, raw diff --git a/providers/qwenwork/chat.py b/providers/qwenwork/chat.py index 22b97d8..0876885 100644 --- a/providers/qwenwork/chat.py +++ b/providers/qwenwork/chat.py @@ -11,6 +11,7 @@ import auth_manager import database as db +from reasoning_controls import resolve_reasoning_control from providers.qwenwork import cosy from providers.qwenwork.constants import ( ALIASES, @@ -118,6 +119,8 @@ def build_body(payload: dict) -> tuple[dict, str, str]: if system: messages = [{"role": "system", "content": system}, *messages] last_user = _last_user_text(messages) + reasoning_control = resolve_reasoning_control(payload) + is_reasoning = reasoning_control.enabled is True parameters = {} for key in ("temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty"): if key in payload and payload[key] is not None: @@ -136,7 +139,7 @@ def build_body(payload: dict) -> tuple[dict, str, str]: "features": [], "extra": { "context": [], - "modelConfig": {"key": model, "is_reasoning": False}, + "modelConfig": {"key": model, "is_reasoning": is_reasoning}, "originalContent": last_user, }, "chatPrompt": "", @@ -156,7 +159,7 @@ def build_body(payload: dict) -> tuple[dict, str, str]: "model": "", "format": "openai", "is_vl": model == "qwork-advanced", - "is_reasoning": False, + "is_reasoning": is_reasoning, "api_key": "", "url": "", "source": "system", diff --git a/proxy.py b/proxy.py index 83219b3..40d752f 100644 --- a/proxy.py +++ b/proxy.py @@ -20,6 +20,11 @@ import database as db import auth_manager +from reasoning_controls import ( + chat_reasoning_effort, + resolve_reasoning_control, + workbuddy_reasoning_effort, +) BACKEND = "https://copilot.tencent.com" RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504} @@ -126,6 +131,7 @@ async def _retry_delay(attempt: int): "deepseek-v4-pro", "deepseek-v4-flash", }) +_DEFAULT_REASONING_EFFORT = "high" _VALID_REASONING_DEFAULTS = frozenset({"low", "high", "max"}) _BACKEND_ROLE_ALIASES = { "developer": "system", @@ -195,14 +201,18 @@ def resolve_model_alias(model: str) -> str: def _configured_reasoning_default(model: str) -> str | None: - """Return the opt-in reasoning default for supported DeepSeek V4 models.""" + """Return the configured reasoning default for supported DeepSeek V4 models.""" if model not in _REASONING_DEFAULT_MODEL_IDS: return None - value = os.environ.get("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "").strip().lower() + value = os.environ.get( + "CB_GATEWAY_DEFAULT_REASONING_EFFORT", + _DEFAULT_REASONING_EFFORT, + ).strip().lower() return value if value in _VALID_REASONING_DEFAULTS else None def build_backend_body(payload: dict) -> dict: + reasoning_control = resolve_reasoning_control(payload) body = {k: payload[k] for k in PASSTHROUGH_BODY_KEYS if k in payload} messages = body.get("messages") if isinstance(messages, list): @@ -215,14 +225,21 @@ def build_backend_body(payload: dict) -> dict: else message for message in messages ] - has_explicit_thinking = "thinking" in payload # Resolve model alias before forwarding raw_model = body.get("model", "auto") body["model"] = resolve_model_alias(raw_model) - if "reasoning_effort" not in body and not has_explicit_thinking: + body.pop("reasoning_effort", None) + if reasoning_control.mode == "default": default_reasoning = _configured_reasoning_default(body["model"]) if default_reasoning: body["reasoning_effort"] = default_reasoning + else: + if body["model"] in _REASONING_DEFAULT_MODEL_IDS: + reasoning_effort = workbuddy_reasoning_effort(reasoning_control) + else: + reasoning_effort = chat_reasoning_effort(reasoning_control) + if reasoning_effort: + body["reasoning_effort"] = reasoning_effort body["stream"] = True if "stream_options" not in body: body["stream_options"] = {"include_usage": True} @@ -283,6 +300,7 @@ def __init__(self, fallback_model: str, expected_choices: int = 1): self.finish_reasons: dict[int, str | None] = {} self.closed_choices: set[int] = set() self.content_choices: set[int] = set() + self.reasoning_choices: set[int] = set() self.tool_call_choices: set[int] = set() self.tool_calls: dict[tuple[int, int], dict] = {} self.malformed_data_event = False @@ -423,6 +441,8 @@ def observe_event(self, data: bytes) -> dict | None: if content: self.content_parts.append(content) self.content_choices.add(index) + if delta.get("reasoning_content"): + self.reasoning_choices.add(index) tool_deltas = delta.get("tool_calls") if tool_deltas is None: continue @@ -505,9 +525,10 @@ def eof_error(self) -> str | None: if ( reason not in {"length", "content_filter"} and choice_index not in self.content_choices + and choice_index not in self.reasoning_choices and choice_index not in self.tool_call_choices ): - return "The upstream choice ended without content or a tool call." + return "The upstream choice ended without content, reasoning, or a tool call." return None def terminal_event(self, choice_indices: list[int]) -> bytes: @@ -1141,6 +1162,7 @@ async def _collect_stream( if ( not content_parts + and not reasoning_parts and not tool_calls and finish_reason not in {"length", "content_filter"} ): @@ -1148,7 +1170,7 @@ async def _collect_stream( "error", (502, { "error": { - "message": "The upstream choice ended without content or a tool call.", + "message": "The upstream choice ended without content, reasoning, or a tool call.", "type": "upstream_error", }, }), diff --git a/reasoning_controls.py b/reasoning_controls.py new file mode 100644 index 0000000..447915f --- /dev/null +++ b/reasoning_controls.py @@ -0,0 +1,366 @@ +"""Normalize reasoning controls used by OpenAI-compatible agent clients.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +_MISSING = object() +_DISABLED_VALUES = {"none", "off", "disable", "disabled", "false"} +_ENABLED_VALUES = {"auto", "enable", "enabled", "adaptive", "on", "true"} +_EFFORT_ALIASES = { + "minimal": "minimal", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "x-high": "xhigh", + "extra_high": "xhigh", + "extra-high": "xhigh", + "max": "max", + "ultra": "ultra", +} + + +class InvalidReasoningControl(ValueError): + """A recognized reasoning control has an invalid or conflicting value.""" + + +@dataclass(frozen=True) +class ReasoningControl: + """Canonical four-state reasoning request.""" + + mode: str + effort: Optional[str] = None + source: Optional[str] = None + budget_tokens: Optional[float] = None + + @property + def enabled(self) -> Optional[bool]: + if self.mode == "disabled": + return False + if self.mode in {"enabled", "effort"}: + return True + return None + + +DEFAULT_REASONING = ReasoningControl("default") + + +def _invalid(source: str, message: str) -> InvalidReasoningControl: + return InvalidReasoningControl(f"{source} {message}") + + +def _from_effort(value, source: str) -> Optional[ReasoningControl]: + if value is None: + return None + if isinstance(value, bool): + return ReasoningControl("enabled" if value else "disabled", source=source) + if not isinstance(value, str): + raise _invalid(source, "must be a string") + normalized = value.strip().lower() + if not normalized: + raise _invalid(source, "must not be empty") + if normalized == "default": + return ReasoningControl("default", source=source) + if normalized in _DISABLED_VALUES: + return ReasoningControl("disabled", source=source) + if normalized in _ENABLED_VALUES: + return ReasoningControl("enabled", source=source) + effort = _EFFORT_ALIASES.get(normalized) + if effort is None: + allowed = "none, minimal, low, medium, high, xhigh, max, or ultra" + raise _invalid(source, f"must be one of {allowed}") + return ReasoningControl("effort", effort=effort, source=source) + + +def _from_switch(value, source: str) -> Optional[ReasoningControl]: + if value is None: + return None + if isinstance(value, bool): + return ReasoningControl("enabled" if value else "disabled", source=source) + if isinstance(value, int) and value in {0, 1}: + return ReasoningControl("enabled" if value else "disabled", source=source) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _DISABLED_VALUES: + return ReasoningControl("disabled", source=source) + if normalized in _ENABLED_VALUES: + return ReasoningControl("enabled", source=source) + raise _invalid(source, "must be a boolean or an enabled/disabled value") + + +def _from_disable_switch(value, source: str) -> Optional[ReasoningControl]: + if value is None or value is False or value == 0: + return None + if value is True or value == 1: + return ReasoningControl("disabled", source=source) + raise _invalid(source, "must be a boolean") + + +def _from_budget(value, source: str) -> Optional[ReasoningControl]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + raise _invalid(source, "must be a non-negative number") + return ReasoningControl( + "disabled" if value == 0 else "enabled", + source=source, + budget_tokens=value, + ) + + +def _append_nested_effort( + candidates: list[ReasoningControl], + container, + container_name: str, +) -> None: + if not isinstance(container, dict) or "effort" not in container: + return + control = _from_effort(container.get("effort"), f"{container_name}.effort") + if control is not None: + candidates.append(control) + + +def _native_control_group(source: Optional[str]) -> Optional[str]: + if source == "reasoning" or (source and source.startswith("reasoning.")): + return "reasoning" + if source == "thinking" or (source and source.startswith("thinking.")): + return "thinking" + return source + + +def _validate_native_object_conflicts( + effort_candidates: list[ReasoningControl], + switch_candidates: list[ReasoningControl], +) -> None: + for group in ("reasoning", "thinking"): + controls = [ + control + for control in [*effort_candidates, *switch_candidates] + if _native_control_group(control.source) == group + and control.enabled is not None + ] + if len({control.enabled for control in controls}) > 1: + sources = ", ".join( + control.source or "reasoning control" for control in controls + ) + raise InvalidReasoningControl(f"conflicting reasoning controls: {sources}") + + +def resolve_reasoning_control( + payload: dict, + *, + prefer_nested: bool = False, +) -> ReasoningControl: + """Resolve common Chat, Responses, DeepSeek, and Claude-style controls.""" + if not isinstance(payload, dict): + return DEFAULT_REASONING + + reasoning = payload.get("reasoning", _MISSING) + thinking = payload.get("thinking", _MISSING) + output_config = payload.get("output_config", _MISSING) + effort_candidates: list[ReasoningControl] = [] + + def add_top_level(key: str) -> None: + if key not in payload: + return + control = _from_effort(payload.get(key), key) + if control is not None: + effort_candidates.append(control) + + if prefer_nested: + _append_nested_effort(effort_candidates, reasoning, "reasoning") + add_top_level("reasoning_effort") + else: + add_top_level("reasoning_effort") + _append_nested_effort(effort_candidates, reasoning, "reasoning") + add_top_level("reasoningEffort") + _append_nested_effort(effort_candidates, output_config, "output_config") + _append_nested_effort(effort_candidates, thinking, "thinking") + + if reasoning is not _MISSING and not isinstance(reasoning, dict): + control = _from_effort(reasoning, "reasoning") + if control is not None: + effort_candidates.append(control) + if thinking is not _MISSING and not isinstance(thinking, dict): + control = _from_effort(thinking, "thinking") + if control is not None: + effort_candidates.append(control) + + switch_candidates: list[ReasoningControl] = [] + if isinstance(thinking, dict): + if "type" in thinking: + kind = thinking.get("type") + if not isinstance(kind, str): + raise _invalid("thinking.type", "must be a string") + normalized = kind.strip().lower() + if normalized == "disabled": + switch_candidates.append(ReasoningControl("disabled", source="thinking.type")) + elif normalized in {"enabled", "adaptive"}: + switch_candidates.append(ReasoningControl("enabled", source="thinking.type")) + else: + raise _invalid("thinking.type", "must be enabled, adaptive, or disabled") + if "budget_tokens" in thinking: + control = _from_budget(thinking.get("budget_tokens"), "thinking.budget_tokens") + if control is not None: + switch_candidates.append(control) + + if isinstance(reasoning, dict): + if "enabled" in reasoning: + control = _from_switch(reasoning.get("enabled"), "reasoning.enabled") + if control is not None: + switch_candidates.append(control) + if "budget_tokens" in reasoning: + control = _from_budget(reasoning.get("budget_tokens"), "reasoning.budget_tokens") + if control is not None: + switch_candidates.append(control) + + for key in ("enable_thinking", "think"): + if key in payload: + control = _from_switch(payload.get(key), key) + if control is not None: + switch_candidates.append(control) + if "disable_reasoning" in payload: + control = _from_disable_switch(payload.get("disable_reasoning"), "disable_reasoning") + if control is not None: + switch_candidates.append(control) + + _validate_native_object_conflicts(effort_candidates, switch_candidates) + + selected_effort = next( + (control for control in effort_candidates if control.mode != "default"), + None, + ) + if selected_effort is not None: + group = _native_control_group(selected_effort.source) + if group in {"reasoning", "thinking"}: + related_switches = [ + control + for control in switch_candidates + if _native_control_group(control.source) == group + ] + else: + related_switches = [] + budget = next( + ( + control.budget_tokens + for control in related_switches + if control.budget_tokens is not None + ), + None, + ) + if budget is not None: + return ReasoningControl( + selected_effort.mode, + effort=selected_effort.effort, + source=selected_effort.source, + budget_tokens=budget, + ) + return selected_effort + + if switch_candidates: + selected = switch_candidates[0] + group = _native_control_group(selected.source) + budget = next( + ( + control.budget_tokens + for control in switch_candidates + if _native_control_group(control.source) == group + and control.budget_tokens is not None + ), + None, + ) + if budget is not None and selected.budget_tokens is None: + return ReasoningControl( + selected.mode, + effort=selected.effort, + source=selected.source, + budget_tokens=budget, + ) + return selected + return DEFAULT_REASONING + + +def chat_reasoning_effort(control: ReasoningControl) -> Optional[str]: + """Project a canonical control to the common Chat reasoning_effort field.""" + if control.mode == "default": + return None + if control.mode == "disabled": + return "none" + if control.mode == "enabled": + return "high" + return control.effort + + +def workbuddy_reasoning_effort(control: ReasoningControl) -> Optional[str]: + """Project standard effort levels to WorkBuddy's low/high/max dialect.""" + if control.mode in {"default", "disabled"}: + return None + if control.mode == "enabled": + return "high" + return { + "minimal": "low", + "low": "low", + "medium": "high", + "high": "high", + "xhigh": "max", + "max": "max", + "ultra": "max", + }.get(control.effort, control.effort) + + +def normalize_chat_reasoning(payload: dict, *, prefer_nested: bool = False) -> dict: + """Return a copy with compatibility controls converted to reasoning_effort.""" + control = resolve_reasoning_control(payload, prefer_nested=prefer_nested) + body = dict(payload) + + reasoning = body.get("reasoning") + if isinstance(reasoning, dict): + remaining = dict(reasoning) + if reasoning.get("summary") is not None and "reasoning_summary" not in body: + body["reasoning_summary"] = reasoning["summary"] + for key in ("effort", "enabled", "summary"): + remaining.pop(key, None) + if remaining: + body["reasoning"] = remaining + else: + body.pop("reasoning", None) + elif "reasoning" in body: + body.pop("reasoning", None) + + thinking = body.get("thinking") + if isinstance(thinking, dict): + remaining = dict(thinking) + for key in ("type", "effort"): + remaining.pop(key, None) + if remaining: + body["thinking"] = remaining + else: + body.pop("thinking", None) + elif "thinking" in body: + body.pop("thinking", None) + + output_config = body.get("output_config") + if isinstance(output_config, dict) and "effort" in output_config: + remaining = {key: value for key, value in output_config.items() if key != "effort"} + if remaining: + body["output_config"] = remaining + else: + body.pop("output_config", None) + + for key in ( + "reasoningEffort", + "enable_thinking", + "disable_reasoning", + "think", + ): + body.pop(key, None) + + effort = chat_reasoning_effort(control) + if effort is None: + body.pop("reasoning_effort", None) + else: + body["reasoning_effort"] = effort + return body diff --git a/responses.py b/responses.py index 9cbbc16..d3d759d 100644 --- a/responses.py +++ b/responses.py @@ -15,6 +15,7 @@ from typing import AsyncGenerator, Optional import proxy +from reasoning_controls import normalize_chat_reasoning _DEBUG_SECRET_KEYS = { @@ -22,6 +23,26 @@ "api_key", "authorization", "session_state", "sessionstate", } _DEBUG_CONTENT_KEYS = {"content", "input", "instructions", "output"} +_RESPONSE_ERROR_CODES = frozenset({ + "server_error", + "rate_limit_exceeded", + "invalid_prompt", + "vector_store_timeout", + "invalid_image", + "invalid_image_format", + "invalid_base64_image", + "invalid_image_url", + "image_too_large", + "image_too_small", + "image_parse_error", + "image_content_policy_violation", + "invalid_image_mode", + "image_file_too_large", + "unsupported_image_media_type", + "empty_image_file", + "failed_to_download_image", + "image_file_not_found", +}) def _redact_debug_value(value, *, include_content: bool = False): @@ -118,7 +139,8 @@ def responses_to_chat(resp_payload: dict) -> dict: 将 Responses API 请求转换为 Chat Completions 请求。 Responses 请求结构: - model, input[], instructions, tools[], stream, temperature, max_output_tokens + model, input[], instructions, tools[], stream, temperature, + max_output_tokens, reasoning.effort Chat 请求结构: model, messages[], tools[], stream, temperature, max_tokens @@ -230,6 +252,20 @@ def responses_to_chat(resp_payload: dict) -> dict: if resp_payload.get("top_p") is not None: chat_payload["top_p"] = resp_payload["top_p"] + # Responses prefers reasoning.effort. Compatibility forms used by + # OpenCode, DSH, Cherry Studio, and Claude-style clients are normalized + # to the Chat reasoning_effort field here. + normalized_reasoning = normalize_chat_reasoning(resp_payload, prefer_nested=True) + for key in ( + "reasoning_effort", + "reasoning_summary", + "reasoning", + "thinking", + "output_config", + ): + if key in normalized_reasoning: + chat_payload[key] = normalized_reasoning[key] + return chat_payload @@ -469,24 +505,63 @@ def _sanitize_tool_description(desc: str) -> str: # Chat → Responses 响应映射(非流式) # ============================================================ -def chat_response_to_responses(chat_resp: dict, model: str) -> dict: +def _responses_usage(usage: dict) -> dict: + input_tokens = usage.get("prompt_tokens", usage.get("input_tokens", 0)) + output_tokens = usage.get("completion_tokens", usage.get("output_tokens", 0)) + input_details = usage.get("prompt_tokens_details") or usage.get("input_tokens_details") or {} + output_details = usage.get("completion_tokens_details") or usage.get("output_tokens_details") or {} + return { + "input_tokens": input_tokens, + "input_tokens_details": { + "cached_tokens": input_details.get("cached_tokens", 0), + }, + "output_tokens": output_tokens, + "output_tokens_details": { + "reasoning_tokens": output_details.get("reasoning_tokens", 0), + }, + "total_tokens": usage.get("total_tokens", input_tokens + output_tokens), + } + + +def _response_error(error: Optional[dict]) -> Optional[dict]: + if not error: + return None + code = str(error.get("code") or "server_error") + return { + "code": code if code in _RESPONSE_ERROR_CODES else "server_error", + "message": str(error.get("message") or "The upstream response failed."), + } + + +def _response_request_fields(resp_payload: Optional[dict]) -> dict: + payload = resp_payload if isinstance(resp_payload, dict) else {} + parallel_tool_calls = payload.get("parallel_tool_calls") + if not isinstance(parallel_tool_calls, bool): + parallel_tool_calls = True + tools = payload.get("tools") + if not isinstance(tools, list): + tools = [] + tool_choice = payload.get("tool_choice") + if tool_choice is None: + tool_choice = "auto" + return { + "parallel_tool_calls": parallel_tool_calls, + "tool_choice": tool_choice, + "tools": tools, + } + + +def chat_response_to_responses( + chat_resp: dict, + model: str, + resp_payload: Optional[dict] = None, +) -> dict: """将 Chat Completions 非流式响应转换为 Responses API 响应。""" resp_id = "resp_" + chat_resp.get("id", os.urandom(12).hex()) output = [] for choice in chat_resp.get("choices") or []: msg = choice.get("message") or {} - - # 文本内容 - content = msg.get("content") - if content: - output.append({ - "type": "message", - "id": _gen_item_id("msg"), - "role": "assistant", - "status": "completed", - "content": [{"type": "output_text", "text": content}], - }) # reasoning 内容 reasoning = msg.get("reasoning_content") @@ -494,9 +569,26 @@ def chat_response_to_responses(chat_resp: dict, model: str) -> dict: output.append({ "type": "reasoning", "id": _gen_item_id("rsn"), + "status": "completed", "summary": [{"type": "summary_text", "text": reasoning}], }) + # 文本内容。部分兼容上游会把 reasoning_content 复制进空 content, + # 相同文本只作为 reasoning 输出,避免客户端重复显示。 + content = msg.get("content") + if content and content != reasoning: + output.append({ + "type": "message", + "id": _gen_item_id("msg"), + "role": "assistant", + "status": "completed", + "content": [{ + "type": "output_text", + "text": content, + "annotations": [], + }], + }) + # tool_calls for tc in msg.get("tool_calls") or []: fn = tc.get("function") or {} @@ -538,13 +630,10 @@ def chat_response_to_responses(chat_resp: dict, model: str) -> dict: "status": status, "model": chat_resp.get("model") or model, "output": output, - "error": error, + "error": _response_error(error), "incomplete_details": incomplete_details, - "usage": { - "input_tokens": usage.get("prompt_tokens", 0), - "output_tokens": usage.get("completion_tokens", 0), - "total_tokens": usage.get("total_tokens", 0), - }, + **_response_request_fields(resp_payload), + "usage": _responses_usage(usage), } @@ -634,6 +723,7 @@ def consume_line(line: bytes) -> Optional[str]: async def chat_stream_to_responses_stream( chat_stream: AsyncGenerator[bytes, None], model: str, + resp_payload: Optional[dict] = None, ) -> AsyncGenerator[str, None]: """Convert a Chat Completions SSE stream into Responses API events.""" resp_id = "resp_" + os.urandom(12).hex() @@ -642,6 +732,7 @@ async def chat_stream_to_responses_stream( seq = -1 output_items: list[dict] = [] item_states: list[dict] = [] + reasoning_states: dict[int, dict] = {} text_states: dict[int, dict] = {} tool_states: dict[tuple[int, int], dict] = {} usage: dict = {} @@ -650,6 +741,7 @@ async def chat_stream_to_responses_stream( productive_choices: set[int] = set() saw_done = False stream_error: Optional[dict] = None + request_fields = _response_request_fields(resp_payload) def event(event_name: str, **data) -> str: nonlocal seq @@ -661,15 +753,7 @@ def event(event_name: str, **data) -> str: }) def response_usage() -> dict: - input_tokens = usage.get("prompt_tokens", usage.get("input_tokens", 0)) - output_tokens = usage.get("completion_tokens", usage.get("output_tokens", 0)) - return { - "input_tokens": input_tokens, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens": output_tokens, - "output_tokens_details": {"reasoning_tokens": 0}, - "total_tokens": usage.get("total_tokens", input_tokens + output_tokens), - } + return _responses_usage(usage) def response_snapshot(status: str, **extra) -> dict: snapshot = { @@ -682,7 +766,10 @@ def response_snapshot(status: str, **extra) -> dict: "error": None, "incomplete_details": None, "usage": None if status == "in_progress" else response_usage(), + **request_fields, } + if extra.get("error") is not None: + extra["error"] = _response_error(extra["error"]) snapshot.update(extra) return snapshot @@ -693,7 +780,29 @@ def close_item(state: dict, status: str) -> list[str]: output_index = state["output_index"] item["status"] = status events = [] - if state["kind"] == "text": + if state["kind"] == "reasoning": + item["summary"] = [{ + "type": "summary_text", + "text": state["text"], + }] + events.append(event( + "response.reasoning_summary_text.done", + item_id=item["id"], + output_index=output_index, + summary_index=0, + text=state["text"], + )) + events.append(event( + "response.reasoning_summary_part.done", + item_id=item["id"], + output_index=output_index, + summary_index=0, + part={ + "type": "summary_text", + "text": state["text"], + }, + )) + elif state["kind"] == "text": events.append(event( "response.output_text.done", item_id=item["id"], @@ -783,13 +892,88 @@ def close_item(state: dict, status: str) -> list[str]: if finish_reason: finished_choices[choice_index] = str(finish_reason) + reasoning_delta = delta.get("reasoning_content", "") + if reasoning_delta: + has_open_tool = any( + current_choice == choice_index and not state.get("closed") + for (current_choice, _), state in tool_states.items() + ) + if choice_index in text_states or has_open_tool: + stream_error = { + "code": "invalid_upstream_event", + "message": ( + "The upstream emitted reasoning after answer or tool output " + "had already started." + ), + } + break + if not isinstance(reasoning_delta, str): + reasoning_delta = str(reasoning_delta) + productive_choices.add(choice_index) + state = reasoning_states.get(choice_index) + if state is None: + text_state = text_states.pop(choice_index, None) + if text_state: + for pending_event in close_item(text_state, "completed"): + yield pending_event + item = { + "type": "reasoning", + "id": _gen_item_id("rsn"), + "status": "in_progress", + "summary": [], + } + output_index = len(output_items) + output_items.append(item) + state = { + "kind": "reasoning", + "item": item, + "output_index": output_index, + "text": "", + "closed": False, + } + reasoning_states[choice_index] = state + item_states.append(state) + yield event( + "response.output_item.added", + output_index=output_index, + item=item, + ) + yield event( + "response.reasoning_summary_part.added", + item_id=item["id"], + output_index=output_index, + summary_index=0, + part={"type": "summary_text", "text": ""}, + ) + state["text"] += reasoning_delta + state["item"]["summary"] = [{ + "type": "summary_text", + "text": state["text"], + }] + yield event( + "response.reasoning_summary_text.delta", + item_id=state["item"]["id"], + output_index=state["output_index"], + summary_index=0, + delta=reasoning_delta, + ) + text = delta.get("content", "") + if reasoning_delta and text == reasoning_delta: + text = "" + reasoning_state = reasoning_states.get(choice_index) + if text and reasoning_state and text == reasoning_state["text"]: + text = "" if text: if not isinstance(text, str): text = str(text) productive_choices.add(choice_index) state = text_states.get(choice_index) if state is None: + reasoning_state = reasoning_states.pop(choice_index, None) + if reasoning_state: + for pending_event in close_item(reasoning_state, "completed"): + yield pending_event item = { "type": "message", "id": _gen_item_id("msg"), @@ -854,6 +1038,10 @@ def close_item(state: dict, status: str) -> list[str]: fn = tc.get("function") or {} if state is None: + reasoning_state = reasoning_states.pop(choice_index, None) + if reasoning_state: + for pending_event in close_item(reasoning_state, "completed"): + yield pending_event text_state = text_states.pop(choice_index, None) if text_state: for pending_event in close_item(text_state, "completed"): @@ -905,6 +1093,8 @@ def close_item(state: dict, status: str) -> list[str]: output_index=state["output_index"], delta=args, ) + if stream_error: + break except (UnicodeDecodeError, TypeError, ValueError) as exc: stream_error = { "code": "upstream_stream_error", @@ -1007,6 +1197,8 @@ def _gen_item_id(prefix: str) -> str: async def proxy_responses( resp_payload: dict, api_key_info: Optional[dict] = None, + *, + chat_handler=None, ) -> tuple: """ 主代理函数 — Responses API 版本。 @@ -1022,7 +1214,8 @@ async def proxy_responses( _maybe_dump("responses_request_chat", chat_payload) # 2. 调用现有 proxy - result = await proxy.proxy_chat_completions(chat_payload, api_key_info) + handler = chat_handler or proxy.proxy_chat_completions + result = await handler(chat_payload, api_key_info) if result[0] == "error": return result @@ -1032,11 +1225,11 @@ async def proxy_responses( if result[0] == "json": # 非流式: 映射响应 chat_resp = result[1] - resp = chat_response_to_responses(chat_resp, model) + resp = chat_response_to_responses(chat_resp, model, resp_payload) return ("json", resp) elif result[0] == "stream": # 流式: 映射事件 chat_gen = result[1] - resp_gen = chat_stream_to_responses_stream(chat_gen, model) + resp_gen = chat_stream_to_responses_stream(chat_gen, model, resp_payload) return ("stream", resp_gen) diff --git a/router.py b/router.py index b804642..7cdc531 100644 --- a/router.py +++ b/router.py @@ -9,6 +9,8 @@ from fastapi import HTTPException import providers +import responses +from reasoning_controls import normalize_chat_reasoning from providers.protocol import ( BindResult, InvalidModel, @@ -166,9 +168,16 @@ def dispatch_payload(payload: dict, inner: str) -> dict: def _rewrite_json_model(obj, original: str): if isinstance(obj, dict): + rewritten = None if "model" in obj and isinstance(obj["model"], str): - obj = dict(obj) - obj["model"] = original + rewritten = dict(obj) + rewritten["model"] = original + if isinstance(obj.get("response"), dict): + if rewritten is None: + rewritten = dict(obj) + rewritten["response"] = _rewrite_json_model(obj["response"], original) + if rewritten is not None: + obj = rewritten return obj return obj @@ -215,14 +224,37 @@ async def echo_original(result: tuple, original: str) -> tuple: async def chat_after_bind( bound: BindResult, payload: dict, api_key_info: dict | None +) -> tuple: + result = await _chat_after_bind_no_echo(bound, payload, api_key_info) + return await echo_original(result, bound.original) + + +async def _chat_after_bind_no_echo( + bound: BindResult, payload: dict, api_key_info: dict | None ) -> tuple: provider = providers.get_provider(bound.channel) if provider is None: raise UnknownChannel(bound.channel) inner = provider.translate_model(bound.inner) - dispatch = dispatch_payload(payload, inner) + dispatch = normalize_chat_reasoning(dispatch_payload(payload, inner)) info = dict(api_key_info or {}) info["_log_model"] = bound.original info["_bind_channel"] = bound.channel - result = await provider.chat_completions(dispatch, info) + return await provider.chat_completions(dispatch, info) + + +async def responses_after_bind( + bound: BindResult, payload: dict, api_key_info: dict | None +) -> tuple: + """Bridge Responses through the provider selected by the existing bind.""" + dispatch = dispatch_payload(payload, bound.inner) + + async def chat_handler(chat_payload: dict, _api_key_info: dict | None) -> tuple: + return await _chat_after_bind_no_echo(bound, chat_payload, api_key_info) + + result = await responses.proxy_responses( + dispatch, + api_key_info, + chat_handler=chat_handler, + ) return await echo_original(result, bound.original) diff --git a/server.py b/server.py index 1f72fa2..f43963c 100644 --- a/server.py +++ b/server.py @@ -35,6 +35,11 @@ import control_plane from providers.protocol import KNOWN_CHANNEL_SET from providers.qclaw.store import default_guid, upsert_account as upsert_qclaw_account +from reasoning_controls import ( + InvalidReasoningControl, + normalize_chat_reasoning, + resolve_reasoning_control, +) from version import VERSION @@ -231,6 +236,19 @@ async def _read_json_object(request: Request, *, allow_empty: bool = False) -> d return data +def _invalid_reasoning_http(exc: InvalidReasoningControl) -> HTTPException: + return HTTPException( + status_code=400, + detail={ + "error": { + "message": str(exc), + "type": "invalid_request_error", + "code": "invalid_reasoning_control", + } + }, + ) + + async def _gather_limited(accounts: list[dict], operation, limit: int = 4) -> list[dict]: semaphore = asyncio.Semaphore(max(1, limit)) @@ -328,6 +346,10 @@ async def chat_completions( raise HTTPException(status_code=400, detail={"error": {"message": "messages is required", "type": "invalid_request_error"}}) if "model" in payload and not isinstance(payload["model"], str): raise HTTPException(status_code=400, detail={"error": {"message": "model must be a string", "type": "invalid_request_error"}}) + try: + payload = normalize_chat_reasoning(payload) + except InvalidReasoningControl as exc: + raise _invalid_reasoning_http(exc) from exc # Codex 类型 Key:自动应用内容清洗 + 工具过滤 if api_key_info and api_key_info.get("client_type") == "codex": payload = responses.apply_codex_sanitize(payload) @@ -370,18 +392,16 @@ async def resp_responses( ) if "model" in payload and not isinstance(payload["model"], str): raise HTTPException(status_code=400, detail={"error": {"message": "model must be a string", "type": "invalid_request_error"}}) + try: + resolve_reasoning_control(payload, prefer_nested=True) + except InvalidReasoningControl as exc: + raise _invalid_reasoning_http(exc) from exc bound = router.bind_http(payload, api_key_info) _check_model_access(api_key_info, bound.original, bound.inner, bound.channel) await router.ensure_usable(bound.channel) await run_in_threadpool(_reserve_client_quota, api_key_info) - dispatch = router.dispatch_payload(payload, bound.inner) - info = dict(api_key_info or {}) - info["_log_model"] = bound.original - info["_bind_channel"] = bound.channel - try: - result = await responses.proxy_responses(dispatch, info) - result = await router.echo_original(result, bound.original) + result = await router.responses_after_bind(bound, payload, api_key_info) except Exception as e: import traceback sys.stderr.write(f"[responses] ERROR: {e}\n{traceback.format_exc()}\n") diff --git a/tests/test_bind.py b/tests/test_bind.py index 6c57b8d..27ceb59 100644 --- a/tests/test_bind.py +++ b/tests/test_bind.py @@ -1,4 +1,5 @@ import asyncio +import json import pytest from fastapi import HTTPException @@ -18,6 +19,10 @@ class _QwenStub: display_name = "QwenWork" checkin_supported = False + def __init__(self): + self.last_payload = None + self.last_api_key_info = None + def list_models(self): return [{"id": "auto"}, {"id": "qwork-advanced"}] @@ -40,7 +45,22 @@ async def has_usable_account(self): return False async def chat_completions(self, payload, api_key_info): - return ("json", {"model": payload.get("model"), "id": "stub"}) + self.last_payload = payload + self.last_api_key_info = api_key_info + return ( + "json", + { + "id": "chatcmpl-stub", + "object": "chat.completion", + "model": payload.get("model"), + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) @pytest.fixture() @@ -136,3 +156,104 @@ def test_ensure_usable_503_does_not_need_workbuddy(qwen_enabled): asyncio.run(router.ensure_usable("qwenwork")) assert err.value.status_code == 503 assert err.value.detail["error"]["code"] == "channel_unavailable" + + +def test_responses_after_bind_dispatches_to_selected_provider(qwen_enabled, monkeypatch): + async def fail_workbuddy(*_args, **_kwargs): + raise AssertionError("Responses bypassed the selected provider") + + monkeypatch.setattr("proxy.proxy_chat_completions", fail_workbuddy) + bound = router.bind( + {"model": "qwenwork/qwork-advanced"}, + {"default_channel": "qwenwork"}, + ) + + result = asyncio.run(router.responses_after_bind( + bound, + { + "model": "qwenwork/qwork-advanced", + "input": "hello", + "reasoning": {"effort": "low"}, + }, + {"id": 7, "name": "qwen-key", "default_channel": "qwenwork"}, + )) + + assert result[0] == "json" + assert result[1]["model"] == "qwenwork/qwork-advanced" + assert result[1]["output"][0]["content"][0]["text"] == "ok" + assert qwen_enabled.last_payload["model"] == "qwork-advanced" + assert qwen_enabled.last_payload["reasoning_effort"] == "low" + assert qwen_enabled.last_api_key_info["_bind_channel"] == "qwenwork" + + +def test_responses_after_bind_rewrites_nested_stream_model(qwen_enabled, monkeypatch): + async def stream_response(payload, api_key_info): + qwen_enabled.last_payload = payload + qwen_enabled.last_api_key_info = api_key_info + + async def chunks(): + yield ( + 'data: {"id":"chatcmpl-stub","model":"qwork-advanced",' + '"choices":[{"index":0,"delta":{"content":"ok"},' + '"finish_reason":null}]}\n\n' + ).encode() + yield ( + 'data: {"id":"chatcmpl-stub","model":"qwork-advanced",' + '"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + ).encode() + yield b"data: [DONE]\n\n" + + return ("stream", chunks()) + + monkeypatch.setattr(qwen_enabled, "chat_completions", stream_response) + original = "qwenwork/qwork-advanced" + bound = router.bind( + {"model": original}, + {"default_channel": "qwenwork"}, + ) + + async def collect_events(): + result = await router.responses_after_bind( + bound, + {"model": original, "input": "hello", "stream": True}, + {"id": 7, "name": "qwen-key", "default_channel": "qwenwork"}, + ) + assert result[0] == "stream" + return [chunk async for chunk in result[1]] + + chunks = asyncio.run(collect_events()) + events = [ + json.loads(line[6:]) + for chunk in chunks + for line in ( + chunk.decode() if isinstance(chunk, (bytes, bytearray)) else chunk + ).splitlines() + if line.startswith("data: {") + ] + snapshots = [event["response"] for event in events if "response" in event] + + assert snapshots + assert all(snapshot["model"] == original for snapshot in snapshots) + assert all(event.get("model", original) == original for event in events) + + +def test_responses_after_bind_preserves_provider_error(qwen_enabled, monkeypatch): + expected = (429, {"error": {"message": "busy", "type": "rate_limit_error"}}) + + async def fail_response(_payload, _api_key_info): + return ("error", expected) + + monkeypatch.setattr(qwen_enabled, "chat_completions", fail_response) + original = "qwenwork/qwork-advanced" + bound = router.bind( + {"model": original}, + {"default_channel": "qwenwork"}, + ) + + result = asyncio.run(router.responses_after_bind( + bound, + {"model": original, "input": "hello"}, + {"id": 7, "name": "qwen-key", "default_channel": "qwenwork"}, + )) + + assert result == ("error", expected) diff --git a/tests/test_core.py b/tests/test_core.py index f3475b6..8594c2b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -10,6 +10,7 @@ import credential_crypto import database as db import proxy +import reasoning_controls import responses import server import auth_manager @@ -20,7 +21,10 @@ def _chat_sse(payload: dict) -> bytes: return f"data: {data}\n\n".encode("utf-8") -def _collect_response_events(chunks: list[bytes]) -> list[tuple[str, dict]]: +def _collect_response_events( + chunks: list[bytes], + resp_payload: dict | None = None, +) -> list[tuple[str, dict]]: async def source(): for chunk in chunks: yield chunk @@ -31,6 +35,7 @@ async def collect(): async for event in responses.chat_stream_to_responses_stream( source(), "test-model", + resp_payload, ) ] @@ -62,6 +67,25 @@ def test_build_backend_body_adds_configured_reasoning_default_for_deepseek(monke assert body["reasoning_effort"] == "high" +@pytest.mark.parametrize("model", ["deepseek-v4-pro", "deepseek-v4-flash"]) +def test_build_backend_body_uses_high_reasoning_default_when_unset(monkeypatch, model): + monkeypatch.delenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", raising=False) + monkeypatch.setattr(proxy, "resolve_model_alias", lambda value: value) + + body = proxy.build_backend_body({"model": model, "messages": []}) + + assert body["reasoning_effort"] == "high" + + +def test_build_backend_body_allows_reasoning_default_to_be_disabled(monkeypatch): + monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "off") + monkeypatch.setattr(proxy, "resolve_model_alias", lambda value: value) + + body = proxy.build_backend_body({"model": "deepseek-v4-pro", "messages": []}) + + assert "reasoning_effort" not in body + + def test_build_backend_body_maps_developer_messages_to_system(monkeypatch): monkeypatch.setattr(proxy, "resolve_model_alias", lambda model: model) messages = [ @@ -99,8 +123,23 @@ def test_build_backend_body_does_not_add_reasoning_default_to_other_models(monke assert "reasoning_effort" not in body -@pytest.mark.parametrize("explicit", ["none", "off", "low", "max"]) -def test_build_backend_body_preserves_explicit_reasoning_effort(monkeypatch, explicit): +@pytest.mark.parametrize( + ("explicit", "expected"), + [ + ("minimal", "low"), + ("low", "low"), + ("medium", "high"), + ("high", "high"), + ("xhigh", "max"), + ("max", "max"), + ("ultra", "max"), + ], +) +def test_build_backend_body_projects_standard_reasoning_effort( + monkeypatch, + explicit, + expected, +): monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "high") monkeypatch.setattr(proxy, "resolve_model_alias", lambda model: model) @@ -110,10 +149,43 @@ def test_build_backend_body_preserves_explicit_reasoning_effort(monkeypatch, exp "reasoning_effort": explicit, }) + assert body["reasoning_effort"] == expected + + +@pytest.mark.parametrize("explicit", ["none", "off"]) +def test_build_backend_body_disables_reasoning_without_reinjecting_default( + monkeypatch, + explicit, +): + monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "high") + monkeypatch.setattr(proxy, "resolve_model_alias", lambda model: model) + + body = proxy.build_backend_body({ + "model": "deepseek-v4-flash", + "messages": [], + "reasoning_effort": explicit, + }) + + assert "reasoning_effort" not in body + + +@pytest.mark.parametrize("explicit", ["none", "minimal", "medium", "xhigh", "ultra"]) +def test_build_backend_body_preserves_explicit_effort_for_other_models( + monkeypatch, + explicit, +): + monkeypatch.setattr(proxy, "resolve_model_alias", lambda value: value) + + body = proxy.build_backend_body({ + "model": "glm-5.2", + "messages": [], + "reasoning_effort": explicit, + }) + assert body["reasoning_effort"] == explicit -def test_build_backend_body_ignores_unsupported_thinking_without_injecting_default(monkeypatch): +def test_build_backend_body_maps_disabled_thinking_without_injecting_default(monkeypatch): monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "high") monkeypatch.setattr(proxy, "resolve_model_alias", lambda model: model) thinking = {"type": "disabled"} @@ -128,6 +200,93 @@ def test_build_backend_body_ignores_unsupported_thinking_without_injecting_defau assert "reasoning_effort" not in body +@pytest.mark.parametrize("thinking", [{"type": "enabled"}, {"type": "adaptive"}]) +def test_build_backend_body_maps_enabled_thinking_to_high(monkeypatch, thinking): + monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "off") + monkeypatch.setattr(proxy, "resolve_model_alias", lambda model: model) + + body = proxy.build_backend_body({ + "model": "deepseek-v4-pro", + "messages": [], + "thinking": thinking, + }) + + assert body["reasoning_effort"] == "high" + + +def test_chat_reasoning_effort_overrides_lower_priority_disable_switch(): + normalized = reasoning_controls.normalize_chat_reasoning({ + "reasoning_effort": "high", + "thinking": {"type": "disabled"}, + }) + + assert normalized["reasoning_effort"] == "high" + + +def test_output_config_effort_overrides_cross_object_thinking_disable(): + normalized = reasoning_controls.normalize_chat_reasoning({ + "output_config": {"effort": "high"}, + "thinking": {"type": "disabled"}, + }) + + assert normalized["reasoning_effort"] == "high" + + +def test_higher_priority_switch_overrides_cross_dialect_switch(): + normalized = reasoning_controls.normalize_chat_reasoning({ + "thinking": {"type": "enabled"}, + "enable_thinking": False, + }) + + assert normalized["reasoning_effort"] == "high" + + +def test_reasoning_control_rejects_conflict_inside_native_object(): + with pytest.raises(reasoning_controls.InvalidReasoningControl): + reasoning_controls.normalize_chat_reasoning({ + "reasoning": {"effort": "high", "enabled": False}, + }) + + +def test_default_reasoning_value_defers_to_lower_priority_explicit_switch(): + normalized = reasoning_controls.normalize_chat_reasoning({ + "reasoning_effort": "default", + "thinking": {"type": "disabled"}, + }) + + assert normalized["reasoning_effort"] == "none" + + +def test_reasoning_normalization_preserves_extensions_and_budget_idempotently(): + payload = { + "thinking": { + "type": "enabled", + "budget_tokens": 4096, + "display": "hidden", + }, + "reasoning": { + "exclude": True, + "max_tokens": 8192, + }, + } + + normalized = reasoning_controls.normalize_chat_reasoning(payload) + normalized_again = reasoning_controls.normalize_chat_reasoning(normalized) + + assert normalized["reasoning_effort"] == "high" + assert normalized["thinking"] == { + "budget_tokens": 4096, + "display": "hidden", + } + assert normalized["reasoning"] == { + "exclude": True, + "max_tokens": 8192, + } + assert normalized_again == normalized + control = reasoning_controls.resolve_reasoning_control(payload) + assert control.budget_tokens == 4096 + + def _collect_chat_proxy_stream( chunks: list[bytes], monkeypatch, @@ -481,6 +640,49 @@ def test_api_auth_fails_closed_without_keys(isolated_db, monkeypatch): assert error.value.status_code == 503 +@pytest.mark.parametrize( + ("endpoint", "payload"), + [ + ( + server.chat_completions, + { + "model": "auto", + "messages": [{"role": "user", "content": "hello"}], + "reasoning_effort": {"level": "high"}, + }, + ), + ( + server.resp_responses, + { + "model": "auto", + "input": "hello", + "reasoning": {"effort": 42}, + }, + ), + ], +) +def test_http_endpoints_reject_invalid_reasoning_controls( + monkeypatch, + endpoint, + payload, +): + class FakeRequest: + async def stream(self): + yield json.dumps(payload).encode("utf-8") + + monkeypatch.setattr( + server, + "_check_client_auth", + lambda *_args, **_kwargs: {"default_channel": "workbuddy"}, + ) + + with pytest.raises(HTTPException) as error: + asyncio.run(endpoint(FakeRequest(), "Bearer test", None)) + + assert error.value.status_code == 400 + assert error.value.detail["error"]["code"] == "invalid_reasoning_control" + + def test_responses_input_image_string_is_preserved(): flattened = responses._flatten_content( [{"type": "input_image", "image_url": "data:image/png;base64,abc"}] @@ -488,6 +690,147 @@ def test_responses_input_image_string_is_preserved(): assert "data:image/png;base64,abc" in flattened +@pytest.mark.parametrize( + "effort", + ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"], +) +def test_responses_to_chat_maps_reasoning_effort_without_mutating_input(effort): + payload = { + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning": {"effort": effort}, + } + original = json.loads(json.dumps(payload)) + + chat_payload = responses.responses_to_chat(payload) + + assert chat_payload["reasoning_effort"] == effort + assert payload == original + + +@pytest.mark.parametrize( + ("compatibility_fields", "expected"), + [ + ({"reasoningEffort": "medium"}, "medium"), + ({"thinking": {"type": "enabled"}}, "high"), + ({"thinking": {"type": "disabled"}}, "none"), + ({"enable_thinking": True}, "high"), + ({"enable_thinking": False}, "none"), + ( + {"thinking": {"type": "adaptive"}, "output_config": {"effort": "xhigh"}}, + "xhigh", + ), + ], +) +def test_responses_to_chat_accepts_agent_reasoning_compatibility_forms( + compatibility_fields, + expected, +): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + **compatibility_fields, + }) + + assert chat_payload["reasoning_effort"] == expected + + +def test_responses_to_chat_maps_reasoning_summary(): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning": {"effort": "high", "summary": "detailed"}, + }) + + assert chat_payload["reasoning_summary"] == "detailed" + + +def test_responses_to_chat_preserves_reasoning_budget_extensions(): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "thinking": { + "type": "enabled", + "budget_tokens": 4096, + "display": "hidden", + }, + }) + + assert chat_payload["reasoning_effort"] == "high" + assert chat_payload["thinking"] == { + "budget_tokens": 4096, + "display": "hidden", + } + + +def test_responses_reasoning_effort_overrides_backend_default(monkeypatch): + monkeypatch.setenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "high") + monkeypatch.setattr(proxy, "resolve_model_alias", lambda value: value) + + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning": {"effort": "low"}, + }) + body = proxy.build_backend_body(chat_payload) + + assert body["reasoning_effort"] == "low" + + +def test_responses_uses_backend_reasoning_default_when_effort_is_omitted(monkeypatch): + monkeypatch.delenv("CB_GATEWAY_DEFAULT_REASONING_EFFORT", raising=False) + monkeypatch.setattr(proxy, "resolve_model_alias", lambda value: value) + + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-flash", + "input": "hello", + }) + body = proxy.build_backend_body(chat_payload) + + assert body["reasoning_effort"] == "high" + + +def test_responses_to_chat_accepts_top_level_reasoning_effort(): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning_effort": "max", + }) + + assert chat_payload["reasoning_effort"] == "max" + + +def test_responses_nested_reasoning_effort_takes_precedence(): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning": {"effort": "low"}, + "reasoning_effort": "max", + }) + + assert chat_payload["reasoning_effort"] == "low" + + +def test_responses_nested_disable_overrides_top_level_enable(): + chat_payload = responses.responses_to_chat({ + "model": "deepseek-v4-pro", + "input": "hello", + "reasoning": {"effort": "none"}, + "reasoning_effort": "high", + }) + + assert chat_payload["reasoning_effort"] == "none" + + +def test_chat_top_level_disable_overrides_nested_enable(): + chat_payload = reasoning_controls.normalize_chat_reasoning({ + "reasoning_effort": "none", + "reasoning": {"effort": "high"}, + }) + + assert chat_payload["reasoning_effort"] == "none" + + def test_responses_to_chat_groups_parallel_function_calls_across_reasoning_items(): payload = { "model": "test-model", @@ -628,6 +971,45 @@ def test_responses_stream_reassembles_byte_split_tool_arguments(): assert len(sequence_numbers) == len(set(sequence_numbers)) +def test_responses_stream_snapshots_include_request_fields_and_usage_details(): + request_tool = { + "type": "function", + "name": "lookup", + "description": "Look up a value", + "parameters": {"type": "object", "properties": {}}, + } + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"content": "done"}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + "prompt_tokens_details": {"cached_tokens": 5}, + "completion_tokens_details": {"reasoning_tokens": 6}, + }, + }), + b"data: [DONE]\n\n", + ], { + "parallel_tool_calls": False, + "tool_choice": "required", + "tools": [request_tool], + }) + + for event_name in ("response.created", "response.in_progress", "response.completed"): + snapshot = _events_of_type(events, event_name)[0]["response"] + assert snapshot["parallel_tool_calls"] is False + assert snapshot["tool_choice"] == "required" + assert snapshot["tools"] == [request_tool] + usage = _events_of_type(events, "response.completed")[0]["response"]["usage"] + assert usage["input_tokens_details"] == {"cached_tokens": 5} + assert usage["output_tokens_details"] == {"reasoning_tokens": 6} + + def test_responses_stream_keeps_parallel_tool_calls_separate(): chunks = [ _chat_sse({ @@ -721,7 +1103,7 @@ def test_responses_stream_fails_on_unexpected_eof_with_partial_tool_call(): failed = _events_of_type(events, "response.failed") output_done = _events_of_type(events, "response.output_item.done") assert len(failed) == 1 - assert failed[0]["response"]["error"]["code"] == "upstream_stream_ended" + assert failed[0]["response"]["error"]["code"] == "server_error" assert output_done[0]["item"]["status"] == "incomplete" assert not _events_of_type(events, "response.function_call_arguments.done") assert not _events_of_type(events, "response.completed") @@ -806,11 +1188,319 @@ def test_responses_stream_emits_complete_text_lifecycle(): assert completed["output"][0]["content"][0]["text"] == "hello" +def test_responses_stream_orders_reasoning_before_text_and_tool(): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "think-"}, + "finish_reason": None, + }], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "more", "content": "answer"}, + "finish_reason": None, + }], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"tool_calls": [{ + "index": 0, + "id": "call_test", + "function": {"name": "test_tool", "arguments": '{"ok":true}'}, + }]}, + "finish_reason": "tool_calls", + }], + }), + b"data: [DONE]\n\n", + ]) + + names = [name for name, _ in events] + added = _events_of_type(events, "response.output_item.added") + assert [item["item"]["type"] for item in added] == [ + "reasoning", + "message", + "function_call", + ] + assert [item["output_index"] for item in added] == [0, 1, 2] + + reasoning_id = added[0]["item"]["id"] + reasoning_events = [ + payload + for name, payload in events + if name.startswith("response.reasoning_summary_") + ] + assert all(event["item_id"] == reasoning_id for event in reasoning_events) + assert all(event["output_index"] == 0 for event in reasoning_events) + assert all(event["summary_index"] == 0 for event in reasoning_events) + assert [ + event["delta"] + for event in _events_of_type(events, "response.reasoning_summary_text.delta") + ] == ["think-", "more"] + assert _events_of_type(events, "response.reasoning_summary_text.done")[0]["text"] == "think-more" + + assert names.index("response.reasoning_summary_text.done") < names.index( + "response.reasoning_summary_part.done" + ) + reasoning_done_index = next( + index + for index, (name, payload) in enumerate(events) + if name == "response.output_item.done" and payload["item"]["type"] == "reasoning" + ) + message_added_index = next( + index + for index, (name, payload) in enumerate(events) + if name == "response.output_item.added" and payload["item"]["type"] == "message" + ) + message_done_index = next( + index + for index, (name, payload) in enumerate(events) + if name == "response.output_item.done" and payload["item"]["type"] == "message" + ) + tool_added_index = next( + index + for index, (name, payload) in enumerate(events) + if name == "response.output_item.added" and payload["item"]["type"] == "function_call" + ) + assert reasoning_done_index < message_added_index + assert message_done_index < tool_added_index + + completed = _events_of_type(events, "response.completed")[0]["response"] + assert [item["type"] for item in completed["output"]] == [ + "reasoning", + "message", + "function_call", + ] + assert completed["output"][0]["summary"][0]["text"] == "think-more" + sequence_numbers = [payload["sequence_number"] for _, payload in events] + assert sequence_numbers == list(range(len(events))) + + +def test_responses_stream_accepts_reasoning_only_completed_choice(): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "internal "}, + "finish_reason": None, + }], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "only"}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ]) + + assert not _events_of_type(events, "response.failed") + assert not _events_of_type(events, "response.output_text.delta") + completed = _events_of_type(events, "response.completed")[0]["response"] + assert [item["type"] for item in completed["output"]] == ["reasoning"] + assert completed["output"][0]["summary"] == [ + {"type": "summary_text", "text": "internal only"} + ] + names = [name for name, _ in events] + assert names.count("response.reasoning_summary_text.done") == 1 + assert names.count("response.reasoning_summary_part.done") == 1 + assert names.count("response.output_item.done") == 1 + assert names.index("response.reasoning_summary_text.done") < names.index( + "response.reasoning_summary_part.done" + ) < names.index("response.output_item.done") < names.index("response.completed") + + +def test_responses_stream_deduplicates_cross_chunk_reasoning_fallback(): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "same text"}, + "finish_reason": None, + }], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"content": "same text"}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ]) + + completed = _events_of_type(events, "response.completed")[0]["response"] + assert [item["type"] for item in completed["output"]] == ["reasoning"] + assert not _events_of_type(events, "response.output_text.delta") + + +@pytest.mark.parametrize( + ("finish_reason", "incomplete_reason"), + [("length", "max_output_tokens"), ("content_filter", "content_filter")], +) +def test_responses_stream_closes_reasoning_when_incomplete( + finish_reason, + incomplete_reason, +): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "partial reasoning"}, + "finish_reason": finish_reason, + }], + }), + b"data: [DONE]\n\n", + ]) + + output_done = _events_of_type(events, "response.output_item.done")[0] + incomplete = _events_of_type(events, "response.incomplete")[0]["response"] + names = [name for name, _ in events] + assert output_done["item"]["status"] == "incomplete" + assert output_done["item"]["summary"][0]["text"] == "partial reasoning" + assert incomplete["incomplete_details"] == {"reason": incomplete_reason} + assert _events_of_type(events, "response.reasoning_summary_text.done")[0]["text"] == ( + "partial reasoning" + ) + assert names.index("response.reasoning_summary_text.done") < names.index( + "response.reasoning_summary_part.done" + ) < names.index("response.output_item.done") < names.index("response.incomplete") + assert not _events_of_type(events, "response.failed") + + +@pytest.mark.parametrize( + "tail", + [ + _chat_sse({"error": {"message": "failed", "code": "upstream_failed"}}), + b"", + ], +) +def test_responses_stream_closes_reasoning_before_failure(tail): + chunks = [_chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "partial reasoning"}, + "finish_reason": None, + }], + })] + if tail: + chunks.append(tail) + events = _collect_response_events(chunks) + + names = [name for name, _ in events] + output_done = _events_of_type(events, "response.output_item.done")[0] + failed = _events_of_type(events, "response.failed")[0]["response"] + assert output_done["item"]["status"] == "incomplete" + assert failed["error"]["code"] == "server_error" + assert names.index("response.reasoning_summary_text.done") < names.index( + "response.reasoning_summary_part.done" + ) < names.index("response.output_item.done") < names.index("response.failed") + + +def test_responses_stream_keeps_reasoning_choices_separate(): + events = _collect_response_events([ + _chat_sse({ + "choices": [ + {"index": 1, "delta": {"reasoning_content": "one"}, "finish_reason": "stop"}, + {"index": 0, "delta": {"reasoning_content": "zero-a"}, "finish_reason": None}, + ], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "zero-b"}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ]) + + added = [ + payload + for payload in _events_of_type(events, "response.output_item.added") + if payload["item"]["type"] == "reasoning" + ] + assert len(added) == 2 + assert len({payload["item"]["id"] for payload in added}) == 2 + assert [payload["output_index"] for payload in added] == [0, 1] + completed = _events_of_type(events, "response.completed")[0]["response"] + assert [item["summary"][0]["text"] for item in completed["output"]] == [ + "one", + "zero-azero-b", + ] + assert len(_events_of_type(events, "response.reasoning_summary_text.done")) == 2 + assert len(_events_of_type(events, "response.reasoning_summary_part.done")) == 2 + + +@pytest.mark.parametrize( + ("first_delta", "output_type"), + [ + ({"content": "answer started"}, "message"), + ({ + "tool_calls": [{ + "index": 0, + "id": "call_started", + "function": {"name": "tool", "arguments": "{}"}, + }], + }, "function_call"), + ], +) +def test_responses_stream_rejects_reasoning_after_output_started( + first_delta, + output_type, +): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": first_delta, + "finish_reason": None, + }], + }), + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "too late"}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ]) + + failed = _events_of_type(events, "response.failed")[0]["response"] + assert failed["error"]["code"] == "server_error" + assert "reasoning after answer or tool output" in failed["error"]["message"] + assert [item["type"] for item in failed["output"]] == [output_type] + assert failed["output"][0]["status"] == "incomplete" + assert not _events_of_type(events, "response.reasoning_summary_text.delta") + + +def test_responses_stream_coerces_truthy_non_string_reasoning_delta(): + events = _collect_response_events([ + _chat_sse({ + "choices": [{ + "index": 0, + "delta": {"reasoning_content": 42}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ]) + + delta = _events_of_type(events, "response.reasoning_summary_text.delta")[0] + completed = _events_of_type(events, "response.completed")[0]["response"] + assert delta["delta"] == "42" + assert completed["output"][0]["summary"][0]["text"] == "42" + + @pytest.mark.parametrize( ("delta", "finish_reason"), [ ({}, "stop"), - ({"reasoning_content": "internal only"}, "stop"), ({}, None), ], ) @@ -824,7 +1514,7 @@ def test_responses_stream_rejects_completed_choice_without_output(delta, finish_ failed = _events_of_type(events, "response.failed") assert len(failed) == 1 - assert failed[0]["response"]["error"]["code"] == "empty_upstream_response" + assert failed[0]["response"]["error"]["code"] == "server_error" assert not _events_of_type(events, "response.completed") @@ -840,7 +1530,7 @@ def test_responses_stream_fails_when_only_one_choice_finishes_before_eof(): failed = _events_of_type(events, "response.failed") assert len(failed) == 1 - assert failed[0]["response"]["error"]["code"] == "upstream_stream_ended" + assert failed[0]["response"]["error"]["code"] == "server_error" assert not _events_of_type(events, "response.completed") @@ -920,6 +1610,28 @@ def test_chat_proxy_stream_rejects_terminal_without_content(monkeypatch): _assert_chat_proxy_error_only(raw) +def test_chat_proxy_stream_accepts_reasoning_only_terminal(monkeypatch): + raw = _collect_chat_proxy_stream([ + _chat_sse({ + "id": "chatcmpl-reasoning", + "object": "chat.completion.chunk", + "created": 123, + "model": "test-model", + "choices": [{ + "index": 0, + "delta": {"reasoning_content": "internal only"}, + "finish_reason": "stop", + }], + }), + b"data: [DONE]\n\n", + ], monkeypatch) + + payloads, done_count = _parse_chat_proxy_sse(raw) + assert not any(payload.get("error") for payload in payloads) + assert payloads[0]["choices"][0]["delta"]["reasoning_content"] == "internal only" + assert done_count == 1 + + def test_non_stream_chat_conversion_rejects_empty_completed_response(): converted = responses.chat_response_to_responses({ "id": "chatcmpl-empty", @@ -934,11 +1646,79 @@ def test_non_stream_chat_conversion_rejects_empty_completed_response(): assert converted["status"] == "failed" assert converted["output"] == [] - assert converted["error"]["code"] == "empty_upstream_response" + assert converted["error"]["code"] == "server_error" + + +def test_non_stream_chat_conversion_orders_reasoning_before_text(): + converted = responses.chat_response_to_responses({ + "id": "chatcmpl-reasoning", + "model": "test-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "reasoning_content": "analysis", + "content": "answer", + }, + "finish_reason": "stop", + }], + "usage": {}, + }, "test-model") + + assert converted["status"] == "completed" + assert [item["type"] for item in converted["output"]] == ["reasoning", "message"] + assert converted["output"][0]["summary"][0]["text"] == "analysis" + + +def test_non_stream_chat_conversion_includes_required_response_fields_and_usage_details(): + converted = responses.chat_response_to_responses({ + "id": "chatcmpl-details", + "model": "test-model", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "answer"}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 4, + "total_tokens": 13, + "prompt_tokens_details": {"cached_tokens": 3}, + "completion_tokens_details": {"reasoning_tokens": 2}, + }, + }, "test-model", { + "parallel_tool_calls": False, + "tool_choice": "none", + "tools": [], + }) + + assert converted["parallel_tool_calls"] is False + assert converted["tool_choice"] == "none" + assert converted["tools"] == [] + assert converted["usage"]["input_tokens_details"] == {"cached_tokens": 3} + assert converted["usage"]["output_tokens_details"] == {"reasoning_tokens": 2} + + +def test_non_stream_chat_conversion_deduplicates_reasoning_fallback_content(): + converted = responses.chat_response_to_responses({ + "id": "chatcmpl-reasoning", + "model": "test-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "reasoning_content": "analysis", + "content": "analysis", + }, + "finish_reason": "stop", + }], + "usage": {}, + }, "test-model") + assert [item["type"] for item in converted["output"]] == ["reasoning"] -@pytest.mark.parametrize("delta", [{}, {"reasoning_content": "internal only"}]) -def test_non_stream_aggregator_rejects_terminal_without_output(monkeypatch, delta): + +def _collect_non_stream_upstream(monkeypatch, delta): payload = { "id": "chatcmpl-empty", "object": "chat.completion.chunk", @@ -975,7 +1755,7 @@ def stream(self, *args, **kwargs): monkeypatch.setattr(proxy.httpx, "AsyncClient", FakeAsyncClient) monkeypatch.setattr(auth_manager, "request_timeout", lambda _default: 30) - result = asyncio.run(proxy._collect_stream( + return asyncio.run(proxy._collect_stream( "https://upstream.test/v2/chat/completions", {"Authorization": "Bearer test"}, {"model": "test-model", "stream": True}, @@ -985,11 +1765,27 @@ def stream(self, *args, **kwargs): 0, )) + +def test_non_stream_aggregator_rejects_terminal_without_output(monkeypatch): + result = _collect_non_stream_upstream(monkeypatch, {}) + assert result[0] == "error" assert result[1][0] == 502 assert "without content" in result[1][1]["error"]["message"] +def test_non_stream_aggregator_accepts_reasoning_only_output(monkeypatch): + result = _collect_non_stream_upstream( + monkeypatch, + {"reasoning_content": "internal only"}, + ) + + assert result[0] == "json" + message = result[1]["choices"][0]["message"] + assert message["content"] is None + assert message["reasoning_content"] == "internal only" + + def test_chat_proxy_stream_normalizes_empty_finish_reason(monkeypatch): chunks = [ _chat_sse({ diff --git a/tests/test_qclaw.py b/tests/test_qclaw.py index 14f300c..6d7c28a 100644 --- a/tests/test_qclaw.py +++ b/tests/test_qclaw.py @@ -10,7 +10,7 @@ import router from providers.protocol import KeyChannelMismatch, UnknownChannel, UnknownModel from providers.qclaw.constants import JPRX_SIGNATURE_KEY, STATIC_MODELS -from providers.qclaw.chat import fill_empty_content +from providers.qclaw.chat import _build_body, fill_empty_content from providers.qclaw.sign import aizone_headers, jprx_ctx from providers.qclaw.store import parse_credentials, qclaw_auth_dirs @@ -54,6 +54,28 @@ def test_fill_empty_content_uses_reasoning(): assert kept["content"] == "可见" +def test_fill_empty_content_normalizes_reasoning_alias(): + filled = fill_empty_content({"role": "assistant", "content": "", "reasoning": "思考"}) + assert filled["content"] == "思考" + assert filled["reasoning_content"] == "思考" + + +@pytest.mark.parametrize( + ("controls", "expected"), + [ + ({"reasoning": {"effort": "xhigh"}}, "xhigh"), + ({"thinking": {"type": "enabled"}}, "high"), + ({"thinking": {"type": "disabled"}}, "none"), + ], +) +def test_qclaw_build_body_normalizes_agent_reasoning_controls(controls, expected): + body, _ = _build_body({"model": "default", "messages": [], **controls}) + + assert body["reasoning_effort"] == expected + assert "thinking" not in body + assert "reasoning" not in body + + def test_jprx_ctx_matches_official_md5_formula(): body = '{"web_version":"1.4.0"}' gid = "abc" diff --git a/tests/test_qwenwork.py b/tests/test_qwenwork.py index cd3a7e5..62bf416 100644 --- a/tests/test_qwenwork.py +++ b/tests/test_qwenwork.py @@ -9,7 +9,7 @@ import router from providers.protocol import UnknownChannel, UnknownModel from providers.qwenwork import cosy -from providers.qwenwork.chat import envelope_error, unwrap_sse_payload +from providers.qwenwork.chat import build_body, envelope_error, unwrap_sse_payload from providers.qwenwork.constants import COSY_VERSION, COSY_VERSION_FROZEN, RSA_PUBLIC_KEY_PEM, STATIC_MODELS from providers.qwenwork.store import parse_credentials, qwenwork_auth_dirs @@ -159,3 +159,24 @@ def test_unwrap_outer_sse_envelope(): def test_static_models_match_official_0_1_8(): assert STATIC_MODELS == ("qwork-advanced", "qwork-auto", "qwork-lite", "qmodel_latest") + + +@pytest.mark.parametrize( + ("controls", "expected"), + [ + ({}, False), + ({"reasoning_effort": "high"}, True), + ({"thinking": {"type": "enabled"}}, True), + ({"reasoning_effort": "none"}, False), + ({"enable_thinking": False}, False), + ], +) +def test_qwenwork_build_body_applies_reasoning_switch(controls, expected): + body, _, _ = build_body({ + "model": "qwork-advanced", + "messages": [{"role": "user", "content": "hello"}], + **controls, + }) + + assert body["chat_context"]["extra"]["modelConfig"]["is_reasoning"] is expected + assert body["model_config"]["is_reasoning"] is expected diff --git a/version.py b/version.py index 127c148..5b0431e 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "2.1.0" +VERSION = "2.1.1" diff --git a/web/index.html b/web/index.html index 3108a00..f274700 100644 --- a/web/index.html +++ b/web/index.html @@ -388,7 +388,7 @@ template:`
-
B2
Buddy 2 API
Local model gateway · v2.1.0
+
B2
Buddy 2 API
Local model gateway · v2.1.1