Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 29 additions & 13 deletions src/codebuddy_proxy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,6 +48,7 @@
)
from codebuddy_proxy.codebuddy_client_demo import CodeBuddyClient, CodeBuddyError
from codebuddy_proxy.billing_proxy import forward_billing
from codebuddy_proxy.role_compat import adapt_developer_roles, uses_legacy_chat_roles

# 尝试导入高级功能模块(可选)
try:
Expand Down Expand Up @@ -927,11 +928,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

# 消息压缩优化(如果启用)
Expand Down Expand Up @@ -990,11 +991,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))

Expand Down Expand Up @@ -1041,7 +1042,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)

Expand All @@ -1050,7 +1067,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(),
Expand Down
49 changes: 49 additions & 0 deletions src/codebuddy_proxy/role_compat.py
Original file line number Diff line number Diff line change
@@ -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
194 changes: 194 additions & 0 deletions test_developer_role_compat.py
Original file line number Diff line number Diff line change
@@ -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"
Loading