From 05a3712e37e81d1956b52d153aa570a066ae4be4 Mon Sep 17 00:00:00 2001 From: marcus Date: Thu, 22 Oct 2026 22:54:35 +0800 Subject: [PATCH] fix(domestic): map unsupported developer messages to system --- README.md | 15 +++ README_zh.md | 12 ++ src/codebuddy_proxy/__main__.py | 42 +++++-- src/codebuddy_proxy/role_compat.py | 49 ++++++++ test_developer_role_compat.py | 194 +++++++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 src/codebuddy_proxy/role_compat.py create mode 100644 test_developer_role_compat.py diff --git a/README.md b/README.md index c4000cc..db5f468 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,21 @@ Endpoint precedence is: explicit `--endpoint` > the `--global` profile default Therefore `--global` without `--endpoint` cannot be redirected to the domestic host by `CODEBUDDY_ENDPOINT`. +### Domestic gateway: developer-role compatibility + +The domestic default upstream (`https://copilot.tencent.com`) may reject +Chat Completions requests containing a `developer` message with HTTP 400 / +code 11128. Before forwarding to **that upstream only**, the proxy maps +`developer` to `system` without changing message order, content, tool-call +history, or the original client request. If enabled, `--desensitize` runs +after this role adaptation. No client-side configuration is needed. + +This compatibility mapping does not apply to the international backend or +custom endpoints. Because the legacy upstream does not represent the separate +`system` and `developer` instruction priorities, it is an approximation, +not exact semantic equivalence. The `upstream_role_compat` log event reports +only the number of mapped messages, not prompt contents. + Runtime model catalogs are loaded from the packaged profile resources `src/codebuddy_proxy/models_config.domestic.json` and `src/codebuddy_proxy/models_config.global.json`. The root diff --git a/README_zh.md b/README_zh.md index 9965978..2c66451 100644 --- a/README_zh.md +++ b/README_zh.md @@ -125,6 +125,18 @@ endpoint 解析优先级为:显式 `--endpoint` > `--global` 对应的 profile `src/codebuddy_proxy/models_config.global.json`。根目录的 `models_config.json` 仅作为开发兼容用的国内版配置副本保留,不是运行时数据源。 +### 国内版 developer 消息兼容 + +当前国内默认上游 `https://copilot.tencent.com` 的 Chat Completions 接口对包含 +`developer` 角色的请求可能返回 HTTP 400 / code 11128。本代理在**转发到该上游之前** +将 `developer` 映射为 `system`,保留消息顺序、内容和工具调用记录,不修改客户端传来的 +原始消息;映射后再执行已启用的 `--desensitize` 处理。无需更改客户端配置。 + +此适配仅针对国内默认上游,不影响国际站及自定义 endpoint。 +由于旧上游不区分 `developer` 和 `system` 的指令优先级,此映射是兼容性近似处理, +不能保证与原生支持 `developer` 的模型完全一致。日志中的 +`upstream_role_compat` 事件仅记录转换数量,不记录提示词内容。 + ### 2. 验证 ```bash diff --git a/src/codebuddy_proxy/__main__.py b/src/codebuddy_proxy/__main__.py index 4a9e607..f544bbb 100755 --- a/src/codebuddy_proxy/__main__.py +++ b/src/codebuddy_proxy/__main__.py @@ -34,7 +34,7 @@ from typing import Any, Optional import httpx -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI, Request, HTTPException from codebuddy_proxy.tool_pairing import ToolPairingError from fastapi.responses import JSONResponse, StreamingResponse import uvicorn @@ -47,6 +47,7 @@ resolve_backend_from_args, ) from codebuddy_proxy.codebuddy_client_demo import CodeBuddyClient, CodeBuddyError +from codebuddy_proxy.role_compat import adapt_developer_roles, uses_legacy_chat_roles # 尝试导入高级功能模块(可选) try: @@ -906,11 +907,11 @@ async def create_response(request: Request): log_client_request("POST", "/v1/responses", body) # 转换 Responses → Chat - try: - chat_body = responses_request_to_chat(body) - except ToolPairingError as exc: - diagnostic("tool_pairing_rejected", protocol="responses", reason=exc.reason, - call_count=len(exc.call_ids)) + try: + chat_body = responses_request_to_chat(body) + except ToolPairingError as exc: + diagnostic("tool_pairing_rejected", protocol="responses", reason=exc.reason, + call_count=len(exc.call_ids)) raise HTTPException(status_code=422, detail=exc.as_detail()) from exc # 消息压缩优化(如果启用) @@ -969,11 +970,11 @@ async def create_message(request: Request): log_client_request("POST", "/v1/messages", body) # 转换 Anthropic → Chat - try: - chat_body = anthropic_to_chat(body) - except ToolPairingError as exc: - diagnostic("tool_pairing_rejected", protocol="anthropic", reason=exc.reason, - call_count=len(exc.call_ids)) + try: + chat_body = anthropic_to_chat(body) + except ToolPairingError as exc: + diagnostic("tool_pairing_rejected", protocol="anthropic", reason=exc.reason, + call_count=len(exc.call_ids)) raise HTTPException(status_code=422, detail=exc.as_detail()) from exc diagnostic("request", protocol="anthropic", **body_summary(chat_body)) @@ -1020,7 +1021,23 @@ async def forward_chat( if "tool_choice" in upstream_body: upstream_body["tool_choice"] = _normalize_tool_choice(upstream_body["tool_choice"]) - # 应用脱敏处理 + url = state.client.build_url(state.client.profile.chat_path) + + # 国内站当前不接受 developer 角色(400/code 11128)。只对已验证的 + # copilot.tencent.com 网关做 copy-on-write 转换,不影响客户端请求对象、 + # 国际站、自定义上游以及已有 assistant/tool 调用历史。 + messages = upstream_body.get("messages") + if (uses_legacy_chat_roles(state.client.profile.key, url) + and isinstance(messages, list)): + compatible_messages, mapped_count = adapt_developer_roles(messages) + if mapped_count: + upstream_body["messages"] = compatible_messages + diagnostic("upstream_role_compat", protocol=protocol, + backend=state.client.profile.key, + mapping="developer_to_system", count=mapped_count) + + # 在角色兼容转换之后应用脱敏/客户端提示压缩;原来的 developer + # 指令现在同 system 一样进入既有处理链路。 if state.enable_desensitize: upstream_body = desensitize_body(upstream_body, compact_harness=True) @@ -1029,7 +1046,6 @@ async def forward_chat( upstream_body.setdefault("stream_options", {"include_usage": True}) - url = state.client.build_url(state.client.profile.chat_path) headers = { "User-Agent": "Mozilla/5.0 (compatible; Genie-IDE/1.0)", **state.client.auth_headers(), diff --git a/src/codebuddy_proxy/role_compat.py b/src/codebuddy_proxy/role_compat.py new file mode 100644 index 0000000..8d8c74b --- /dev/null +++ b/src/codebuddy_proxy/role_compat.py @@ -0,0 +1,49 @@ +"""Upstream chat-role compatibility for legacy CodeBuddy gateways. + +The OpenAI Chat Completions input supports a developer role, but the domestic +CodeBuddy chat gateway currently rejects its presence with HTTP 400/code 11128. +Map only this unsupported role to system for that specific upstream, preserving +content, relative order, metadata and tool-call history. + +This is a best-effort compatibility conversion: older upstreams cannot +represent the separate system > developer > user instruction hierarchy. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlsplit + + +def uses_legacy_chat_roles(backend_key: str, upstream_url: str) -> bool: + """Only the verified domestic upstream needs developer -> system mapping. + + In particular, do not force this behavior on custom endpoints or the + international CodeBuddy gateway without independent compatibility tests. + """ + try: + host = (urlsplit(upstream_url).hostname or "").lower().rstrip(".") + except ValueError: + return False + return backend_key == "domestic" and host == "copilot.tencent.com" + + +def adapt_developer_roles(messages: list[Any]) -> tuple[list[Any], int]: + """Return a copy-on-write messages list and the number of mapped roles. + + Do not merge/reorder messages. Merging can disrupt assistant tool-call / + tool-result groups; copying only changed message dicts also guarantees the + downstream request is not mutated. + """ + converted: list[Any] | None = None + mapped_count = 0 + + for i, message in enumerate(messages): + if not isinstance(message, dict) or message.get("role") != "developer": + continue + if converted is None: + converted = list(messages) + converted[i] = {**message, "role": "system"} + mapped_count += 1 + + return (converted if converted is not None else messages), mapped_count diff --git a/test_developer_role_compat.py b/test_developer_role_compat.py new file mode 100644 index 0000000..ffc9e96 --- /dev/null +++ b/test_developer_role_compat.py @@ -0,0 +1,194 @@ +"""Compatibility regression for developer messages on the domestic CodeBuddy gateway. + +No live CodeBuddy credentials or network are used in this module. +""" + +from copy import deepcopy +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +import codebuddy_proxy.__main__ as proxy +from codebuddy_proxy.role_compat import adapt_developer_roles, uses_legacy_chat_roles + + +def test_legacy_role_detection_is_strictly_scoped(): + assert uses_legacy_chat_roles("domestic", "https://copilot.tencent.com/v2/chat/completions") + assert not uses_legacy_chat_roles("global", "https://www.codebuddy.ai/v2/chat/completions") + assert not uses_legacy_chat_roles("domestic", "https://example.test/v2/chat/completions?host=copilot.tencent.com") + assert not uses_legacy_chat_roles("domestic", "https://copilot.tencent.com.example.test/v2/chat/completions") + assert not uses_legacy_chat_roles("global", "https://copilot.tencent.com/v2/chat/completions") + + +def test_developer_role_mapping_preserves_order_data_and_input(): + messages = [ + {"role": "system", "content": "platform policy"}, + {"role": "developer", "content": [{"type": "text", "text": "coding policy"}], "name": "rules"}, + {"role": "user", "content": "run"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c1", "type": "function", "function": {"name": "shell", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + {"role": "developer", "content": "second rule"}, + ] + original = deepcopy(messages) + + converted, count = adapt_developer_roles(messages) + + assert count == 2 + assert [m["role"] for m in converted] == [ + "system", "system", "user", "assistant", "tool", "system", + ] + assert converted[1]["content"] == original[1]["content"] + assert converted[1]["name"] == "rules" + assert converted[3:5] == original[3:5] + assert messages == original + assert converted[1] is not messages[1] + assert converted[1]["content"] is messages[1]["content"] + + +def test_no_developer_does_not_modify_messages(): + messages = [{"role": "user", "content": "hi"}] + result, count = adapt_developer_roles(messages) + assert result is messages + assert count == 0 + + +class StubResponse: + status_code = 200 + + async def aiter_lines(self): + yield 'data: {"id":"msg-test","model":"test","choices":[{"index":0,"delta":{"content":"OK"},"finish_reason":null}]}' + yield "data: [DONE]" + + async def aclose(self): + pass + + +class StubAsyncClient: + def __init__(self): + self.sent_body = None + self.closed = False + + def build_request(self, method, url, *, headers, json): + self.sent_body = json + return object() + + async def send(self, request, *, stream): + return StubResponse() + + async def aclose(self): + self.closed = True + + +@pytest.fixture +def capture_gateway(monkeypatch): + gateway = SimpleNamespace( + profile=SimpleNamespace(key="domestic", chat_path="/v2/chat/completions"), + build_url=lambda path: "https://copilot.tencent.com" + path, + auth_headers=lambda: {}, + ) + proxy_state = SimpleNamespace( + client=gateway, + ensure_auth=lambda: None, + enable_desensitize=True, + enable_optimize_context=False, + verbose_llm=False, + logger=None, + json_logger=None, + write_log=lambda *args, **kwargs: None, + write_body_log=lambda *args, **kwargs: None, + ) + monkeypatch.setattr(proxy, "proxy_state", proxy_state) + return gateway + + +@pytest.mark.parametrize("stream", [True, False]) +def test_chat_endpoint_sends_compatible_role_before_desensitize( + monkeypatch, capture_gateway, stream +): + body = { + "model": "test", + "stream": stream, + "messages": [ + {"role": "developer", "content": "You are a coding agent running in the Codex CLI"}, + {"role": "user", "content": "hi"}, + ], + } + sent = [] + if stream: + stub = StubAsyncClient() + monkeypatch.setattr(proxy.httpx, "AsyncClient", lambda **kwargs: stub) + else: + async def collect(url, headers, upstream_body, protocol): + sent.append(upstream_body) + return {"model": "test", "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, + ]} + monkeypatch.setattr(proxy, "collect_upstream", collect) + + with TestClient(proxy.app) as http: + result = http.post("/v1/chat/completions", json=body) + + assert result.status_code == 200 + upstream_body = stub.sent_body if stream else sent[0] + assert [m["role"] for m in upstream_body["messages"]] == ["system", "user"] + assert upstream_body["messages"][0]["content"] != body["messages"][0]["content"] + assert body["messages"][0]["role"] == "developer" + if stream: + assert "[DONE]" in result.text + + +@pytest.mark.parametrize("endpoint,backend,expected_roles", [ + ("https://www.codebuddy.ai", "global", ["developer", "user"]), + ("https://custom.example.test", "domestic", ["developer", "user"]), + ("https://copilot.tencent.com", "domestic", ["system", "user"]), +]) +def test_gateway_policy_applies_only_to_supported_backend( + monkeypatch, capture_gateway, endpoint, backend, expected_roles +): + capture_gateway.profile.key = backend + capture_gateway.build_url = lambda path: endpoint + path + capture_gateway.conversation_headers = lambda body: {} + fake = StubAsyncClient() + monkeypatch.setattr(proxy.httpx, "AsyncClient", lambda **kwargs: fake) + with TestClient(proxy.app) as http: + response = http.post("/v1/chat/completions", json={ + "model": "test", "stream": True, + "messages": [ + {"role": "developer", "content": "do the work"}, + {"role": "user", "content": "hi"}, + ], + }) + assert response.status_code == 200 + assert [m["role"] for m in fake.sent_body["messages"]] == expected_roles + + +@pytest.mark.parametrize("path,body", [ + ("/v1/responses", { + "model": "test", "stream": False, + "input": [{"role": "developer", "content": "coding guidance"}, {"role": "user", "content": "hi"}], + }), + ("/v1/messages", { + "model": "test", "stream": False, "max_tokens": 32, + "system": "coding guidance", "messages": [{"role": "user", "content": "hi"}], + }), +]) +def test_other_protocols_keep_instruction_roles(monkeypatch, capture_gateway, path, body): + sent = [] + stub = StubAsyncClient() + monkeypatch.setattr(proxy.httpx, "AsyncClient", lambda **kwargs: stub) + + async def collect(url, headers, upstream_body, protocol): + sent.append(upstream_body) + return {"model": "test", "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, + ]} + monkeypatch.setattr(proxy, "collect_upstream", collect) + with TestClient(proxy.app) as http: + response = http.post(path, json=body) + assert response.status_code == 200, response.text + upstream_body = stub.sent_body if path == "/v1/responses" else sent[0] + assert upstream_body["messages"][0]["role"] == "system" + assert upstream_body["messages"][0]["content"] == "coding guidance"