refactor: 统一请求头处理逻辑到 headers.py

- 新增 src/core/headers.py 集中管理头部处理函数
- 扩展 api_format_metadata.py 添加 extra_headers 和 protected_keys 配置
- 将各 adapter 中重复的头部方法提取到基类,统一调用 headers.py
- 移除 transport.py 中未使用的 build_provider_headers 函数
- 添加 headers 模块的单元测试
This commit is contained in:
fawney19
2026-01-15 22:25:56 +08:00
parent 80887dd2cc
commit ea45918561
17 changed files with 811 additions and 353 deletions

View File

@@ -22,12 +22,13 @@ from abc import abstractmethod
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import HTTPException
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.enums import APIFormat
from src.core.exceptions import (
InvalidRequestException,
ModelNotSupportedException,
@@ -39,6 +40,12 @@ from src.core.exceptions import (
QuotaExceededException,
UpstreamClientException,
)
from src.core.headers import (
build_adapter_base_headers,
build_adapter_headers,
extract_client_api_key,
get_adapter_protected_keys,
)
from src.core.logger import logger
from src.services.billing import calculate_request_cost as _calculate_request_cost
from src.services.request.result import RequestResult
@@ -67,6 +74,14 @@ class ChatAdapterBase(ApiAdapter):
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini"
BILLING_TEMPLATE: str = "claude"
@classmethod
def _get_api_format(cls) -> APIFormat:
"""获取 API 格式枚举,用于调用 headers.py 的统一函数"""
try:
return APIFormat[cls.FORMAT_ID]
except KeyError:
return APIFormat.OPENAI # 默认回退
# 子类可以配置的特殊方法用于check_endpoint
@classmethod
def build_endpoint_url(cls, base_url: str) -> str:
@@ -76,18 +91,20 @@ class ChatAdapterBase(ApiAdapter):
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建基础请求头,子类可以覆盖以自定义认证头"""
# 默认实现Bearer token认证
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
"""构建基础请求头,使用统一的 headers.py 实现"""
return build_adapter_base_headers(cls._get_api_format(), api_key)
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回不应被extra_headers覆盖的头部key子类可以覆盖"""
# 默认保护认证相关头部
return ("authorization", "content-type")
"""返回不应被extra_headers覆盖的头部key使用统一的 headers.py 实现"""
return get_adapter_protected_keys(cls._get_api_format())
@classmethod
def build_headers_with_extra(
cls, api_key: str, extra_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""构建完整请求头(包含 extra_headers使用统一的 headers.py 实现"""
return build_adapter_headers(cls._get_api_format(), api_key, extra_headers)
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
@@ -95,6 +112,10 @@ class ChatAdapterBase(ApiAdapter):
# 默认实现:直接使用请求数据
return request_data.copy()
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥,使用统一的 headers.py 实现"""
return extract_client_api_key(dict(request.headers), self._get_api_format())
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
@@ -626,13 +647,11 @@ class ChatAdapterBase(ApiAdapter):
Returns:
测试响应数据
"""
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
from src.api.handlers.base.endpoint_checker import run_endpoint_check
# 使用子类配置方法构建请求组件
url = cls.build_endpoint_url(base_url)
base_headers = cls.build_base_headers(api_key)
protected_keys = cls.get_protected_header_keys()
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
headers = cls.build_headers_with_extra(api_key, extra_headers)
body = cls.build_request_body(request_data)
# 使用通用的endpoint checker执行请求

View File

@@ -20,12 +20,13 @@ import traceback
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import HTTPException
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.core.enums import APIFormat
from src.core.exceptions import (
InvalidRequestException,
ModelNotSupportedException,
@@ -37,6 +38,12 @@ from src.core.exceptions import (
QuotaExceededException,
UpstreamClientException,
)
from src.core.headers import (
build_adapter_base_headers,
build_adapter_headers,
extract_client_api_key,
get_adapter_protected_keys,
)
from src.core.logger import logger
from src.services.billing import calculate_request_cost as _calculate_request_cost
from src.services.request.result import RequestResult
@@ -68,6 +75,55 @@ class CliAdapterBase(ApiAdapter):
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
# =========================================================================
# API 格式与头部处理 - 使用统一的 headers.py 函数
# =========================================================================
@classmethod
def _get_api_format(cls) -> APIFormat:
"""将 FORMAT_ID 转换为 APIFormat 枚举"""
try:
return APIFormat[cls.FORMAT_ID]
except KeyError:
return APIFormat.OPENAI
def extract_api_key(self, request: Request) -> Optional[str]:
"""
从请求中提取 API 密钥
使用统一的头部处理函数,根据 API 格式自动识别认证头。
"""
return extract_client_api_key(dict(request.headers), self._get_api_format())
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""
构建 CLI API 认证头
使用统一的头部处理函数。
"""
return build_adapter_base_headers(cls._get_api_format(), api_key)
@classmethod
def build_headers_with_extra(
cls, api_key: str, extra_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""
构建带额外头部的完整请求头
使用统一的头部处理函数,自动保护关键头部不被覆盖。
"""
return build_adapter_headers(cls._get_api_format(), api_key, extra_headers)
@classmethod
def get_protected_header_keys(cls) -> tuple[str, ...]:
"""
返回 CLI API 的保护头部 key
使用统一的头部处理函数。
"""
return get_adapter_protected_keys(cls._get_api_format())
async def handle(self, context: ApiRequestContext):
"""处理 CLI API 请求"""
http_request = context.request
@@ -575,20 +631,19 @@ class CliAdapterBase(ApiAdapter):
Returns:
测试响应数据
"""
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
from src.api.handlers.base.endpoint_checker import run_endpoint_check
# 构建请求组件
url = cls.build_endpoint_url(base_url, request_data, model_name)
base_headers = cls.build_base_headers(api_key)
protected_keys = cls.get_protected_header_keys()
# 添加CLI User-Agent
# 添加 CLI User-Agent 到 extra_headers
cli_user_agent = cls.get_cli_user_agent()
merged_extra = dict(extra_headers) if extra_headers else {}
if cli_user_agent:
base_headers["User-Agent"] = cli_user_agent
protected_keys = tuple(list(protected_keys) + ["user-agent"])
merged_extra["User-Agent"] = cli_user_agent
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
# 使用统一的头部构建函数
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
body = cls.build_request_body(request_data)
# 获取有效的模型名称
@@ -610,7 +665,7 @@ class CliAdapterBase(ApiAdapter):
)
# =========================================================================
# CLI Adapter 配置方法 - 子类应覆盖这些方法而不是整个 check_endpoint
# CLI Adapter 配置方法 - 子类应覆盖这些方法
# =========================================================================
@classmethod
@@ -628,29 +683,6 @@ class CliAdapterBase(ApiAdapter):
"""
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""
构建CLI API认证头 - 子类应覆盖
Args:
api_key: API密钥
Returns:
基础认证头部字典
"""
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_base_headers")
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""
返回CLI API的保护头部key - 子类应覆盖
Returns:
保护头部key的元组
"""
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement get_protected_header_keys")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""

View File

@@ -27,23 +27,11 @@ from collections import defaultdict
import httpx
from src.core.logger import logger
_SENSITIVE_HEADER_KEYS = {
"authorization",
"x-api-key",
"x-goog-api-key",
}
from src.core.headers import CORE_REDACT_HEADERS, merge_headers_with_protection, redact_headers_for_log
def _redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
redacted: Dict[str, str] = {}
for key, value in headers.items():
if key.lower() in _SENSITIVE_HEADER_KEYS:
redacted[key] = "***"
else:
redacted[key] = value
return redacted
return redact_headers_for_log(headers, CORE_REDACT_HEADERS)
def _truncate_repr(value: Any, limit: int = 1200) -> str:
@@ -64,14 +52,7 @@ def build_safe_headers(
"""
合并 extra_headers但防止覆盖 protected_keys大小写不敏感
"""
headers = dict(base_headers)
if not extra_headers:
return headers
protected = {k.lower() for k in protected_keys}
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() not in protected}
headers.update(safe_headers)
return headers
return merge_headers_with_protection(base_headers, extra_headers, set(protected_keys))
async def run_endpoint_check(

View File

@@ -17,27 +17,14 @@ from abc import ABC, abstractmethod
from typing import Any, Dict, FrozenSet, Optional, Tuple
from src.core.crypto import crypto_service
from src.core.headers import HeaderBuilder, UPSTREAM_DROP_HEADERS
# ==============================================================================
# 统一的头部配置常量
# ==============================================================================
# 敏感头部 - 透传时需要清理(黑名单)
# 这些头部要么包含认证信息,要么由代理层重新生成
SENSITIVE_HEADERS: FrozenSet[str] = frozenset(
{
"authorization",
"x-api-key",
"x-goog-api-key", # Gemini API 认证头
"host",
"content-length",
"transfer-encoding",
"connection",
# 不透传 accept-encoding让 httpx 自己协商压缩格式
# 避免客户端请求 brotli/zstd 但 httpx 不支持解压的问题
"accept-encoding",
}
)
# 兼容别名:历史代码使用 SENSITIVE_HEADERS 命名
SENSITIVE_HEADERS: FrozenSet[str] = UPSTREAM_DROP_HEADERS
# ==============================================================================
@@ -140,8 +127,6 @@ class PassthroughRequestBuilder(RequestBuilder):
"""
from src.core.api_format_metadata import get_auth_config, resolve_api_format
headers: Dict[str, str] = {}
# 1. 根据 API 格式自动设置认证头
decrypted_key = crypto_service.decrypt(key.api_key)
api_format = getattr(endpoint, "api_format", None)
@@ -150,32 +135,32 @@ class PassthroughRequestBuilder(RequestBuilder):
get_auth_config(resolved_format) if resolved_format else ("Authorization", "bearer")
)
if auth_type == "bearer":
headers[auth_header] = f"Bearer {decrypted_key}"
else:
headers[auth_header] = decrypted_key
auth_value = f"Bearer {decrypted_key}" if auth_type == "bearer" else decrypted_key
protected_keys = {auth_header.lower(), "content-type"}
# 2. 添加 endpoint 配置的额外头部
if endpoint.headers:
headers.update(endpoint.headers)
builder = HeaderBuilder()
# 3. 透传原始头部(排除敏感头部 - 黑名单模式)
# 2. 透传原始头部(排除敏感头部 - 黑名单模式)
if original_headers:
for name, value in original_headers.items():
lower_name = name.lower()
# 跳过敏感头部
if lower_name in SENSITIVE_HEADERS:
if name.lower() in SENSITIVE_HEADERS:
continue
builder.add(name, value)
headers[name] = value
# 3. 添加 endpoint 配置的额外头部(不能覆盖认证头/Content-Type
if endpoint.headers:
builder.add_protected(endpoint.headers, protected_keys)
# 4. 添加额外头部
if extra_headers:
headers.update(extra_headers)
builder.add_many(extra_headers)
# 5. 确保有 Content-Type
if "Content-Type" not in headers and "content-type" not in headers:
# 5. 设置认证头(最高优先级)
builder.add(auth_header, auth_value)
# 6. 确保有 Content-Type
headers = builder.build()
if not any(k.lower() == "content-type" for k in headers):
headers["Content-Type"] = "application/json"
return headers

View File

@@ -6,6 +6,7 @@ import json
from typing import Any, Dict, Optional
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
from src.core.headers import filter_response_headers
from src.core.logger import logger
@@ -94,25 +95,6 @@ def build_sse_headers(extra_headers: Optional[Dict[str, str]] = None) -> Dict[st
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]:
"""
过滤上游响应头中不应透传给客户端的字段。
@@ -123,9 +105,7 @@ def filter_proxy_response_headers(headers: Optional[Dict[str, str]]) -> Dict[str
如果透传上游的 `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}
return filter_response_headers(headers)
def check_html_response(line: str) -> bool:

View File

@@ -14,6 +14,7 @@ from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.headers import get_header_value
from src.core.logger import logger
from src.core.optimization_utils import TokenCounter
from src.models.claude import ClaudeMessagesRequest, ClaudeTokenCountRequest
@@ -39,17 +40,10 @@ class ClaudeCapabilityDetector:
"""
requirements: Dict[str, bool] = {}
# 检查 anthropic-beta 请求头(大小写不敏感
beta_header = None
for key, value in headers.items():
if key.lower() == "anthropic-beta":
beta_header = value
break
if beta_header:
# 检查是否包含 context-1m 标识
if "context-1m" in beta_header.lower():
requirements["context_1m"] = True
# 使用统一的大小写不敏感获取
beta_header = get_header_value(headers, "anthropic-beta")
if beta_header and "context-1m" in beta_header.lower():
requirements["context_1m"] = True
return requirements
@@ -77,10 +71,6 @@ class ClaudeChatAdapter(ChatAdapterBase):
super().__init__(allowed_api_formats or ["CLAUDE"])
logger.info(f"[{self.name}] 初始化Chat模式适配器 | API格式: {self.allowed_api_formats}")
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (x-api-key)"""
return request.headers.get("x-api-key")
def detect_capability_requirements(
self,
headers: Dict[str, str],
@@ -166,18 +156,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
extra_headers: Optional[Dict[str, str]] = None,
) -> Tuple[list, Optional[str]]:
"""查询 Claude API 支持的模型列表"""
headers = {
"x-api-key": api_key,
"Authorization": f"Bearer {api_key}",
"anthropic-version": "2023-06-01",
}
if extra_headers:
# 防止 extra_headers 覆盖认证头
safe_headers = {
k: v for k, v in extra_headers.items()
if k.lower() not in ("x-api-key", "authorization", "anthropic-version")
}
headers.update(safe_headers)
headers = cls.build_headers_with_extra(api_key, extra_headers)
# 构建 /v1/models URL
base_url = base_url.rstrip("/")
@@ -219,20 +198,6 @@ class ClaudeChatAdapter(ChatAdapterBase):
else:
return f"{base_url}/v1/messages"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建Claude API认证头"""
return {
"x-api-key": api_key,
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回Claude API的保护头部key"""
return ("x-api-key", "content-type", "anthropic-version")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Claude API请求体"""

View File

@@ -4,10 +4,9 @@ Claude CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
继承 CliAdapterBase只需配置 FORMAT_ID 和 HANDLER_CLASS。
"""
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import Request
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
@@ -37,13 +36,6 @@ class ClaudeCliAdapter(CliAdapterBase):
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
super().__init__(allowed_api_formats or ["CLAUDE_CLI"])
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (Authorization: Bearer)"""
authorization = request.headers.get("authorization")
if authorization and authorization.startswith("Bearer "):
return authorization.replace("Bearer ", "")
return None
def detect_capability_requirements(
self,
headers: Dict[str, str],
@@ -136,19 +128,6 @@ class ClaudeCliAdapter(CliAdapterBase):
else:
return f"{base_url}/v1/messages"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建Claude CLI API认证头"""
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回Claude CLI API的保护头部key"""
return ("authorization", "content-type")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Claude CLI API请求体"""

View File

@@ -4,15 +4,14 @@ Gemini Chat Adapter
处理 Gemini API 格式的请求适配
"""
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import HTTPException, Request
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
from src.core.logger import logger
from src.models.gemini import GeminiRequest
@@ -41,10 +40,6 @@ class GeminiChatAdapter(ChatAdapterBase):
super().__init__(allowed_api_formats or ["GEMINI"])
logger.info(f"[{self.name}] 初始化 Gemini Chat 适配器 | API格式: {self.allowed_api_formats}")
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (x-goog-api-key)"""
return request.headers.get("x-goog-api-key")
def _merge_path_params(
self, original_request_body: Dict[str, Any], path_params: Dict[str, Any] # noqa: ARG002
) -> Dict[str, Any]:
@@ -163,7 +158,7 @@ class GeminiChatAdapter(ChatAdapterBase):
extra_headers: Optional[Dict[str, str]] = None,
) -> Tuple[list, Optional[str]]:
"""查询 Gemini API 支持的模型列表"""
# 兼容 base_url 已包含 /v1beta 的情况
# Gemini 使用 URL 参数传递 key不需要 headers 中的认证
base_url_clean = base_url.rstrip("/")
if base_url_clean.endswith("/v1beta"):
models_url = f"{base_url_clean}/models?key={api_key}"
@@ -210,19 +205,6 @@ class GeminiChatAdapter(ChatAdapterBase):
else:
return f"{base_url}/v1beta"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建Gemini API认证头"""
return {
"x-goog-api-key": api_key,
"Content-Type": "application/json",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回Gemini API的保护头部key"""
return ("x-goog-api-key", "content-type")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Gemini API请求体"""
@@ -263,13 +245,11 @@ class GeminiChatAdapter(ChatAdapterBase):
}
# 使用基类配置方法但重写URL构建逻辑
base_url = cls.build_endpoint_url(base_url)
url = f"{base_url}/models/{effective_model_name}:generateContent"
base_url_resolved = cls.build_endpoint_url(base_url)
url = f"{base_url_resolved}/models/{effective_model_name}:generateContent"
# 构建请求组件
base_headers = cls.build_base_headers(api_key)
protected_keys = cls.get_protected_header_keys()
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
headers = cls.build_headers_with_extra(api_key, extra_headers)
body = cls.build_request_body(request_data)
# 使用基类的通用endpoint checker
@@ -290,7 +270,7 @@ class GeminiChatAdapter(ChatAdapterBase):
)
def build_gemini_adapter(x_app_header: str = "") -> GeminiChatAdapter:
def build_gemini_adapter(x_app_header: str = "") -> GeminiChatAdapter: # noqa: ARG001
"""
根据请求头构建适当的 Gemini 适配器

View File

@@ -4,10 +4,9 @@ Gemini CLI Adapter - 基于通用 CLI Adapter 基类的实现
继承 CliAdapterBase处理 Gemini CLI 格式的请求。
"""
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import Request
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
@@ -37,10 +36,6 @@ class GeminiCliAdapter(CliAdapterBase):
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
super().__init__(allowed_api_formats or ["GEMINI_CLI"])
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (x-goog-api-key)"""
return request.headers.get("x-goog-api-key")
def _merge_path_params(
self, original_request_body: Dict[str, Any], path_params: Dict[str, Any] # noqa: ARG002
) -> Dict[str, Any]:
@@ -138,19 +133,6 @@ class GeminiCliAdapter(CliAdapterBase):
prefix = f"{base_url}/v1beta"
return f"{prefix}/models/{effective_model_name}:generateContent"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建Gemini CLI API认证头"""
return {
"x-goog-api-key": api_key,
"Content-Type": "application/json",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回Gemini CLI API的保护头部key"""
return ("x-goog-api-key", "content-type")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Gemini CLI API请求体"""

View File

@@ -4,14 +4,12 @@ OpenAI Chat Adapter - 基于 ChatAdapterBase 的 OpenAI Chat API 适配器
处理 /v1/chat/completions 端点的 OpenAI Chat 格式请求。
"""
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.logger import logger
from src.models.openai import OpenAIRequest
@@ -39,13 +37,6 @@ class OpenAIChatAdapter(ChatAdapterBase):
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
super().__init__(allowed_api_formats or ["OPENAI"])
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (Authorization: Bearer)"""
authorization = request.headers.get("authorization")
if authorization and authorization.startswith("Bearer "):
return authorization.replace("Bearer ", "")
return None
def _validate_request_body(self, original_request_body: dict, path_params: dict = None):
"""验证请求体"""
if not isinstance(original_request_body, dict):
@@ -117,13 +108,7 @@ class OpenAIChatAdapter(ChatAdapterBase):
extra_headers: Optional[Dict[str, str]] = None,
) -> Tuple[list, Optional[str]]:
"""查询 OpenAI 兼容 API 支持的模型列表"""
headers = {
"Authorization": f"Bearer {api_key}",
}
if extra_headers:
# 防止 extra_headers 覆盖 Authorization
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
headers.update(safe_headers)
headers = cls.build_headers_with_extra(api_key, extra_headers)
# 构建 /v1/models URL
base_url = base_url.rstrip("/")
@@ -165,23 +150,5 @@ class OpenAIChatAdapter(ChatAdapterBase):
else:
return f"{base_url}/v1/chat/completions"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建OpenAI API认证头"""
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回OpenAI API的保护头部key"""
return ("authorization", "content-type")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建OpenAI API请求体"""
return request_data.copy()
__all__ = ["OpenAIChatAdapter"]

View File

@@ -4,10 +4,9 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
继承 CliAdapterBase只需配置 FORMAT_ID 和 HANDLER_CLASS。
"""
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, Optional, Tuple, Type
import httpx
from fastapi import Request
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
@@ -37,13 +36,6 @@ class OpenAICliAdapter(CliAdapterBase):
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
super().__init__(allowed_api_formats or ["OPENAI_CLI"])
def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥 (Authorization: Bearer)"""
authorization = request.headers.get("authorization")
if authorization and authorization.startswith("Bearer "):
return authorization.replace("Bearer ", "")
return None
# =========================================================================
# 模型列表查询
# =========================================================================
@@ -78,19 +70,6 @@ class OpenAICliAdapter(CliAdapterBase):
else:
return f"{base_url}/v1/chat/completions"
@classmethod
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
"""构建OpenAI CLI API认证头"""
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回OpenAI CLI API的保护头部key"""
return ("authorization", "content-type")
@classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建OpenAI CLI API请求体"""

View File

@@ -36,6 +36,8 @@ class ApiFormatDefinition:
- path_prefix: 本站路径前缀(如 /claude, /openai为空表示无前缀
- auth_header: 认证头名称 (如 "x-api-key", "x-goog-api-key")
- auth_type: 认证类型 ("header" 直接放值, "bearer" 加 Bearer 前缀)
- extra_headers: 该格式必须携带的额外头部(如 anthropic-version
- protected_keys: 不应被 extra_headers 覆盖的头部(小写)
"""
api_format: APIFormat
@@ -44,6 +46,8 @@ class ApiFormatDefinition:
path_prefix: str = "" # 本站路径前缀,为空表示无前缀
auth_header: str = "Authorization"
auth_type: str = "bearer" # "bearer" or "header"
extra_headers: Mapping[str, str] = field(default_factory=dict) # 格式必须的额外头部
protected_keys: frozenset[str] = field(default_factory=frozenset) # 受保护的头部 key小写
def iter_aliases(self) -> Iterable[str]:
"""返回大小写统一后的别名集合,包含枚举名本身。"""
@@ -62,14 +66,17 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
path_prefix="", # 通过请求头区分格式,不使用路径前缀
auth_header="x-api-key",
auth_type="header",
extra_headers={"anthropic-version": "2023-06-01"},
protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}),
),
APIFormat.CLAUDE_CLI: ApiFormatDefinition(
api_format=APIFormat.CLAUDE_CLI,
aliases=("claude_cli", "claude-cli"),
default_path="/v1/messages",
path_prefix="", # 与 CLAUDE 共享入口,通过 header 区分
auth_header="authorization",
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
),
APIFormat.OPENAI: ApiFormatDefinition(
api_format=APIFormat.OPENAI,
@@ -88,6 +95,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
path_prefix="", # 默认格式
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
),
APIFormat.OPENAI_CLI: ApiFormatDefinition(
api_format=APIFormat.OPENAI_CLI,
@@ -96,6 +104,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
path_prefix="", # 与 OPENAI 共享入口
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
),
APIFormat.GEMINI: ApiFormatDefinition(
api_format=APIFormat.GEMINI,
@@ -104,6 +113,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
path_prefix="", # 通过请求头区分格式
auth_header="x-goog-api-key",
auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
),
APIFormat.GEMINI_CLI: ApiFormatDefinition(
api_format=APIFormat.GEMINI_CLI,
@@ -112,6 +122,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
path_prefix="", # 与 GEMINI 共享入口
auth_header="x-goog-api-key",
auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
),
}
@@ -180,6 +191,36 @@ def get_auth_config(api_format: APIFormat) -> tuple[str, str]:
return "Authorization", "bearer"
def get_extra_headers(api_format: APIFormat) -> Mapping[str, str]:
"""
获取该格式必须携带的额外头部。
例如 Claude 需要 anthropic-version 头部。
Returns:
额外头部字典(只读)
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition:
return definition.extra_headers
return {}
def get_protected_keys(api_format: APIFormat) -> frozenset[str]:
"""
获取该格式的受保护头部 key小写
这些头部不应被 extra_headers 覆盖。
Returns:
受保护的头部 key 集合
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition:
return definition.protected_keys
return frozenset({"authorization", "content-type"})
@lru_cache(maxsize=1)
def _alias_lookup_cache() -> Dict[str, APIFormat]:
"""缓存 alias -> APIFormat 查找表,减少重复构建。"""

