From b99fba0cc45b19bbf3c8c6676dc8402687e598ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:52:08 +0800 Subject: [PATCH 1/2] Add configurable Responses projection --- .env.example | 7 + README.md | 1 + README.zh-CN.md | 1 + app/adapters/responses_adapter.py | 5 +- app/adapters/responses_projection.py | 924 +++++++++------------------ app/audit_store.py | 5 + app/observability.py | 12 + app/output_truncation.py | 112 ++++ app/settings.py | 13 + converter.py | 58 +- docker-compose.yml | 2 + docs/advanced.md | 18 +- docs/advanced.zh-CN.md | 18 +- docs/clients.md | 4 + docs/clients.zh-CN.md | 4 + docs/deployment.md | 3 +- docs/deployment.zh-CN.md | 2 +- tests/test_deployment.py | 12 + tests/test_environment_config.py | 67 +- tests/test_harness_projection.py | 647 ++++++++++--------- tests/test_observability.py | 22 +- tests/test_output_truncation.py | 120 ++++ tests/test_request_limits.py | 6 +- tests/test_responses_adapter.py | 377 +++++------ tests/test_runtime_endpoints.py | 24 +- tests/test_tool_metadata.py | 161 +++-- tests/test_workbuddy_filter.py | 2 +- 27 files changed, 1345 insertions(+), 1282 deletions(-) create mode 100644 app/output_truncation.py create mode 100644 tests/test_output_truncation.py diff --git a/.env.example b/.env.example index 153de6b..71a70d9 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,13 @@ CODEBUDDY2API_MAX_CONCURRENT=64 # CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT=0 # Optional session/attempt tracing; legacy preserves existing upstream header behavior. # CODEBUDDY2API_REQUEST_CONTEXT_MODE=legacy +# Responses projection is hot and WebUI-editable unless explicitly set here. +# balanced rewrites only recognized harness blocks with stable summaries; other text stays verbatim. +# CODEBUDDY2API_RESPONSES_PROJECTION_MODE=balanced +# Per-item head/tail limit for generated assistant text, tool arguments and tool results; +# zero disables per-item trimming. Global request/output gates still apply. +# Non-zero values must be between 256 and 33554432 bytes. +# CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES=40000 # compatible preserves aggregation; realtime forwards all protocol output incrementally # and does not regenerate malformed tool arguments. # CODEBUDDY2API_STREAM_MODE=compatible diff --git a/README.md b/README.md index fd97942..5891d49 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces - **No Docker?** After installing dependencies and the WebUI, run `uv run converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). - **Where is my data?** Everything lives in `auth/` (or `/data/auth` in Docker): credentials, settings and log databases — see [Data and backups](docs/webui.md#data-and-backups). - **Which image tag should I use?** `latest` follows stable releases, `edge` follows main, version tags pin one release — see [Published images](docs/deployment.md#use-published-images). +- **Responses tool output/arguments compressed or need verbatim passthrough?** Set `responses_projection_mode` (default `balanced`, or `passthrough` to disable projection) and `responses_projection_max_bytes` (default `40000`, `0` disables per-item trimming). Balanced trimming keeps the head/tail and reports original bytes, estimated tokens and total lines; client addresses stay unchanged — see [Responses projection](docs/clients.md#responses-projection) and [details](docs/advanced.md#responses-projection). ## Disclaimer diff --git a/README.zh-CN.md b/README.zh-CN.md index 506bf57..b1847cd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -85,6 +85,7 @@ docker compose up -d --no-build - **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 - **数据在哪里?** 全部位于 `auth/`(Docker 中为 `/data/auth`):凭证、设置与日志数据库——见[数据与备份](docs/webui.zh-CN.md#数据与备份)。 - **镜像标签怎么选?** `latest` 跟随稳定版,`edge` 跟随 main,版本标签固定某一发行版——见[使用已发布镜像](docs/deployment.zh-CN.md#使用已发布镜像)。 +- **Responses 工具输出/参数被压缩或需要完全原样?** 设置 `responses_projection_mode`(默认 `balanced`,设 `passthrough` 可完全关闭投影)及 `responses_projection_max_bytes`(默认 `40000`,`0` 禁用单项裁剪)。balanced 裁剪保留头尾并显示原始 bytes、估算 tokens 与总行数,客户端地址不变——见 [Responses 投影](docs/clients.zh-CN.md#responses-投影)及[详细说明](docs/advanced.zh-CN.md#responses-投影)。 ## 免责声明 diff --git a/app/adapters/responses_adapter.py b/app/adapters/responses_adapter.py index 844746b..6dbd900 100644 --- a/app/adapters/responses_adapter.py +++ b/app/adapters/responses_adapter.py @@ -160,6 +160,9 @@ def _flush_assistant(): # Merge calls into the preceding assistant message. if item_type == "function_call": + arguments = item.get("arguments", "{}") + if not isinstance(arguments, str): + raise ValueError("function_call.arguments must be a JSON string") if pending_assistant_content is None: pending_assistant_content = "" pending_tool_calls.append({ @@ -167,7 +170,7 @@ def _flush_assistant(): "type": "function", "function": { "name": item.get("name", ""), - "arguments": item.get("arguments", "{}"), + "arguments": arguments, }, }) continue diff --git a/app/adapters/responses_projection.py b/app/adapters/responses_projection.py index 35d5d0e..2981454 100644 --- a/app/adapters/responses_projection.py +++ b/app/adapters/responses_projection.py @@ -1,4 +1,4 @@ -"""Compact trusted harness context, history and tools while preserving user text and images.""" +"""Apply optional Responses context projection without changing real user text.""" from __future__ import annotations @@ -6,704 +6,352 @@ from typing import Any from app.harness_context import parse_harness_text +from app.output_truncation import TruncationResult, count_text_lines, truncate_middle_bytes -AGENTIC_TOOL_NAMES = { - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "view_image", - "get_goal", - "create_goal", - "update_goal", - "apply_patch", - "tool_search_tool", -} - -HARNESS_USER_MARKERS = ( - "# AGENTS.md instructions", - "", - "", - "", - "", - "", - "# claudeMd", -) - -HARNESS_SYSTEM_MARKERS = ( - "You are a coding agent running in the Codex CLI", - "Within this context, Codex refers to", - "# AGENTS.md spec", - "", - "", - "", - "The following deferred tools are now available via ToolSearch.", - "### Available skills", - "## request_user_input availability", - "You are Claude Code", -) - -BASE_SYSTEM_PROMPT = ( - "You are a coding assistant serving an OpenAI-compatible CLI. " - "Be precise, concise, safe, and action-oriented. " - "Use available tools when needed, follow repository instructions and durable user context, " - "and continue from the preserved recent context. " - "If earlier history was condensed, rely on the preserved recent messages and rerun tools when exact old details are required." -) - -HISTORY_PREFIX = "Earlier conversation summary (condensed):" - -MAX_SYSTEM_GUIDANCE_CHARS = 1200 -MAX_USER_CONTEXT_CHARS = 3200 -MAX_ASSISTANT_CHARS = 1800 -MAX_TOOL_OUTPUT_CHARS = 1600 -MAX_TOOL_ARGS_CHARS = 900 -MAX_HISTORY_SUMMARY_CHARS = 2200 -MAX_HISTORY_ITEMS = 10 -MAX_TAIL_MESSAGES = 8 -MAX_TAIL_CHARS = 7000 - -SCHEMA_KEEP_KEYS = { - "type", - "properties", - "required", - "items", - "enum", - "oneOf", - "anyOf", - "allOf", - "additionalProperties", - "format", - "minimum", - "maximum", - "minItems", - "maxItems", - "minLength", - "maxLength", - "nullable", -} - - -def project_responses_chat_body(body: dict, *, keep_tool_metadata: bool = False) -> tuple[dict, dict]: - """Project a Responses-derived Chat body into bounded upstream context.""" - projected = dict(body) - messages = list(body.get("messages") or []) - tools = list(body.get("tools") or []) +PROJECTION_MODES = ("balanced", "passthrough") +_TEXT_TYPES = {"text", "input_text", "output_text"} - projected_tools, tool_stats = _project_tools(tools, keep_tool_metadata=keep_tool_metadata) - if projected_tools: - projected["tools"] = projected_tools - elif "tools" in projected: - projected["tools"] = [] - - # Image history must not disappear into a text-only summary or harness filter. - has_images = any(isinstance(msg, dict) and _has_image_content(msg.get("content")) - for msg in messages) - aggressive = _looks_like_agentic_cli(messages, tools) and not has_images - if not aggressive: - projected["messages"] = _project_messages_conservative(messages) - return projected, { - "mode": "conservative", - "aggressive": False, - "original_messages": len(messages), - "projected_messages": len(projected["messages"]), - "original_message_chars": _messages_size(messages), - "projected_message_chars": _messages_size(projected["messages"]), - **tool_stats, - } - - tool_name_by_call_id = _build_tool_call_name_map(messages) - preserved_guidance: list[dict] = [] - conversation: list[dict] = [] - # Provenance stays outside the wire messages; summaries are not new user turns. - context_only_indices: set[int] = set() - dropped_harness_messages = 0 - - for msg in messages: - if not isinstance(msg, dict): - continue - role = msg.get("role") - if role in {"system", "user"}: - limit = MAX_SYSTEM_GUIDANCE_CHARS if role == "system" else MAX_USER_CONTEXT_CHARS - content, matched, has_user_text = _project_harness_content(msg.get("content", ""), limit) - dropped_harness_messages += int(matched) - projected_msg = {**msg, "content": content} - if role == "system": - # Custom guidance has no shared metadata budget or message-count cap. - preserved_guidance.append(projected_msg) - continue - if matched and not has_user_text: - context_only_indices.add(len(conversation)) - else: - projected_msg = _project_conversation_message(msg) - if projected_msg is not None: - conversation.append(projected_msg) - - tail_start = _choose_tail_start(conversation) - tail_start = _expand_tail_for_tool_context(conversation, tail_start) - latest_user_idx = _latest_user_index(conversation, context_only_indices) - - anchor_user = None - if latest_user_idx is not None and latest_user_idx < tail_start: - anchor_user = dict(conversation[latest_user_idx]) - - omitted: list[dict] = [] - omitted_context_indices: set[int] = set() - for idx, msg in enumerate(conversation): - if idx >= tail_start: - break - if latest_user_idx is not None and idx == latest_user_idx and anchor_user is not None: - continue - if idx in context_only_indices: - omitted_context_indices.add(len(omitted)) - omitted.append(msg) +_JSON_OVERHEAD_RESERVE = 256 +_MAX_TOOL_ARGUMENTS_PARSE_BYTES = 1024 * 1024 +_MAX_WRAPPER_EDGE_BYTES = 8192 - final_messages: list[dict] = [{"role": "system", "content": BASE_SYSTEM_PROMPT}] - final_messages.extend(preserved_guidance) - history_summary = _build_history_summary(omitted, tool_name_by_call_id, omitted_context_indices) - if history_summary: - final_messages.append({"role": "system", "content": history_summary}) +class _InvalidJsonConstant(ValueError): + pass - if anchor_user is not None: - final_messages.append(anchor_user) - final_messages.extend(conversation[tail_start:]) - projected["messages"] = final_messages +def _reject_json_constant(value: str) -> None: + raise _InvalidJsonConstant(f"unsupported JSON constant: {value}") - return projected, { - "mode": "aggressive", - "aggressive": True, - "dropped_harness_messages": dropped_harness_messages, - "preserved_guidance_messages": len(preserved_guidance), - "summarized_history_messages": len(omitted), - "anchor_user_preserved": anchor_user is not None, - "tail_messages": len(conversation[tail_start:]), - "original_messages": len(messages), - "projected_messages": len(final_messages), - "original_message_chars": _messages_size(messages), - "projected_message_chars": _messages_size(final_messages), - **tool_stats, - } +def project_responses_chat_body( + body: dict, + *, + mode: str = "balanced", + max_item_bytes: int = 40000, +) -> tuple[dict, dict]: + """Project a Responses-derived Chat body or return it unchanged.""" + if mode not in PROJECTION_MODES: + raise ValueError("invalid Responses projection mode") + if isinstance(max_item_bytes, bool) or not isinstance(max_item_bytes, int) or max_item_bytes < 0: + raise ValueError("max_item_bytes must be a non-negative integer") + if 0 < max_item_bytes < 256: + raise ValueError("max_item_bytes must be 0 or at least 256") -def _looks_like_agentic_cli(messages: list[dict], tools: list[dict]) -> bool: - tool_names = { - _tool_name(tool) - for tool in tools - if _tool_name(tool) + messages = list(body.get("messages") or []) + tools = list(body.get("tools") or []) + counters = { + "harness_messages_projected": 0, + "truncated_items": 0, + "truncated_original_bytes": 0, + "truncated_projected_bytes": 0, } - if tool_names & AGENTIC_TOOL_NAMES: - return True - - for msg in messages: - if not isinstance(msg, dict): - continue - text = _content_to_text(msg.get("content", "")) - if _looks_like_harness_user(text) or _looks_like_harness_system(text): - return True - return False + projected = dict(body) + if mode == "passthrough": + projected_messages = messages + else: + projected_messages = [ + _project_message(message, max_item_bytes, counters) for message in messages + ] -def _project_messages_conservative(messages: list[dict]) -> list[dict]: - out: list[dict] = [] - for msg in messages: - projected = _project_conversation_message(msg, conservative=True) - if projected is not None: - out.append(projected) - return out + if "messages" in body: + projected["messages"] = projected_messages + if "tools" in body: + projected["tools"] = tools + return projected, { + "mode": mode, + "max_item_bytes": max_item_bytes, + "original_messages": len(messages), + "projected_messages": len(projected_messages), + "original_message_chars": _messages_size(messages), + "projected_message_chars": _messages_size(projected_messages), + "original_tools": len(tools), + "projected_tools": len(tools), + "original_tool_chars": _tools_size(tools), + "projected_tool_chars": _tools_size(tools), + **counters, + } -def _project_conversation_message(msg: dict, conservative: bool = False) -> dict | None: - if not isinstance(msg, dict): - return None - role = msg.get("role") - out = dict(msg) +def _project_message(message: Any, max_item_bytes: int, counters: dict[str, int]) -> Any: + if not isinstance(message, dict): + return message + role = message.get("role") + projected = dict(message) if role in {"system", "user"}: - limit = MAX_SYSTEM_GUIDANCE_CHARS if role == "system" else MAX_USER_CONTEXT_CHARS - out["content"], _, _ = _project_harness_content(msg.get("content", ""), limit) - return out - - if role == "assistant": - out["content"] = _project_content(msg.get("content", ""), - lambda text: _summarize_free_text(text, MAX_ASSISTANT_CHARS)) - tool_calls = [] - for tool_call in msg.get("tool_calls") or []: - projected_call = _project_tool_call(tool_call) - if projected_call is not None: - tool_calls.append(projected_call) - if tool_calls: - out["tool_calls"] = tool_calls - elif "tool_calls" in out: - out.pop("tool_calls", None) - return out - - if role == "tool": - out["content"] = _project_content(msg.get("content", ""), _summarize_tool_output) - return out - - if conservative: - out["content"] = _project_content(msg.get("content", ""), - lambda text: _truncate_text(text, MAX_ASSISTANT_CHARS)) - return out - - return None - - -def _has_image_content(content: Any) -> bool: - return isinstance(content, list) and any( - isinstance(block, dict) and block.get("type") == "image_url" for block in content - ) - - -def _project_content(content: Any, transform) -> Any: - """Keep content blocks and their order, including text-only block lists.""" - if not isinstance(content, list): - return transform(_content_to_text(content)) - return [ - {**block, "text": transform(block.get("text", ""))} - if isinstance(block, dict) and block.get("type") == "text" - else transform(block) if isinstance(block, str) else block - for block in content - ] - - -def _project_harness_content(content: Any, context_limit: int) -> tuple[Any, bool, bool]: - """Budget recognized context within each block; preserve real text, images and unknown blocks.""" - matched = False - has_user_text = isinstance(content, list) and any( - isinstance(block, dict) and block.get("type") != "text" for block in content - ) - remaining = context_limit - - def budget_context(text: str) -> str: - nonlocal remaining - if remaining <= 0: - return "" - if len(text) <= remaining: - result = text - else: - suffix = f" ... [{len(text)} context chars condensed]" - result = text[:max(0, remaining - len(suffix))] + suffix[:remaining] - remaining -= len(result) - return result - - def project_text(text: str) -> str: - nonlocal matched, has_user_text - parsed = parse_harness_text(text) - matched = matched or parsed.matched - has_user_text = has_user_text or bool(parsed.user_text.strip()) - return parsed.render(context_transform=budget_context) - - projected = _project_content(content, project_text) - return projected, matched, has_user_text - - -def _project_tool_call(tool_call: dict) -> dict | None: - if not isinstance(tool_call, dict): - return None - - function = tool_call.get("function") or {} - name = function.get("name", "") - arguments = function.get("arguments", "") - - return { - "id": tool_call.get("id"), - "type": tool_call.get("type", "function"), - "function": { - "name": name, - "arguments": _summarize_tool_arguments(name, arguments), - }, - } - - -def _summarize_tool_arguments(name: str, arguments: Any) -> str: - if not isinstance(arguments, str): - try: - return json.dumps(arguments, ensure_ascii=False) - except Exception: - return json.dumps({"summary": _truncate_text(str(arguments), 240)}, ensure_ascii=False) - - if len(arguments) <= MAX_TOOL_ARGS_CHARS: - return arguments - - if name == "apply_patch": - return json.dumps( - {"summary": "Large apply_patch payload omitted; a patch was prepared or applied in a previous step."}, - ensure_ascii=False, + content, changed = _map_text_content( + message.get("content", ""), + lambda text: _project_instruction_text(text), ) + projected["content"] = content + counters["harness_messages_projected"] += int(changed) + elif role == "assistant": + projected["content"] = _project_generated_content( + message.get("content", ""), max_item_bytes, counters + ) + if "tool_calls" in message: + projected["tool_calls"] = [ + _project_tool_call(call, max_item_bytes, counters) + for call in message.get("tool_calls") or [] + ] + elif role == "tool": + projected["content"] = _project_generated_content( + message.get("content", ""), max_item_bytes, counters + ) + return projected - try: - parsed = json.loads(arguments) - except Exception: - return json.dumps({"summary": _truncate_text(arguments, 320)}, ensure_ascii=False) - - return json.dumps(_shrink_json_value(parsed), ensure_ascii=False) +def _project_instruction_text(text: str) -> str: + parsed = parse_harness_text(text) + return parsed.render() if parsed.matched else text -def _shrink_json_value(value: Any, depth: int = 0, key: str = "") -> Any: - if depth >= 4: - return "" - if isinstance(value, dict): - out = {} - items = list(value.items()) - for idx, (item_key, item_value) in enumerate(items): - if idx >= 12: - out["_omitted_keys"] = len(items) - idx - break - out[item_key] = _shrink_json_value(item_value, depth + 1, item_key) - return out +def _project_generated_content(content: Any, max_item_bytes: int, counters: dict[str, int]) -> Any: + transformed, _ = _map_text_content( + content, + lambda text: _truncate_generated_text(text, max_item_bytes, counters), + ) + return transformed - if isinstance(value, list): - trimmed = [_shrink_json_value(item, depth + 1, key) for item in value[:6]] - if len(value) > 6: - trimmed.append(f"") - return trimmed - if isinstance(value, str): - limit = 240 if key in {"cmd", "chars", "patch", "content", "text", "question"} else 120 - return _truncate_text(value, limit) +def _truncate_generated_text(text: str, max_item_bytes: int, counters: dict[str, int]) -> str: + result = truncate_middle_bytes(text, max_item_bytes) + if result.truncated: + _record_truncation(result, counters) + return result.text - return value +def _map_text_content(content: Any, transform) -> tuple[Any, bool]: + if isinstance(content, str): + projected = transform(content) + return projected, projected != content + if not isinstance(content, list): + return content, False -def _project_tools(tools: list[dict], *, keep_tool_metadata: bool = False) -> tuple[list[dict], dict]: projected = [] - original_chars = _tools_size(tools) - - for tool in tools: - if not isinstance(tool, dict): - continue - - if tool.get("type") != "function": - continue - - function = tool.get("function") or tool - name = function.get("name") - if not name: - continue - - projected_function: dict[str, Any] = {"name": name} - if keep_tool_metadata: - for key in ("description", "title"): - if isinstance(function.get(key), str): - projected_function[key] = function[key] - if "parameters" in function: - projected_function["parameters"] = _project_schema( - function.get("parameters"), keep_tool_metadata=keep_tool_metadata) - if "strict" in function: - projected_function["strict"] = function.get("strict") - - projected.append({"type": "function", "function": projected_function}) - - return projected, { - "original_tools": len(tools), - "projected_tools": len(projected), - "original_tool_chars": original_chars, - "projected_tool_chars": _tools_size(projected), + changed = False + for block in content: + replacement = block + if isinstance(block, str): + replacement = transform(block) + elif isinstance(block, dict) and block.get("type") in _TEXT_TYPES and isinstance(block.get("text"), str): + replacement = {**block, "text": transform(block["text"])} + changed = changed or replacement is not block and replacement != block + projected.append(replacement) + return (projected if changed else content), changed + + +def _project_tool_call(tool_call: Any, max_item_bytes: int, counters: dict[str, int]) -> Any: + if not isinstance(tool_call, dict) or not isinstance(tool_call.get("function"), dict): + return tool_call + + projected = dict(tool_call) + function = dict(tool_call["function"]) + arguments = function.get("arguments") + if not isinstance(arguments, str): + raise ValueError("tool call arguments must be a JSON string") + if max_item_bytes == 0: + return projected + argument_bytes = len(arguments.encode("utf-8")) + if argument_bytes > _MAX_TOOL_ARGUMENTS_PARSE_BYTES: + function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) + projected["function"] = function + return projected + try: + decoded = json.loads(arguments, parse_constant=_reject_json_constant) + except _InvalidJsonConstant: + function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) + projected["function"] = function + return projected + except (TypeError, ValueError, RecursionError): + if argument_bytes <= max_item_bytes: + return projected + function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) + projected["function"] = function + return projected + if _compact_json(decoded) is None: + function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) + projected["function"] = function + return projected + if argument_bytes <= max_item_bytes: + return projected + string_limit = max(256, max_item_bytes - _JSON_OVERHEAD_RESERVE) + trial_counters = { + "truncated_items": 0, + "truncated_original_bytes": 0, + "truncated_projected_bytes": 0, } - - -def _project_schema(schema: Any, depth: int = 0, *, keep_tool_metadata: bool = False) -> Any: - if depth >= 6: - return {"type": "object"} - - if isinstance(schema, dict): - out: dict[str, Any] = {} - for key, value in schema.items(): - if key not in SCHEMA_KEEP_KEYS: - if keep_tool_metadata and key in ("description", "title") and isinstance(value, str): - out[key] = value - continue - if key == "properties" and isinstance(value, dict): - out["properties"] = { - prop: _project_schema(prop_schema, depth + 1, keep_tool_metadata=keep_tool_metadata) - for prop, prop_schema in value.items() - } - elif key == "items": - out["items"] = _project_schema(value, depth + 1, keep_tool_metadata=keep_tool_metadata) - elif key in {"oneOf", "anyOf", "allOf"} and isinstance(value, list): - out[key] = [_project_schema(item, depth + 1, keep_tool_metadata=keep_tool_metadata) for item in value[:6]] - elif key == "additionalProperties" and isinstance(value, dict): - out[key] = _project_schema(value, depth + 1, keep_tool_metadata=keep_tool_metadata) - else: - out[key] = value - # Annotations must not change the historical empty-schema object fallback. - return out if any(key in SCHEMA_KEEP_KEYS for key in out) else {"type": "object", **out} - - if isinstance(schema, list): - return [_project_schema(item, depth + 1, keep_tool_metadata=keep_tool_metadata) for item in schema[:6]] - - return schema - - -def _choose_tail_start(messages: list[dict]) -> int: - if not messages: - return 0 - - start = len(messages) - 1 - total_chars = 0 - kept = 0 - - for idx in range(len(messages) - 1, -1, -1): - cost = _message_cost(messages[idx]) - if kept > 0 and (kept >= MAX_TAIL_MESSAGES or total_chars + cost > MAX_TAIL_CHARS): - break - start = idx - total_chars += cost - kept += 1 - return start - - -def _expand_tail_for_tool_context(messages: list[dict], start: int) -> int: - if start <= 0 or not messages: - return start - - needed_call_ids = { - msg.get("tool_call_id") - for msg in messages[start:] - if isinstance(msg, dict) and msg.get("role") == "tool" and msg.get("tool_call_id") + projected_value, changed = _truncate_json_strings(decoded, string_limit, trial_counters) + serialized = _compact_json(projected_value) + if serialized is not None and len(serialized.encode("utf-8")) <= max_item_bytes: + if changed or serialized != arguments: + function["arguments"] = serialized + projected["function"] = function + _merge_counters(trial_counters, counters) + return projected + + function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) + projected["function"] = function + return projected + + +def _truncate_json_argument_text(text: str, max_bytes: int, counters: dict[str, int]) -> str: + """Return bounded valid JSON with the original head, tail and size metadata.""" + raw = text.encode("utf-8") + original_bytes = len(raw) + original_tokens = (original_bytes + 3) // 4 + total_lines = count_text_lines(text) + wrapper = { + "_truncated": { + "warning": "middle omitted; head and tail retained", + "original_bytes": original_bytes, + "estimated_tokens": original_tokens, + "total_lines": total_lines, + }, + "head": "", + "tail": "", } - if not needed_call_ids: - return start - - expanded = start - for idx in range(start - 1, -1, -1): - msg = messages[idx] - if msg.get("role") != "assistant": - continue - call_ids = { - tool_call.get("id") - for tool_call in msg.get("tool_calls") or [] - if isinstance(tool_call, dict) - } - if call_ids & needed_call_ids: - expanded = idx - needed_call_ids -= call_ids - if not needed_call_ids: - break - return expanded - - -def _latest_user_index(messages: list[dict], context_only_indices: set[int]) -> int | None: - for idx in range(len(messages) - 1, -1, -1): - if messages[idx].get("role") == "user" and idx not in context_only_indices: - return idx - return None - - -def _build_history_summary(messages: list[dict], tool_name_by_call_id: dict[str, str], - context_only_indices: set[int]) -> str: - lines: list[str] = [] - total_chars = 0 - summarized = 0 - - for idx, msg in enumerate(messages): - line = _history_line(msg, tool_name_by_call_id, idx in context_only_indices) - if not line: - continue - if summarized >= MAX_HISTORY_ITEMS or total_chars + len(line) > MAX_HISTORY_SUMMARY_CHARS: - break - lines.append(f"- {line}") - total_chars += len(line) - summarized += 1 - - remaining = len(messages) - summarized - if remaining > 0: - lines.append(f"- {remaining} earlier messages or tool results were further condensed.") - - if not lines: - return "" - return HISTORY_PREFIX + "\n" + "\n".join(lines) - - -def _history_line(msg: dict, tool_name_by_call_id: dict[str, str], context_only: bool = False) -> str: - role = msg.get("role") - text = _content_to_text(msg.get("content", "")) + empty = _compact_json(wrapper) or "{}" + remaining = max(0, max_bytes - len(empty.encode("utf-8"))) + edge_limit = min(remaining // 2, _MAX_WRAPPER_EDGE_BYTES) + wrapper["head"] = _fit_json_prefix(raw, edge_limit) + wrapper["tail"] = _fit_json_suffix(raw, min(remaining - edge_limit, _MAX_WRAPPER_EDGE_BYTES)) + payload = _compact_json(wrapper) or empty + if len(payload.encode("utf-8")) > max_bytes: + payload = _compact_json({"truncated": True, "original_bytes": original_bytes}) or "{}" + projected_bytes = len(payload.encode("utf-8")) + projected_tokens = (projected_bytes + 3) // 4 + result = TruncationResult( + text=payload, + truncated=True, + original_bytes=original_bytes, + projected_bytes=projected_bytes, + original_estimated_tokens=original_tokens, + projected_estimated_tokens=projected_tokens, + total_lines=total_lines, + omitted_estimated_tokens=max(0, original_tokens - projected_tokens), + ) + _record_truncation(result, counters) + return payload - if role == "user": - label = "Harness context" if context_only else "User asked" - return f"{label}: {_truncate_text(text, 220)}" - if role == "assistant": - tool_names = [ - (tool_call.get("function") or {}).get("name") - for tool_call in msg.get("tool_calls") or [] - if isinstance(tool_call, dict) - ] - tool_names = [name for name in tool_names if name] - if text and tool_names: - return f"Assistant replied: {_truncate_text(text, 160)} Then called tools: {', '.join(tool_names[:4])}." - if tool_names: - return f"Assistant called tools: {', '.join(tool_names[:4])}." - if text: - return f"Assistant replied: {_truncate_text(text, 180)}" - return "" +def _compact_json(value: Any) -> str | None: + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + except (TypeError, ValueError, UnicodeError, RecursionError): + return None - if role == "tool": - tool_name = tool_name_by_call_id.get(msg.get("tool_call_id", ""), "tool") - summary = _tool_output_inline_summary(text) - return f"Tool {tool_name} returned: {summary}" - if role == "system": - return f"System guidance: {_truncate_text(text, 180)}" +def _json_escape_size(value: str) -> int: + encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return len(encoded[1:-1].encode("utf-8")) - return "" +def _fit_json_prefix(raw: bytes, limit: int) -> str: + return _fit_json_edge(raw, limit, from_end=False) -def _build_tool_call_name_map(messages: list[dict]) -> dict[str, str]: - mapping: dict[str, str] = {} - for msg in messages: - if not isinstance(msg, dict) or msg.get("role") != "assistant": - continue - for tool_call in msg.get("tool_calls") or []: - if not isinstance(tool_call, dict): - continue - call_id = tool_call.get("id") - name = (tool_call.get("function") or {}).get("name") - if call_id and name: - mapping[call_id] = name - return mapping +def _fit_json_suffix(raw: bytes, limit: int) -> str: + return _fit_json_edge(raw, limit, from_end=True) - -def _summarize_tool_output(text: str) -> str: - text = (text or "").strip() - if not text: +def _fit_json_edge(raw: bytes, limit: int, *, from_end: bool) -> str: + if limit <= 0: return "" - if len(text) <= MAX_TOOL_OUTPUT_CHARS and text.count("\n") <= 24: - return text - - lines = text.splitlines() - exit_line = next((line.strip() for line in lines if "Process exited with code" in line), "") - useful_lines = [] - saw_output = False - for line in lines: - stripped = line.rstrip() - if stripped == "Output:": - saw_output = True - continue - if ( - stripped.startswith("Chunk ID:") - or stripped.startswith("Wall time:") - or stripped.startswith("Original token count:") - or stripped.startswith("Process exited with code") - ): - continue - useful_lines.append(stripped) - - body_lines = useful_lines - - head = body_lines[:10] - tail = body_lines[-6:] if len(body_lines) > 16 else [] - omitted = max(len(body_lines) - len(head) - len(tail), 0) - - parts: list[str] = [] - if exit_line: - parts.append(exit_line) - if head: - parts.append("Key output:") - parts.extend(head) - if omitted: - parts.append(f"... [omitted {omitted} lines] ...") - if tail: - parts.append("Recent tail:") - parts.extend(tail) - - summary = "\n".join(part for part in parts if part).strip() - return _truncate_text(summary or text, MAX_TOOL_OUTPUT_CHARS) - + low = 0 + high = min(len(raw), max(limit, limit * 6)) + while low < high: + middle = (low + high + 1) // 2 + candidate = raw[-middle:] if from_end else raw[:middle] + value = candidate.decode("utf-8", "ignore") + if _json_escape_size(value) <= limit: + low = middle + else: + high = middle - 1 + candidate = raw[-low:] if from_end else raw[:low] + return candidate.decode("utf-8", "ignore") if low else "" -def _tool_output_inline_summary(text: str) -> str: - summarized = _summarize_tool_output(text) - summarized = summarized.replace("\n", " | ") - return _truncate_text(summarized, 220) +def _merge_counters(source: dict[str, int], target: dict[str, int]) -> None: + for key in ("truncated_items", "truncated_original_bytes", "truncated_projected_bytes"): + target[key] += source.get(key, 0) -def _summarize_free_text(text: str, limit: int) -> str: - text = (text or "").strip() - if not text: - return "" - if len(text) <= limit: - return text - head = text[: limit // 2].rstrip() - tail = text[-(limit // 3):].lstrip() - omitted = len(text) - len(head) - len(tail) - return f"{head}\n... [{omitted} chars omitted] ...\n{tail}" +def _truncate_json_strings(value: Any, max_item_bytes: int, counters: dict[str, int], depth: int = 0) -> tuple[Any, bool]: + if depth > 64: + return value, False + if isinstance(value, str): + projected = _truncate_generated_text(value, max_item_bytes, counters) + return projected, projected != value + if isinstance(value, list): + projected = [] + changed = False + for item in value: + projected_item, item_changed = _truncate_json_strings( + item, max_item_bytes, counters, depth + 1 + ) + projected.append(projected_item) + changed = changed or item_changed + return projected, changed + if isinstance(value, dict): + projected = {} + changed = False + for key, item in value.items(): + projected_item, item_changed = _truncate_json_strings( + item, max_item_bytes, counters, depth + 1 + ) + projected[key] = projected_item + changed = changed or item_changed + return projected, changed + return value, False -def _truncate_text(text: str, limit: int) -> str: - text = (text or "").strip() - if not text: - return "" - if len(text) <= limit: - return text - return text[: max(limit - 24, 0)].rstrip() + f" ... [truncated {len(text) - max(limit - 24, 0)} chars]" +def _record_truncation(result, counters: dict[str, int]) -> None: + counters["truncated_items"] += 1 + counters["truncated_original_bytes"] += result.original_bytes + counters["truncated_projected_bytes"] += result.projected_bytes def _content_to_text(content: Any) -> str: - if content is None: - return "" if isinstance(content, str): return content - if isinstance(content, list): - parts = [] - for block in content: - if isinstance(block, dict): - if "text" in block: - parts.append(str(block.get("text", ""))) - elif "output" in block: - parts.append(str(block.get("output", ""))) - elif isinstance(block, str): - parts.append(block) - return "".join(parts) - return str(content) - - -def _looks_like_harness_user(text: str) -> bool: - return any(marker in text for marker in HARNESS_USER_MARKERS) - - -def _looks_like_harness_system(text: str) -> bool: - return any(marker in text for marker in HARNESS_SYSTEM_MARKERS) - - -def _message_cost(msg: dict) -> int: - cost = len(_content_to_text(msg.get("content", ""))) - for tool_call in msg.get("tool_calls") or []: + if not isinstance(content, list): + return "" + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + for key in ("text", "output"): + if key in block: + parts.append(str(block.get(key) or "")) + break + return "".join(parts) + + +def _message_cost(message: Any) -> int: + if not isinstance(message, dict): + return 0 + cost = len(_content_to_text(message.get("content", ""))) + for tool_call in message.get("tool_calls") or []: if not isinstance(tool_call, dict): continue function = tool_call.get("function") or {} - cost += len(function.get("name", "")) - cost += len(function.get("arguments", "")) + cost += len(str(function.get("name") or "")) + cost += len(str(function.get("arguments") or "")) return cost -def _messages_size(messages: list[dict]) -> int: - total = 0 - for msg in messages: - if not isinstance(msg, dict): - continue - total += _message_cost(msg) - total += len(msg.get("role", "")) - return total - - -def _tool_name(tool: dict) -> str: - if not isinstance(tool, dict): - return "" - function = tool.get("function") or tool - return str(function.get("name", "") or "") +def _messages_size(messages: list[Any]) -> int: + return sum(_message_cost(message) + len(message.get("role", "")) for message in messages if isinstance(message, dict)) -def _tools_size(tools: list[dict]) -> int: +def _tools_size(tools: list[Any]) -> int: try: return len(json.dumps(tools, ensure_ascii=False)) - except Exception: + except (TypeError, ValueError, UnicodeError, RecursionError): return 0 diff --git a/app/audit_store.py b/app/audit_store.py index 6436fc4..3ab4bdc 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -267,6 +267,11 @@ def _sanitize_record(self, source): ("upstream_model", "profile", "credential", "protocol", "error_code", "usage_source")} result["stream_mode"] = (source.get("stream_mode") if source.get("stream_mode") in ("compatible", "realtime") else None) + projection_mode = source.get("responses_projection_mode") + if projection_mode in ("balanced", "passthrough"): + result["responses_projection_mode"] = projection_mode + result["responses_projection_max_bytes"] = number(source.get("responses_projection_max_bytes")) + result["responses_truncated_items"] = number(source.get("responses_truncated_items")) result["public_model"] = safe_label(source.get("public_model", source.get("model"))) result["model"] = result["public_model"] result["id"] = safe_label(source.get("id", source.get("event_id"))) or uuid.uuid4().hex diff --git a/app/observability.py b/app/observability.py index 81df0a8..80ae552 100644 --- a/app/observability.py +++ b/app/observability.py @@ -137,6 +137,18 @@ def observe_stream_mode(mode): observation.record["stream_mode"] = mode +def observe_responses_projection(stats): + """Record non-sensitive Responses projection counters for audit details.""" + observation = _current.get() + if observation is None or not isinstance(stats, dict): + return + mode = stats.get("mode") + if mode in ("balanced", "passthrough"): + observation.record["responses_projection_mode"] = mode + observation.record["responses_projection_max_bytes"] = number(stats.get("max_item_bytes")) + observation.record["responses_truncated_items"] = number(stats.get("truncated_items")) + + def observe_route(public_model, upstream_model, profile, credential): observation = _current.get() if observation is not None: diff --git a/app/output_truncation.py b/app/output_truncation.py new file mode 100644 index 0000000..c02f9d9 --- /dev/null +++ b/app/output_truncation.py @@ -0,0 +1,112 @@ +"""Truncate oversized text while preserving its head and tail.""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ["TruncationResult", "count_text_lines", "truncate_middle_bytes"] + +_BYTES_PER_TOKEN = 4 +_MIN_ACTIVE_LIMIT = 256 + + +@dataclass(frozen=True) +class TruncationResult: + """Text projection and its byte and token estimates.""" + + text: str + truncated: bool + original_bytes: int + projected_bytes: int + original_estimated_tokens: int + projected_estimated_tokens: int + total_lines: int + omitted_estimated_tokens: int + + +def _estimate_tokens(byte_count: int) -> int: + return (byte_count + _BYTES_PER_TOKEN - 1) // _BYTES_PER_TOKEN + + +def count_text_lines(text: str) -> int: + """Count newline-delimited lines without materializing a split list.""" + if not text: + return 0 + return text.count("\n") + (not text.endswith("\n")) + + +def _warning(original_bytes: int, estimated_tokens: int, total_lines: int) -> str: + return ( + f"\n[Warning: middle omitted; original bytes: {original_bytes}, " + f"estimated tokens: ~{estimated_tokens} (4 bytes/token), " + f"total lines: {total_lines}.]\n" + ) + + +def _decode_prefix(raw: bytes, limit: int) -> str: + end = min(limit, len(raw)) + while end > 0: + try: + return raw[:end].decode("utf-8") + except UnicodeDecodeError: + end -= 1 + return "" + + +def _decode_suffix(raw: bytes, limit: int) -> str: + start = max(len(raw) - limit, 0) + while start < len(raw): + try: + return raw[start:].decode("utf-8") + except UnicodeDecodeError: + start += 1 + return "" + + +def truncate_middle_bytes(text: str, max_bytes: int) -> TruncationResult: + """Return text within max_bytes, keeping valid UTF-8 from both ends.""" + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 0: + raise ValueError("max_bytes must be a non-negative integer") + if 0 < max_bytes < _MIN_ACTIVE_LIMIT: + raise ValueError("max_bytes must be 0 or at least 256") + + raw = text.encode("utf-8") + original_bytes = len(raw) + original_estimated_tokens = _estimate_tokens(original_bytes) + total_lines = count_text_lines(text) + + if max_bytes == 0 or original_bytes <= max_bytes: + return TruncationResult( + text=text, + truncated=False, + original_bytes=original_bytes, + projected_bytes=original_bytes, + original_estimated_tokens=original_estimated_tokens, + projected_estimated_tokens=original_estimated_tokens, + total_lines=total_lines, + omitted_estimated_tokens=0, + ) + + warning = _warning(original_bytes, original_estimated_tokens, total_lines) + content_budget = max_bytes - len(warning.encode("utf-8")) + if content_budget <= 0: + raise ValueError("max_bytes is too small for the truncation warning") + + head_budget = content_budget // 2 + tail_budget = content_budget - head_budget + head = _decode_prefix(raw, head_budget) + tail = _decode_suffix(raw, tail_budget) + projected = head + warning + tail + projected_bytes = len(projected.encode("utf-8")) + projected_estimated_tokens = _estimate_tokens(projected_bytes) + + return TruncationResult( + text=projected, + truncated=True, + original_bytes=original_bytes, + projected_bytes=projected_bytes, + original_estimated_tokens=original_estimated_tokens, + projected_estimated_tokens=projected_estimated_tokens, + total_lines=total_lines, + omitted_estimated_tokens=original_estimated_tokens - projected_estimated_tokens, + ) diff --git a/app/settings.py b/app/settings.py index 4277280..de4f7a5 100644 --- a/app/settings.py +++ b/app/settings.py @@ -33,6 +33,12 @@ def normalize_allowed_origins(value): return ",".join(normalized) +def validate_projection_max_bytes(value): + """Allow zero or enough room for a bounded head/tail warning.""" + if value != 0 and value < 256: + raise ValueError("responses_projection_max_bytes 必须为 0 或至少 256") + return value + def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=None, choices=None, sensitive=False, allow_empty=False, max_length=255, validator=None): @@ -87,6 +93,13 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= env="CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT", minimum=0, maximum=10000), "request_context_mode": _item("legacy", "string", "请求上下文模式", env="CODEBUDDY2API_REQUEST_CONTEXT_MODE", choices=["legacy", "scoped"]), + "responses_projection_mode": _item("balanced", "string", "Responses 投影模式", + env="CODEBUDDY2API_RESPONSES_PROJECTION_MODE", + choices=["balanced", "passthrough"]), + "responses_projection_max_bytes": _item( + 40000, "integer", "Responses 单项字节上限(0 或 ≥256)", + env="CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES", minimum=0, maximum=33554432, + validator=validate_projection_max_bytes), "stream_mode": _item("compatible", "string", "流式模式(实时模式不重生成工具参数)", env="CODEBUDDY2API_STREAM_MODE", choices=["compatible", "realtime"]), "audit_max_bytes": _item(256 * 1024 * 1024, "integer", "审计明细预算", minimum=1024**2, maximum=1024**4), diff --git a/converter.py b/converter.py index 01f6454..543c27a 100644 --- a/converter.py +++ b/converter.py @@ -42,7 +42,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, responses_request_to_chat, ResponsesStreamConverter, ) -from app.adapters.responses_projection import project_responses_chat_body +from app.adapters.responses_projection import PROJECTION_MODES, project_responses_chat_body from app.adapters.anthropic_adapter import ( anthropic_request_to_chat, AnthropicStreamConverter, @@ -56,8 +56,8 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.usage_snapshots import UsageSnapshots from app.client_hangup import ClientHungUp, await_or_hangup from app.observability import (AuditMiddleware, observe_recovery, observe_route, observe_stream_mode, - observe_usage, observe_attempt, observe_failure, - observe_failure_seq) + observe_responses_projection, observe_usage, observe_attempt, + observe_failure, observe_failure_seq) from app.credential_io import (CredentialFileError, read_import_file, atomic_write_credential, credential_file_lock) from app.upstream_io import (ChatSSEAccumulator, StreamOutputBudget, UpstreamHTTPError, @@ -1818,6 +1818,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): "upstream_keepalive": False, "max_inflight_per_account": 0, "request_context_mode": "legacy", "stream_mode": "compatible", "model_capability_guard": True, + "responses_projection_mode": "balanced", "responses_projection_max_bytes": 40000, "failover_max": 0, # Credential failovers allowed before the first response byte "retry_write_timeout": False, # Opt-in replay after incomplete writes "usage_daily": None, # Usage aggregated by date and model @@ -3586,8 +3587,18 @@ async def create_response(request: Request, raise HTTPException(status_code=400, detail={"error": {"message": f"request conversion error: {e}", "type": "invalid_request_error"}}) await run_in_threadpool(_bind_request_session, payload, chat_body) - chat_body, projection_stats = project_responses_chat_body( - chat_body, keep_tool_metadata=CONFIG.get("keep_tool_metadata", False)) + projection_mode = CONFIG.get("responses_projection_mode", "balanced") + projection_max_bytes = int(CONFIG.get("responses_projection_max_bytes", 40000)) + try: + chat_body, projection_stats = await run_in_threadpool( + project_responses_chat_body, chat_body, mode=projection_mode, + max_item_bytes=projection_max_bytes) + except UnicodeError: + raise HTTPException(status_code=400, detail={"error": { + "message": "request contains text that cannot be encoded as UTF-8", + "type": "invalid_request_error", "param": "input", + "code": "invalid_unicode"}}) from None + observe_responses_projection(projection_stats) chat_body = await run_in_threadpool(_prepare_chat_body, chat_body) client_wants_stream = _client_wants_stream(payload) @@ -3603,9 +3614,10 @@ async def create_response(request: Request, f"| chars {projection_stats.get('original_message_chars')}→{projection_stats.get('projected_message_chars')} " f"| tools {projection_stats.get('original_tools')}→{projection_stats.get('projected_tools')} " f"| tool_chars {projection_stats.get('original_tool_chars')}→{projection_stats.get('projected_tool_chars')} " - f"| summarized_history={projection_stats.get('summarized_history_messages', 0)} " - f"| dropped_harness={projection_stats.get('dropped_harness_messages', 0)} " - f"| anchor_user={projection_stats.get('anchor_user_preserved', False)}" + f"| harness_messages={projection_stats.get('harness_messages_projected', 0)} " + f"| truncated_items={projection_stats.get('truncated_items', 0)} " + f"| truncated_bytes={projection_stats.get('truncated_original_bytes', 0)}→" + f"{projection_stats.get('truncated_projected_bytes', 0)}" ) # Keep blocking credential selection and refresh off the event loop. prepared = chat_body # Preserve canonical input for routing policy checks. @@ -3617,11 +3629,16 @@ async def create_response(request: Request, def attempt(routed, cred, headers, url): return _stream_responses(url, headers, _body_with_stream_policy(routed, stream_policy), model_name, t0, rid, cred=cred) - return _routed_stream(payload, prepared, model_name, rid, t0, attempt, - chat_body, cred, headers, url) + response = _routed_stream(payload, prepared, model_name, rid, t0, attempt, + chat_body, cred, headers, url) + response.headers["X-CodeBuddy-Responses-Projection"] = projection_mode + return response - return await _nonstream_adapted(url, headers, chat_body, model_name, t0, rid, cred, - payload=payload, canonical=prepared, request=request, policy=stream_policy) + response = await _nonstream_adapted( + url, headers, chat_body, model_name, t0, rid, cred, payload=payload, canonical=prepared, + request=request, policy=stream_policy) + response.headers["X-CodeBuddy-Responses-Projection"] = projection_mode + return response async def _nonstream_adapted(url, headers, body, model_name, t0, rid, cred, *, anthropic=False, @@ -3917,6 +3934,14 @@ def _positive_int(value): raise argparse.ArgumentTypeError("必须为正整数") return number +def _projection_bytes_arg(value): + number = _nonnegative_int(value) + if number != 0 and number < 256: + raise argparse.ArgumentTypeError("必须为 0 或至少 256") + if number > 33554432: + raise argparse.ArgumentTypeError("不能超过 33554432") + return number + def _origins_arg(value): from app.settings import normalize_allowed_origins @@ -3967,6 +3992,12 @@ def main(): ap.add_argument("--keep-tool-metadata", type=_boolean_arg, nargs="?", const=True, default=os.environ.get("CODEBUDDY2API_KEEP_TOOL_METADATA", "false"), help="保留工具描述及参数 description/title;启用脱敏时仍处理描述文本,默认 false") + ap.add_argument("--responses-projection-mode", choices=PROJECTION_MODES, + default=os.environ.get("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", "balanced"), + help="Responses 上下文:balanced 仅改写有固定摘要的已识别 harness,其余文本原样保留;passthrough 完全关闭投影;默认 balanced") + ap.add_argument("--responses-projection-max-bytes", type=_projection_bytes_arg, metavar="BYTES", + default=os.environ.get("CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES", "40000"), + help="Responses 单项 UTF-8 字节上限;0 禁用,非零范围 256..33554432,默认 40000") ap.add_argument("--skip-check", action="store_true", help="跳过启动预检") ap.add_argument("--auth-file", action="append", default=[], metavar="PATH", help="凭据文件(可重复传入组成凭证池;默认自动扫描 auth 目录全部 *.info)") @@ -4043,7 +4074,8 @@ def main(): for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", "tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent", "failover_max", "retry_write_timeout", "upstream_keepalive", "max_inflight_per_account", - "request_context_mode", "stream_mode", "model_capability_guard", "admin_allowed_origins"): + "request_context_mode", "stream_mode", "model_capability_guard", "admin_allowed_origins", + "responses_projection_mode", "responses_projection_max_bytes"): CONFIG[key] = getattr(args, key) CONFIG["api_key"] = args.api_key CONFIG["desensitize"] = args.desensitize diff --git a/docker-compose.yml b/docker-compose.yml index 6dfdfe5..bc094ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: CODEBUDDY2API_UPSTREAM_KEEPALIVE: CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT: CODEBUDDY2API_REQUEST_CONTEXT_MODE: + CODEBUDDY2API_RESPONSES_PROJECTION_MODE: + CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES: CODEBUDDY2API_STREAM_MODE: CODEBUDDY2API_MODEL_CAPABILITY_GUARD: CODEBUDDY2API_TOOL_CALL_MAX_RETRY: ${CODEBUDDY2API_TOOL_CALL_MAX_RETRY:-3} diff --git a/docs/advanced.md b/docs/advanced.md index 5fc4fee..8306381 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -30,6 +30,8 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--model-capability-guard [true/false]` | `true` | Preflight declared image, tool, reasoning and mapped output limits; changes affect new requests | | `--max-images` | `16` | Total images per request; `0` permits no images | | `--image-policy` | `truncate` | Keep newest images; `error` rejects excess images with 413 | +| `--responses-projection-mode balanced\|passthrough` | `balanced` | Rewrite recognized harness blocks that have stable summaries; passthrough disables Responses projection | +| `--responses-projection-max-bytes` | `40000` | Complete per-item UTF-8 limit for assistant text, tool-argument JSON and tool results; `0` disables, otherwise valid range is `256..33554432` | | `--tool-call-max-retry` | `3` | Extra generations after malformed tool calls (each consumes credits); `0` disables retries | | `--max-inbound-bytes` | `67108864` | Raw body limit for generation and token-count POSTs, before parsing (chunked included); other routes are not buffered; 413 beyond it | | `--max-collect-bytes` | `8388608` | Total retained-output budget for aggregation and realtime validation (content + reasoning + tool arguments/metadata); `response_too_large` beyond it; `0` disables | @@ -43,17 +45,25 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--max-request-bytes` | `33554432` | Positive byte limit for the processed upstream JSON | | `--log-body-limit` | `65536` | Legacy text-preview option; text output is retired and SQLite diagnostics use their own budget | -Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_ADMIN_ORIGINS`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_STREAM_MODE`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. +Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_ADMIN_ORIGINS`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_STREAM_MODE`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_RESPONSES_PROJECTION_MODE`, `CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. + +### Responses projection + +Configure these hot settings through the WebUI, CLI, process environment or `.env`. Precedence is CLI > process environment > `.env` > saved SQLite value > default; CLI/environment values lock the WebUI fields. Compose forwards a variable only when the host environment sets it, so leaving both unset keeps the WebUI editable. + +`responses_projection_mode` defaults to `balanced` and accepts only `balanced` or `passthrough`. Balanced mode rewrites only recognized harness blocks that have stable summaries; text outside those blocks is not budgeted or truncated. It also applies Codex-style head/tail truncation to generated assistant content, complete tool-argument JSON and tool results according to `responses_projection_max_bytes`. Text markers report original bytes, estimated tokens and total lines. Oversized tool arguments remain valid JSON and use a bounded object containing the original head, tail and size metadata. Passthrough disables Responses projection completely. + +`responses_projection_max_bytes` defaults to `40000`; valid values are `0` or `256..33554432`. `0` disables per-item truncation only; global inbound/request and output gates still apply. Neither setting changes the client Base URL. ### Tool metadata retention -Off by default, preserving the existing policy: desensitization strips tool descriptions, and Responses tool projection also strips them; `--no-compact` does not change this. When enabled, Chat, Responses and Messages retain supported tool descriptions and string `description/title` annotations in parameter schemas. With desensitization enabled, retained text is still processed. Prompt compaction and existing content-filter retry conditions/counts are unchanged; fallback processing also respects this option. +Responses projection no longer changes tool definitions or schemas. When desensitization is enabled, it strips tool descriptions and string `description/title` annotations by default; enabling this setting retains and processes that text across Chat, Responses and Messages. `--no-compact` does not change this setting. - **WebUI:** Settings → Keep tool descriptions; unlocked changes apply immediately and persist. - **CLI:** append `--keep-tool-metadata` or `--keep-tool-metadata true` to the existing command; explicit `false` overrides the environment. - **Environment:** set `CODEBUDDY2API_KEEP_TOOL_METADATA=true`. Compose passes it only when set, leaving the WebUI unlocked otherwise. Remove or comment out the variable to remove the environment lock; do not set an empty string. -Use a source/image build and Compose configuration containing this feature; recreate containers after changing their environment. Retained descriptions may increase input tokens and content-filter rejections; compatibility across accounts/models is not guaranteed. Set `false` to restore the previous policy. This option does not restore other schema fields or deep nodes removed by existing Responses projection, nor relax the request-size budget. +Use a source/image build and Compose configuration containing this feature; recreate containers after changing its environment. Retained descriptions may increase input tokens and content-filter risk; set `false` to restore the desensitization default. This setting does not change Responses balanced/passthrough mode or relax request-size limits. ### Streaming modes @@ -227,7 +237,7 @@ Both international profiles merge image-bearing consecutive `user` runs only aft ## 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. Responses projection and optional desensitization process content separately; the whole pipeline is not a verbatim pass-through. +- 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. - Images count across all history and tool results, including duplicates, in message/content array order. The default keeps the newest 16, removing only excess images while retaining text and message structure; emptied image content receives a text placeholder. - `--image-policy error` returns local `413 / too_many_images`. JSON still over budget after processing returns `413 / request_too_large`, without further text truncation to fit the limit. - Image count does not guarantee acceptable individual image sizes or model vision support. URL/base64 images can be converted; Responses image `file_id` is unsupported. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 3a7bf73..66c726a 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -30,6 +30,8 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--model-capability-guard [true/false]` | `true` | 预检模型声明的图片、工具、思考及已映射输出上限;新请求生效 | | `--max-images` | `16` | 单请求图片总数;`0` 不允许图片 | | `--image-policy` | `truncate` | 保留最新图片;设为 `error` 时超限返回 413 | +| `--responses-projection-mode balanced\|passthrough` | `balanced` | 仅改写有固定摘要的已识别 harness;passthrough 完全关闭 Responses 投影 | +| `--responses-projection-max-bytes` | `40000` | assistant、完整工具参数 JSON 与工具结果的单项 UTF-8 字节上限;`0` 禁用,`256..33554432` 为有效范围 | | `--tool-call-max-retry` | `3` | 工具参数损坏时的额外生成上限(每次都消耗额度);`0` 不重试 | | `--max-inbound-bytes` | `67108864` | 生成及 token 估算 POST 的解析前原始字节上限(含 chunked),超限 413;其他路由不缓冲请求体 | | `--max-collect-bytes` | `8388608` | 聚合及实时校验所需保留输出的总字节上限(正文+思考+工具参数/元数据),超限返回 `response_too_large`;`0` 不限制 | @@ -43,17 +45,25 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--max-request-bytes` | `33554432` | 处理后的上游 JSON 字节上限,须为正整数 | | `--log-body-limit` | `65536` | 旧文本预览兼容项;文本输出已停用,SQLite 诊断使用独立预算 | -环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_ADMIN_ORIGINS`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_STREAM_MODE`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见[部署指南](deployment.zh-CN.md)。 +环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_ADMIN_ORIGINS`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_STREAM_MODE`、`CODEBUDDY2API_LOG`、`CODEBUDDY2API_RESPONSES_PROJECTION_MODE`、`CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES`、`CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见[部署指南](deployment.zh-CN.md)。 + +### Responses 投影 + +通过 WebUI、CLI、环境变量或 `.env` 配置这两项热更新设置。优先级为 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值;CLI/环境变量会锁定 WebUI 字段。Compose 仅转发宿主环境已设置的变量,两项均未设置时 WebUI 仍可编辑。 + +`responses_projection_mode` 默认为 `balanced`,仅接受 `balanced` 和 `passthrough`。balanced 仅改写有固定摘要的已识别 harness;这些块之外的文本不计入预算、不会被裁剪。它还会按 `responses_projection_max_bytes` 对 assistant 内容、完整工具参数 JSON 和工具结果采用 Codex 风格头尾截断;文本标记显示原始 bytes、估算 tokens 与总行数。超限工具参数仍是有效 JSON,并以有界对象保留原始头尾和大小元数据。passthrough 完全关闭 Responses 投影。 + +`responses_projection_max_bytes` 默认 `40000`,有效范围为 `0` 或 `256..33554432`。`0` 只禁用单项裁剪,仍受全局入站/请求与输出门禁约束。两项设置均不改变客户端 Base URL。 ### 工具元数据保留 -默认关闭,沿用旧策略:启用脱敏会剥离工具描述,Responses 的工具投影也会剥离描述;`--no-compact` 不改变这一行为。开启后,Chat、Responses、Messages 保留已支持工具定义中的描述及参数 schema 的字符串 `description/title`。若启用脱敏,保留的文本仍会处理;提示词压缩、现有审核兜底条件与重试次数不变,兜底也遵守本开关。 +Responses 投影不再修改工具定义或 schema。启用脱敏时,默认剥离工具描述及参数 schema 的字符串 `description/title`;开启本开关后,Chat、Responses、Messages 会保留并按脱敏规则处理这些文本。`--no-compact` 不改变本开关。 - **WebUI**:系统设置 → 保留工具描述,未被启动来源锁定时可立即生效并持久化。 - **CLI**:在原启动命令追加 `--keep-tool-metadata` 或 `--keep-tool-metadata true`;显式 `false` 可覆盖环境变量。 - **环境变量**:设置 `CODEBUDDY2API_KEEP_TOOL_METADATA=true`;Compose 会传入已设置的值,未设置时不锁定 WebUI。删除或注释变量可解除环境锁定,不要设为空串。 -需使用包含此功能的源码/镜像和 Compose 配置;修改容器环境后重新创建容器。保留描述可能增加输入 token 和审核拦截风险,不保证所有账号/模型都同样兼容;设为 `false` 可恢复旧策略。此开关不恢复 Responses 原有投影裁掉的其他 schema 字段或深层节点,也不放宽请求体预算。 +需使用包含此功能的源码/镜像和 Compose 配置;修改容器环境后重新创建容器。保留描述可能增加输入 token 和审核拦截风险;设为 `false` 恢复脱敏默认剥离行为。此开关不改变 Responses 的 balanced/passthrough 模式,也不放宽请求体预算。 ### 流式模式 @@ -227,7 +237,7 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` ## 请求边界 -- 三个生成协议统一将 `developer` 归一为 `system`,已有 system 移到首位,缺失时补默认值;归一化不修改调用方 payload。Responses 上下文投影和可选脱敏另行处理内容,不能据此理解为整个链路逐字透传。 +- 三个生成协议统一将 `developer` 归一为 `system`,已有 system 移到首位,缺失时补默认值;归一化不修改调用方 payload。可选的 [Responses 投影](#responses-投影)和脱敏会另行处理内容,因此默认链路并非逐字透传。 - 图片计入全部历史和工具结果,重复图片逐次计数,按消息与内容块数组顺序判断新旧。默认保留最新 16 张,只移除超额图片并保留文本和消息结构;图片清空的内容用文本占位。 - `--image-policy error` 在本地返回 `413 / too_many_images`。处理后仍超过字节上限则返回 `413 / request_too_large`,不为满足预算继续截断文本。 - 图片数量合规不保证单图大小或模型视觉能力满足上游要求。URL/base64 图片可转换,Responses 图片 `file_id` 不支持。 diff --git a/docs/clients.md b/docs/clients.md index 33f0ef5..29db1f9 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -45,6 +45,10 @@ codex --profile workbuddy "your task" Codex uses `/v1/responses`. Runtime context is processed separately from real instructions; oversized requests return HTTP 413 rather than silently truncating the latest user request. +## Responses projection + +Responses projection is a server setting; client addresses do not change. Balanced mode is the default, while `passthrough` disables Responses projection completely. To change generated assistant/tool truncation limits, see [Responses projection](advanced.md#responses-projection). + ## Claude Code / CC Switch ```bash diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 90b5559..71d589c 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -45,6 +45,10 @@ codex --profile workbuddy "your task" Codex 使用 `/v1/responses`。运行时上下文与真实指令分开处理;请求超限返回 HTTP 413,而不会静默截断最新的用户请求。 +## Responses 投影 + +Responses 投影是服务端设置,客户端地址保持不变。默认使用 balanced;如需完全关闭 Responses 投影可设 passthrough。生成内容与工具内容的裁剪上限见 [Responses 投影](advanced.zh-CN.md#responses-投影)。 + ## Claude Code / CC Switch ```bash diff --git a/docs/deployment.md b/docs/deployment.md index 40c4a32..8a1e49a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -15,8 +15,7 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o | `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` | | `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container | | `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose | - -The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables the aggregate/concurrency limit or extra retries, not the required positive inbound limit. +The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, Responses projection, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. Compose reads declared variables from `.env`; shell variables take precedence. The default host mapping is loopback. Set a random key, HTTPS and access restrictions before allowing remote connections. Container binding remains `0.0.0.0:8787`; change host exposure using `BIND/PORT`, not container listener arguments. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index 569756a..be1f8c9 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -16,7 +16,7 @@ | `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` | | `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 | -示例文件列出了全部生效的运行时变量,包括入站/聚合字节上限、并发、工具重试与故障转移。Compose 会转发这些限制;未设置的可选项(工具元数据、来源白名单、故障转移等)仍可在 WebUI 配置。取零表示关闭聚合/并发限制或额外重试,而入站上限必须为正值。 +示例文件列出了全部生效的运行时变量,包括入站/聚合字节上限、并发、工具重试与故障转移。Compose 会转发这些限制;未设置的可选项(工具元数据、Responses 投影、来源白名单、故障转移等)仍可在 WebUI 配置。取零表示关闭聚合/并发限制、Responses 单项裁剪或额外重试,而入站上限必须为正值。 Compose 从 `.env` 读取已声明变量,shell 变量优先。默认宿主机映射仅回环。开放远程访问前请设置随机密钥、HTTPS 与访问限制。容器内监听保持 `0.0.0.0:8787`;调整对外暴露用 `BIND/PORT`,不要改容器监听参数。 diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 35095e4..9e72736 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -21,6 +21,7 @@ "max_images": 16, "image_policy": "truncate", "max_request_bytes": 33554432, "log_body_limit": 65536, "admin_csrf": True, "keep_tool_metadata": False, + "responses_projection_mode": "balanced", "responses_projection_max_bytes": 40000, } API_ENDPOINTS = { "chat/completions": "POST", "responses": "POST", "messages": "POST", @@ -83,6 +84,8 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertEqual(values["CODEBUDDY2API_ADMIN_CSRF"], str(RUNTIME_DEFAULTS["admin_csrf"]).lower()) self.assertNotIn("CODEBUDDY2API_ADMIN_ORIGINS", values) self.assertNotIn("CODEBUDDY2API_KEEP_TOOL_METADATA", values) + self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", values) + self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES", values) def test_tool_metadata_compose_environment_is_optional(self): key = "CODEBUDDY2API_KEEP_TOOL_METADATA" @@ -91,6 +94,15 @@ def test_tool_metadata_compose_environment_is_optional(self): origins = "CODEBUDDY2API_ADMIN_ORIGINS" self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{origins}: *$") self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {origins}=https://") + + def test_responses_projection_compose_environment_is_optional(self): + compose = (ROOT / "docker-compose.yml").read_text() + example = (ROOT / ".env.example").read_text() + for key in ("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", + "CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES"): + self.assertRegex(compose, rf"(?m)^ +{key}: *$") + self.assertRegex(example, rf"(?m)^# {key}=") + def test_docker_copies_and_allows_all_local_runtime_imports(self): files = docker_sources() self.assertTrue({"app/client_profiles.py", "app/site_routing.py", "app/trial_rewards.py"} <= files) diff --git a/tests/test_environment_config.py b/tests/test_environment_config.py index 3260932..358d2cd 100644 --- a/tests/test_environment_config.py +++ b/tests/test_environment_config.py @@ -173,6 +173,64 @@ def test_admin_allowed_origins_precedence_normalization_and_locking(self): self.start({key: 'ftp://example.com'}) + def test_responses_projection_settings_default_and_environment(self): + _, items, config = self.start() + self.assertEqual((config['responses_projection_mode'], config['responses_projection_max_bytes']), + ('balanced', 40000)) + self.assertEqual(items['responses_projection_mode']['choices'], ['balanced', 'passthrough']) + self.assertEqual((items['responses_projection_max_bytes']['min'], + items['responses_projection_max_bytes']['max']), (0, 33554432)) + _, items, config = self.start({ + 'CODEBUDDY2API_RESPONSES_PROJECTION_MODE': 'passthrough', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES': '0'}) + self.assertEqual((config['responses_projection_mode'], config['responses_projection_max_bytes']), + ('passthrough', 0)) + self.assertTrue(all(items[key]['locked'] for key in ( + 'responses_projection_mode', 'responses_projection_max_bytes'))) + with self.assertRaises(ValueError): + self.start({'CODEBUDDY2API_RESPONSES_PROJECTION_MODE': 'invalid'}) + for value in ('1', '128', '255', '33554433'): + with self.subTest(value=value), self.assertRaises(SystemExit): + self.start({'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES': value}) + for value in ('1', '128', '255', '33554433'): + with self.subTest(value=value), self.assertRaises(SystemExit): + self.start(cli=(f'--responses-projection-max-bytes={value}',)) + with self.assertRaises(SystemExit): + self.start(cli=('--responses-projection-mode=legacy',)) + + def test_responses_projection_settings_management_precedence_and_schema(self): + _, items, config = self.start(saved={ + 'responses_projection_mode': 'passthrough', 'responses_projection_max_bytes': 0}) + self.assertEqual((config['responses_projection_mode'], config['responses_projection_max_bytes']), + ('passthrough', 0)) + self.assertEqual(items['responses_projection_mode'], { + 'key': 'responses_projection_mode', 'value': 'passthrough', 'stored': 'passthrough', + 'source': 'management', 'mode': 'hot', 'type': 'string', + 'label': 'Responses 投影模式', 'locked': False, + 'choices': ['balanced', 'passthrough']}) + self.assertEqual(items['responses_projection_max_bytes'], { + 'key': 'responses_projection_max_bytes', 'value': 0, 'stored': 0, + 'source': 'management', 'mode': 'hot', 'type': 'integer', + 'label': 'Responses 单项字节上限(0 或 ≥256)', 'locked': False, + 'min': 0, 'max': 33554432}) + _, items, config = self.start({ + 'CODEBUDDY2API_RESPONSES_PROJECTION_MODE': 'balanced', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES': '33554432'}, saved={ + 'responses_projection_mode': 'passthrough', 'responses_projection_max_bytes': 0}) + self.assertEqual((config['responses_projection_mode'], config['responses_projection_max_bytes']), + ('balanced', 33554432)) + self.assertTrue(all(items[key]['source'] == 'environment' and items[key]['locked'] + for key in ('responses_projection_mode', 'responses_projection_max_bytes'))) + + _, items, config = self.start({ + 'CODEBUDDY2API_RESPONSES_PROJECTION_MODE': 'balanced', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES': '33554432'}, + cli=('--responses-projection-mode=passthrough', '--responses-projection-max-bytes=0'), + saved={'responses_projection_mode': 'balanced', 'responses_projection_max_bytes': 256}) + self.assertEqual((config['responses_projection_mode'], config['responses_projection_max_bytes']), + ('passthrough', 0)) + self.assertTrue(all(items[key]['source'] == 'cli' and items[key]['locked'] + for key in ('responses_projection_mode', 'responses_projection_max_bytes'))) def test_request_context_mode_precedence_and_validation(self): _, items, config = self.start(saved={'request_context_mode': 'scoped'}) self.assertEqual(config['request_context_mode'], 'scoped') @@ -221,7 +279,10 @@ def test_compose_forwards_dotenv_limits_and_retries_without_changing_internal_bi 'CODEBUDDY2API_MAX_CONCURRENT': '2', 'CODEBUDDY2API_TOOL_CALL_MAX_RETRY': '1', 'CODEBUDDY2API_FAILOVER_MAX': '1', 'CODEBUDDY2API_RETRY_WRITE_TIMEOUT': 'true', 'CODEBUDDY2API_UPSTREAM_KEEPALIVE': 'true', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT': '2', - 'CODEBUDDY2API_REQUEST_CONTEXT_MODE': 'scoped', 'CODEBUDDY2API_STREAM_MODE': 'realtime', + 'CODEBUDDY2API_REQUEST_CONTEXT_MODE': 'scoped', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MODE': 'passthrough', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES': '0', + 'CODEBUDDY2API_STREAM_MODE': 'realtime', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'false', 'CODEBUDDY2API_ADMIN_ORIGINS': 'https://chat.example.com', 'CODEBUDDY2API_KEEP_TOOL_METADATA': 'false', 'CODEBUDDY_IMPORT_DIR': '/data/auth/incoming'} @@ -238,7 +299,9 @@ def test_compose_unset_optional_settings_do_not_override_webui(self): service = self.compose({}) for name in ('CODEBUDDY2API_KEEP_TOOL_METADATA', 'CODEBUDDY2API_FAILOVER_MAX', 'CODEBUDDY2API_RETRY_WRITE_TIMEOUT', 'CODEBUDDY2API_UPSTREAM_KEEPALIVE', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT', - 'CODEBUDDY2API_REQUEST_CONTEXT_MODE', 'CODEBUDDY2API_STREAM_MODE', + 'CODEBUDDY2API_REQUEST_CONTEXT_MODE', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MODE', + 'CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES', 'CODEBUDDY2API_STREAM_MODE', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD', 'CODEBUDDY2API_ADMIN_ORIGINS'): self.assertIsNone(service['environment'].get(name)) diff --git a/tests/test_harness_projection.py b/tests/test_harness_projection.py index 07a7665..720df10 100644 --- a/tests/test_harness_projection.py +++ b/tests/test_harness_projection.py @@ -1,287 +1,286 @@ -"""Responses harness regressions; endpoint requests use only a mock upstream. - -Run: python -B tests/test_harness_projection.py -""" -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - +"""Responses projection regressions for harness blocks, generated content, and limits.""" from copy import deepcopy import json +from pathlib import Path +import sys import unittest from unittest.mock import patch +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import httpx from fastapi.testclient import TestClient import converter from app import upstream_io -from app.adapters.responses_projection import ( - BASE_SYSTEM_PROMPT, - HISTORY_PREFIX, - MAX_USER_CONTEXT_CHARS, - project_responses_chat_body, -) from app.desensitize import desensitize_body + +from app.adapters.responses_projection import project_responses_chat_body from app.harness_context import parse_harness_text -TOOLS = [{"type": "function", "function": { - "name": "exec_command", "parameters": {"type": "object"}, -}}] -SYSTEM = {"role": "system", "content": "You are a coding agent running in the Codex CLI."} -ENVIRONMENT = "platform: synthetic" -TASK = "Inspect the implementation. " + "Repository context. " * 200 + ( - "\nMUST_KEEP_LATEST_USER_CONSTRAINT: do not change data." -) -BULLET_TASK = "- MUST_KEEP_USER_BULLET_TASK: inspect only\n- Do not modify files." -CUSTOM_POLICY = "MUST_KEEP_CUSTOM_SYSTEM_POLICY: never modify production resources." +TOOL = { + "type": "function", + "function": { + "name": "exec_command", + "description": "Run a command", + "parameters": { + "type": "object", + "properties": {"cmd": {"type": "string", "description": "Command"}}, + "additionalProperties": False, + }, + }, +} -def tool_rounds(count=10, start=0): - messages = [] - for index in range(start, start + count): - messages.extend([ - {"role": "assistant", "content": "", "tool_calls": [{ - "id": f"call-{index}", "type": "function", "function": { - "name": "exec_command", "arguments": "{}", - }, - }]}, - {"role": "tool", "tool_call_id": f"call-{index}", - "content": f"Synthetic inspection step {index} completed."}, - ]) - return messages - - -def differential_cases(): - """The three review failures, with full-text assertions rather than markers only.""" - yield "context_replaces_anchor", [ - SYSTEM, {"role": "user", "content": TASK}, *tool_rounds(), - {"role": "user", "content": ENVIRONMENT}, - ], "user", TASK - yield "harness_displaces_system", [ - {"role": "system", "content": SYSTEM["content"] + "\n" + "General runtime guidance. " * 100}, - {"role": "system", "content": CUSTOM_POLICY}, - {"role": "user", "content": "Inspect the repository."}, - ], "system", CUSTOM_POLICY - yield "markdown_task_disappears", [SYSTEM, {"role": "user", "content": ( - "# AGENTS.md instructions\nUse tabs.\n\n" + BULLET_TASK - )}], "user", BULLET_TASK - - -def body_for(messages): - return {"model": "auto", "tools": deepcopy(TOOLS), "messages": deepcopy(messages)} - - -def text_of(message): - content = message.get("content", "") - if isinstance(content, list): - return "".join(block.get("text", "") for block in content if isinstance(block, dict)) - return content or "" - - -def responses_items(messages): - """Use real Responses function items so endpoint probes retain the tool chain.""" - items = [] - for message in messages: - if message["role"] == "tool": - items.append({"type": "function_call_output", "call_id": message["tool_call_id"], - "output": message["content"]}) - elif message["role"] == "assistant": - items.append({"type": "message", "role": "assistant", "content": message.get("content", "")}) - for call in message.get("tool_calls", []): - items.append({"type": "function_call", "call_id": call["id"], **call["function"]}) - else: - items.append(deepcopy(message)) - return items +def body(messages, tools=None): + return {"model": "auto", "messages": deepcopy(messages), "tools": deepcopy(tools or [TOOL])} class ProjectionTests(unittest.TestCase): - def assert_preserved(self, body, role, expected): - self.assertTrue(any(expected in text_of(message) for message in body["messages"] - if message["role"] == role), (role, expected[-100:], body)) - - def assert_wire_fields(self, body): - self.assertEqual(set(body), {"model", "tools", "messages"}) - for message in body["messages"]: - allowed = {"role", "content", "tool_calls"} if message["role"] == "assistant" else ( - {"role", "content", "tool_call_id"} if message["role"] == "tool" else {"role", "content"} - ) - self.assertFalse(set(message) - allowed, message) - - def test_three_differential_failures_and_second_desensitization(self): - for name, messages, role, expected in differential_cases(): - with self.subTest(case=name): - body = body_for(messages) - before = deepcopy(body) - out, stats = project_responses_chat_body(body) - self.assertEqual(body, before) - self.assertEqual(stats["mode"], "aggressive") - self.assert_preserved(out, role, expected) - self.assert_wire_fields(out) - if name == "context_replaces_anchor": - self.assertTrue(stats["anchor_user_preserved"]) - self.assertIn({"role": "user", "content": TASK}, out["messages"]) - self.assertTrue(any(text_of(m).startswith(HISTORY_PREFIX) for m in out["messages"])) - calls = {call["id"] for m in out["messages"] for call in m.get("tool_calls", [])} - self.assertIn("call-9", calls) - self.assertTrue(all(m["tool_call_id"] in calls for m in out["messages"] if m["role"] == "tool")) - for compact in (False, True): - again = desensitize_body(out, roles=("system", "developer"), - desensitize_harness_user=True, compact_harness=compact) - self.assert_preserved(again, role, expected) - self.assert_wire_fields(again) - - def test_all_custom_system_constraints_have_independent_budget(self): - policies = [f"Policy {index}: " + "Keep every condition. " * 80 + f"END_POLICY_{index}" - for index in range(4)] - messages = [{"role": "system", "content": "" + "Runtime notes. " * 800 - + ""}] - for policy in policies: - messages.extend([{"role": "system", "content": ENVIRONMENT}, - {"role": "system", "content": policy}]) - messages.append({"role": "user", "content": "Inspect only."}) - out, _ = project_responses_chat_body(body_for(messages)) - for policy in policies: - self.assertIn({"role": "system", "content": policy}, out["messages"]) - self.assertNotIn("Additional instructions:", "\n".join(map(text_of, out["messages"]))) - - def test_system_custom_text_after_long_context_is_not_truncated(self): - text = "" + "Runtime metadata. " * 500 + "\n" + CUSTOM_POLICY - out, _ = project_responses_chat_body(body_for([ - {"role": "system", "content": text}, {"role": "user", "content": "Inspect."}, - ])) - self.assert_preserved(out, "system", CUSTOM_POLICY) - - def test_unwrapped_system_paragraph_does_not_become_a_fabricated_summary(self): - text = (SYSTEM["content"] + "\nThe following deferred tools are now available via ToolSearch.\n" - + CUSTOM_POLICY + "\n# Custom rules\nKeep this later system paragraph exactly.") - out, _ = project_responses_chat_body(body_for([ - {"role": "system", "content": text}, {"role": "user", "content": "Inspect only."}, - ])) - self.assert_preserved(out, "system", text) - # --no-compact preserves unbounded system prose; explicit whole-harness - # compaction remains an existing opt-in behavior tested separately. - again = desensitize_body(out, roles=("system", "developer"), - desensitize_harness_user=True, compact_harness=False) - self.assert_preserved(again, "system", text) - - def test_mixed_user_sensitive_words_are_not_reclassified_as_metadata(self): + def test_balanced_replaces_only_recognized_harness_blocks(self): + messages = [ + {"role": "system", "content": "SYSTEM POLICY\nvolatile\nSYSTEM TAIL"}, + {"role": "user", "content": "# AGENTS.md instructions\nUse tabs.\nUSER TASK"}, + {"role": "user", "content": "old rules\nLATEST TASK"}, + ] + payload = body(messages) + before = deepcopy(payload) + result, stats = project_responses_chat_body(payload) + self.assertEqual(payload, before) + self.assertEqual(result["messages"][0]["content"], "SYSTEM POLICY\n\n\nEnvironment context is provided by the harness.\n\n\nSYSTEM TAIL") + self.assertIn("Repository instructions and durable user context are provided.", result["messages"][1]["content"]) + self.assertIn("Use tabs.", result["messages"][1]["content"]) + self.assertIn("Runtime permissions apply", result["messages"][2]["content"]) + self.assertIn("LATEST TASK", result["messages"][2]["content"]) + self.assertNotIn("# AGENTS.md instructions", result["messages"][1]["content"]) + self.assertEqual(result["tools"], payload["tools"]) + self.assertEqual(stats["mode"], "balanced") + self.assertEqual(stats["harness_messages_projected"], 3) + + def test_real_user_task_in_legacy_harness_message_is_retained(self): + task = "MUST_KEEP_TASK: inspect only and do not modify files." + text = "# AGENTS.md instructions\nold skills\n" + task + payload = body([{"role": "user", "content": text}]) + parsed = parse_harness_text(text) + self.assertTrue(parsed.matched) + self.assertIn(task, parsed.user_text) + result, _ = project_responses_chat_body(payload) + self.assertIn(task, result["messages"][0]["content"]) + self.assertNotIn("old skills", result["messages"][0]["content"]) + + + def test_real_text_survives_balanced_and_optional_desensitization(self): + system = "CUSTOM SYSTEM: keep every rule. " + "Detail. " * 200 task = "请解释 exploit development 和 sandbox 的含义,不执行任何操作。" - text = "# AGENTS.md instructions\nRuntime sandbox rules.\n" + task - out, _ = project_responses_chat_body(body_for([{"role": "user", "content": text}])) + user = "# AGENTS.md instructions\nold runtime state\n" + task + result, _ = project_responses_chat_body(body([ + {"role": "system", "content": system}, {"role": "user", "content": user}, + ])) + self.assertEqual(result["messages"][0]["content"], system) + self.assertIn(task, result["messages"][1]["content"]) for compact in (False, True): - again = desensitize_body(out, desensitize_harness_user=True, compact_harness=compact) - self.assert_preserved(again, "user", task) - - def test_long_reminder_before_and_after_full_user_task(self): - reminder = "" + "Older memory context. " * 800 + "" - for text in (reminder + "\n" + TASK, TASK + "\n" + reminder, - reminder + "\n" + TASK + "\n" + reminder): - with self.subTest(order=text[:30]): - out, _ = project_responses_chat_body(body_for([SYSTEM, {"role": "user", "content": text}])) - user = next(message for message in out["messages"] if message["role"] == "user") - self.assertIn(TASK, user["content"]) - self.assertLessEqual(len(user["content"]), len(TASK) + MAX_USER_CONTEXT_CHARS + 4) - self.assertIn("Older memory context.", user["content"]) - again = desensitize_body(out, desensitize_harness_user=True, compact_harness=True) - self.assert_preserved(again, "user", TASK) - - def test_short_real_task_is_not_budgeted_as_long_context(self): - task = "只审查登录页,不修改文件。" - text = "" + "Old memory. " * 600 + "\n" + task - out, _ = project_responses_chat_body(body_for([{"role": "user", "content": text}])) - self.assert_preserved(out, "user", task) - - def test_context_only_turns_do_not_take_latest_user_anchor(self): - contexts = [ENVIRONMENT, "runtime rules", - "runtime skills", - "old memory", "# AGENTS.md instructions"] - for context in contexts: - with self.subTest(context=context): - parsed = parse_harness_text(context) - self.assertTrue(parsed.matched) - self.assertFalse(parsed.user_text.strip()) - out, stats = project_responses_chat_body(body_for([ - {"role": "user", "content": TASK}, *tool_rounds(), {"role": "user", "content": context}, - ])) - self.assertTrue(stats["anchor_user_preserved"]) - self.assertIn({"role": "user", "content": TASK}, out["messages"]) - self.assert_wire_fields(out) - - def test_only_context_and_system_do_not_duplicate_fallback(self): - for messages in ([SYSTEM], [SYSTEM, {"role": "user", "content": ENVIRONMENT}]): - with self.subTest(messages=messages): - out, stats = project_responses_chat_body(body_for(messages)) - self.assertFalse(stats["anchor_user_preserved"]) - self.assertEqual(sum(text_of(m) == BASE_SYSTEM_PROMPT for m in out["messages"]), 1) - self.assertEqual(sum(text_of(m) == SYSTEM["content"] for m in out["messages"]), 1) - self.assertEqual(len(out["messages"]), len(messages) + 1) - self.assert_wire_fields(out) - - def test_unclosed_wrappers_and_markdown_are_real_latest_users(self): - tasks = [ - "\n" + "Unclosed metadata-looking text. " * 150 + "\n" + BULLET_TASK, - "# AGENTS.md instructions\n" + BULLET_TASK, - "# claudeMd\n" + BULLET_TASK, - "```xml\nquoted example\n```\n" + BULLET_TASK, - "# AGENTS.md instructions\n\n" + "- A required detail\n" * 150 + BULLET_TASK, + processed = desensitize_body( + result, roles=("system", "developer"), desensitize_harness_user=True, + compact_harness=compact, + ) + self.assertEqual(processed["messages"][0]["content"].replace("\u200b", ""), system) + self.assertIn(task, processed["messages"][1]["content"].replace("\u200b", "")) + + def test_unclosed_harness_markup_stays_literal(self): + task = "- Inspect only\nDo not modify files." + text = "\nUnclosed metadata-looking text.\n" + task + parsed = parse_harness_text(text) + self.assertFalse(parsed.matched) + self.assertEqual(parsed.user_text, text) + result, stats = project_responses_chat_body(body([{"role": "user", "content": text}])) + self.assertEqual(result["messages"][0]["content"], text) + self.assertEqual(stats["harness_messages_projected"], 0) + def test_passthrough_does_not_change_messages_or_tools(self): + messages = [ + {"role": "user", "content": "# AGENTS.md instructions\nvolatile"}, + {"role": "assistant", "content": "x" * 500, "tool_calls": [{ + "id": "call", "type": "function", + "function": {"name": "exec_command", "arguments": '{"cmd":"echo hi"}'}, + }]}, + {"role": "tool", "tool_call_id": "call", "content": "y" * 500}, ] - for task in tasks: - with self.subTest(task=task[:60]): - parsed = parse_harness_text(task) - self.assertIn(BULLET_TASK, parsed.user_text) - out, stats = project_responses_chat_body(body_for([ - {"role": "user", "content": "Previous task"}, *tool_rounds(), - {"role": "user", "content": task}, *tool_rounds(start=10), - ])) - self.assertTrue(stats["anchor_user_preserved"]) - self.assert_preserved(out, "user", parsed.user_text) - again = desensitize_body(out, desensitize_harness_user=True, compact_harness=True) - self.assert_preserved(again, "user", parsed.user_text) - - def test_text_blocks_are_preserved_and_share_only_context_budget(self): - blocks = [ - {"type": "text", "text": "" + "Old memory. " * 600 + ""}, - {"type": "text", "text": TASK}, - {"type": "text", "text": "\nFinal user condition."}, + payload = body(messages) + before = deepcopy(payload) + result, stats = project_responses_chat_body(payload, mode="passthrough", max_item_bytes=256) + self.assertEqual(result, before) + self.assertEqual(payload, before) + self.assertEqual(stats["mode"], "passthrough") + self.assertEqual(stats["truncated_items"], 0) + self.assertEqual(stats["harness_messages_projected"], 0) + + def test_generated_content_keeps_utf8_head_tail_and_metadata(self): + assistant_text = "HEAD\n" + "中" * 180 + "\nTAIL" + tool_output = "OUTPUT\n" + "输出" * 180 + "\nEND" + messages = [ + {"role": "assistant", "content": assistant_text}, + {"role": "tool", "tool_call_id": "call", "content": tool_output}, ] - out, _ = project_responses_chat_body(body_for([{"role": "user", "content": blocks}])) - content = out["messages"][-1]["content"] - self.assertIsInstance(content, list) - self.assertEqual(len(content), len(blocks)) - self.assertEqual(content[1:], blocks[1:]) - self.assertLess(len(content[0]["text"]), len(blocks[0]["text"])) - - def test_images_use_conservative_path_and_keep_old_chain_and_real_text(self): - image = {"type": "image_url", "image_url": {"url": "https://synthetic.invalid/image.png", "detail": "high"}} - blocks = [{"type": "text", "text": "" + "Old memory. " * 600 + ""}, - image, {"type": "text", "text": TASK}] - messages = [SYSTEM, {"role": "system", "content": CUSTOM_POLICY}, - {"role": "user", "content": blocks}, *tool_rounds(), - {"role": "user", "content": ENVIRONMENT}] - body = body_for(messages) - before = deepcopy(body) - out, stats = project_responses_chat_body(body) - self.assertEqual(stats["mode"], "conservative") - self.assertEqual(len(out["messages"]), len(messages)) - self.assertEqual(out["messages"][2]["content"][1:], blocks[1:]) - self.assertEqual(out["messages"][3:-1], messages[3:-1]) - self.assert_preserved(out, "system", CUSTOM_POLICY) - self.assert_wire_fields(out) - again = desensitize_body(out, desensitize_harness_user=True, compact_harness=True) - self.assertEqual(again["messages"][2]["content"][1:], blocks[1:]) - self.assertEqual(body, before) - - def test_normal_requests_preserve_user_and_custom_system_verbatim(self): - body = {"model": "auto", "messages": [ - {"role": "system", "content": " Custom system. " + "Rule. " * 300}, - {"role": "user", "content": " Plain user. " + "Detail. " * 600 + "\n"}, - ]} - out, stats = project_responses_chat_body(body) - self.assertEqual(stats["mode"], "conservative") - self.assertEqual(out, body) + result, stats = project_responses_chat_body(body(messages, []), max_item_bytes=256) + assistant = result["messages"][0]["content"] + output = result["messages"][1]["content"] + self.assertTrue(assistant.startswith("HEAD")) + self.assertTrue(assistant.endswith("TAIL")) + self.assertTrue(output.startswith("OUTPUT")) + self.assertTrue(output.endswith("END")) + for value in (assistant, output): + self.assertIn("original bytes:", value) + self.assertIn("estimated tokens:", value) + self.assertIn("total lines:", value) + self.assertLessEqual(len(value.encode("utf-8")), 256) + self.assertEqual(stats["truncated_items"], 2) + + def test_json_tool_arguments_remain_valid_and_apply_patch_is_not_omitted(self): + arguments = json.dumps({"cmd": "echo " + "x" * 1500, "workdir": "/tmp"}) + patch = json.dumps({"patch": "*** Begin Patch\n" + "+" * 1500 + "*** End Patch"}) + messages = [{"role": "assistant", "content": "", "tool_calls": [ + {"id": "command", "type": "function", "function": { + "name": "exec_command", "arguments": arguments, + }}, + {"id": "patch", "type": "function", "function": { + "name": "apply_patch", "arguments": patch, + }}, + ]}] + result, stats = project_responses_chat_body(body(messages, []), max_item_bytes=1024) + command = json.loads(result["messages"][0]["tool_calls"][0]["function"]["arguments"]) + self.assertEqual(command["workdir"], "/tmp") + self.assertTrue(command["cmd"].startswith("echo ")) + self.assertTrue(command["cmd"].endswith("x")) + self.assertIn("middle omitted", command["cmd"]) + self.assertIn("original bytes:", command["cmd"]) + self.assertIn("estimated tokens:", command["cmd"]) + self.assertIn("total lines:", command["cmd"]) + patch_args = json.loads(result["messages"][0]["tool_calls"][1]["function"]["arguments"]) + self.assertTrue(patch_args["patch"].startswith("*** Begin Patch")) + self.assertTrue(patch_args["patch"].endswith("*** End Patch")) + self.assertEqual(stats["truncated_items"], 2) + + + def test_non_string_json_arguments_use_bounded_valid_wrapper(self): + arguments = json.dumps({"values": list(range(10000))}) + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "numbers", "type": "function", + "function": {"name": "numbers", "arguments": arguments}, + }]}] + result, stats = project_responses_chat_body(body(messages, []), max_item_bytes=256) + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + parsed = json.loads(wire) + self.assertLessEqual(len(wire.encode("utf-8")), 256) + self.assertEqual(parsed["_truncated"]["original_bytes"], len(arguments.encode("utf-8"))) + self.assertIn("values", parsed["head"]) + self.assertTrue(parsed["tail"].endswith("]}")) + self.assertEqual(stats["truncated_items"], 1) + self.assertEqual(stats["truncated_original_bytes"], len(arguments.encode("utf-8"))) + self.assertEqual(stats["truncated_projected_bytes"], len(wire.encode("utf-8"))) + + def test_large_tool_arguments_skip_full_json_materialization(self): + arguments = "[" + ("0," * 600000) + "0]" + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "large", "type": "function", + "function": {"name": "large", "arguments": arguments}, + }]}] + with patch("app.adapters.responses_projection.json.loads") as loads: + result, stats = project_responses_chat_body(body(messages, []), max_item_bytes=40000) + loads.assert_not_called() + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + self.assertLessEqual(len(wire.encode("utf-8")), 40000) + self.assertEqual(json.loads(wire)["_truncated"]["original_bytes"], len(arguments.encode("utf-8"))) + self.assertEqual(stats["truncated_items"], 1) + + def test_escape_dense_wrapper_keeps_both_edges(self): + arguments = json.dumps({"x": "\\" * 60000}, separators=(",", ":")) + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "escaped", "type": "function", + "function": {"name": "escaped", "arguments": arguments}, + }]}] + result, _ = project_responses_chat_body(body(messages, []), max_item_bytes=40000) + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + parsed = json.loads(wire) + self.assertLessEqual(len(wire.encode("utf-8")), 40000) + self.assertIn("_truncated", parsed) + self.assertTrue(parsed["head"]) + self.assertTrue(parsed["tail"]) + + def test_nonstandard_json_constants_fall_back_to_strict_wrapper(self): + arguments = '{"x":NaN,"s":"' + ("x" * 500) + '"}' + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "nan", "type": "function", + "function": {"name": "nan", "arguments": arguments}, + }]}] + result, _ = project_responses_chat_body(body(messages, []), max_item_bytes=1024) + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + + def reject_constant(value): + raise ValueError(f"unexpected constant: {value}") + + parsed = json.loads(wire, parse_constant=reject_constant) + self.assertIn("_truncated", parsed) + self.assertLessEqual(len(wire.encode("utf-8")), 1024) + + def test_zero_limit_disables_generated_clipping(self): + messages = [ + {"role": "assistant", "content": "a" * 1000}, + {"role": "assistant", "content": "", "tool_calls": [{ + "id": "call", "type": "function", + "function": {"name": "tool", "arguments": '{"value":"' + "x" * 1000 + '"}'}, + }]}, + ] + payload = body(messages, []) + before = deepcopy(payload) + result, stats = project_responses_chat_body(payload, max_item_bytes=0) + self.assertEqual(result, before) + self.assertEqual(payload, before) + self.assertEqual(stats["truncated_items"], 0) + + def test_history_tool_chain_images_and_system_text_are_preserved(self): + image = {"type": "image_url", "image_url": {"url": "https://example.invalid/image.png"}} + call = {"id": "call-old", "type": "function", "function": {"name": "view", "arguments": "{}"}} + messages = [ + {"role": "system", "content": "CUSTOM SYSTEM POLICY"}, + {"role": "user", "content": [{"type": "text", "text": "TASK"}, image]}, + {"role": "assistant", "content": "inspect", "tool_calls": [call]}, + {"role": "tool", "tool_call_id": "call-old", "content": "old result"}, + ] + payload = body(messages) + result, stats = project_responses_chat_body(payload) + self.assertEqual(result["messages"], payload["messages"]) + self.assertEqual(result["messages"][1]["content"][1], image) + self.assertEqual(result["messages"][2]["tool_calls"][0]["id"], "call-old") + self.assertEqual(result["messages"][3]["tool_call_id"], "call-old") + self.assertIn("CUSTOM SYSTEM POLICY", result["messages"][0]["content"]) + self.assertEqual(stats["projected_messages"], stats["original_messages"]) + self.assertEqual(stats["harness_messages_projected"], 0) + + def test_invalid_mode_and_limit_are_rejected(self): + for mode in ("aggressive", "conservative", "", None, True): + with self.subTest(mode=mode): + with self.assertRaises(ValueError): + project_responses_chat_body({"messages": []}, mode=mode) + for limit in (-1, 1, 128, 255, True, 1.5, "256", None, [], {}): + with self.subTest(limit=limit): + with self.assertRaises(ValueError): + project_responses_chat_body({"messages": []}, max_item_bytes=limit) + self.assertEqual(project_responses_chat_body({"messages": []}, max_item_bytes=256)[1]["max_item_bytes"], 256) + + def test_stats_have_current_official_shape(self): + payload = body([{"role": "user", "content": "hello"}]) + _, stats = project_responses_chat_body(payload, mode="balanced", max_item_bytes=40000) + self.assertEqual(set(stats), { + "mode", "max_item_bytes", "original_messages", "projected_messages", + "original_message_chars", "projected_message_chars", "original_tools", + "projected_tools", "original_tool_chars", "projected_tool_chars", + "harness_messages_projected", "truncated_items", "truncated_original_bytes", + "truncated_projected_bytes", + }) + self.assertEqual(stats["mode"], "balanced") + self.assertEqual(stats["max_item_bytes"], 40000) + self.assertEqual(stats["original_messages"], stats["projected_messages"]) + self.assertEqual(stats["original_tools"], stats["projected_tools"]) class ResponsesEndpointTests(unittest.TestCase): @@ -290,8 +289,9 @@ def setUp(self): "api_key": "", "cred": None, "cred_pool": None, "model_guard": False, "max_images": 16, "image_policy": "truncate", "max_request_bytes": 32 * 1024 * 1024, "log_body_limit": 0, "log_path": None, "desensitize": False, "no_compact": False, + "responses_projection_mode": "balanced", "responses_projection_max_bytes": 512, })) - self.credentials = self.enterContext(patch.object(converter, "_cred_for", return_value=(None, {}))) + self.enterContext(patch.object(converter, "_cred_for", return_value=(None, {}))) self.enterContext(patch.object(converter, "_log")) self.captured = [] real_client = httpx.AsyncClient @@ -305,52 +305,81 @@ def handle(self, request): chunk = {"choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": "stop"}]} return httpx.Response(200, content=("data: " + json.dumps(chunk) + "\n\ndata: [DONE]\n\n").encode()) - def post(self, messages): - return self.client.post("/v1/responses", json={ - "model": "auto", "stream": False, "input": responses_items(messages), - "tools": [{"type": "function", "name": "exec_command", "parameters": {"type": "object"}}], - }) - - def test_three_differential_cases_reach_mock_upstream_with_desensitization_on_and_off(self): - for enabled in (False, True): - converter.CONFIG["desensitize"] = enabled - for name, messages, role, expected in differential_cases(): - with self.subTest(desensitize=enabled, case=name): - self.captured.clear() - response = self.post(messages) - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(len(self.captured), 1) - out = self.captured[0] - self.assertTrue(any(expected in text_of(m) for m in out["messages"] if m["role"] == role)) - for message in out["messages"]: - self.assertFalse(set(message) - {"role", "content", "tool_calls", "tool_call_id"}) - if name == "context_replaces_anchor": - self.assertIn({"role": "user", "content": TASK}, out["messages"]) - self.assertTrue(any(m.get("tool_call_id") == "call-9" for m in out["messages"])) - - def test_long_reminder_and_image_reach_mock_upstream(self): - converter.CONFIG["desensitize"] = True - image_url = "https://synthetic.invalid/prompt.png" - blocks = [ - {"type": "input_text", "text": "" + "Old memory. " * 600 + ""}, - {"type": "input_image", "image_url": image_url}, - {"type": "input_text", "text": TASK}, - ] - response = self.post([SYSTEM, {"role": "user", "content": blocks}, *tool_rounds()]) - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(len(self.captured), 1) - users = [m for m in self.captured[0]["messages"] if m["role"] == "user"] - self.assertEqual(users[0]["content"][1], {"type": "image_url", "image_url": {"url": image_url}}) - self.assertEqual(users[0]["content"][2], {"type": "text", "text": TASK}) - - def test_existing_gateway_limit_rejects_long_task_without_upstream_call(self): - converter.CONFIG["max_request_bytes"] = 1024 - response = self.post([{"role": "user", "content": TASK}]) - self.assertEqual(response.status_code, 413, response.text) - self.assertEqual(response.json()["error"]["code"], "request_too_large") - self.assertFalse(self.captured) - self.credentials.assert_not_called() - + def request_payload(self, assistant_text, arguments, tool_output): + return { + "model": "auto", "stream": False, + "input": [ + {"role": "user", "content": "inspect"}, + {"type": "message", "role": "assistant", "content": [ + {"type": "output_text", "text": assistant_text}, + ]}, + {"type": "function_call", "call_id": "call_1", "name": "exec_command", "arguments": arguments}, + {"type": "function_call_output", "call_id": "call_1", "output": tool_output}, + ], + "tools": [{ + "type": "function", "name": "exec_command", "description": "Run command", + "parameters": {"type": "object", "properties": { + "cmd": {"type": "string", "description": "Command"}, + }, "additionalProperties": False}, + }], + } + + def test_projection_mode_header_and_upstream_payload(self): + assistant_text = "HEAD\n" + "中" * 180 + "\nTAIL" + arguments = json.dumps({"cmd": "echo " + "x" * 500}) + tool_output = "OUTPUT\n" + "输出" * 180 + "\nEND" + for mode in ("balanced", "passthrough"): + with self.subTest(mode=mode): + converter.CONFIG["responses_projection_mode"] = mode + self.captured.clear() + payload = self.request_payload(assistant_text, arguments, tool_output) + original = deepcopy(payload) + response = self.client.post("/v1/responses", json=payload) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.headers["X-CodeBuddy-Responses-Projection"], mode) + self.assertEqual(payload, original) + self.assertEqual(len(self.captured), 1) + captured = self.captured[0] + self.assertEqual(captured["tools"][0]["function"]["parameters"], payload["tools"][0]["parameters"]) + assistant = next(message for message in captured["messages"] if message["role"] == "assistant") + tool = next(message for message in captured["messages"] if message["role"] == "tool") + if mode == "passthrough": + self.assertEqual(assistant["content"], assistant_text) + self.assertEqual(assistant["tool_calls"][0]["function"]["arguments"], arguments) + self.assertEqual(tool["content"], tool_output) + else: + self.assertTrue(assistant["content"].startswith("HEAD")) + self.assertTrue(assistant["content"].endswith("TAIL")) + self.assertIn("original bytes:", assistant["content"]) + self.assertTrue(tool["content"].startswith("OUTPUT")) + self.assertTrue(tool["content"].endswith("END")) + projected_args = json.loads(assistant["tool_calls"][0]["function"]["arguments"]) + self.assertTrue(projected_args["cmd"].startswith("echo ")) + self.assertTrue(projected_args["cmd"].endswith("x")) + self.assertIn("middle omitted", projected_args["cmd"]) + + + def test_invalid_unicode_is_rejected_before_upstream(self): + payload = {"model": "auto", "stream": False, "input": [ + {"type": "function_call", "name": "tool", "arguments": '{"x":"\ud800"}'}, + ]} + raw = json.dumps(payload, ensure_ascii=True).encode("utf-8") + self.captured.clear() + response = self.client.post( + "/v1/responses", content=raw, headers={"content-type": "application/json"}) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "invalid_unicode") + self.assertEqual(self.captured, []) + + def test_non_string_tool_arguments_are_rejected_before_upstream(self): + payload = {"model": "auto", "stream": False, "input": [ + {"type": "function_call", "name": "tool", "arguments": {"x": 1}}, + ]} + self.captured.clear() + response = self.client.post("/v1/responses", json=payload) + self.assertEqual(response.status_code, 400, response.text) + self.assertIn("JSON string", response.json()["error"]["message"]) + self.assertEqual(self.captured, []) if __name__ == "__main__": - unittest.main() + unittest.main(verbosity=2) diff --git a/tests/test_observability.py b/tests/test_observability.py index facc22e..332528a 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -11,8 +11,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.audit_store import AuditStore from app.observability import (AuditMiddleware, _Observation, _Parser, normalize_usage, - observe_attempt, observe_failure, observe_route, observe_usage) - + observe_attempt, observe_failure, observe_responses_projection, + observe_route, observe_usage) class ObservabilityTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -49,6 +49,7 @@ async def app(scope, receive, send): observe_route("public-model", "upstream-model", "profile-a", "fingerprint") observe_attempt("send", status_code=200, body="private request", token="sk-synthetic") observe_usage({"input_tokens": 12, "credit": 0}) + observe_responses_projection({"mode": "balanced", "max_item_bytes": 40000, "truncated_items": 2}) for message in messages: await send(message) sent = await self.invoke(app) @@ -64,9 +65,26 @@ async def app(scope, receive, send): self.assertEqual(record["usage_source"], "mixed") self.assertEqual(record["usage_sources"]["input_tokens"], "upstream_hook") self.assertIsNone(record["first_token_ms"]) + self.assertEqual(record["responses_projection_mode"], "balanced") + self.assertEqual(record["responses_projection_max_bytes"], 40000) + self.assertEqual(record["responses_truncated_items"], 2) self.assertNotIn("private", json.dumps(record)) self.assertNotIn("sk-synthetic", json.dumps(record)) + async def test_non_responses_audit_omits_projection_fields(self): + body = json.dumps({"model": "public-model", "choices": [{"message": {"content": "ok"}}]}).encode() + messages = [{"type": "http.response.start", "status": 200, + "headers": [(b"content-type", b"application/json")]}, + {"type": "http.response.body", "body": body}] + async def app(scope, receive, send): + for message in messages: + await send(message) + await self.invoke(app, path="/v1/chat/completions") + record = self.only_record() + for key in ("responses_projection_mode", "responses_projection_max_bytes", + "responses_truncated_items"): + self.assertNotIn(key, record) + async def stream(self, chunks, path="/v1/chat/completions", complete=True): async def app(scope, receive, send): await send({"type": "http.response.start", "status": 200, diff --git a/tests/test_output_truncation.py b/tests/test_output_truncation.py new file mode 100644 index 0000000..9f7c8f4 --- /dev/null +++ b/tests/test_output_truncation.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Test byte-safe middle truncation and its exact projections.""" +import sys +from dataclasses import fields, is_dataclass +from pathlib import Path +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. + +from app.output_truncation import TruncationResult, truncate_middle_bytes + + +class OutputTruncationTests(unittest.TestCase): + def test_public_result_shape(self): + self.assertTrue(is_dataclass(TruncationResult)) + self.assertEqual( + [field.name for field in fields(TruncationResult)], + [ + "text", + "truncated", + "original_bytes", + "projected_bytes", + "original_estimated_tokens", + "projected_estimated_tokens", + "total_lines", + "omitted_estimated_tokens", + ], + ) + + def test_zero_limit_and_fitting_ascii_are_returned_unchanged(self): + text = "first line\nsecond line\nthird line" + byte_count = len(text.encode("utf-8")) + expected_tokens = (byte_count + 3) // 4 + for limit in (0, 256, 512): + with self.subTest(limit=limit): + result = truncate_middle_bytes(text, limit) + self.assertEqual(result.text, text) + self.assertFalse(result.truncated) + self.assertEqual(result.original_bytes, byte_count) + self.assertEqual(result.projected_bytes, byte_count) + self.assertEqual(result.original_estimated_tokens, expected_tokens) + self.assertEqual(result.projected_estimated_tokens, expected_tokens) + self.assertEqual(result.total_lines, 3) + self.assertEqual(result.omitted_estimated_tokens, 0) + + def test_ascii_multiline_keeps_head_and_tail_with_warning(self): + text = "HEAD-MARKER\n" + ("middle line\n" * 30) + "TAIL-MARKER" + result = truncate_middle_bytes(text, 256) + self.assertTrue(result.truncated) + self.assertTrue(result.text.startswith("HEAD-MARKER\n")) + self.assertTrue(result.text.endswith("TAIL-MARKER")) + marker_start = result.text.index("[Warning:") + marker_end = result.text.index("]\n", marker_start) + 2 + head = result.text[:marker_start].removesuffix("\n") + tail = result.text[marker_end:] + self.assertTrue(text.startswith(head)) + self.assertTrue(text.endswith(tail)) + self.assertLess(len(head) + len(tail), len(text)) + self.assertIn("Warning: middle omitted", result.text) + self.assertIn(f"original bytes: {len(text.encode('utf-8'))}", result.text) + self.assertIn("4 bytes/token", result.text) + self.assertIn("total lines: 32", result.text) + + def test_single_line_and_tight_budget(self): + text = "HEAD-middle-content-TAIL" * 20 + result = truncate_middle_bytes(text, 256) + self.assertTrue(result.truncated) + self.assertLessEqual(result.projected_bytes, 256) + self.assertTrue(result.text.startswith("HEAD-")) + self.assertTrue(result.text.endswith("TAIL")) + self.assertIn("middle omitted", result.text) + self.assertEqual(result.total_lines, 1) + + def test_chinese_and_emoji_boundaries_remain_valid_utf8(self): + text = "开头-中文-🙂-" + "中间内容🙂" * 30 + "-结尾-🚀" + before = text + result = truncate_middle_bytes(text, 256) + self.assertTrue(result.truncated) + self.assertLessEqual(len(result.text.encode("utf-8")), 256) + self.assertTrue(result.text.startswith("开头-中文-🙂-")) + self.assertTrue(result.text.endswith("-结尾-🚀")) + self.assertIn("middle omitted", result.text) + self.assertEqual(text, before) + result.text.encode("utf-8").decode("utf-8") + + def test_projection_statistics_are_exact(self): + text = "é\n" + "🙂漢" * 50 + "\n終" + original_bytes = len(text.encode("utf-8")) + result = truncate_middle_bytes(text, 256) + self.assertEqual(result.projected_bytes, len(result.text.encode("utf-8"))) + self.assertEqual(result.original_bytes, original_bytes) + self.assertEqual(result.original_estimated_tokens, (original_bytes + 3) // 4) + self.assertEqual(result.projected_estimated_tokens, (result.projected_bytes + 3) // 4) + self.assertEqual( + result.omitted_estimated_tokens, + result.original_estimated_tokens - result.projected_estimated_tokens, + ) + self.assertEqual(result.total_lines, 3) + + def test_empty_text_statistics(self): + result = truncate_middle_bytes("", 0) + self.assertEqual(result.text, "") + self.assertEqual(result.total_lines, 0) + self.assertEqual(result.original_estimated_tokens, 0) + self.assertEqual(result.projected_estimated_tokens, 0) + + + def test_newline_flood_line_count_does_not_require_split_list(self): + text = "\n" * 1_000_000 + result = truncate_middle_bytes(text, 256) + self.assertEqual(result.total_lines, 1_000_000) + self.assertLessEqual(result.projected_bytes, 256) + def test_small_or_invalid_limits_raise(self): + for limit in (-1, 1, 128, 255): + with self.subTest(limit=limit), self.assertRaises(ValueError): + truncate_middle_bytes("text", limit) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_request_limits.py b/tests/test_request_limits.py index 1768983..10cb3a5 100644 --- a/tests/test_request_limits.py +++ b/tests/test_request_limits.py @@ -319,15 +319,15 @@ def test_projection_preserves_old_images_harness_and_full_tool_chain(self): "parameters": {"type": "object", "properties": {}}}}]} before = deepcopy(body) out, stats = project_responses_chat_body(body) - self.assertEqual(stats["mode"], "conservative") + self.assertEqual(stats["mode"], "balanced") self.assertEqual(len(out["messages"]), len(body["messages"])) for index in (0, 1, 2, 3, 16): self.assertEqual(out["messages"][index]["content"][-1], body["messages"][index]["content"][-1]) - self.assertLess(len(out["messages"][3]["content"][0]["text"]), 5000) + self.assertEqual(out["messages"][3]["content"][0]["text"], "x" * 5000) self.assertEqual(out["messages"][2]["tool_calls"], [call]) self.assertEqual(out["messages"][2]["reasoning_content"], "unchanged reasoning") self.assertEqual(out["messages"][3]["tool_call_id"], "call_old") - self.assertNotIn("description", out["tools"][0]["function"]) + self.assertEqual(out["tools"], body["tools"]) self.assertEqual(body, before) diff --git a/tests/test_responses_adapter.py b/tests/test_responses_adapter.py index d319e13..eab1ff2 100644 --- a/tests/test_responses_adapter.py +++ b/tests/test_responses_adapter.py @@ -59,6 +59,17 @@ def test_array_input_request(): assert msgs[4] == {"role": "user", "content": "Now fix it"} print("✅ test_array_input_request") +def test_function_call_arguments_must_be_string(): + """Reject non-standard object arguments before Chat adaptation.""" + try: + responses_request_to_chat({"model": "auto", "input": [ + {"type": "function_call", "name": "tool", "arguments": {"x": 1}}, + ]}) + except ValueError as error: + assert "JSON string" in str(error) + else: + raise AssertionError("non-string function call arguments were accepted") + def test_tools_conversion(): """Convert flat Responses tools to nested Chat definitions.""" @@ -227,224 +238,171 @@ def test_no_compact_still_prunes_codex_runtime_metadata(): print("✅ test_no_compact_still_prunes_codex_runtime_metadata") -def test_responses_projection_compacts_codex_harness_and_tools(): - """Project Codex requests into short system context and minimal tool schemas.""" - body = { - "messages": [ - { - "role": "system", - "content": ( - "You are a coding agent running in the Codex CLI.\n" - "# AGENTS.md spec\nVery long harness instructions." - ), - }, - { - "role": "system", - "content": "Additional repo rule: always run tests after editing.", - }, - { - "role": "user", - "content": "# AGENTS.md instructions\n\nlong context\n", - }, - {"role": "user", "content": "实现该方案"}, - ], - "tools": [ - { - "type": "function", - "function": { - "name": "exec_command", - "description": "Run a command with a long dangerous description", - "parameters": { - "type": "object", - "properties": { - "cmd": {"type": "string", "description": "Shell command to execute."}, - "yield_time_ms": {"type": "number", "description": "Wait time"}, - }, - "required": ["cmd"], - "additionalProperties": False, - }, - "strict": False, - }, - } - ], - } - out, stats = project_responses_chat_body(body) - assert stats["mode"] == "aggressive" - assert out["messages"][0]["role"] == "system" - assert "OpenAI-compatible CLI" in out["messages"][0]["content"] - assert all("# AGENTS.md instructions" not in msg.get("content", "") for msg in out["messages"]) - assert any("Additional repo rule" in msg.get("content", "") for msg in out["messages"]) - assert out["messages"][-1] == {"role": "user", "content": "实现该方案"} - tool = out["tools"][0]["function"] - assert tool["name"] == "exec_command" - assert "description" not in tool - assert "description" not in tool["parameters"]["properties"]["cmd"] - assert stats["projected_tool_chars"] < stats["original_tool_chars"] - print("✅ test_responses_projection_compacts_codex_harness_and_tools") - - -def test_responses_projection_preserves_recent_tool_chain_and_summarizes_history(): - """Summarize older turns while retaining the recent tool chain.""" - big_output = "Chunk ID: a1\nWall time: 0.0\nProcess exited with code 0\nOutput:\n" + "\n".join( - f"line {i}" for i in range(40) - ) - body = { - "messages": [ - {"role": "system", "content": "You are a coding agent running in the Codex CLI."}, - {"role": "user", "content": "# AGENTS.md instructions\nctx"}, - {"role": "user", "content": "先看 README"}, - { - "role": "assistant", - "content": "I will inspect the repository.", - "tool_calls": [ - { - "id": "call_old", - "type": "function", - "function": {"name": "exec_command", "arguments": "{\"cmd\":\"ls -la\"}"}, - } - ], - }, - {"role": "tool", "tool_call_id": "call_old", "content": "Output:\nREADME.md\nsrc\n"}, - {"role": "assistant", "content": "README is present."}, - {"role": "user", "content": "现在修复 converter 的 responses 链路"}, - { - "role": "assistant", - "content": "I will patch the proxy and then run tests.", - "tool_calls": [ - { - "id": "call_recent", - "type": "function", - "function": { - "name": "exec_command", - "arguments": json.dumps({"cmd": "sed -n '1,200p' converter.py", "yield_time_ms": 1000}), - }, - } - ], - }, - {"role": "tool", "tool_call_id": "call_recent", "content": big_output}, - {"role": "assistant", "content": "I found the endpoint and will implement projection now."}, - {"role": "user", "content": "继续,别依赖 fallback retry"}, - ], - "tools": [ - { - "type": "function", - "function": { - "name": "exec_command", - "parameters": { - "type": "object", - "properties": { - "cmd": {"type": "string"}, - "yield_time_ms": {"type": "number"}, - }, - "required": ["cmd"], - }, - }, - } - ], - } - out, stats = project_responses_chat_body(body) - system_messages = [m["content"] for m in out["messages"] if m["role"] == "system"] - assert system_messages[0].startswith("You are a coding assistant serving an OpenAI-compatible CLI.") - assert any("Earlier conversation summary" in text for text in system_messages) - assert any("先看 README" in text for text in system_messages) - - recent_assistant = next( - msg for msg in out["messages"] - if msg.get("role") == "assistant" and any(tc.get("id") == "call_recent" for tc in msg.get("tool_calls", [])) - ) - recent_tool = next(msg for msg in out["messages"] if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_recent") - assert recent_assistant["tool_calls"][0]["function"]["name"] == "exec_command" - assert "Process exited with code 0" in recent_tool["content"] - assert "line 39" in recent_tool["content"] - assert len(recent_tool["content"]) < len(big_output) - assert out["messages"][-1] == {"role": "user", "content": "继续,别依赖 fallback retry"} - assert stats["summarized_history_messages"] >= 1 - print("✅ test_responses_projection_preserves_recent_tool_chain_and_summarizes_history") - - -def test_responses_projection_shrinks_large_tool_arguments(): - """Compact oversized tool arguments into structured JSON summaries.""" - long_cmd = "echo " + ("x" * 1600) - body = { - "messages": [ - {"role": "system", "content": "You are a coding agent running in the Codex CLI."}, - {"role": "user", "content": "执行一个很长的命令"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_long", - "type": "function", - "function": { - "name": "exec_command", - "arguments": json.dumps({"cmd": long_cmd, "yield_time_ms": 1000, "workdir": "/tmp"}), - }, - } - ], - }, - ], - "tools": [], - } - out, _ = project_responses_chat_body(body) - args = out["messages"][-1]["tool_calls"][0]["function"]["arguments"] - parsed = json.loads(args) - assert parsed["cmd"].startswith("echo ") - assert "truncated" in parsed["cmd"] - print("✅ test_responses_projection_shrinks_large_tool_arguments") - - -def _agentic_tool(): - return [{ +def test_responses_projection_balanced_preserves_real_text_tools_and_structure(): + """Balanced mode replaces recognized harness blocks without changing tools or real text.""" + tool = { "type": "function", "function": { "name": "exec_command", - "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + "description": "Run a command", + "parameters": { + "type": "object", + "properties": {"cmd": {"type": "string", "description": "Command"}}, + "required": ["cmd"], + "additionalProperties": False, + "x-vendor-detail": {"deep": {"schema": "kept"}}, + }, + "strict": False, }, - }] - - -def test_responses_projection_keeps_user_text_sharing_harness_message(): - """Preserve user and reminder text embedded in messages containing harness context.""" + } body = { "model": "auto", - "tools": _agentic_tool(), "messages": [ - {"role": "system", "content": "You are a coding agent running in the Codex CLI. # How you work"}, - {"role": "user", "content": "# AGENTS.md instructions\n\nUse tabs\n"}, - {"role": "user", "content": "\n剩余任务:改看板\n\n\n继续之前的前端改造工程,把登录页也改了"}, - {"role": "assistant", "content": "好的,我来改登录页"}, - {"role": "user", "content": "另外把看板的按钮也加上"}, + {"role": "system", "content": "Repository policy: run tests."}, + {"role": "user", "content": "# AGENTS.md instructions\n\nvolatile context\n"}, + {"role": "user", "content": "实现该方案"}, ], + "tools": [tool], } + before = json.loads(json.dumps(body, ensure_ascii=False)) out, stats = project_responses_chat_body(body) - assert stats["mode"] == "aggressive" - blob = "\n".join(str(m.get("content", "")) for m in out["messages"]) - assert "继续之前的前端改造工程" in blob, "与 harness 同条的用户原话被丢弃" - assert "剩余任务:改看板" in blob, "system-reminder 正文被丢弃" - assert "另外把看板的按钮也加上" in blob - assert "# AGENTS.md instructions" not in blob, "纯 harness 载荷应以摘要出现" - assert stats["anchor_user_preserved"] or "继续之前的前端改造工程" in blob - print("✅ test_responses_projection_keeps_user_text_sharing_harness_message") - - -def test_responses_projection_keeps_last_user_when_it_carries_harness(): - """Preserve real text when the final user message also contains harness context.""" + assert body == before + assert out["tools"] == body["tools"] + assert out["messages"][0] == body["messages"][0] + assert out["messages"][1]["content"] != body["messages"][1]["content"] + assert "# AGENTS.md instructions" not in out["messages"][1]["content"] + assert "Environment context is provided by the harness." in out["messages"][1]["content"] + assert out["messages"][2] == body["messages"][2] + assert stats["mode"] == "balanced" + assert stats["original_tools"] == stats["projected_tools"] == 1 + assert stats["original_tool_chars"] == stats["projected_tool_chars"] + assert stats["harness_messages_projected"] == 1 + print("✅ test_responses_projection_balanced_preserves_real_text_tools_and_structure") + + +def test_responses_projection_preserves_history_and_tool_chain(): + """Balanced mode does not summarize history or drop tool-call relationships.""" body = { - "model": "auto", - "tools": _agentic_tool(), "messages": [ - {"role": "system", "content": "You are a coding agent running in the Codex CLI."}, - {"role": "assistant", "content": "上一轮的答复"}, - {"role": "user", "content": "\n剩余任务:改看板\n\n\n继续之前的前端改造工程,把登录页也改了"}, + {"role": "user", "content": "old task"}, + {"role": "assistant", "content": "old answer", "tool_calls": [{ + "id": "call_old", "type": "function", + "function": {"name": "exec_command", "arguments": '{"cmd":"ls"}'}, + }]}, + {"role": "tool", "tool_call_id": "call_old", "content": "old output"}, + {"role": "user", "content": "new task"}, ], + "tools": [{"type": "function", "function": {"name": "exec_command", "parameters": {"type": "object"}}}], } - out, stats = project_responses_chat_body(body) - blob = "\n".join(str(m.get("content", "")) for m in out["messages"]) - assert "继续之前的前端改造工程" in blob, "最后一轮用户真话丢失" - assert "剩余任务:改看板" in blob, "reminder 正文丢失" - print("✅ test_responses_projection_keeps_last_user_when_it_carries_harness") - + before = json.loads(json.dumps(body)) + out, stats = project_responses_chat_body(body, max_item_bytes=40000) + assert body == before + assert out["messages"] == body["messages"] + assert stats["original_messages"] == stats["projected_messages"] == 4 + assert "anchor_user_preserved" not in stats + print("✅ test_responses_projection_preserves_history_and_tool_chain") + + +def test_responses_projection_truncates_generated_content_and_json_arguments(): + """Oversized assistant, tool output, and JSON values retain UTF-8 head and tail.""" + long_text = "HEAD\n" + ("中" * 180) + "\nTAIL" + long_output = "OUTPUT\n" + ("输出" * 180) + "\nEND" + arguments = json.dumps({"cmd": "echo " + ("x" * 500), "workdir": "/tmp"}) + apply_patch = json.dumps({"patch": "*** Begin Patch\n" + ("+" * 500) + "*** End Patch"}) + body = {"messages": [ + {"role": "assistant", "content": long_text, "tool_calls": [{ + "id": "call_1", "type": "function", + "function": {"name": "exec_command", "arguments": arguments}, + }]}, + {"role": "tool", "tool_call_id": "call_1", "content": long_output}, + {"role": "assistant", "content": "", "tool_calls": [{ + "id": "call_patch", "type": "function", + "function": {"name": "apply_patch", "arguments": apply_patch}, + }]}, + ]} + before = json.loads(json.dumps(body, ensure_ascii=False)) + out, stats = project_responses_chat_body(body, max_item_bytes=256) + assert body == before + assistant = out["messages"][0]["content"] + assert assistant.startswith("HEAD") + assert assistant.endswith("TAIL") + assert "middle omitted" in assistant + assert "original bytes:" in assistant + assert "estimated tokens:" in assistant + assert "total lines:" in assistant + tool_output = out["messages"][1]["content"] + assert tool_output.startswith("OUTPUT") + assert tool_output.endswith("END") + args_wire = out["messages"][0]["tool_calls"][0]["function"]["arguments"] + args = json.loads(args_wire) + assert len(args_wire.encode("utf-8")) <= 256 + assert "middle omitted" in args["_truncated"]["warning"] + assert '"cmd"' in args["head"] and "echo" in args["head"] + assert "workdir" in args["tail"] and args["tail"].endswith("}") + patch_wire = out["messages"][2]["tool_calls"][0]["function"]["arguments"] + patch_args = json.loads(patch_wire) + assert len(patch_wire.encode("utf-8")) <= 256 + assert '"patch"' in patch_args["head"] and "*** Begin Patch" in patch_args["head"] + assert patch_args["tail"].endswith('*** End Patch"}') + assert stats["truncated_items"] == 4 + assert stats["truncated_original_bytes"] > stats["truncated_projected_bytes"] + print("✅ test_responses_projection_truncates_generated_content_and_json_arguments") + + +def test_responses_projection_passthrough_and_zero_limit_are_lossless(): + """Passthrough and max_item_bytes=0 preserve the request payload.""" + body = {"messages": [ + {"role": "user", "content": "# AGENTS.md instructions\nvolatile\nreal task"}, + {"role": "assistant", "content": "assistant " + "x" * 500}, + {"role": "tool", "tool_call_id": "c", "content": "output " + "y" * 500}, + ], "tools": [{"type": "function", "function": {"name": "tool", "parameters": {"type": "object", "x": 1}}}]} + before = json.loads(json.dumps(body, ensure_ascii=False)) + for kwargs in ({"mode": "passthrough"}, {"max_item_bytes": 0}): + out, stats = project_responses_chat_body(body, **kwargs) + if "mode" in kwargs: + assert out == before + else: + assert out["messages"][1:] == before["messages"][1:] + assert out["tools"] == before["tools"] + assert body == before + assert stats["mode"] == ("passthrough" if "mode" in kwargs else "balanced") + assert stats["truncated_items"] == 0 + print("✅ test_responses_projection_passthrough_and_zero_limit_are_lossless") + + +def test_responses_projection_rejects_invalid_mode_and_limit(): + """Projection validates its public mode and byte-limit arguments.""" + for mode in ("aggressive", "conservative", "", None, True): + try: + project_responses_chat_body({"messages": []}, mode=mode) + except ValueError: + pass + else: + raise AssertionError(f"invalid mode accepted: {mode!r}") + for limit in (-1, 1, 128, 255, True, 1.5, "256", None, [], {}): + try: + project_responses_chat_body({"messages": []}, max_item_bytes=limit) + except ValueError: + pass + else: + raise AssertionError(f"invalid max_item_bytes accepted: {limit!r}") + assert project_responses_chat_body({"messages": []}, mode="balanced", max_item_bytes=256)[1]["max_item_bytes"] == 256 + print("✅ test_responses_projection_rejects_invalid_mode_and_limit") + + +def test_responses_projection_stats_have_official_shape(): + """Stats expose current projection counters without legacy mode fields.""" + body = {"messages": [{"role": "user", "content": "hello"}], "tools": [{"type": "function", "function": {"name": "t"}}]} + _, stats = project_responses_chat_body(body, mode="balanced", max_item_bytes=0) + assert stats == { + "mode": "balanced", "max_item_bytes": 0, "original_messages": 1, + "projected_messages": 1, "original_message_chars": stats["original_message_chars"], + "projected_message_chars": stats["projected_message_chars"], "original_tools": 1, + "projected_tools": 1, "original_tool_chars": stats["original_tool_chars"], + "projected_tool_chars": stats["projected_tool_chars"], "harness_messages_projected": 0, + "truncated_items": 0, "truncated_original_bytes": 0, "truncated_projected_bytes": 0, + } + print("✅ test_responses_projection_stats_have_official_shape") def test_stream_converter_text(): @@ -675,11 +633,12 @@ def test_parallel_tool_calls_roundtrip(): test_desensitize_harness_user_and_tools() test_compact_harness_messages_and_strip_tool_metadata() test_no_compact_still_prunes_codex_runtime_metadata() - test_responses_projection_compacts_codex_harness_and_tools() - test_responses_projection_preserves_recent_tool_chain_and_summarizes_history() - test_responses_projection_shrinks_large_tool_arguments() - test_responses_projection_keeps_user_text_sharing_harness_message() - test_responses_projection_keeps_last_user_when_it_carries_harness() + test_responses_projection_balanced_preserves_real_text_tools_and_structure() + test_responses_projection_preserves_history_and_tool_chain() + test_responses_projection_truncates_generated_content_and_json_arguments() + test_responses_projection_passthrough_and_zero_limit_are_lossless() + test_responses_projection_rejects_invalid_mode_and_limit() + test_responses_projection_stats_have_official_shape() test_stream_converter_text() test_stream_converter_function_call() test_nonstream_response() diff --git a/tests/test_runtime_endpoints.py b/tests/test_runtime_endpoints.py index 201fee2..91a237b 100644 --- a/tests/test_runtime_endpoints.py +++ b/tests/test_runtime_endpoints.py @@ -243,7 +243,7 @@ def flaky(request): converter.CONFIG["tool_call_max_retry"] = 3 def test_credential_selection_runs_off_the_event_loop(self): - """Offload blocking credential routing to the thread pool for all protocols.""" + """Offload blocking Responses projection and credential routing.""" import inspect import re src = inspect.getsource(converter) @@ -252,6 +252,7 @@ def test_credential_selection_runs_off_the_event_loop(self): self.assertEqual(direct, []) # All initial and failover routing must run outside the event-loop thread. self.assertGreaterEqual(len(pooled), 3) + self.assertRegex(src, r"await run_in_threadpool\(\s*project_responses_chat_body") def test_tool_metadata_policy_reaches_all_protocols(self): description = "Read sandbox data without destructive changes." @@ -274,7 +275,7 @@ def test_tool_metadata_policy_reaches_all_protocols(self): self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(self.requests), 1) sent = json.loads(self.requests[0].content)["tools"][0]["function"] - retained = keep or (not desensitize and route != "/v1/responses") + retained = keep or not desensitize self.assertEqual("description" in sent, retained) self.assertEqual("title" in sent["parameters"], retained) prop = sent["parameters"]["properties"]["path"] @@ -927,12 +928,15 @@ def configure(self, env=None, flags=(), invalid=False, stored=None, expected_hos self.assertEqual(server.call_args.kwargs["host"], expected_host) return {key: converter.CONFIG[key] for key in ( "max_images", "image_policy", "max_request_bytes", "log_body_limit", "admin_csrf", - "keep_tool_metadata", "admin_allowed_origins")} + "keep_tool_metadata", "admin_allowed_origins", "responses_projection_mode", + "responses_projection_max_bytes")} def test_defaults(self): - self.assertEqual(self.configure(), {"max_images": 16, "image_policy": "truncate", - "max_request_bytes": 33554432, "log_body_limit": 65536, - "admin_csrf": True, "keep_tool_metadata": False, "admin_allowed_origins": ""}) + self.assertEqual(self.configure(), { + "max_images": 16, "image_policy": "truncate", "max_request_bytes": 33554432, + "log_body_limit": 65536, "admin_csrf": True, "keep_tool_metadata": False, + "admin_allowed_origins": "", "responses_projection_mode": "balanced", + "responses_projection_max_bytes": 40000}) def test_open_binding_without_key_requires_explicit_opt_in(self): # Reject unauthenticated public binding by default. @@ -958,9 +962,11 @@ def test_persisted_host_is_validated_after_configuration_resolution(self): def test_environment_and_explicit_cli_precedence(self): env = {"CODEBUDDY2API_MAX_IMAGES": "8", "CODEBUDDY2API_IMAGE_POLICY": "error", "CODEBUDDY2API_MAX_REQUEST_BYTES": "100000", "CODEBUDDY2API_LOG_BODY_LIMIT": "0"} - self.assertEqual(self.configure(env), {"max_images": 8, "image_policy": "error", - "max_request_bytes": 100000, "log_body_limit": 0, - "admin_csrf": True, "keep_tool_metadata": False, "admin_allowed_origins": ""}) + self.assertEqual(self.configure(env), { + "max_images": 8, "image_policy": "error", "max_request_bytes": 100000, + "log_body_limit": 0, "admin_csrf": True, "keep_tool_metadata": False, + "admin_allowed_origins": "", "responses_projection_mode": "balanced", + "responses_projection_max_bytes": 40000}) env["CODEBUDDY2API_IMAGE_POLICY"] = "invalid-overridden" self.assertEqual(self.configure(env, ("--max-images", "0", "--image-policy", "truncate"))["max_images"], 0) diff --git a/tests/test_tool_metadata.py b/tests/test_tool_metadata.py index 8c6774a..d644e95 100644 --- a/tests/test_tool_metadata.py +++ b/tests/test_tool_metadata.py @@ -1,108 +1,101 @@ -"""Tool metadata retention and projection boundaries; synthetic requests only.""" +"""Responses projection keeps complete tool metadata in every supported mode.""" import copy -from itertools import product +import json from pathlib import Path import sys import unittest -from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import converter -from app.adapters.responses_projection import _project_schema, project_responses_chat_body +from app.adapters.responses_projection import project_responses_chat_body -def tool_body(agentic=False): - schema = { - "type": "object", "title": "Tool inputs", "description": "Read sandbox inputs.", - "properties": { - "path": {"type": "string", "description": "Read a sandbox path.", "title": "Path", "enum": ["sandbox", "local"]}, - "list": {"type": "array", "items": {"type": "string", "description": "Item description"}}, - "choice": {"anyOf": [{"type": "string", "description": "Text choice"}, {"type": "integer", "title": "Number"}]}, - "joined": {"allOf": [{"type": "string", "description": "Joined choice"}]}, - "one": {"oneOf": [{"type": "string", "title": "One choice"}]}, - "mapping": {"type": "object", "additionalProperties": {"type": "string", "description": "Map value"}}, - "description": {"type": "string", "title": "A property named description"}, +SCHEMA = { + "type": "object", + "title": "Tool inputs", + "description": "Read sandbox inputs.", + "properties": { + "path": { + "type": "string", "title": "Path", "description": "A path.", + "enum": ["sandbox", "local"], }, - "required": ["path"], "additionalProperties": False, - } - return { - "messages": [{"role": "system", "content": "You are a coding agent running in the Codex CLI." if agentic else "You are a helpful assistant."}, - {"role": "user", "content": "Read a file."}], - "tools": [{"type": "function", "function": {"name": "lookup_data", "description": "Read sandbox data without destructive changes.", - "title": "Data reader", "parameters": schema, "strict": True}}], - } + "list": {"type": "array", "items": {"type": "string", "description": "Item"}}, + "choice": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "mapping": {"type": "object", "additionalProperties": {"type": "string"}}, + "description": {"type": "string", "title": "A description property"}, + }, + "required": ["path"], + "additionalProperties": False, + "x-vendor": {"deep": {"schema": "must remain"}}, +} -def metadata(value, path=()): - result = {} - if isinstance(value, dict): - for key, item in value.items(): - if key in ("description", "title") and isinstance(item, str): - result[path + (key,)] = item - result.update(metadata(item, path + (key,))) - elif isinstance(value, list): - for index, item in enumerate(value): - result.update(metadata(item, path + (index,))) - return result +def tool_body(agentic=False): + return { + "model": "auto", + "messages": [ + {"role": "system", "content": ( + "You are a coding agent running in the Codex CLI." + if agentic else "You are a helpful assistant." + )}, + {"role": "user", "content": "Read a file."}, + ], + "tools": [{ + "type": "function", + "function": { + "name": "lookup_data", + "description": "Read sandbox data.", + "title": "Data reader", + "parameters": SCHEMA, + "strict": True, + }, + }], + } class ToolMetadataTests(unittest.TestCase): - def test_projection_retains_annotations_in_both_modes_without_mutation(self): - for agentic, keep in product((False, True), repeat=2): - with self.subTest(agentic=agentic, keep=keep): + def test_balanced_and_passthrough_preserve_complete_tool_schema(self): + for agentic, mode in ((False, "balanced"), (True, "balanced"), (False, "passthrough"), (True, "passthrough")): + with self.subTest(agentic=agentic, mode=mode): body = tool_body(agentic) before = copy.deepcopy(body) - result, stats = project_responses_chat_body(body, keep_tool_metadata=keep) - self.assertEqual(stats["mode"], "aggressive" if agentic else "conservative") - self.assertEqual(metadata(result["tools"]), metadata(body["tools"]) if keep else {}) - function = result["tools"][0]["function"] - schema = function["parameters"] - self.assertEqual(function["name"], "lookup_data") - self.assertIs(function["strict"], True) - self.assertEqual(schema["required"], ["path"]) - self.assertIs(schema["additionalProperties"], False) - self.assertEqual(schema["properties"]["path"]["enum"], ["sandbox", "local"]) - self.assertEqual(schema["properties"]["description"]["type"], "string") + result, stats = project_responses_chat_body(body, mode=mode) self.assertEqual(body, before) - if not keep: - self.assertEqual(project_responses_chat_body(body), (result, stats)) + self.assertEqual(result["tools"], body["tools"]) + self.assertEqual(result["messages"], body["messages"]) + self.assertEqual(result["tools"][0]["function"]["parameters"], SCHEMA) + self.assertEqual(result["tools"][0]["function"]["description"], "Read sandbox data.") + self.assertEqual(result["tools"][0]["function"]["title"], "Data reader") + self.assertIs(result["tools"][0]["function"]["strict"], True) + self.assertEqual(stats["mode"], mode) + self.assertEqual(stats["original_tools"], stats["projected_tools"]) + self.assertEqual(stats["original_tool_chars"], stats["projected_tool_chars"]) - def test_retained_metadata_is_desensitized_independently_of_compaction(self): + def test_projection_does_not_mutate_nested_tool_structures(self): body = tool_body(agentic=True) - before = copy.deepcopy(body) - for keep, no_compact, force_compact in product((False, True), repeat=3): - with self.subTest(keep=keep, no_compact=no_compact, force_compact=force_compact), patch.dict( - converter.CONFIG, {"desensitize": True, "keep_tool_metadata": keep, "no_compact": no_compact}): - result = converter._chat_body_desensitize(body, force_compact=force_compact) - values = metadata(result["tools"]) - if keep: - self.assertEqual({key: value.replace("\u200b", "") for key, value in values.items()}, metadata(body["tools"])) - self.assertIn("\u200b", result["tools"][0]["function"]["description"]) - else: - self.assertEqual(values, {}) - self.assertEqual(result["tools"][0]["function"]["parameters"]["properties"]["path"]["enum"], ["sandbox", "local"]) - self.assertEqual(body, before) - with patch.dict(converter.CONFIG, {"desensitize": False, "keep_tool_metadata": True}): - self.assertEqual(converter._chat_body_desensitize(body), before) - - def test_metadata_only_schemas_keep_the_object_fallback(self): - for schema in ({}, {"description": "Hint"}, {"title": "Node", "$ref": "#/$defs/value"}): - with self.subTest(schema=schema): - base = _project_schema(schema) - retained = _project_schema(schema, keep_tool_metadata=True) - self.assertEqual(base, {"type": "object"}) - self.assertEqual(retained, {"type": "object", **{key: value for key, value in schema.items() if key in ("description", "title")}}) + before = json.dumps(body, sort_keys=True, ensure_ascii=False) + result, _ = project_responses_chat_body(body, max_item_bytes=0) + self.assertEqual(json.dumps(body, sort_keys=True, ensure_ascii=False), before) + self.assertEqual(result["tools"], body["tools"]) + self.assertEqual(result["tools"][0]["function"]["parameters"], SCHEMA) - def test_retention_does_not_expand_other_schema_projection_rules(self): - schema = {"type": "string", "description": "Hint", "const": "unchanged legacy projection"} - self.assertEqual(_project_schema(schema, keep_tool_metadata=True), {"type": "string", "description": "Hint"}) - self.assertEqual(_project_schema(schema, depth=6, keep_tool_metadata=True), {"type": "object"}) - branches = {"oneOf": [schema] * 8} - result = _project_schema(branches, keep_tool_metadata=True) - self.assertEqual(len(result["oneOf"]), 6) - self.assertTrue(all(item == {"type": "string", "description": "Hint"} for item in result["oneOf"])) - self.assertEqual(len(_project_schema([schema] * 8, keep_tool_metadata=True)), 6) + def test_stats_expose_tool_preservation_in_official_shape(self): + body = tool_body() + _, stats = project_responses_chat_body(body, mode="balanced", max_item_bytes=40000) + self.assertEqual( + set(stats), + { + "mode", "max_item_bytes", "original_messages", "projected_messages", + "original_message_chars", "projected_message_chars", "original_tools", + "projected_tools", "original_tool_chars", "projected_tool_chars", + "harness_messages_projected", "truncated_items", "truncated_original_bytes", + "truncated_projected_bytes", + }, + ) + self.assertEqual(stats["mode"], "balanced") + self.assertEqual(stats["max_item_bytes"], 40000) + self.assertEqual(stats["original_tools"], 1) + self.assertEqual(stats["projected_tools"], 1) if __name__ == "__main__": diff --git a/tests/test_workbuddy_filter.py b/tests/test_workbuddy_filter.py index 251df2f..ee8f0c3 100644 --- a/tests/test_workbuddy_filter.py +++ b/tests/test_workbuddy_filter.py @@ -39,7 +39,7 @@ def reply(text=REFUSAL, *, field="content", finish="stop"): def payload(route, *, stream=False, tools=False, identity=IDENTITY.lower()): - # Lowercase identity uses conservative Responses projection and retains fallback context. + # Lowercase identity exercises the same Responses path while retaining fallback context. system = (identity + ".\n" + BRANCH + ": main\n## Planning\n" + "Read relevant source files, preserve project conventions, and verify changes with tests.\n" * 5) user = {"role": "user", "content": "List the repository files."} From fc35170c7b485d882a20d5be52250f9569454237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:22:34 +0800 Subject: [PATCH 2/2] Preserve fitting Responses tool arguments --- app/adapters/responses_projection.py | 37 +++++++++++++++++++++++++++ tests/test_harness_projection.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/app/adapters/responses_projection.py b/app/adapters/responses_projection.py index 2981454..21efa6a 100644 --- a/app/adapters/responses_projection.py +++ b/app/adapters/responses_projection.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from typing import Any from app.harness_context import parse_harness_text @@ -15,6 +16,7 @@ _JSON_OVERHEAD_RESERVE = 256 _MAX_TOOL_ARGUMENTS_PARSE_BYTES = 1024 * 1024 _MAX_WRAPPER_EDGE_BYTES = 8192 +_JSON_QUOTE_OR_NONSTANDARD = re.compile(r'"|NaN|-?Infinity') class _InvalidJsonConstant(ValueError): @@ -25,6 +27,38 @@ def _reject_json_constant(value: str) -> None: raise _InvalidJsonConstant(f"unsupported JSON constant: {value}") +def _find_unescaped_quote(text: str, start: int) -> int: + """Find the next unescaped quote without copying the string.""" + cursor = start + while True: + quote = text.find('"', cursor) + if quote < 0: + return -1 + slash = quote - 1 + backslashes = 0 + while slash >= start and text[slash] == "\\": + backslashes += 1 + slash -= 1 + if backslashes % 2 == 0: + return quote + cursor = quote + 1 + + +def _contains_nonstandard_json_constant(text: str) -> bool: + """Detect bare non-standard JSON constants without materializing parsed data.""" + cursor = 0 + while True: + match = _JSON_QUOTE_OR_NONSTANDARD.search(text, cursor) + if match is None: + return False + if text[match.start()] != '"': + return True + quote = _find_unescaped_quote(text, match.end()) + if quote < 0: + return False + cursor = quote + 1 + + def project_responses_chat_body( body: dict, *, @@ -158,6 +192,9 @@ def _project_tool_call(tool_call: Any, max_item_bytes: int, counters: dict[str, return projected argument_bytes = len(arguments.encode("utf-8")) if argument_bytes > _MAX_TOOL_ARGUMENTS_PARSE_BYTES: + if (argument_bytes <= max_item_bytes + and not _contains_nonstandard_json_constant(arguments)): + return projected function["arguments"] = _truncate_json_argument_text(arguments, max_item_bytes, counters) projected["function"] = function return projected diff --git a/tests/test_harness_projection.py b/tests/test_harness_projection.py index 720df10..76bc31e 100644 --- a/tests/test_harness_projection.py +++ b/tests/test_harness_projection.py @@ -192,6 +192,44 @@ def test_large_tool_arguments_skip_full_json_materialization(self): self.assertEqual(json.loads(wire)["_truncated"]["original_bytes"], len(arguments.encode("utf-8"))) self.assertEqual(stats["truncated_items"], 1) + def test_large_fitting_tool_arguments_are_unchanged(self): + arguments = json.dumps({"items": ['escaped " quote and NaN'] + ["x" * 1000] * 1099}, separators=(",", ":")) + argument_bytes = len(arguments.encode("utf-8")) + self.assertGreater(argument_bytes, 1024 * 1024) + self.assertLessEqual(argument_bytes, 2_000_000) + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "large-fitting", "type": "function", + "function": {"name": "large", "arguments": arguments}, + }]}] + payload = body(messages, []) + before = deepcopy(payload) + with patch("app.adapters.responses_projection.json.loads") as loads: + result, stats = project_responses_chat_body(payload, max_item_bytes=2_000_000) + loads.assert_not_called() + self.assertEqual(payload, before) + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + self.assertEqual(wire, arguments) + self.assertEqual(stats["truncated_items"], 0) + + def test_large_fitting_arguments_reject_nonstandard_constants_without_parsing(self): + arguments = '{"prefix":"' + ("x" * 1_100_000) + '","bad":NaN}' + self.assertGreater(len(arguments.encode("utf-8")), 1024 * 1024) + messages = [{"role": "assistant", "content": "", "tool_calls": [{ + "id": "large-nan", "type": "function", + "function": {"name": "large", "arguments": arguments}, + }]}] + with patch("app.adapters.responses_projection.json.loads") as loads: + result, stats = project_responses_chat_body(body(messages, []), max_item_bytes=2_000_000) + loads.assert_not_called() + wire = result["messages"][0]["tool_calls"][0]["function"]["arguments"] + + def reject_constant(value): + raise ValueError(f"unexpected constant: {value}") + + parsed = json.loads(wire, parse_constant=reject_constant) + self.assertIn("_truncated", parsed) + self.assertEqual(stats["truncated_items"], 1) + def test_escape_dense_wrapper_keeps_both_edges(self): arguments = json.dumps({"x": "\\" * 60000}, separators=(",", ":")) messages = [{"role": "assistant", "content": "", "tool_calls": [{