diff --git a/app/adapters/anthropic_adapter.py b/app/adapters/anthropic_adapter.py index d33d65a..e65a1d4 100644 --- a/app/adapters/anthropic_adapter.py +++ b/app/adapters/anthropic_adapter.py @@ -7,6 +7,7 @@ import time from typing import Any +from app.reasoning import extract_reasoning_text, map_reasoning_controls from app.upstream_io import (StreamOutputBudget, merge_tool_call_delta, new_tool_state, seal_tool_identities, tool_identity_complete) @@ -93,6 +94,7 @@ def anthropic_request_to_chat(body: dict) -> dict: raise ValueError("stop_sequences must be an array of strings") chat["stop"] = stop_sequences + map_reasoning_controls(body, chat, protocol="messages") return chat @@ -124,6 +126,9 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: # Structured content blocks blocks = content + if role != "assistant" and any(isinstance(block, dict) and block.get("type") in + ("thinking", "redacted_thinking") for block in blocks): + raise ValueError("thinking requires an assistant message") # User messages may contain tool results. if role == "user": @@ -157,11 +162,14 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: if role == "assistant": content_out = _convert_content_blocks(blocks) tool_calls: list[dict] = [] + thoughts: list[str] = [] for block in blocks: if not isinstance(block, dict): continue - bt = block.get("type", "") - if bt == "tool_use": + thought = extract_reasoning_text(block) + if thought is not None: + thoughts.append(thought) + elif block.get("type") == "tool_use": tc = { "id": block.get("id", _rand_id("call_")), "type": "function", @@ -176,6 +184,8 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: msg_out["content"] = content_out if content_out or has_text else None if tool_calls: msg_out["tool_calls"] = tool_calls + if thoughts: + msg_out["reasoning_content"] = "".join(thoughts) return [msg_out] content_out = _convert_content_blocks(blocks) diff --git a/app/adapters/chat_input.py b/app/adapters/chat_input.py index c40bda4..f452b1b 100644 --- a/app/adapters/chat_input.py +++ b/app/adapters/chat_input.py @@ -3,6 +3,8 @@ from fastapi import HTTPException +from app.reasoning import ReasoningInputError, extract_reasoning_text + _ANTHROPIC_BLOCKS = ("tool_use", "tool_result", "thinking", "redacted_thinking", "image") @@ -116,17 +118,15 @@ def _convert_message(message, index, pending): raise _invalid(location + ".tool_use_id", "tool_result must match one preceding, unanswered tool call") result_ids.add(identifier) results.append(result) - elif kind == "thinking": + elif kind in ("thinking", "redacted_thinking"): if role != "assistant": raise _invalid(location, "thinking requires an assistant message") if message.get("reasoning_content") not in (None, ""): raise _invalid(location, "thinking conflicts with existing reasoning_content") - if not isinstance(block.get("thinking"), str): - raise _invalid(location + ".thinking", "Thinking content must be a string") - # Anthropic signatures have no Chat equivalent and must not become visible text. - thoughts.append(block["thinking"]) - elif kind == "redacted_thinking": - raise _invalid(location, "redacted_thinking cannot be converted to Chat; use the Messages protocol") + try: + thoughts.append(extract_reasoning_text(block)) + except ReasoningInputError as error: + raise _invalid(location + ("." + error.field if error.field else ""), str(error)) from None else: parts.append(_content_part(block, location)) if results: diff --git a/app/adapters/responses_adapter.py b/app/adapters/responses_adapter.py index 6dbd900..a520247 100644 --- a/app/adapters/responses_adapter.py +++ b/app/adapters/responses_adapter.py @@ -7,6 +7,7 @@ import time from typing import Any +from app.reasoning import extract_reasoning_text, map_reasoning_controls from app.upstream_io import (StreamOutputBudget, merge_tool_call_delta, new_tool_state, seal_tool_identities, tool_identity_complete) @@ -78,16 +79,12 @@ def responses_request_to_chat(body: dict) -> dict: # Forward supported parameters. for key in ("temperature", "top_p", "stop", "seed", "presence_penalty", "frequency_penalty", - "response_format", "reasoning_effort", "parallel_tool_calls", "prompt_cache_key"): + "response_format", "parallel_tool_calls", "prompt_cache_key"): if key in body: chat[key] = body[key] # Explicit top-level values override equivalent nested fields. - reasoning = body.get("reasoning") - if isinstance(reasoning, dict) and "reasoning_effort" not in chat: - effort = reasoning.get("effort") - if isinstance(effort, str) and effort.strip(): - chat["reasoning_effort"] = effort + map_reasoning_controls(body, chat, protocol="responses") text = body.get("text") if isinstance(text, dict) and "response_format" not in chat: mapped = _text_format_to_response_format(text.get("format")) @@ -108,17 +105,36 @@ def _convert_input_items(items: list) -> list[dict]: # Buffer adjacent assistant text and function calls. pending_assistant_content: str | list[dict] | None = None pending_tool_calls: list[dict] = [] + pending_reasoning: list[str] = [] def _flush_assistant(): nonlocal pending_assistant_content, pending_tool_calls - if pending_assistant_content is not None or pending_tool_calls: + if pending_assistant_content is not None or pending_tool_calls or pending_reasoning: msg: dict[str, Any] = {"role": "assistant", "content": pending_assistant_content or ""} if pending_tool_calls: msg["tool_calls"] = pending_tool_calls[:] + if pending_reasoning: + msg["reasoning_content"] = "".join(pending_reasoning) messages.append(msg) pending_assistant_content = None pending_tool_calls.clear() + pending_reasoning.clear() + + def _set_assistant_content(content): + nonlocal pending_assistant_content + if pending_assistant_content is not None and not pending_tool_calls: + _flush_assistant() + # Realtime output can place text after a tool call within the same turn. + if pending_tool_calls and pending_assistant_content: + if isinstance(pending_assistant_content, str) and isinstance(content, str): + content = pending_assistant_content + content + else: + previous = (pending_assistant_content if isinstance(pending_assistant_content, list) + else [{"type": "text", "text": pending_assistant_content}]) + following = content if isinstance(content, list) else [{"type": "text", "text": content}] + content = previous + following + pending_assistant_content = content for item in items: if not isinstance(item, dict): @@ -127,6 +143,13 @@ def _flush_assistant(): item_type = item.get("type") role = item.get("role", "") + if item_type == "reasoning": + if role not in ("", "assistant"): + raise ValueError("reasoning requires an assistant item") + text = extract_reasoning_text(item) + pending_reasoning.append(text) + continue + # Untyped role messages if item_type is None and role in ("user", "system", "developer"): _flush_assistant() @@ -145,17 +168,15 @@ def _flush_assistant(): # Assistant output from history if item_type == "message" and role == "assistant": - _flush_assistant() content_parts = item.get("content", []) text = _extract_output_text(content_parts) if isinstance(content_parts, list) else str(content_parts) - pending_assistant_content = text + _set_assistant_content(text) continue # Untyped assistant messages if item_type is None and role == "assistant": - _flush_assistant() content = _extract_content(item.get("content", "")) - pending_assistant_content = content + _set_assistant_content(content) continue # Merge calls into the preceding assistant message. diff --git a/app/model_capabilities.py b/app/model_capabilities.py index 4637dd2..f5c4a13 100644 --- a/app/model_capabilities.py +++ b/app/model_capabilities.py @@ -9,6 +9,7 @@ from app.safe_logging import sanitize_log_text from app.model_catalog_view import SharedModel +from app.reasoning import resolve_reasoning_effort, thinking_mode PROFILES = frozenset(("cn-cli", "cn-work", "intl-cli", "intl-work")) _TEXT = frozenset(("id", "name", "vendor", "description", "descriptionZh", "descriptionEn", "credits", "summary")) _BOOL = frozenset(("supportsImages", "disabledMultimodal", "supportsToolCall", "supportsReasoning", @@ -209,8 +210,7 @@ def from_request(cls, body, payload=None, protocol="chat"): tools = bool(body.get("tools")) or any(isinstance(message, dict) and (message.get("role") == "tool" or message.get("tool_calls")) for message in messages) effort = body.get("reasoning_effort") - thinking = (payload or {}).get("thinking") if protocol == "messages" else None - thinking = thinking.get("type") if isinstance(thinking, dict) else None + thinking = thinking_mode(payload or {}) if protocol == "messages" else None output = body.get("max_tokens") param = "max_output_tokens" if protocol == "responses" else "max_tokens" if output is not None and _positive(output) is None: @@ -227,16 +227,17 @@ def violations(self, model): failures.append(("unsupported_image_input", self.image_param, "当前路由的模型声明不支持图片输入")) if self.tools and caps["tools"] is False: failures.append(("unsupported_tools", "tools", "当前路由的模型声明不支持工具调用")) - enabled = self.effort not in (None, "none") or self.thinking in ("enabled", "adaptive") - disabled = self.effort == "none" or self.thinking == "disabled" + effort = resolve_reasoning_effort(self.effort, self.thinking, model) + enabled = effort not in (None, "none") + disabled = effort == "none" if enabled and caps["reasoning"] is False: failures.append(("unsupported_reasoning", "reasoning_effort", "当前路由的模型声明不支持思考")) if disabled and caps["thinking_disable"] is False: failures.append(("reasoning_required", "reasoning_effort", "当前路由的模型声明不能关闭思考")) reasoning = model.get("reasoning") if isinstance(model.get("reasoning"), dict) else {} efforts = reasoning.get("supportedEfforts") - if (self.effort not in (None, "none") and isinstance(efforts, list) and (efforts or isinstance(model, SharedModel)) - and all(isinstance(value, str) for value in efforts) and self.effort not in efforts): + if (effort not in (None, "none") and isinstance(efforts, list) and (efforts or isinstance(model, SharedModel)) + and all(isinstance(value, str) for value in efforts) and effort not in efforts): failures.append(("unsupported_reasoning_effort", "reasoning_effort", "思考强度不在当前模型声明的选项中")) maximum = _positive(model.get("maxOutputTokens")) if maximum is not None and self.max_output is not None and self.max_output > maximum: diff --git a/app/reasoning.py b/app/reasoning.py new file mode 100644 index 0000000..542f6bf --- /dev/null +++ b/app/reasoning.py @@ -0,0 +1,121 @@ +"""Normalize readable reasoning and request controls for Chat upstreams.""" + + +class ReasoningInputError(ValueError): + """Identify an unsupported reasoning field without exposing its contents.""" + + def __init__(self, message, field=""): + self.field = field + super().__init__(f"{field}: {message}" if field else message) + + +def _text_parts(parts, kinds, field): + if parts is None: + return [] + if not isinstance(parts, list): + raise ReasoningInputError("must be an array", field) + texts = [] + for index, part in enumerate(parts): + location = f"{field}[{index}]" + if not isinstance(part, dict) or part.get("type") not in kinds: + raise ReasoningInputError("unsupported reasoning text block", location) + if not isinstance(part.get("text"), str): + raise ReasoningInputError("must be a string", location + ".text") + texts.append(part["text"]) + return texts + + +def extract_reasoning_text(block): + """Return readable thinking without interpreting signatures or encrypted state.""" + kind = block.get("type") + if kind == "redacted_thinking": + raise ReasoningInputError("encrypted reasoning cannot be converted to Chat", "data") + if kind == "thinking": + text = block.get("thinking") + if not isinstance(text, str): + raise ReasoningInputError("must be a string", "thinking") + if not text and block.get("signature") not in (None, ""): + raise ReasoningInputError("encrypted-only thinking cannot be converted to Chat", "signature") + return text + if kind == "reasoning": + if block.get("encrypted_content") not in (None, ""): + raise ReasoningInputError("encrypted reasoning cannot be converted to Chat", "encrypted_content") + content = _text_parts(block.get("content"), ("reasoning_text", "text"), "content") + summary = _text_parts(block.get("summary"), ("summary_text",), "summary") + return "".join(content if content else summary) + return None + + +def thinking_mode(body): + """Read the already-validated Messages thinking mode for account selection.""" + thinking = body.get("thinking") + return thinking.get("type") if isinstance(thinking, dict) else None + + +def _object(body, field): + value = body.get(field) + if value is None: + return {} + if not isinstance(value, dict): + raise ReasoningInputError("must be an object", field) + return value + + +def _effort(value, field, allowed=None): + if value is not None and (not isinstance(value, str) or not value.strip() + or (allowed is not None and value not in allowed)): + raise ReasoningInputError("unsupported reasoning effort", field) + return value + + +def map_reasoning_controls(body, chat, *, protocol): + """Map explicit controls, leaving implicit activation to the selected account.""" + explicit = "reasoning_effort" in body + effort = _effort(body.get("reasoning_effort"), "reasoning_effort") + if protocol == "responses": + reasoning = _object(body, "reasoning") + if not explicit and "effort" in reasoning: + effort = _effort(reasoning["effort"], "reasoning.effort") + explicit = True + elif protocol == "messages": + thinking = _object(body, "thinking") + mode = thinking_mode(body) + if body.get("thinking") is not None and mode not in ("enabled", "adaptive", "disabled"): + raise ReasoningInputError("must be enabled, adaptive or disabled", "thinking.type") + if mode == "enabled": + budget = thinking.get("budget_tokens") + if type(budget) is not int or budget < 1024: + raise ReasoningInputError("must be an integer >= 1024", "thinking.budget_tokens") + elif "budget_tokens" in thinking: + raise ReasoningInputError("requires thinking.type=enabled", "thinking.budget_tokens") + if thinking.get("display") not in (None, "summarized"): + raise ReasoningInputError("only summarized display is supported by Chat upstreams", "thinking.display") + output = _object(body, "output_config") + if not explicit and "effort" in output: + effort = _effort(output["effort"], "output_config.effort", ("low", "medium", "high", "xhigh", "max")) + explicit = True + if mode == "disabled": + effort, explicit = "none", True + else: + raise ValueError("unsupported reasoning protocol") + if explicit: + chat["reasoning_effort"] = effort + + +def resolve_reasoning_effort(effort, mode, model): + """Use the same account-owned default for capability checks and upstream requests.""" + if mode == "disabled": + return "none" + if effort is not None or mode not in ("enabled", "adaptive"): + return effort + model = model or {} + reasoning = model.get("reasoning") if isinstance(model.get("reasoning"), dict) else {} + supported = reasoning.get("supportedEfforts") + supported = supported if isinstance(supported, list) and supported and all(isinstance(v, str) for v in supported) else None + candidates = [reasoning.get("defaultEffort"), reasoning.get("effort"), + "high", "medium", "low", "xhigh", "max", "minimal", *(supported or [])] + for candidate in candidates: + if (isinstance(candidate, str) and candidate.strip() and candidate != "none" + and (supported is None or candidate in supported)): + return candidate + return "high" diff --git a/converter.py b/converter.py index 543c27a..194be8a 100644 --- a/converter.py +++ b/converter.py @@ -66,6 +66,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.inference_resources import (AccountCapacity, InferenceResourcesMiddleware, inference_lifespan, request_resources, release_credential) from app.request_context import SessionIdentifierError, current_context +from app.reasoning import resolve_reasoning_effort, thinking_mode from app import model_capabilities from app.message_normalization import merge_intl_user_images from app.adapters.chat_input import normalize_chat_messages @@ -1931,21 +1932,28 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=(), def _route_chat(payload, body, rid, *, tried=()): """Validate account capabilities and derive each routed body from canonical input.""" context = current_context() + protocol = context.protocol if context is not None else "chat" + mode = thinking_mode(payload) if protocol == "messages" else None enabled = context.capability_guard if context is not None else CONFIG.get("model_capability_guard", True) cred = None try: - requirements = (model_capabilities.Requirements.from_request( - body, payload, context.protocol if context is not None else "chat") if enabled else None) + requirements = model_capabilities.Requirements.from_request(body, payload, protocol) if enabled else None cred, headers = _cred_for(payload, body.get("model"), tried=tried, requirements=requirements) profile = profile_for_headers(headers) routed_model = _upstream_model(body.get("model"), profile) - if requirements is not None and CONFIG.get("cred_pool") is None: + metadata = None + if mode in ("enabled", "adaptive") or (requirements is not None and CONFIG.get("cred_pool") is None): entry = {"profile": profile, "account_key": account_key( profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id"))} - failures = requirements.violations(model_capabilities.entry_model(sys.modules[__name__], entry, body.get("model"))) + metadata = model_capabilities.entry_model(sys.modules[__name__], entry, body.get("model")) + if requirements is not None and CONFIG.get("cred_pool") is None: + failures = requirements.violations(metadata) if failures: raise model_capabilities.capability_error(failures) canonical = body + effort = resolve_reasoning_effort(body.get("reasoning_effort"), mode, metadata) + if effort != body.get("reasoning_effort"): + body = {**body, "reasoning_effort": effort} if routed_model != body.get("model"): body = {**body, "model": routed_model} body, merged_runs, merged_messages = merge_intl_user_images(body, profile) diff --git a/docs/advanced.md b/docs/advanced.md index 7557048..186a088 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -243,6 +243,16 @@ Credential domain / token issuer determine the product identity. Chat and refres Both international profiles merge image-bearing consecutive `user` runs only after routing, preserving content order and image data. Domestic bodies, text-only runs and system/assistant/tool boundaries remain unchanged. Conflicting message attributes or unrepresentable content return `400 / image_user_run_not_mergeable`; final byte limits still apply. This compatibility step remains enabled when capability preflight is disabled; it neither adds retries nor makes a text model natively visual. +## Reasoning compatibility + +Messages `enabled` / `adaptive` activate Chat reasoning; `output_config.effort` and Responses `reasoning.effort` map to `reasoning_effort`. An explicit top-level `reasoning_effort` takes precedence, except Messages `disabled` always selects `none`; model capability checks still apply. Omitted controls leave upstream defaults unchanged. + +Without an explicit effort, Messages activation uses the selected account's `reasoning.defaultEffort` or legacy `reasoning.effort`, restricted to its declared options. Otherwise it prefers `high`, then an available option; unknown declarations fall back to `high`. Failover resolves the replacement account's default again. + +Manual `enabled` requires an integer `budget_tokens >= 1024`, but the budget is not an exact upstream token limit; `max_tokens` is forwarded unchanged. This mapping does not reproduce native adaptive scheduling. Only `display: summarized` is supported. + +Readable history is kept in `reasoning_content`, never ordinary answer text. Responses uses readable `content` before `summary`; summaries cannot reconstruct native hidden reasoning. Signatures are not forwarded. `redacted_thinking`, encrypted-only thinking and non-empty Responses `encrypted_content` return 400 before routing; upstreams decide which readable history they use. + ## Request boundaries - All three generation protocols normalize `developer` to `system`, move an existing system message first or insert a default. This normalization does not mutate the caller's payload. Optional [Responses projection](#responses-projection) and desensitization process content separately; the whole pipeline is not a verbatim pass-through by default. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 58414d3..7728ceb 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -243,6 +243,16 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` 两种国际产品在选路后归并含图的连续 `user` 段,保留内容顺序和图片数据;国内请求、纯文本段及 system/assistant/tool 边界不变。消息级属性冲突或内容无法无损表达时返回 `400 / image_user_run_not_mergeable`,最终字节限制仍生效。图片兼容不随能力开关关闭,不增加重试,也不让文本模型获得原生视觉。 +## 思考兼容 + +Messages 的 `enabled` / `adaptive` 启用 Chat 推理,`output_config.effort` 和 Responses 的 `reasoning.effort` 映射为 `reasoning_effort`。显式顶层 `reasoning_effort` 优先,但 Messages 的 `disabled` 始终使用 `none`;模型能力检查仍生效。未提供控制参数时保留上游默认行为。 + +未指定强度时,Messages 从实际选中账号的 `reasoning.defaultEffort` 或旧 `reasoning.effort` 选择声明支持的默认值;否则优先 `high`,再选可用选项,声明未知时回落为 `high`。换号后重新解析新账号的默认值。 + +手工 `enabled` 要求整数 `budget_tokens >= 1024`,但该预算不等于上游精确 token 限额,`max_tokens` 原样转发;兼容映射不模拟原生自适应调度。仅支持 `display: summarized`。 + +可读历史统一放入 `reasoning_content`,不混入普通正文。Responses 优先使用可读 `content`,其次使用 `summary`;摘要不能还原原生隐藏推理。签名不转发,`redacted_thinking`、仅含加密签名的思考及非空 Responses `encrypted_content` 在选路前返回 400;上游自行决定使用哪些可读历史。 + ## 请求边界 - 三个生成协议统一将 `developer` 归一为 `system`,已有 system 移到首位,缺失时补默认值;归一化不修改调用方 payload。可选的 [Responses 投影](#responses-投影)和脱敏会另行处理内容,因此默认链路并非逐字透传。 diff --git a/docs/clients.md b/docs/clients.md index 29db1f9..3deca52 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -75,6 +75,8 @@ Streaming policy is a server setting, not a client request field. The default `c - `developer` messages become `system`; the first system message is placed first before matching tool results, without mutating the original payload. - Chat accepts mixed Anthropic `tool_use` / `tool_result` history, preserving call IDs, arguments, result images and error markers; ordinary `thinking` becomes `reasoning_content`, not visible text. Native Chat fields stay unchanged. - Conflicting fields, unmatched tool results, unsupported mixed blocks and `redacted_thinking` return HTTP 400 before routing. Split user messages accept only `role` and `content`, with all `tool_result` blocks before ordinary text/images; Anthropic thinking signatures are not forwarded. +- Messages `thinking` blocks and Responses readable `reasoning` items are retained as assistant `reasoning_content`, including full-history tool continuations; encrypted-only history returns HTTP 400. +- Messages `thinking` / `output_config.effort` and Responses `reasoning.effort` map to upstream reasoning controls. See [reasoning compatibility](advanced.md#reasoning-compatibility) for defaults and budget limits. - Named function choices are sent upstream as `required` with only that function available; invalid names are rejected locally. - Errors follow the client protocol's own shape (OpenAI `error` object vs Anthropic `{"type":"error"}`), and status codes are preserved. Realtime mode can deliver useful deltas before a later invalid terminal, disconnect or size error; valid truncation/filter distinctions remain native. Clients must not treat an opened SSE connection as proof of successful completion. - `POST /v1/messages/count_tokens` returns a character-based heuristic estimate for budgeting, not an exact count. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 71d589c..dc93e32 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -75,6 +75,8 @@ Cherry Studio、ZCode、LobeChat、NextChat、Open WebUI 或自研 SDK 客户端 - 先将 `developer` 转为 `system` 并置顶首条系统消息,再关联工具结果;不改动调用方原始载荷。 - Chat 兼容混入的 Anthropic `tool_use` / `tool_result` 历史,保留调用 ID、参数、结果图片与错误标记;普通 `thinking` 转为 `reasoning_content`,不混入正文,原生 Chat 字段保持不变。 - 字段冲突、工具结果无法关联、不支持的混合内容块及 `redacted_thinking` 在选路前返回 HTTP 400。需拆分的用户消息只能包含 `role`、`content`,且 `tool_result` 必须在普通文本/图片之前;Anthropic 思考签名不转发。 +- Messages 的 `thinking` 块和 Responses 的可读 `reasoning` 项统一保留为 assistant `reasoning_content`,支持完整历史及工具续接;仅含加密状态的历史返回 HTTP 400。 +- Messages 的 `thinking` / `output_config.effort` 与 Responses 的 `reasoning.effort` 会映射到上游思考控制;默认值和预算限制见[思考兼容](advanced.zh-CN.md#思考兼容)。 - 指定名称的函数选择会以 `required` 且仅含该函数的形式发往上游;无效名称在本地拒绝。 - 错误按客户端协议各自的形态返回(OpenAI 的 `error` 对象与 Anthropic 的 `{"type":"error"}`),状态码保留。实时模式可能先送出有效增量,随后才遇到非法终端状态、断连或大小错误;合法截断/过滤仍保留协议原生区别。客户端不能仅凭 SSE 已开启就认定最终成功。 - `POST /v1/messages/count_tokens` 返回按字符估算的启发式结果,用于预算参考,不是精确计数。 diff --git a/tests/test_reasoning_requests.py b/tests/test_reasoning_requests.py new file mode 100644 index 0000000..94ff06d --- /dev/null +++ b/tests/test_reasoning_requests.py @@ -0,0 +1,376 @@ +"""Verify shared reasoning controls, protocol round trips and account-owned defaults.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from copy import deepcopy +from itertools import permutations, product +import json +import unittest +from unittest.mock import patch + +import httpx +import converter as gateway +from app.control_store import ControlStore +import test_api_flow as fixtures +import test_runtime_endpoints as runtime + +MODES = ((False, "compatible"), (True, "compatible"), (True, "realtime")) +PROTOCOLS = ("chat/completions", "messages", "responses") + + +def model(effort="medium", **extra): + return {"id": "shared-model", "credits": "x0.00", "supportsReasoning": True, + "supportsToolCall": True, "canDisableThinking": True, + "reasoning": {"defaultEffort": effort, "supportedEfforts": ["low", "medium", "high", "xhigh", "max"]}, **extra} + + +def payload(protocol, **extra): + body = {"model": "shared-model", "max_tokens": 2048} + body["input" if protocol == "responses" else "messages"] = [{"role": "user", "content": "question"}] + body.update(extra) + return body + + +def reasoning(text): + return {"type": "reasoning", "summary": [{"type": "summary_text", "text": text}]} + + +def events(response): + return [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ") and line[6:] != "[DONE]"] + + +def output(response, protocol, stream): + if not stream: + return response.json() + parsed = events(response) + if protocol == "responses": + return next(event["response"] for event in parsed if event.get("type") == "response.completed") + blocks = [] + for event in parsed: + if event["type"] == "content_block_start": + blocks.append(deepcopy(event["content_block"])) + elif event["type"] == "content_block_delta": + block, delta = blocks[event["index"]], event["delta"] + if delta["type"] == "thinking_delta": + block["thinking"] += delta["thinking"] + elif delta["type"] == "text_delta": + block["text"] += delta["text"] + elif delta["type"] == "input_json_delta": + block["_arguments"] = block.get("_arguments", "") + delta["partial_json"] + for block in blocks: + if "_arguments" in block: + block["input"] = json.loads(block.pop("_arguments")) + return {"content": blocks} + + +class ReasoningEndpointTests(unittest.TestCase): + def setUp(self): + self.fx = runtime.EndpointTests("test_stateful_responses_fields_are_rejected") + self.addCleanup(self.fx.doCleanups) + self.fx.setUp() + self.metadata = self.enterContext(patch.object(gateway.model_capabilities, "entry_model", return_value=model())) + self.fx.respond = self.respond + + def respond(self, request): + body = json.loads(request.content) + delta = {"content": "answer"} + if body.get("reasoning_effort") not in (None, "none"): + delta["reasoning_content"] = "new reasoning" + return httpx.Response(200, content=runtime.sse(delta)) + + def post(self, protocol, body, *, stream=False, mode="compatible", projection="balanced", desensitize=False): + original = deepcopy(body) + before = len(self.fx.requests) + with patch.dict(gateway.CONFIG, stream_mode=mode, responses_projection_mode=projection, desensitize=desensitize): + response = self.fx.client.post("/v1/" + protocol, json={**body, "stream": stream}) + self.assertEqual(body, original) + upstream = [json.loads(request.content) for request in self.fx.requests[before:]] + return response, upstream + + def test_controls_and_output_in_all_modes(self): + cases = [ + ("chat/completions", {"reasoning_effort": "high"}, "high"), + ("responses", {"reasoning": {"effort": "high"}}, "high"), + ("responses", {"reasoning": {"effort": "high"}, "reasoning_effort": "low"}, "low"), + ("responses", {"reasoning": {"effort": "none"}}, "none"), + ("messages", {"thinking": {"type": "enabled", "budget_tokens": 1024}}, "medium"), + ("messages", {"thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}}, "low"), + ("messages", {"thinking": {"type": "enabled", "budget_tokens": 1024}, "output_config": {"effort": "high"}}, "high"), + ("messages", {"thinking": {"type": "disabled"}, "output_config": {"effort": "high"}}, "none"), + ("messages", {"reasoning_effort": "xhigh", "output_config": {"effort": "low"}}, "xhigh"), + ("messages", {"output_config": {"effort": "high"}}, "high"), + ] + [(protocol, {}, None) for protocol in PROTOCOLS] + for (protocol, fields, expected), (stream, mode) in product(cases, MODES): + with self.subTest(protocol=protocol, fields=fields, stream=stream, mode=mode): + response, sent = self.post(protocol, payload(protocol, **fields), stream=stream, mode=mode) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0].get("reasoning_effort"), expected) + self.assertEqual(sent[0]["max_tokens"], 2048) + self.assertTrue({"thinking", "output_config", "reasoning", "budget_tokens"}.isdisjoint(sent[0])) + self.assertEqual("new reasoning" in response.text, expected not in (None, "none")) + + def test_readable_history_is_separate_from_visible_text(self): + thoughts = [{"type": "thinking", "thinking": "first", "signature": "signature-canary"}, + {"type": "thinking", "thinking": "second"}] + for protocol, (stream, mode), only, projection, desensitize in product( + PROTOCOLS, MODES, (False, True), ("balanced", "passthrough"), (False, True)): + with self.subTest(protocol=protocol, stream=stream, mode=mode, only=only, projection=projection, desensitize=desensitize): + body = payload(protocol) + history = [{"role": "user", "content": "question"}] + if protocol == "responses": + history += [reasoning("first"), reasoning("second")] + if not only: + history.append({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}) + else: + history.append({"role": "assistant", "content": deepcopy(thoughts) + ([] if only else [{"type": "text", "text": "answer"}])}) + history.append({"role": "user", "content": "continue"}) + body["input" if protocol == "responses" else "messages"] = history + response, sent = self.post(protocol, body, stream=stream, mode=mode, projection=projection, desensitize=desensitize) + self.assertEqual(response.status_code, 200, response.text) + assistants = [m for m in sent[0]["messages"] if m["role"] == "assistant"] + self.assertEqual(len(assistants), 1) + self.assertEqual(assistants[0]["reasoning_content"], "firstsecond") + self.assertNotIn("first", json.dumps(assistants[0]["content"])) + self.assertNotIn("signature-canary", json.dumps(sent)) + + def test_gateway_output_roundtrips_through_tool_results(self): + call = {"index": 0, "id": "call_shared", "type": "function", "function": {"name": "lookup", "arguments": "{}"}} + first = runtime.sse({"reasoning_content": "firstsecond", "tool_calls": [call]}, finish="tool_calls") + for protocol, (stream, mode), projection in product(("messages", "responses"), MODES, ("balanced", "passthrough")): + with self.subTest(protocol=protocol, stream=stream, mode=mode, projection=projection): + self.fx.respond = lambda request: httpx.Response(200, content=first) + body = payload(protocol) + body["tools"] = ([{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}] + if protocol == "messages" else [{"type": "function", "name": "lookup", "parameters": {"type": "object", "properties": {}}}]) + response, _ = self.post(protocol, body, stream=stream, mode=mode, projection=projection) + self.assertEqual(response.status_code, 200, response.text) + result = output(response, protocol, stream) + if protocol == "messages": + body["messages"] += [{"role": "assistant", "content": result["content"]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_shared", "content": "43"}]}] + else: + body["input"] += result["output"] + [{"type": "function_call_output", "call_id": "call_shared", "output": "43"}] + self.fx.respond = self.respond + response, sent = self.post(protocol, body, stream=stream, mode=mode, projection=projection) + self.assertEqual(response.status_code, 200, response.text) + messages = sent[0]["messages"] + assistant = next(m for m in messages if m["role"] == "assistant") + self.assertEqual(assistant["reasoning_content"], "firstsecond") + self.assertEqual(assistant["tool_calls"][0]["id"], "call_shared") + self.assertEqual(messages[messages.index(assistant) + 1], {"role": "tool", "tool_call_id": "call_shared", "content": "43"}) + + def test_realtime_tool_first_output_preserves_one_assistant_turn(self): + deltas = [{"tool_calls": [{"index": 0, "id": "call_first", "type": "function", + "function": {"name": "lookup", "arguments": "{}"}}]}, + {"reasoning_content": "late reasoning"}, {"content": "after tool"}] + chunks = [{"choices": [{"index": 0, "delta": delta, "finish_reason": None}]} for delta in deltas] + chunks.append({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]}) + raw = ("".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n").encode() + self.fx.respond = lambda request: httpx.Response(200, content=raw) + body = payload("responses", tools=[{"type": "function", "name": "lookup", "parameters": {"type": "object", "properties": {}}}]) + response, _ = self.post("responses", body, stream=True, mode="realtime") + self.assertEqual(response.status_code, 200, response.text) + result = output(response, "responses", True) + self.assertEqual([item["type"] for item in result["output"]], ["function_call", "reasoning", "message"]) + body["input"] += result["output"] + [{"type": "function_call_output", "call_id": "call_first", "output": "43"}] + self.fx.respond = self.respond + response, sent = self.post("responses", body, stream=True, mode="realtime") + self.assertEqual(response.status_code, 200, response.text) + messages = sent[0]["messages"] + assistants = [m for m in messages if m["role"] == "assistant"] + self.assertEqual(len(assistants), 1) + self.assertEqual(assistants[0]["reasoning_content"], "late reasoning") + self.assertEqual(assistants[0]["content"], "after tool") + self.assertEqual(assistants[0]["tool_calls"][0]["id"], "call_first") + self.assertEqual(messages[messages.index(assistants[0]) + 1]["tool_call_id"], "call_first") + + def test_responses_keeps_upstream_declared_effort_extensions(self): + self.metadata.return_value = model(reasoning={"supportedEfforts": ["ultra"]}) + response, sent = self.post("responses", payload("responses", reasoning={"effort": "ultra"})) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(sent[0]["reasoning_effort"], "ultra") + response, sent = self.post("responses", payload("responses", reasoning={"effort": "low"})) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "unsupported_reasoning_effort") + self.assertEqual(sent, []) + + def test_responses_assistant_item_orders_keep_tools_and_reasoning_together(self): + items = [reasoning("same turn"), + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}, + {"type": "function_call", "name": "lookup", "call_id": "call_1", "arguments": "{}"}] + for ordered in permutations(items): + with self.subTest(order=[item["type"] for item in ordered]): + body = payload("responses", input=[{"role": "user", "content": "question"}, *ordered, + {"type": "function_call_output", "call_id": "call_1", "output": "43"}]) + response, sent = self.post("responses", body) + self.assertEqual(response.status_code, 200, response.text) + assistants = [m for m in sent[0]["messages"] if m["role"] == "assistant"] + self.assertEqual(len(assistants), 1) + self.assertEqual(assistants[0]["reasoning_content"], "same turn") + self.assertEqual(assistants[0]["content"], "answer") + self.assertEqual(assistants[0]["tool_calls"][0]["id"], "call_1") + + def test_responses_keeps_reasoning_with_its_assistant_turn(self): + body = payload("responses", input=[ + {"role": "user", "content": "question"}, reasoning("before answer"), + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}, + {"role": "user", "content": "look it up next"}, + reasoning("before tool"), {"type": "function_call", "name": "lookup", "call_id": "call_1", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "43"}, + reasoning("after tool"), {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}, + {"role": "user", "content": "continue"}, + ]) + response, sent = self.post("responses", body) + self.assertEqual(response.status_code, 200, response.text) + assistants = [m for m in sent[0]["messages"] if m["role"] == "assistant"] + self.assertEqual([m["reasoning_content"] for m in assistants], ["before answer", "before tool", "after tool"]) + self.assertEqual([m["content"] for m in assistants], ["answer", "", "done"]) + + def test_responses_prefers_readable_content_over_its_summary(self): + item = {**reasoning("summary"), "content": [{"type": "reasoning_text", "text": "full "}, {"type": "text", "text": "content"}]} + body = payload("responses", input=[{"role": "user", "content": "question"}, item, + {"role": "assistant", "content": "answer"}, {"role": "user", "content": "continue"}]) + response, sent = self.post("responses", body) + self.assertEqual(response.status_code, 200, response.text) + assistant = next(m for m in sent[0]["messages"] if m["role"] == "assistant") + self.assertEqual(assistant["reasoning_content"], "full content") + + def test_opaque_or_malformed_reasoning_is_rejected_without_upstream(self): + blocks = [{"type": "redacted_thinking", "data": "opaque-canary"}, {"type": "thinking", "thinking": 7}, + {"type": "thinking", "thinking": "", "signature": "opaque-canary"}] + for protocol in ("chat/completions", "messages"): + for block in blocks: + body = payload(protocol, messages=[{"role": "assistant", "content": [block]}]) + response, sent = self.post(protocol, body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(sent, []) + self.assertNotIn("opaque-canary", response.text) + for item in ({**reasoning("readable summary"), "encrypted_content": "opaque-canary"}, + {"type": "reasoning", "summary": "opaque-canary"}, + {"type": "reasoning", "summary": [{"type": "summary_text", "text": 7}]}): + response, sent = self.post("responses", payload("responses", input=[item])) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(sent, []) + self.assertNotIn("opaque-canary", response.text) + self.assertEqual(self.fx.credentials.call_count, 0) + + def test_invalid_controls_fail_before_routing(self): + for controls in ({"thinking": []}, {"thinking": {"type": "unknown"}}, + {"thinking": {"type": "enabled", "budget_tokens": True}}, + {"thinking": {"type": "enabled", "budget_tokens": 100}}, + {"thinking": {"type": "adaptive", "budget_tokens": 1024}}, + {"thinking": {"type": "adaptive", "display": "omitted"}}, + {"output_config": {"effort": "unknown"}}, {"output_config": []}): + response, sent = self.post("messages", payload("messages", **controls)) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(sent, []) + for control in ([], {"effort": True}, {"effort": " "}): + response, sent = self.post("responses", payload("responses", reasoning=control)) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(sent, []) + self.assertEqual(self.fx.credentials.call_count, 0) + + def test_activation_uses_legacy_defaults_and_skips_disabled_defaults(self): + for metadata, expected in (({"reasoning": {"effort": "low"}}, "low"), + ({"reasoning": {"defaultEffort": "low", "supportedEfforts": []}}, "low"), + ({"reasoning": {"defaultEffort": "none", "supportedEfforts": ["low", "high"]}}, "high"), + ({"reasoning": {"supportedEfforts": ["low"]}}, "low"), ({}, "high")): + self.metadata.return_value = metadata + response, sent = self.post("messages", payload("messages", thinking={"type": "adaptive"})) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(sent[0]["reasoning_effort"], expected) + + def test_disabled_preserves_capability_checks_and_does_not_request_reasoning(self): + self.metadata.return_value = model(supportsReasoning=False) + body = payload("messages", thinking={"type": "disabled"}, output_config={"effort": "high"}) + response, sent = self.post("messages", body) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(sent[0]["reasoning_effort"], "none") + self.metadata.return_value = model(canDisableThinking=False) + response, sent = self.post("messages", body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "reasoning_required") + self.assertEqual(sent, []) + + def test_reasoning_counts_toward_request_size_limit(self): + for protocol in ("messages", "responses"): + body = payload(protocol) + if protocol == "messages": + body["messages"] = [{"role": "assistant", "content": [{"type": "thinking", "thinking": "x" * 2048}]}] + else: + body["input"] = [reasoning("x" * 2048)] + with patch.dict(gateway.CONFIG, max_request_bytes=1024): + response, sent = self.post(protocol, body) + self.assertEqual(response.status_code, 413, response.text) + self.assertEqual(sent, []) + + +class ReasoningRoutingTests(fixtures.GatewayFixture, unittest.TestCase): + def test_each_account_uses_its_own_default_even_within_one_profile(self): + self.fx.add_account("second", "intl-cli") + self.fx.configure(profiles=("intl-cli", "second")) + self.fx.account_catalogs({"intl-cli": [model("low")], "second": [model("high")]}) + seen = set() + for guard in (True, False): + for _ in range(4): + body = self.fx.payload("messages") + body["thinking"] = {"type": "adaptive"} + with patch.dict(gateway.CONFIG, model_capability_guard=guard): + request, sent = self.fx.post_ok("messages", body, {"intl-cli", "second"}) + uid = request.headers["x-user-id"] + seen.add(uid) + self.assertEqual(sent["reasoning_effort"], {"intl-cli": "low", "second": "high"}[uid]) + self.assertEqual(seen, {"intl-cli", "second"}) + body["output_config"] = {"effort": "medium"} + _, sent = self.fx.post_ok("messages", body, seen) + self.assertEqual(sent["reasoning_effort"], "medium") + + def test_failover_recomputes_defaults_without_mutating_canonical_input(self): + for stream, mode in MODES: + with self.subTest(stream=stream, mode=mode): + self.fx.configure(profiles=("cn-cli", "intl-work")) + self.fx.account_catalogs({"cn-cli": [model("low")], "intl-work": [model("high")]}) + seen = [] + def respond(request): + seen.append(request) + if len(seen) == 1: + return httpx.Response(429, json={"error": {"message": "synthetic quota"}}) + return httpx.Response(200, content=fixtures.fixtures.success_sse()) + body = self.fx.payload("messages", stream=stream) + body["thinking"] = {"type": "adaptive"} + original = deepcopy(body) + with self.responder(respond), patch.dict(gateway.CONFIG, failover_max=1, stream_mode=mode): + response = self.fx.client.post("/v1/messages", json=body) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(body, original) + self.assertEqual(len(seen), 2) + self.assertNotEqual(seen[0].headers["x-user-id"], seen[1].headers["x-user-id"]) + for request in seen: + expected = {"cn-cli": "low", "intl-work": "high"}[request.headers["x-user-id"]] + self.assertEqual(json.loads(request.content)["reasoning_effort"], expected) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_activation_cannot_escape_free_tier_or_strict_binding(self): + self.fx.configure(profiles=("cn-cli", "intl-work")) + self.fx.account_catalogs({"intl-work": [model(supportsReasoning=False)], "cn-cli": [model(credits="x1.00")]}) + body = self.fx.payload("messages") + body["thinking"] = {"type": "adaptive"} + response = self.fx.client.post("/v1/messages", json=body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.fx.account_catalogs({"intl-work": [model(supportsReasoning=False)], "cn-cli": [model()]}) + store = ControlStore(self.fx.root / "reasoning-control.sqlite3") + self.addCleanup(store.close) + store.update_model("shared-model", {"profile": "intl-work"}, store.snapshot()["revision"], {"shared-model"}) + with patch.dict(gateway.CONFIG, control_store=store): + response = self.fx.client.post("/v1/messages", json=body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + +if __name__ == "__main__": + unittest.main()