fix(proxy): 新增响应头过滤函数,避免透传不兼容的上游头

添加 filter_proxy_response_headers 函数过滤 content-length、content-encoding、
transfer-encoding 等 hop-by-hop 和 body-dependent 头,防止客户端解码失败。
统一在 chat_handler_base、cli_handler_base、stream_telemetry 和 usage recorder
中使用该函数处理响应头透传。
This commit is contained in:
fawney19
2026-01-14 14:50:33 +08:00
parent 68737b9ed6
commit 5e4099a588
6 changed files with 81 additions and 11 deletions

View File

@@ -35,7 +35,7 @@ from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_processor import StreamProcessor from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
from src.api.handlers.base.utils import build_sse_headers from src.api.handlers.base.utils import build_sse_headers, filter_proxy_response_headers
from src.config.settings import config from src.config.settings import config
from src.core.error_utils import extract_client_error_message from src.core.error_utils import extract_client_error_message
from src.core.exceptions import ( from src.core.exceptions import (
@@ -387,7 +387,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 透传提供商的响应头给客户端 # 透传提供商的响应头给客户端
# 同时添加必要的 SSE 头以确保流式传输正常工作 # 同时添加必要的 SSE 头以确保流式传输正常工作
client_headers = dict(ctx.response_headers) if ctx.response_headers else {} client_headers = filter_proxy_response_headers(ctx.response_headers)
# 添加/覆盖 SSE 必需的头 # 添加/覆盖 SSE 必需的头
client_headers.update(build_sse_headers()) client_headers.update(build_sse_headers())
client_headers["content-type"] = "text/event-stream" client_headers["content-type"] = "text/event-stream"
@@ -849,7 +849,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 非流式成功时,返回给客户端的是提供商响应头(透传) # 非流式成功时,返回给客户端的是提供商响应头(透传)
# JSONResponse 会自动设置 content-type但我们记录实际返回的完整头 # JSONResponse 会自动设置 content-type但我们记录实际返回的完整头
client_response_headers = dict(response_headers) if response_headers else {} client_response_headers = filter_proxy_response_headers(response_headers)
client_response_headers["content-type"] = "application/json" client_response_headers["content-type"] = "application/json"
total_cost = await self.telemetry.record_success( total_cost = await self.telemetry.record_success(
@@ -888,7 +888,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
return JSONResponse( return JSONResponse(
status_code=status_code, status_code=status_code,
content=response_json, content=response_json,
headers=response_headers if response_headers else None, headers=client_response_headers,
) )
except Exception as e: except Exception as e:

View File

@@ -44,6 +44,7 @@ from src.api.handlers.base.utils import (
build_sse_headers, build_sse_headers,
check_html_response, check_html_response,
check_prefetched_response_error, check_prefetched_response_error,
filter_proxy_response_headers,
) )
from src.config.constants import StreamDefaults from src.config.constants import StreamDefaults
from src.config.settings import config from src.config.settings import config
@@ -381,7 +382,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 透传提供商的响应头给客户端 # 透传提供商的响应头给客户端
# 同时添加必要的 SSE 头以确保流式传输正常工作 # 同时添加必要的 SSE 头以确保流式传输正常工作
client_headers = dict(ctx.response_headers) if ctx.response_headers else {} client_headers = filter_proxy_response_headers(ctx.response_headers)
# 添加/覆盖 SSE 必需的头 # 添加/覆盖 SSE 必需的头
client_headers.update(build_sse_headers()) client_headers.update(build_sse_headers())
client_headers["content-type"] = "text/event-stream" client_headers["content-type"] = "text/event-stream"
@@ -1379,7 +1380,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._finalize_stream_metadata(ctx) self._finalize_stream_metadata(ctx)
# 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头 # 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头
client_response_headers = dict(ctx.response_headers) if ctx.response_headers else {} client_response_headers = filter_proxy_response_headers(ctx.response_headers)
client_response_headers.update({ client_response_headers.update({
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
@@ -1769,7 +1770,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
actual_request_body = provider_request_body or original_request_body actual_request_body = provider_request_body or original_request_body
# 非流式成功时,返回给客户端的是提供商响应头(透传) # 非流式成功时,返回给客户端的是提供商响应头(透传)
client_response_headers = dict(response_headers) if response_headers else {} client_response_headers = filter_proxy_response_headers(response_headers)
client_response_headers["content-type"] = "application/json" client_response_headers["content-type"] = "application/json"
total_cost = await self.telemetry.record_success( total_cost = await self.telemetry.record_success(
@@ -1805,7 +1806,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
return JSONResponse( return JSONResponse(
status_code=status_code, status_code=status_code,
content=response_json, content=response_json,
headers=response_headers if response_headers else None, headers=client_response_headers,
) )
except Exception as e: except Exception as e:

View File

@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from src.api.handlers.base.base_handler import MessageTelemetry from src.api.handlers.base.base_handler import MessageTelemetry
from src.api.handlers.base.stream_context import StreamContext from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.utils import filter_proxy_response_headers
from src.config.settings import config from src.config.settings import config
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
@@ -155,7 +156,7 @@ class StreamTelemetryRecorder:
) -> None: ) -> None:
"""记录成功的请求""" """记录成功的请求"""
# 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头 # 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头
client_response_headers = dict(ctx.response_headers) if ctx.response_headers else {} client_response_headers = filter_proxy_response_headers(ctx.response_headers)
client_response_headers.update({ client_response_headers.update({
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",

View File

@@ -94,6 +94,40 @@ def build_sse_headers(extra_headers: Optional[Dict[str, str]] = None) -> Dict[st
return headers return headers
_PROXY_RESPONSE_HEADER_BLOCKLIST = frozenset(
{
# Body-dependent headers: 我们会重编码响应体JSONResponse / SSE不能透传上游值
"content-length",
"content-encoding",
"transfer-encoding",
"content-type",
# Hop-by-hop headers (RFC 7230)
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
}
)
def filter_proxy_response_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
"""
过滤上游响应头中不应透传给客户端的字段。
主要用于“解析/转换后再返回”的场景:
- 非流式:我们会 `resp.json()` 后再由 `JSONResponse` 重新序列化
- 流式:我们会解析/重组 SSE 行再输出
如果透传上游的 `content-length/content-encoding/...`,会导致客户端解码失败或等待更多字节。
"""
if not headers:
return {}
return {k: v for k, v in headers.items() if k.lower() not in _PROXY_RESPONSE_HEADER_BLOCKLIST}
def check_html_response(line: str) -> bool: def check_html_response(line: str) -> bool:
""" """
检查行是否为 HTML 响应base_url 配置错误的常见症状) 检查行是否为 HTML 响应base_url 配置错误的常见症状)

View File

@@ -26,6 +26,7 @@ from typing import Any, Dict, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.handlers.base.utils import filter_proxy_response_headers
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ApiKey, User from src.models.database import ApiKey, User
from src.services.request.result import RequestResult from src.services.request.result import RequestResult
@@ -94,7 +95,7 @@ class UsageRecorder:
target_model = metadata.model target_model = metadata.model
# 非流式成功时,返回给客户端的是提供商响应头(透传)+ content-type # 非流式成功时,返回给客户端的是提供商响应头(透传)+ content-type
client_response_headers = dict(metadata.provider_response_headers) if metadata.provider_response_headers else {} client_response_headers = filter_proxy_response_headers(metadata.provider_response_headers)
client_response_headers["content-type"] = "application/json" client_response_headers["content-type"] = "application/json"
await UsageService.record_usage( await UsageService.record_usage(

View File

@@ -2,7 +2,11 @@
import pytest import pytest
from src.api.handlers.base.utils import build_sse_headers, extract_cache_creation_tokens from src.api.handlers.base.utils import (
build_sse_headers,
extract_cache_creation_tokens,
filter_proxy_response_headers,
)
class TestExtractCacheCreationTokens: class TestExtractCacheCreationTokens:
@@ -102,3 +106,32 @@ class TestBuildSSEHeaders:
headers = build_sse_headers({"X-Test": "1", "Cache-Control": "custom"}) headers = build_sse_headers({"X-Test": "1", "Cache-Control": "custom"})
assert headers["X-Test"] == "1" assert headers["X-Test"] == "1"
assert headers["Cache-Control"] == "custom" assert headers["Cache-Control"] == "custom"
class TestFilterProxyResponseHeaders:
def test_none_returns_empty(self) -> None:
assert filter_proxy_response_headers(None) == {}
def test_filters_blocklisted_headers_case_insensitive(self) -> None:
headers = {
"Content-Length": "123",
"content-encoding": "gzip",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Keep-Alive": "timeout=5",
"Content-Type": "application/json",
"X-Request-Id": "abc",
"Anthropic-RateLimit-Requests-Remaining": "10",
}
result = filter_proxy_response_headers(headers)
assert "Content-Length" not in result
assert "content-encoding" not in result
assert "Transfer-Encoding" not in result
assert "Connection" not in result
assert "Keep-Alive" not in result
assert "Content-Type" not in result
assert result["X-Request-Id"] == "abc"
assert result["Anthropic-RateLimit-Requests-Remaining"] == "10"