refactor(prompt-cache): 将 prompt_cache_key 生成从 Codex 专用模块提取为通用服务,支持 OpenAI 官方 API 和 Codex 端点

- 新增 prompt_cache.py 统一管理 prompt cache key 的生成逻辑
- 基于 User-Agent 识别客户端家族(openai_python/openai_node/codex_desktop 等),不同客户端生成不同 cache key
- 在 chat_handler_base/cli_stream_mixin/cli_sync_mixin 统一调用 maybe_patch_request_with_prompt_cache_key
- Codex request_patching 不再负责 prompt cache key 注入,仅保留内部标记清理
- 新增 is_official_openai_api_url 工具函数区分 OpenAI 官方 API 与兼容端点
This commit is contained in:
fawney19
2026-03-17 01:55:24 +08:00
parent 4ecaefbade
commit d2f1431269
9 changed files with 469 additions and 52 deletions

View File

@@ -81,6 +81,9 @@ from src.models.database import (
User,
)
from src.services.provider.behavior import get_provider_behavior
from src.services.provider.prompt_cache import (
maybe_patch_request_with_prompt_cache_key,
)
from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
@@ -807,6 +810,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
upstream_is_stream=upstream_is_stream,
)
request_body = maybe_patch_request_with_prompt_cache_key(
request_body,
provider_api_format=str(provider_api_format) if provider_api_format else None,
provider_type=provider_type,
base_url=getattr(endpoint, "base_url", None),
user_api_key_id=str(getattr(self.api_key, "id", "") or ""),
request_headers=original_headers,
)
# 获取 URL 模型名
url_model = self.get_model_for_url(request_body, mapped_model) or model

View File

@@ -41,6 +41,9 @@ from src.core.exceptions import (
)
from src.core.logger import logger
from src.services.provider.behavior import get_provider_behavior
from src.services.provider.prompt_cache import (
maybe_patch_request_with_prompt_cache_key,
)
from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
@@ -398,6 +401,15 @@ class CliStreamMixin:
upstream_is_stream=upstream_is_stream,
)
request_body = maybe_patch_request_with_prompt_cache_key(
request_body,
provider_api_format=provider_api_format,
provider_type=provider_type,
base_url=getattr(endpoint, "base_url", None),
user_api_key_id=str(getattr(self.api_key, "id", "") or ""),
request_headers=original_headers,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)

View File

@@ -36,6 +36,9 @@ from src.core.exceptions import (
)
from src.core.logger import logger
from src.services.provider.behavior import get_provider_behavior
from src.services.provider.prompt_cache import (
maybe_patch_request_with_prompt_cache_key,
)
from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
@@ -219,6 +222,15 @@ class CliSyncMixin:
upstream_is_stream=upstream_is_stream,
)
request_body = maybe_patch_request_with_prompt_cache_key(
request_body,
provider_api_format=provider_api_format,
provider_type=provider_type,
base_url=getattr(endpoint, "base_url", None),
user_api_key_id=str(getattr(self.api_key, "id", "") or ""),
request_headers=original_headers,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)

View File

@@ -69,18 +69,14 @@ class CodexOAuthEnvelope:
# Compact sentinel may have been popped earlier by finalize_provider_request;
# prefer the pre-set context var (set by adapter), fall back to request body.
existing_ctx = get_codex_request_context()
user_api_key_id = existing_ctx.user_api_key_id if existing_ctx else None
is_compact = (existing_ctx.is_compact if existing_ctx else False) or bool(
request_body.get("_aether_compact", False)
)
patched_request_body = patch_openai_cli_request_for_codex(
request_body,
user_api_key_id=user_api_key_id,
)
patched_request_body = patch_openai_cli_request_for_codex(request_body)
set_codex_request_context(
CodexRequestContext(
account_id=str(account_id) if account_id else None,
user_api_key_id=user_api_key_id,
user_api_key_id=existing_ctx.user_api_key_id if existing_ctx else None,
is_compact=is_compact,
)
)

View File

