feat(codex): 引入 upstream_headers hook 机制,为 Codex 注入 session/conversation/account headers

- 新增 upstream_headers.py:可注册 provider+endpoint 维度的 extra headers 构建 hook
- Codex openai:cli 注入 session_id + conversation_id(由 prompt_cache_key sha256 派生)
- Codex openai:compact 注入 chatgpt-account-id(来自 auth_config)+ session_id,不注入 conversation_id
- 修复 prompt_cache:compact 格式现统一为 codex 策略,不再跳过注入
- chat_handler_base / cli_request_mixin 均在 extra_headers 阶段调用 build_upstream_extra_headers
This commit is contained in:
fawney19
2026-03-18 20:43:14 +08:00
parent 203cd5a9d5
commit 3d5b6141a5
8 changed files with 305 additions and 17 deletions

View File

@@ -91,6 +91,7 @@ from src.services.provider.stream_policy import (
from src.services.provider.transport import ( from src.services.provider.transport import (
build_provider_url, build_provider_url,
) )
from src.services.provider.upstream_headers import build_upstream_extra_headers
from src.services.scheduling.aware_scheduler import ProviderCandidate from src.services.scheduling.aware_scheduler import ProviderCandidate
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.task.request_state import MutableRequestBodyState from src.services.task.request_state import MutableRequestBodyState
@@ -836,6 +837,16 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
if envelope: if envelope:
extra_headers.update(envelope.extra_headers() or {}) extra_headers.update(envelope.extra_headers() or {})
hook_headers = build_upstream_extra_headers(
provider_type=provider_type,
endpoint_sig=str(provider_api_format) if provider_api_format else None,
request_body=request_body,
original_headers=original_headers,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
if hook_headers:
extra_headers.update(hook_headers)
return ProviderRequestResult( return ProviderRequestResult(
request_body=request_body, request_body=request_body,
url_model=url_model, url_model=url_model,

View File

@@ -20,6 +20,7 @@ from src.services.provider.stream_policy import (
resolve_upstream_is_stream, resolve_upstream_is_stream,
) )
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
from src.services.provider.upstream_headers import build_upstream_extra_headers
if TYPE_CHECKING: if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol from src.api.handlers.base.cli_protocol import CliHandlerProtocol
@@ -302,6 +303,16 @@ class CliRequestMixin:
if envelope: if envelope:
extra_headers.update(envelope.extra_headers() or {}) extra_headers.update(envelope.extra_headers() or {})
hook_headers = build_upstream_extra_headers(
provider_type=provider_type,
endpoint_sig=provider_api_format,
request_body=request_body,
original_headers=original_headers,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
if hook_headers:
extra_headers.update(hook_headers)
provider_payload, provider_headers = self._request_builder.build( provider_payload, provider_headers = self._request_builder.build(
request_body, request_body,
original_headers, original_headers,

View File

@@ -11,6 +11,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from collections.abc import Mapping
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -31,6 +33,85 @@ fetch_models_codex = create_preset_models_fetcher("codex")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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 _build_short_header_id(seed: str) -> str:
return hashlib.sha256(seed.encode()).hexdigest()[:16]
def _build_codex_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
include_conversation_id: bool,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Codex upstream: chatgpt-account-id + session_id + conversation_id."""
headers: dict[str, str] = {}
if decrypted_auth_config:
account_id = str(decrypted_auth_config.get("account_id") or "").strip()
if account_id:
headers["chatgpt-account-id"] = account_id
if isinstance(request_body, dict):
cache_key = str(request_body.get("prompt_cache_key") or "").strip()
if cache_key:
short_id = _build_short_header_id(cache_key)
if not _get_header_value(original_headers, "session_id"):
headers["session_id"] = short_id
if include_conversation_id and not _get_header_value(
original_headers, "conversation_id"
):
headers["conversation_id"] = short_id
return headers
def build_codex_cli_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
from src.services.provider.adapters.codex.context import is_codex_compact_request
return _build_codex_headers(
request_body,
original_headers,
include_conversation_id=not is_codex_compact_request(endpoint_sig="openai:cli"),
decrypted_auth_config=decrypted_auth_config,
)
def build_codex_compact_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
return _build_codex_headers(
request_body,
original_headers,
include_conversation_id=False,
decrypted_auth_config=decrypted_auth_config,
)
def build_codex_url( def build_codex_url(
endpoint: Any, endpoint: Any,
*, *,
@@ -165,10 +246,13 @@ def register_all() -> None:
from src.core.provider_oauth_utils import register_auth_enricher from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.transport import register_transport_hook from src.services.provider.transport import register_transport_hook
from src.services.provider.upstream_headers import register_upstream_headers_hook
# Transport # Transport
register_transport_hook("codex", "openai:cli", build_codex_url) register_transport_hook("codex", "openai:cli", build_codex_url)
register_transport_hook("codex", "openai:compact", build_codex_url) register_transport_hook("codex", "openai:compact", build_codex_url)
register_upstream_headers_hook("codex", "openai:cli", build_codex_cli_headers)
register_upstream_headers_hook("codex", "openai:compact", build_codex_compact_headers)
# Auth # Auth
register_auth_enricher("codex", enrich_codex) register_auth_enricher("codex", enrich_codex)

View File

@@ -8,7 +8,6 @@ from collections.abc import Mapping
from typing import Any from typing import Any
from src.core.provider_types import ProviderType, normalize_provider_type from src.core.provider_types import ProviderType, normalize_provider_type
from src.services.provider.adapters.codex.context import is_codex_compact_request
from src.utils.url_utils import is_official_openai_api_url from src.utils.url_utils import is_official_openai_api_url
_OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS: frozenset[str] = frozenset({"openai:chat", "openai:cli"}) _OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS: frozenset[str] = frozenset({"openai:chat", "openai:cli"})
@@ -112,15 +111,13 @@ def resolve_prompt_cache_key_scope(
) -> str | None: ) -> str | None:
"""Resolve which prompt cache strategy should be used for this request.""" """Resolve which prompt cache strategy should be used for this request."""
fmt = str(provider_api_format or "").strip().lower() fmt = str(provider_api_format or "").strip().lower()
pt = normalize_provider_type(provider_type)
if pt == ProviderType.CODEX.value and fmt in {"openai:cli", "openai:compact"}:
return "codex"
if fmt == "openai:compact": if fmt == "openai:compact":
return None return None
pt = normalize_provider_type(provider_type)
if pt == ProviderType.CODEX.value and fmt == "openai:cli":
if is_codex_compact_request(endpoint_sig=fmt):
return None
return "codex"
if fmt in _OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS and is_official_openai_api_url(base_url): if fmt in _OFFICIAL_OPENAI_PROMPT_CACHE_FORMATS and is_official_openai_api_url(base_url):
return "openai" return "openai"

View File

@@ -0,0 +1,59 @@
"""Provider-specific upstream request header hooks."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Callable
from src.core.provider_types import normalize_provider_type
from src.services.provider.envelope import ensure_providers_bootstrapped
UpstreamHeadersHookFn = Callable[..., dict[str, str]]
_hooks: dict[tuple[str, str], UpstreamHeadersHookFn] = {}
def register_upstream_headers_hook(
provider_type: str,
endpoint_sig: str,
hook: UpstreamHeadersHookFn,
) -> None:
"""Register a provider-specific extra upstream headers builder."""
pt = normalize_provider_type(provider_type)
sig = str(endpoint_sig or "").strip().lower()
if not pt or not sig:
return
_hooks[(pt, sig)] = hook
def build_upstream_extra_headers(
*,
provider_type: str | None,
endpoint_sig: str | None,
request_body: Any,
original_headers: Mapping[str, Any] | None,
decrypted_auth_config: dict[str, Any] | None,
) -> dict[str, str]:
"""Build provider-specific extra upstream headers for the current request."""
pt = normalize_provider_type(provider_type)
sig = str(endpoint_sig or "").strip().lower()
if not pt or not sig:
return {}
ensure_providers_bootstrapped(provider_types=[pt])
hook = _hooks.get((pt, sig))
if hook is None:
return {}
return hook(
request_body,
original_headers,
decrypted_auth_config=decrypted_auth_config,
)
__all__ = [
"UpstreamHeadersHookFn",
"build_upstream_extra_headers",
"register_upstream_headers_hook",
]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -17,9 +18,10 @@ from src.services.provider.prompt_cache import build_stable_codex_prompt_cache_k
class _DummyAuthInfo: class _DummyAuthInfo:
auth_header = "Authorization" def __init__(self, decrypted_auth_config: dict[str, Any] | None = None) -> None:
auth_value = "Bearer upstream-token" self.auth_header = "Authorization"
decrypted_auth_config = None self.auth_value = "Bearer upstream-token"
self.decrypted_auth_config = decrypted_auth_config
def as_tuple(self) -> tuple[str, str]: def as_tuple(self) -> tuple[str, str]:
return self.auth_header, self.auth_value return self.auth_header, self.auth_value
@@ -86,6 +88,10 @@ def _assert_common_codex_headers(headers: dict[str, str]) -> None:
assert "x-forwarded-scheme" not in headers assert "x-forwarded-scheme" not in headers
def _expected_codex_header_id(prompt_cache_key: str) -> str:
return hashlib.sha256(prompt_cache_key.encode()).hexdigest()[:16]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_upstream_request_codex_cli_injects_prompt_cache_and_forces_stream( async def test_build_upstream_request_codex_cli_injects_prompt_cache_and_forces_stream(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -130,15 +136,18 @@ async def test_build_upstream_request_codex_cli_injects_prompt_cache_and_forces_
assert "max_output_tokens" not in result.payload assert "max_output_tokens" not in result.payload
assert "temperature" not in result.payload assert "temperature" not in result.payload
assert "top_p" not in result.payload assert "top_p" not in result.payload
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
assert result.headers["session_id"] == short_id
assert result.headers["conversation_id"] == short_id
_assert_common_codex_headers(result.headers) _assert_common_codex_headers(result.headers)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_upstream_request_codex_compact_drops_stream_and_skips_prompt_cache( async def test_build_upstream_request_codex_compact_drops_stream_and_injects_prompt_cache(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo: async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
return _DummyAuthInfo() return _DummyAuthInfo({"account_id": "acc-1"})
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth) monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
@@ -164,9 +173,13 @@ async def test_build_upstream_request_codex_compact_drops_stream_and_skips_promp
assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact" assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact"
assert result.upstream_is_stream is False assert result.upstream_is_stream is False
assert "stream" not in result.payload assert "stream" not in result.payload
assert "prompt_cache_key" not in result.payload assert result.payload["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
assert result.payload["instructions"] == "You are GPT-5." assert result.payload["instructions"] == "You are GPT-5."
assert result.payload["store"] is False assert result.payload["store"] is False
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
assert result.headers["chatgpt-account-id"] == "acc-1"
assert result.headers["session_id"] == short_id
assert "conversation_id" not in result.headers
_assert_common_codex_headers(result.headers) _assert_common_codex_headers(result.headers)
@@ -205,8 +218,11 @@ async def test_build_upstream_request_legacy_codex_compact_context_uses_compact_
assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact" assert result.url == "https://chatgpt.com/backend-api/codex/responses/compact"
assert result.upstream_is_stream is False assert result.upstream_is_stream is False
assert "stream" not in result.payload assert "stream" not in result.payload
assert "prompt_cache_key" not in result.payload assert result.payload["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
assert result.payload["instructions"] == "You are GPT-5." assert result.payload["instructions"] == "You are GPT-5."
short_id = _expected_codex_header_id(result.payload["prompt_cache_key"])
assert result.headers["session_id"] == short_id
assert "conversation_id" not in result.headers
_assert_common_codex_headers(result.headers) _assert_common_codex_headers(result.headers)

View File

@@ -106,6 +106,21 @@ def test_maybe_patch_request_with_prompt_cache_key_for_codex_openai_cli() -> Non
assert out["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123") assert out["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
def test_maybe_patch_request_with_prompt_cache_key_for_codex_openai_compact() -> None:
req = {"model": "gpt-5", "input": []}
out = maybe_patch_request_with_prompt_cache_key(
req,
provider_api_format="openai:compact",
provider_type="codex",
base_url="https://chatgpt.com/backend-api/codex",
user_api_key_id="user-key-123",
)
assert out is not req
assert out["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
def test_maybe_patch_request_with_prompt_cache_key_skips_official_compact() -> None: def test_maybe_patch_request_with_prompt_cache_key_skips_official_compact() -> None:
req = {"model": "gpt-5", "input": []} req = {"model": "gpt-5", "input": []}
@@ -121,7 +136,7 @@ def test_maybe_patch_request_with_prompt_cache_key_skips_official_compact() -> N
assert "prompt_cache_key" not in out assert "prompt_cache_key" not in out
def test_maybe_patch_request_with_prompt_cache_key_skips_legacy_codex_compact_context() -> None: def test_maybe_patch_request_with_prompt_cache_key_for_legacy_codex_compact_context() -> None:
req = {"model": "gpt-5", "input": []} req = {"model": "gpt-5", "input": []}
try: try:
@@ -136,8 +151,8 @@ def test_maybe_patch_request_with_prompt_cache_key_skips_legacy_codex_compact_co
finally: finally:
set_codex_request_context(None) set_codex_request_context(None)
assert out is req assert out is not req
assert "prompt_cache_key" not in out assert out["prompt_cache_key"] == build_stable_codex_prompt_cache_key("user-key-123")
def test_maybe_patch_request_with_prompt_cache_key_preserves_existing_key() -> None: def test_maybe_patch_request_with_prompt_cache_key_preserves_existing_key() -> None:

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import hashlib
from src.services.provider.adapters.codex.context import (
CodexRequestContext,
set_codex_request_context,
)
from src.services.provider.upstream_headers import build_upstream_extra_headers
def test_build_upstream_extra_headers_for_codex_openai_cli() -> None:
request_body = {"model": "gpt-5", "input": [], "prompt_cache_key": "pcache-123"}
headers = build_upstream_extra_headers(
provider_type="codex",
endpoint_sig="openai:cli",
request_body=request_body,
original_headers={},
decrypted_auth_config={"account_id": "acc-1"},
)
short_id = hashlib.sha256(b"pcache-123").hexdigest()[:16]
assert headers == {
"chatgpt-account-id": "acc-1",
"session_id": short_id,
"conversation_id": short_id,
}
def test_build_upstream_extra_headers_respects_existing_session_headers() -> None:
headers = build_upstream_extra_headers(
provider_type="codex",
endpoint_sig="openai:cli",
request_body={"model": "gpt-5", "input": [], "prompt_cache_key": "pcache-123"},
original_headers={
"Session_ID": "client-session",
"Conversation_ID": "client-conversation",
},
decrypted_auth_config={"account_id": "acc-1"},
)
assert headers == {"chatgpt-account-id": "acc-1"}
def test_build_upstream_extra_headers_for_codex_openai_compact() -> None:
request_body = {"model": "gpt-5", "input": [], "prompt_cache_key": "pcache-123"}
headers = build_upstream_extra_headers(
provider_type="codex",
endpoint_sig="openai:compact",
request_body=request_body,
original_headers={},
decrypted_auth_config={"account_id": "acc-1"},
)
short_id = hashlib.sha256(b"pcache-123").hexdigest()[:16]
assert headers == {
"chatgpt-account-id": "acc-1",
"session_id": short_id,
}
def test_build_upstream_extra_headers_for_legacy_codex_compact_context() -> None:
request_body = {"model": "gpt-5", "input": [], "prompt_cache_key": "pcache-123"}
try:
set_codex_request_context(CodexRequestContext(is_compact=True))
headers = build_upstream_extra_headers(
provider_type="codex",
endpoint_sig="openai:cli",
request_body=request_body,
original_headers={},
decrypted_auth_config={"account_id": "acc-1"},
)
finally:
set_codex_request_context(None)
short_id = hashlib.sha256(b"pcache-123").hexdigest()[:16]
assert headers == {
"chatgpt-account-id": "acc-1",
"session_id": short_id,
}
def test_build_upstream_extra_headers_returns_empty_without_match() -> None:
headers = build_upstream_extra_headers(
provider_type="codex",
endpoint_sig="openai:chat",
request_body={"model": "gpt-5", "messages": []},
original_headers={},
decrypted_auth_config={"account_id": "acc-1"},
)
assert headers == {}