473
src/core/headers.py Normal file
View File

@@ -0,0 +1,473 @@
"""
统一的请求头处理模块
职责:
1. 请求头规范化(大小写统一)
2. 客户端 API Key 提取
3. 能力需求检测
4. 上游请求头构建
5. 响应头过滤
6. 日志脱敏
"""
from __future__ import annotations
from typing import AbstractSet, Any, Dict, FrozenSet, Optional, Set
from .api_format_metadata import get_auth_config, get_extra_headers, get_protected_keys
from .enums import APIFormat
# =============================================================================
# 头部常量定义
# =============================================================================
# 转发给上游时需要剔除的头部(系统管理 + 认证替换)
UPSTREAM_DROP_HEADERS: FrozenSet[str] = frozenset(
{
# 认证头 - 会被替换为 Provider 的认证
"authorization",
"x-api-key",
"x-goog-api-key",
# 系统管理头 - 由 HTTP 客户端重新生成
"host",
"content-length",
"transfer-encoding",
"connection",
# 编码头 - 避免客户端请求 brotli/zstd 但 httpx 不支持
"accept-encoding",
}
)
# 最小必脱敏集合(编译时常量,用于快速路径)
# 完整脱敏应使用 SystemConfigService.get_sensitive_headers()
CORE_REDACT_HEADERS: FrozenSet[str] = frozenset(
{
"authorization",
"x-api-key",
"x-goog-api-key",
}
)
# Hop-by-hop 头部 (RFC 7230)
HOP_BY_HOP_HEADERS: FrozenSet[str] = frozenset(
{
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
)
# 响应时需要过滤的头部body-dependent + hop-by-hop
RESPONSE_DROP_HEADERS: FrozenSet[str] = (
frozenset(
{
"content-length",
"content-encoding",
"transfer-encoding",
"content-type",
}
)
| HOP_BY_HOP_HEADERS
)
# =============================================================================
# 请求头规范化
# =============================================================================
def normalize_headers(headers: Dict[str, str]) -> Dict[str, str]:
"""
将请求头 key 统一为小写
用于处理 context.original_headers 的大小写敏感问题。
"""
return {k.lower(): v for k, v in headers.items()}
def get_header_value(headers: Dict[str, str], key: str, default: str = "") -> str:
"""
大小写不敏感地获取请求头值
Args:
headers: 原始请求头(可能大小写不一致)
key: 要获取的 key任意大小写
default: 未找到时的默认值
Returns:
头部值,未找到返回 default
"""
key_lower = key.lower()
for k, v in headers.items():
if k.lower() == key_lower:
return v
return default
# =============================================================================
# 客户端 API Key 提取
# =============================================================================
def extract_client_api_key(headers: Dict[str, str], api_format: APIFormat) -> Optional[str]:
"""
从客户端请求头提取 API Key
自动处理大小写,根据 API 格式使用正确的认证头和类型。
Args:
headers: 原始请求头(自动处理大小写)
api_format: API 格式
Returns:
提取的 API Key未找到返回 None
"""
auth_header, auth_type = get_auth_config(api_format)
value = get_header_value(headers, auth_header)
if not value:
return None
if auth_type == "bearer":
# Bearer token 格式: "Bearer <token>"
if value.lower().startswith("bearer "):
return value[7:] # 移除 "Bearer " 前缀
return None
# 直接 header 格式
return value
# =============================================================================
# 能力需求检测
# =============================================================================
def detect_capabilities(
headers: Dict[str, str],
api_format: APIFormat,
request_body: Optional[Dict[str, Any]] = None, # noqa: ARG001 - 预留给部分格式使用
) -> Dict[str, bool]:
"""
从请求头检测能力需求
当前支持:
- Claude/Claude CLI: anthropic-beta 头中的 context-1m
Args:
headers: 原始请求头(自动处理大小写)
api_format: API 格式
request_body: 请求体(部分格式可能需要)
Returns:
能力需求字典,如 {"context_1m": True}
"""
requirements: Dict[str, bool] = {}
if api_format in (APIFormat.CLAUDE, APIFormat.CLAUDE_CLI):
beta_header = get_header_value(headers, "anthropic-beta")
if "context-1m" in beta_header.lower():
requirements["context_1m"] = True
return requirements
# =============================================================================
# 上游请求头构建
# =============================================================================
class HeaderBuilder:
"""
请求头构建器
使用 lower-case key 索引确保唯一性和确定的优先级。
优先级(后者覆盖前者):原始头部 < endpoint 头部 < extra 头部 < 认证头
"""
def __init__(self) -> None:
# key: (original_case_key, value)
self._headers: Dict[str, tuple[str, str]] = {}
def add(self, key: str, value: str) -> "HeaderBuilder":
"""添加单个头部(会覆盖同名头部)"""
self._headers[key.lower()] = (key, value)
return self
def add_many(self, headers: Dict[str, str]) -> "HeaderBuilder":
"""批量添加头部"""
for k, v in headers.items():
self.add(k, v)
return self
def add_protected(self, headers: Dict[str, str], protected_keys: AbstractSet[str]) -> "HeaderBuilder":
"""
添加头部但保护指定的 key 不被覆盖
用于 endpoint.headers 不能覆盖认证头的场景。
"""
protected_lower = {k.lower() for k in protected_keys}
for k, v in headers.items():
if k.lower() not in protected_lower:
self.add(k, v)
return self
def remove(self, keys: FrozenSet[str]) -> "HeaderBuilder":
"""移除指定的头部"""
for k in keys:
self._headers.pop(k.lower(), None)
return self
def build(self) -> Dict[str, str]:
"""构建最终的头部字典"""
return {original_key: value for original_key, value in self._headers.values()}
def build_upstream_headers(
original_headers: Dict[str, str],
api_format: APIFormat,
provider_api_key: str,
*,
endpoint_headers: Optional[Dict[str, str]] = None,
extra_headers: Optional[Dict[str, str]] = None,
drop_headers: Optional[FrozenSet[str]] = None,
) -> Dict[str, str]:
"""
构建发送给上游 Provider 的请求头
优先级(后者覆盖前者):
1. 原始头部(排除 drop_headers
2. endpoint 配置头部
3. extra_headers
4. 认证头(最高优先级,始终设置)
Args:
original_headers: 客户端原始请求头
api_format: API 格式
provider_api_key: Provider 的 API Key已解密
endpoint_headers: Endpoint 配置的额外头部
extra_headers: 调用方传入的额外头部
drop_headers: 需要剔除的头部集合None 使用默认值,空集合表示不剔除)
Returns:
构建好的请求头字典
"""
# 使用 is None 判断,允许显式传空集合
if drop_headers is None:
drop_headers = UPSTREAM_DROP_HEADERS
auth_header, auth_type = get_auth_config(api_format)
auth_value = f"Bearer {provider_api_key}" if auth_type == "bearer" else provider_api_key
# 认证头是受保护的,不能被 endpoint_headers 覆盖
protected_keys = {auth_header.lower(), "content-type"}
builder = HeaderBuilder()
# 1. 添加原始头部(排除 drop_headers
for k, v in original_headers.items():
if k.lower() not in drop_headers:
builder.add(k, v)
# 2. 添加 endpoint 头部(保护认证头)
if endpoint_headers:
builder.add_protected(endpoint_headers, protected_keys)
# 3. 添加 extra_headers
if extra_headers:
builder.add_many(extra_headers)
# 4. 设置认证头(最高优先级)
builder.add(auth_header, auth_value)
# 5. 确保 Content-Type
result = builder.build()
if not any(k.lower() == "content-type" for k in result):
result["Content-Type"] = "application/json"
return result
def merge_headers_with_protection(
base_headers: Dict[str, str],
extra_headers: Optional[Dict[str, str]],
protected_keys: FrozenSet[str] | Set[str],
) -> Dict[str, str]:
"""
合并头部但保护指定的 key 不被覆盖
等价于原 build_safe_headers 的功能。
Args:
base_headers: 基础头部
extra_headers: 要合并的额外头部
protected_keys: 受保护的 key 集合
Returns:
合并后的头部
"""
if not extra_headers:
return dict(base_headers)
builder = HeaderBuilder()
builder.add_many(base_headers)
builder.add_protected(extra_headers, protected_keys)
return builder.build()
# =============================================================================
# 响应头过滤
# =============================================================================
def filter_response_headers(
headers: Optional[Dict[str, str]],
drop_headers: Optional[FrozenSet[str]] = None,
) -> Dict[str, str]:
"""
过滤上游响应头中不应透传给客户端的字段
Args:
headers: 上游响应头
drop_headers: 要剔除的头部集合None 使用默认值)
Returns:
过滤后的头部
"""
if not headers:
return {}
if drop_headers is None:
drop_headers = RESPONSE_DROP_HEADERS
return {k: v for k, v in headers.items() if k.lower() not in drop_headers}
# =============================================================================
# 日志脱敏
# =============================================================================
def redact_headers_for_log(
headers: Dict[str, str],
redact_keys: Optional[FrozenSet[str]] = None,
) -> Dict[str, str]:
"""
将敏感头部值替换为 *** 用于日志记录
Args:
headers: 原始头部
redact_keys: 要脱敏的 key 集合None 使用 CORE_REDACT_HEADERS
Returns:
脱敏后的头部
Note:
完整的脱敏应该使用 SystemConfigService.get_sensitive_headers()
来获取用户配置的敏感头列表。
"""
if redact_keys is None:
redact_keys = CORE_REDACT_HEADERS
return {k: "***" if k.lower() in redact_keys else v for k, v in headers.items()}
# =============================================================================
# 兼容层(向后兼容,逐步废弃)
# =============================================================================
# 兼容 request_builder.py 的 SENSITIVE_HEADERS
SENSITIVE_HEADERS = UPSTREAM_DROP_HEADERS
# =============================================================================
# Adapter 统一接口
# =============================================================================
def build_adapter_base_headers(
api_format: APIFormat,
api_key: str,
*,
include_extra: bool = True,
) -> Dict[str, str]:
"""
根据 API 格式构建基础请求头
包含:认证头 + Content-Type + 格式特定的额外头部(如 anthropic-version
Args:
api_format: API 格式
api_key: API Key已解密
include_extra: 是否包含格式特定的额外头部(默认 True
Returns:
基础请求头字典
"""
auth_header, auth_type = get_auth_config(api_format)
auth_value = f"Bearer {api_key}" if auth_type == "bearer" else api_key
headers: Dict[str, str] = {
auth_header: auth_value,
"Content-Type": "application/json",
}
if include_extra:
extra = get_extra_headers(api_format)
if extra:
headers.update(extra)
return headers
def build_adapter_headers(
api_format: APIFormat,
api_key: str,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
"""
构建完整的 Adapter 请求头
在基础头部上合并 extra_headers同时保护关键头部不被覆盖。
Args:
api_format: API 格式
api_key: API Key已解密
extra_headers: 调用方传入的额外头部
Returns:
完整的请求头字典
"""
base = build_adapter_base_headers(api_format, api_key)
if not extra_headers:
return base
protected = get_protected_keys(api_format)
return merge_headers_with_protection(base, extra_headers, protected)
def get_adapter_protected_keys(api_format: APIFormat) -> tuple[str, ...]:
"""
获取 Adapter 的受保护头部 key
用于 get_protected_header_keys() 方法返回值。
Args:
api_format: API 格式
Returns:
受保护的头部 key 元组
"""
return tuple(get_protected_keys(api_format))

View File

@@ -16,6 +16,7 @@ from src.core.key_capabilities import (
CapabilityConfigMode,
get_user_configurable_capabilities,
)
from src.core.headers import get_header_value
from src.core.logger import logger
# Adapter 检测器类型:接受 headers 和可选的 request_body返回能力需求字典
@@ -87,7 +88,7 @@ class CapabilityResolver:
# 3. 从请求头 X-Require-Capability 获取(显式声明)
if request_headers:
header_caps = request_headers.get("X-Require-Capability", "")
header_caps = get_header_value(request_headers, "X-Require-Capability")
if header_caps:
for cap in header_caps.split(","):
cap = cap.strip()

View File

@@ -6,11 +6,10 @@ Provider 服务模块
from src.services.provider.format import normalize_api_format
from src.services.provider.service import ProviderService
from src.services.provider.transport import build_provider_headers, build_provider_url
from src.services.provider.transport import build_provider_url
__all__ = [
"ProviderService",
"normalize_api_format",
"build_provider_headers",
"build_provider_url",
]

View File

@@ -2,75 +2,18 @@
统一的 Provider 请求构建工具。
负责:
- 根据 endpoint/key 构建标准请求头
- 根据 API 格式或端点配置生成请求 URL
"""
from typing import TYPE_CHECKING, Any, Dict, Optional
from urllib.parse import urlencode
from src.core.api_format_metadata import get_auth_config, get_default_path, resolve_api_format
from src.core.crypto import crypto_service
from src.core.api_format_metadata import get_default_path, resolve_api_format
from src.core.enums import APIFormat
from src.core.logger import logger
if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint
def build_provider_headers(
endpoint: "ProviderEndpoint",
key: "ProviderAPIKey",
original_headers: Optional[Dict[str, str]] = None,
*,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
"""
根据 endpoint/key 构建请求头,并透传客户端自定义头。
"""
headers: Dict[str, str] = {}
# api_key 在数据库中是 NOT NULL类型标注为 Optional 是 SQLAlchemy 限制
decrypted_key = crypto_service.decrypt(key.api_key) # type: ignore[arg-type]
# 根据 API 格式自动选择认证头
api_format = getattr(endpoint, "api_format", None)
resolved_format = resolve_api_format(api_format)
auth_header, auth_type = (
get_auth_config(resolved_format) if resolved_format else ("Authorization", "bearer")
)
if auth_type == "bearer":
headers[auth_header] = f"Bearer {decrypted_key}"
else:
headers[auth_header] = decrypted_key
if endpoint.headers:
headers.update(endpoint.headers)
excluded_headers = {
"host",
"authorization",
"x-api-key",
"x-goog-api-key",
"content-length",
"transfer-encoding",
}
if original_headers:
for name, value in original_headers.items():
if name.lower() not in excluded_headers:
headers[name] = value
if extra_headers:
headers.update(extra_headers)
if "Content-Type" not in headers and "content-type" not in headers:
headers["Content-Type"] = "application/json"
return headers
from src.models.database import ProviderEndpoint
def _normalize_base_url(base_url: str, path: str) -> str:
"""