2026-02-06 16:37:06 +08:00
|
|
|
|
"""Codex provider plugin — 统一注册入口。
|
|
|
|
|
|
|
2026-03-09 18:26:53 +08:00
|
|
|
|
将 Codex 对各通用 registry / capability registry 的注册集中在一个文件中:
|
2026-02-06 16:37:06 +08:00
|
|
|
|
- Transport Hook (URL 构建)
|
|
|
|
|
|
- Auth Enricher (OAuth enrichment)
|
2026-03-18 12:35:57 +08:00
|
|
|
|
- Provider Format Capability(默认 body_rules)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
- Model Fetcher (fixed catalog — Codex has no /v1/models endpoint)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
新增 provider 时参照此文件创建对应的 plugin.py 即可。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-03-18 20:43:14 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
from collections.abc import Mapping
|
2026-02-06 16:37:06 +08:00
|
|
|
|
from typing import Any
|
|
|
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-02-09 12:44:32 +08:00
|
|
|
|
# Preset model catalog
|
2026-02-09 01:05:48 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Codex upstream (chatgpt.com/backend-api/codex) has no /v1/models endpoint.
|
2026-02-09 12:44:32 +08:00
|
|
|
|
# We use the unified preset models registry from preset_models.py.
|
|
|
|
|
|
from src.services.provider.preset_models import create_preset_models_fetcher
|
|
|
|
|
|
|
|
|
|
|
|
fetch_models_codex = create_preset_models_fetcher("codex")
|
2026-02-09 01:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Transport Hook
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 20:43:14 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 00:50:18 +08:00
|
|
|
|
def _normalize_codex_debug_organizations(value: Any) -> list[dict[str, Any]]:
|
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
|
for raw in value:
|
|
|
|
|
|
if not isinstance(raw, Mapping):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
normalized: dict[str, Any] = {}
|
|
|
|
|
|
org_id = str(raw.get("id") or "").strip()
|
|
|
|
|
|
if org_id:
|
|
|
|
|
|
normalized["id"] = org_id
|
|
|
|
|
|
|
|
|
|
|
|
title = str(raw.get("title") or "").strip()
|
|
|
|
|
|
if title:
|
|
|
|
|
|
normalized["title"] = title
|
|
|
|
|
|
|
|
|
|
|
|
role = str(raw.get("role") or "").strip()
|
|
|
|
|
|
if role:
|
|
|
|
|
|
normalized["role"] = role
|
|
|
|
|
|
|
|
|
|
|
|
if "is_default" in raw:
|
|
|
|
|
|
normalized["is_default"] = bool(raw.get("is_default"))
|
|
|
|
|
|
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
items.append(normalized)
|
|
|
|
|
|
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_safe_codex_debug_snapshot(values: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
|
|
|
|
if not isinstance(values, Mapping):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
snapshot: dict[str, Any] = {}
|
|
|
|
|
|
for field in (
|
|
|
|
|
|
"email",
|
|
|
|
|
|
"account_id",
|
|
|
|
|
|
"account_user_id",
|
|
|
|
|
|
"plan_type",
|
|
|
|
|
|
"user_id",
|
|
|
|
|
|
"account_name",
|
|
|
|
|
|
):
|
|
|
|
|
|
raw = values.get(field)
|
|
|
|
|
|
if isinstance(raw, str):
|
|
|
|
|
|
normalized = raw.strip()
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
snapshot[field] = normalized
|
|
|
|
|
|
elif raw is not None:
|
|
|
|
|
|
snapshot[field] = raw
|
|
|
|
|
|
|
|
|
|
|
|
organizations = _normalize_codex_debug_organizations(values.get("organizations"))
|
|
|
|
|
|
if organizations:
|
|
|
|
|
|
snapshot["organizations"] = organizations
|
|
|
|
|
|
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 20:43:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
def build_codex_url(
|
|
|
|
|
|
endpoint: Any,
|
|
|
|
|
|
*,
|
|
|
|
|
|
is_stream: bool,
|
|
|
|
|
|
effective_query_params: dict[str, Any],
|
2026-03-01 23:32:48 +08:00
|
|
|
|
**_kwargs: Any,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
) -> str:
|
|
|
|
|
|
"""构建 Codex OAuth URL。
|
|
|
|
|
|
|
|
|
|
|
|
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
|
2026-02-19 21:26:18 +08:00
|
|
|
|
而非标准 OpenAI 的 /v1/responses。compact 模式使用 /responses/compact。
|
2026-02-06 16:37:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
_ = is_stream # Codex 不需要根据 stream 切换路径
|
|
|
|
|
|
|
2026-03-01 23:32:48 +08:00
|
|
|
|
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
|
2026-03-18 12:35:57 +08:00
|
|
|
|
from src.services.provider.adapters.codex.context import is_codex_compact_request
|
|
|
|
|
|
|
|
|
|
|
|
is_compact = is_codex_compact_request(endpoint_sig=endpoint_sig)
|
2026-02-19 21:26:18 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
base = str(endpoint.base_url).rstrip("/")
|
2026-02-19 21:26:18 +08:00
|
|
|
|
# 如果用户已在 base_url 中包含了 /responses,不要重复追加
|
|
|
|
|
|
if base.endswith("/responses"):
|
|
|
|
|
|
url = f"{base}/compact" if is_compact else base
|
|
|
|
|
|
elif base.endswith("/responses/compact"):
|
|
|
|
|
|
url = base if is_compact else base.removesuffix("/compact")
|
|
|
|
|
|
else:
|
|
|
|
|
|
suffix = "/responses/compact" if is_compact else "/responses"
|
|
|
|
|
|
url = f"{base}{suffix}"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if effective_query_params:
|
|
|
|
|
|
query_string = urlencode(effective_query_params, doseq=True)
|
|
|
|
|
|
if query_string:
|
|
|
|
|
|
url = f"{url}?{query_string}"
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Auth Enricher
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def enrich_codex(
|
|
|
|
|
|
auth_config: dict[str, Any],
|
|
|
|
|
|
token_response: dict[str, Any],
|
2026-03-18 20:58:33 +08:00
|
|
|
|
access_token: str,
|
|
|
|
|
|
proxy_config: dict[str, Any] | None,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
) -> dict[str, Any]:
|
2026-03-11 21:37:08 +08:00
|
|
|
|
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
|
2026-03-18 20:58:33 +08:00
|
|
|
|
from src.core.provider_oauth_utils import (
|
|
|
|
|
|
fetch_openai_account_name,
|
|
|
|
|
|
parse_codex_id_token,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-03-04 10:00:38 +08:00
|
|
|
|
def _read_non_empty_str(*values: Any) -> str | None:
|
|
|
|
|
|
for value in values:
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
normalized = value.strip()
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Prefer explicit fields if token endpoint returns them directly.
|
|
|
|
|
|
direct_account_id = _read_non_empty_str(
|
|
|
|
|
|
token_response.get("account_id"),
|
|
|
|
|
|
token_response.get("accountId"),
|
|
|
|
|
|
token_response.get("chatgpt_account_id"),
|
|
|
|
|
|
token_response.get("chatgptAccountId"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if direct_account_id and not auth_config.get("account_id"):
|
|
|
|
|
|
auth_config["account_id"] = direct_account_id
|
|
|
|
|
|
|
2026-03-11 21:37:08 +08:00
|
|
|
|
direct_account_user_id = _read_non_empty_str(
|
|
|
|
|
|
token_response.get("account_user_id"),
|
|
|
|
|
|
token_response.get("accountUserId"),
|
|
|
|
|
|
token_response.get("chatgpt_account_user_id"),
|
|
|
|
|
|
token_response.get("chatgptAccountUserId"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if direct_account_user_id and not auth_config.get("account_user_id"):
|
|
|
|
|
|
auth_config["account_user_id"] = direct_account_user_id
|
|
|
|
|
|
|
2026-03-04 10:00:38 +08:00
|
|
|
|
direct_plan_type = _read_non_empty_str(
|
|
|
|
|
|
token_response.get("plan_type"),
|
|
|
|
|
|
token_response.get("planType"),
|
|
|
|
|
|
token_response.get("chatgpt_plan_type"),
|
|
|
|
|
|
token_response.get("chatgptPlanType"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if direct_plan_type and not auth_config.get("plan_type"):
|
|
|
|
|
|
auth_config["plan_type"] = direct_plan_type
|
|
|
|
|
|
|
|
|
|
|
|
direct_user_id = _read_non_empty_str(
|
|
|
|
|
|
token_response.get("user_id"),
|
|
|
|
|
|
token_response.get("userId"),
|
|
|
|
|
|
token_response.get("chatgpt_user_id"),
|
|
|
|
|
|
token_response.get("chatgptUserId"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if direct_user_id and not auth_config.get("user_id"):
|
|
|
|
|
|
auth_config["user_id"] = direct_user_id
|
|
|
|
|
|
|
|
|
|
|
|
direct_email = _read_non_empty_str(token_response.get("email"))
|
|
|
|
|
|
if direct_email and not auth_config.get("email"):
|
|
|
|
|
|
auth_config["email"] = direct_email
|
|
|
|
|
|
|
2026-03-19 00:50:18 +08:00
|
|
|
|
direct_snapshot = _build_safe_codex_debug_snapshot(
|
|
|
|
|
|
{
|
|
|
|
|
|
"email": direct_email,
|
|
|
|
|
|
"account_id": direct_account_id,
|
|
|
|
|
|
"account_user_id": direct_account_user_id,
|
|
|
|
|
|
"plan_type": direct_plan_type,
|
|
|
|
|
|
"user_id": direct_user_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
logger.debug(
|
2026-03-04 10:00:38 +08:00
|
|
|
|
"Codex enrich_auth_config: id_token_present={} access_token_present={} token_keys={}",
|
|
|
|
|
|
bool(token_response.get("id_token") or token_response.get("idToken")),
|
|
|
|
|
|
bool(token_response.get("access_token") or token_response.get("accessToken")),
|
2026-02-06 16:37:06 +08:00
|
|
|
|
list(token_response.keys()),
|
|
|
|
|
|
)
|
2026-03-19 00:50:18 +08:00
|
|
|
|
logger.debug("Codex direct token response values: {}", direct_snapshot)
|
2026-03-04 10:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
token_candidates = [
|
2026-03-19 00:50:18 +08:00
|
|
|
|
("id_token", token_response.get("id_token")),
|
|
|
|
|
|
("idToken", token_response.get("idToken")),
|
|
|
|
|
|
("access_token", token_response.get("access_token")),
|
|
|
|
|
|
("accessToken", token_response.get("accessToken")),
|
2026-03-04 10:00:38 +08:00
|
|
|
|
]
|
2026-03-19 00:50:18 +08:00
|
|
|
|
for source_name, token_payload in token_candidates:
|
2026-03-04 10:00:38 +08:00
|
|
|
|
codex_info = parse_codex_id_token(token_payload)
|
|
|
|
|
|
if not codex_info:
|
|
|
|
|
|
continue
|
2026-03-19 00:50:18 +08:00
|
|
|
|
logger.debug(
|
|
|
|
|
|
"Codex parsed token values: source={} fields={} values={}",
|
|
|
|
|
|
source_name,
|
|
|
|
|
|
list(codex_info.keys()),
|
|
|
|
|
|
_build_safe_codex_debug_snapshot(codex_info),
|
|
|
|
|
|
)
|
2026-03-04 10:00:38 +08:00
|
|
|
|
for key, value in codex_info.items():
|
|
|
|
|
|
if not auth_config.get(key):
|
|
|
|
|
|
auth_config[key] = value
|
|
|
|
|
|
|
2026-03-18 20:58:33 +08:00
|
|
|
|
account_id = _read_non_empty_str(auth_config.get("account_id"))
|
|
|
|
|
|
if account_id:
|
|
|
|
|
|
account_name = await fetch_openai_account_name(
|
|
|
|
|
|
access_token,
|
|
|
|
|
|
account_id,
|
|
|
|
|
|
proxy_config=proxy_config,
|
|
|
|
|
|
timeout_seconds=10.0,
|
|
|
|
|
|
)
|
2026-03-19 00:50:18 +08:00
|
|
|
|
logger.debug(
|
|
|
|
|
|
"Codex account_name lookup: account_id={} resolved_account_name={}",
|
|
|
|
|
|
account_id,
|
|
|
|
|
|
account_name,
|
|
|
|
|
|
)
|
2026-03-18 20:58:33 +08:00
|
|
|
|
if account_name:
|
|
|
|
|
|
auth_config["account_name"] = account_name
|
|
|
|
|
|
|
2026-03-19 00:50:18 +08:00
|
|
|
|
logger.debug(
|
|
|
|
|
|
"Codex enrich_auth_config final metadata: {}",
|
|
|
|
|
|
_build_safe_codex_debug_snapshot(auth_config),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
return auth_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Unified Registration
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_all() -> None:
|
|
|
|
|
|
"""一次性注册 Codex 的所有 hooks 到各通用 registry。"""
|
2026-03-18 12:35:57 +08:00
|
|
|
|
from src.core.api_format.capabilities import register_provider_default_body_rules
|
2026-02-06 16:37:06 +08:00
|
|
|
|
from src.core.provider_oauth_utils import register_auth_enricher
|
2026-02-09 01:05:48 +08:00
|
|
|
|
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
|
2026-02-06 16:37:06 +08:00
|
|
|
|
from src.services.provider.transport import register_transport_hook
|
2026-03-18 20:43:14 +08:00
|
|
|
|
from src.services.provider.upstream_headers import register_upstream_headers_hook
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
# Transport
|
|
|
|
|
|
register_transport_hook("codex", "openai:cli", build_codex_url)
|
2026-03-01 23:32:48 +08:00
|
|
|
|
register_transport_hook("codex", "openai:compact", build_codex_url)
|
2026-03-18 20:43:14 +08:00
|
|
|
|
register_upstream_headers_hook("codex", "openai:cli", build_codex_cli_headers)
|
|
|
|
|
|
register_upstream_headers_hook("codex", "openai:compact", build_codex_compact_headers)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
# Auth
|
|
|
|
|
|
register_auth_enricher("codex", enrich_codex)
|
|
|
|
|
|
|
2026-03-18 12:35:57 +08:00
|
|
|
|
# Provider Format Capability:默认 body_rules
|
2026-03-09 18:26:53 +08:00
|
|
|
|
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
|
|
|
|
|
|
|
2026-03-19 00:50:18 +08:00
|
|
|
|
register_provider_default_body_rules("codex", "openai:cli", CODEX_DEFAULT_BODY_RULES)
|
2026-03-05 19:04:21 +08:00
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
# Export: Codex uses the default export builder (strip null + temp fields)
|
|
|
|
|
|
# No need to register a custom one — the default in export.py suffices.
|
|
|
|
|
|
|
|
|
|
|
|
# Model Fetcher
|
|
|
|
|
|
UpstreamModelsFetcherRegistry.register(
|
|
|
|
|
|
provider_types=["codex"],
|
|
|
|
|
|
fetcher=fetch_models_codex,
|
|
|
|
|
|
)
|