@@ -3,30 +3,17 @@
Codex requests are mostly passthrough:
- Do not mutate client payload fields unless Codex-specific compatibility requires it.
- Strip internal sentinel fields that must never reach upstream.
- When the caller's user API key is known and the request did not provide one,
synthesize a stable ``prompt_cache_key`` so prompt caching can be reused.
"""
from __future__ import annotations
import uuid
from typing import Any
from src.core.provider_types import ProviderType
def build_stable_codex_prompt_cache_key(user_api_key_id: str | None) -> str | None:
"""Build a deterministic Codex prompt cache key from the caller's user API key id."""
normalized = str(user_api_key_id or "").strip()
if not normalized:
return None
return str(uuid.uuid5(uuid.NAMESPACE_OID, f"aether:codex:prompt-cache:user:{normalized}"))
def patch_openai_cli_request_for_codex(
request_body: dict[str, Any],
*,
user_api_key_id: str | None = None,
) -> dict[str, Any]:
"""
Patch an OpenAI CLI (Responses API style) request body for Codex gateways.
@@ -36,11 +23,6 @@ def patch_openai_cli_request_for_codex(
out: dict[str, Any] = dict(request_body)
# Internal routing marker; never send upstream.
out.pop("_aether_compact", None)
prompt_cache_key = str(out.get("prompt_cache_key") or "").strip()
if not prompt_cache_key:
stable_key = build_stable_codex_prompt_cache_key(user_api_key_id)
if stable_key:
out["prompt_cache_key"] = stable_key
return out
@@ -49,7 +31,6 @@ def maybe_patch_request_for_codex(
provider_type: str | None,
provider_api_format: str | None,
request_body: Any,
user_api_key_id: str | None = None,
) -> Any:
"""
Conditionally patch request body for Codex gateways.
@@ -65,11 +46,10 @@ def maybe_patch_request_for_codex(
return request_body
if not isinstance(request_body, dict):
return request_body
return patch_openai_cli_request_for_codex(request_body, user_api_key_id=user_api_key_id)
return patch_openai_cli_request_for_codex(request_body)
__all__ = [
"build_stable_codex_prompt_cache_key",
"maybe_patch_request_for_codex",
"patch_openai_cli_request_for_codex",
]

View File

@@ -0,0 +1,183 @@
"""Helpers for synthesizing stable prompt cache keys."""
from __future__ import annotations
import re
import uuid
from collections.abc import Mapping
from typing import Any
from src.core.provider_types import ProviderType, normalize_provider_type
from src.utils.url_utils import is_official_openai_api_url
_OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS: frozenset[str] = frozenset({"openai:chat", "openai:cli"})
_USER_AGENT_CLIENT_FAMILY_PATTERNS: tuple[tuple[tuple[str, ...], str], ...] = (
(("codex desktop",), "codex_desktop"),
(("asyncopenai/python", "openai/python"), "openai_python"),
(("openai-node", "openai/javascript"), "openai_node"),
(("openai-go",), "openai_go"),
(("openai-java",), "openai_java"),
(("openai-ruby",), "openai_ruby"),
(("postmanruntime/",), "postman"),
(("curl/",), "curl"),
(("mozilla/",), "browser"),
)
def _get_header_value(headers: Mapping[str, Any] | None, header_name: str) -> str | None:
if not isinstance(headers, Mapping):
return None
target = str(header_name or "").strip().lower()
if not target:
return None
for name, value in headers.items():
if str(name or "").strip().lower() != target:
continue
normalized = str(value or "").strip()
return normalized or None
return None
def normalize_prompt_cache_client_family(user_agent: str | None) -> str:
"""Reduce a raw User-Agent string to a stable client-family token."""
raw = str(user_agent or "").strip().lower()
if not raw:
return "generic"
for patterns, family in _USER_AGENT_CLIENT_FAMILY_PATTERNS:
if any(pattern in raw for pattern in patterns):
return family
normalized = re.sub(r"[^a-z0-9]+", "_", raw.split()[0].split("/")[0]).strip("_")
return normalized[:48] if normalized else "generic"
def resolve_prompt_cache_client_family(request_headers: Mapping[str, Any] | None) -> str:
"""Best-effort client-family extraction from request headers."""
return normalize_prompt_cache_client_family(_get_header_value(request_headers, "user-agent"))
def _build_stable_prompt_cache_key(
user_api_key_id: str | None,
*,
scope: str,
client_family: str | None = None,
) -> str | None:
normalized = str(user_api_key_id or "").strip()
if not normalized:
return None
family = str(client_family or "").strip().lower() or "generic"
namespace = f"aether:{scope}:prompt-cache:v2:user:{normalized}:client:{family}"
return str(uuid.uuid5(uuid.NAMESPACE_OID, namespace))
def build_stable_openai_prompt_cache_key(
user_api_key_id: str | None,
*,
client_family: str | None = None,
) -> str | None:
"""Build a deterministic official OpenAI prompt cache key from the caller's user API key id."""
return _build_stable_prompt_cache_key(
user_api_key_id,
scope="openai",
client_family=client_family,
)
def build_stable_codex_prompt_cache_key(
user_api_key_id: str | None,
*,
client_family: str | None = None,
) -> str | None:
"""Build a deterministic Codex prompt cache key from the caller's user API key id."""
return _build_stable_prompt_cache_key(
user_api_key_id,
scope="codex",
client_family=client_family,
)
def resolve_prompt_cache_key_scope(
*,
request_body: dict[str, Any] | None = None,
provider_api_format: str | None,
provider_type: str | None = None,
base_url: str | None = None,
) -> str | None:
"""Resolve which prompt cache strategy should be used for this request."""
fmt = str(provider_api_format or "").strip().lower()
if fmt == "openai:compact":
return None
# Belt-and-suspenders: finalize_provider_request 通常已 pop _aether_compact
# 但 openai:compact format 检查在上方已拦截;此处防御非 compact 格式端点意外携带标记。
request = request_body if isinstance(request_body, dict) else {}
if bool(request.get("_aether_compact", False)):
return None
pt = normalize_provider_type(provider_type)
if pt == ProviderType.CODEX.value and fmt == "openai:cli":
return "codex"
if fmt in _OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS and is_official_openai_api_url(base_url):
return "openai"
return None
def maybe_patch_request_with_prompt_cache_key(
request_body: Any,
*,
provider_api_format: str | None,
provider_type: str | None = None,
base_url: str | None = None,
user_api_key_id: str | None = None,
request_headers: Mapping[str, Any] | None = None,
) -> Any:
"""Inject a stable prompt_cache_key when the target upstream supports deterministic reuse."""
if not isinstance(request_body, dict):
return request_body
scope = resolve_prompt_cache_key_scope(
request_body=request_body,
provider_api_format=provider_api_format,
provider_type=provider_type,
base_url=base_url,
)
if not scope:
return request_body
prompt_cache_key = str(request_body.get("prompt_cache_key") or "").strip()
if prompt_cache_key:
return request_body
client_family = resolve_prompt_cache_client_family(request_headers)
if scope == "codex":
stable_key = build_stable_codex_prompt_cache_key(
user_api_key_id,
client_family=client_family,
)
else:
stable_key = build_stable_openai_prompt_cache_key(
user_api_key_id,
client_family=client_family,
)
if not stable_key:
return request_body
out = dict(request_body)
out["prompt_cache_key"] = stable_key
return out
__all__ = [
"build_stable_codex_prompt_cache_key",
"build_stable_openai_prompt_cache_key",
"maybe_patch_request_with_prompt_cache_key",
"normalize_prompt_cache_client_family",
"resolve_prompt_cache_client_family",
"resolve_prompt_cache_key_scope",
]

View File

@@ -6,6 +6,19 @@ URL 处理工具函数
from __future__ import annotations
from urllib.parse import urlparse
def is_official_openai_api_url(base_url: str | None) -> bool:
"""判断是否为 OpenAI 官方 API 端点。"""
value = str(base_url or "").strip()
if not value:
return False
parsed = urlparse(value if "://" in value else f"https://{value}")
host = str(parsed.hostname or "").strip().lower()
return host == "api.openai.com"
def is_codex_url(base_url: str) -> bool:
"""判断是否是 Codex OAuth 端点。