fix(headers): 兼容非 ASCII header 值,避免 httpx ASCII 编码报错

将 HeaderBuilder 中的 latin-1 透传方案替换为 ASCII 归一化策略:
- 对 x-codex-turn-metadata 做 JSON 重编码(ensure_ascii=true)保留语义
- 其他非 ASCII 头值仅转义非 ASCII 字符为 \uXXXX,保留 ASCII 字符原样
- 新增单测覆盖中文 header 与 codex turn metadata 场景

Close #180

Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-02-26 02:56:54 +08:00
parent c0b80c923a
commit b6ca16e084
2 changed files with 57 additions and 16 deletions

View File

@@ -12,6 +12,7 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Set as AbstractSet from collections.abc import Set as AbstractSet
from typing import Any from typing import Any
@@ -365,27 +366,48 @@ class HeaderBuilder:
return self return self
def build(self) -> dict[str, str]: def build(self) -> dict[str, str]:
"""构建最终的头部字典 """构建最终的头部字典"""
httpx 要求 header 值可被 latin-1 编码。对于包含非 latin-1 字符
(如中文)的值,先 UTF-8 编码再按 latin-1 解码,使 httpx 将原始
UTF-8 字节逐字节发送到上游 —— 与 Go net/http 的行为一致。
"""
result: dict[str, str] = {} result: dict[str, str] = {}
for original_key, value in self._headers.values(): for original_key, value in self._headers.values():
try: result[original_key] = _normalize_header_value_for_httpx(original_key, value)
value.encode("latin-1")
except (UnicodeEncodeError, UnicodeDecodeError):
# 将 UTF-8 字节逐字节映射为 latin-1 字符串httpx 会原样发送
logger.debug(
"Header '{}' contains non-latin-1 chars, encoding as raw UTF-8 bytes",
original_key,
)
value = value.encode("utf-8").decode("latin-1")
result[original_key] = value
return result return result
def _normalize_header_value_for_httpx(key: str, value: str) -> str:
"""将 header 值归一化为 httpx/h11 可发送的 ASCII 字符串。
说明:
- 当前 httpx/h11 栈会对 str 类型 header 值执行 ASCII 编码。
- 若值包含非 ASCII 字符(如中文),会抛出 UnicodeEncodeError。
- 因此这里统一做 ASCII 归一化,确保请求可稳定发出。
"""
if value.isascii():
return value
key_lower = key.lower()
# Codex CLI 元数据是 JSON 字符串,优先重编码为 ASCII JSON语义最稳定。
if key_lower == "x-codex-turn-metadata":
try:
normalized = json.dumps(json.loads(value), ensure_ascii=True, separators=(",", ":"))
logger.debug(
"Header '{}' contains non-ASCII chars, normalized as ASCII JSON",
key,
)
return normalized
except Exception:
# 非法 JSON 时走通用兜底,避免阻断请求。
pass
# 兜底:仅将非 ASCII 字符替换为 \uXXXX保留 ASCII 字符原样
escaped = "".join(c if c.isascii() else f"\\u{ord(c):04x}" for c in value)
logger.warning(
"Header '{}' contains non-ASCII chars, escaped for httpx compatibility",
key,
)
return escaped
def build_upstream_headers_for_endpoint( def build_upstream_headers_for_endpoint(
original_headers: dict[str, str], original_headers: dict[str, str],
endpoint: str | EndpointSignature | tuple, endpoint: str | EndpointSignature | tuple,

View File

@@ -1,3 +1,5 @@
import json
from src.core.api_format import ( from src.core.api_format import (
CORE_REDACT_HEADERS, CORE_REDACT_HEADERS,
HeaderBuilder, HeaderBuilder,
@@ -73,6 +75,23 @@ class TestHeaderBuilder:
assert built["Authorization"] == "base" assert built["Authorization"] == "base"
assert built["X-Test"] == "1" assert built["X-Test"] == "1"
def test_non_ascii_value_is_escaped_for_httpx(self) -> None:
builder = HeaderBuilder()
builder.add("X-Test", "桌面")
built = builder.build()
assert built["X-Test"] == r"\u684c\u9762"
def test_codex_turn_metadata_is_normalized_to_ascii_json(self) -> None:
builder = HeaderBuilder()
raw = '{"turn_id":"t1","workspaces":{"d:\\\\桌面\\\\123\\\\Aether":{"has_changes":true}}}'
builder.add("x-codex-turn-metadata", raw)
built = builder.build()
normalized = built["x-codex-turn-metadata"]
assert normalized.isascii()
parsed = json.loads(normalized)
assert "d:\\桌面\\123\\Aether" in parsed["workspaces"]
class TestBuildUpstreamHeaders: class TestBuildUpstreamHeaders:
def test_priority_and_drop_headers(self) -> None: def test_priority_and_drop_headers(self) -> None: