Files
Aether/_deprecated_py_src/services/provider/adapters/codex/plugin.py

372 lines
12 KiB
Python
Raw Normal View History

"""Codex provider plugin — 统一注册入口。
Codex 对各通用 registry / capability registry 的注册集中在一个文件中
- Transport Hook (URL 构建)
- Auth Enricher (OAuth enrichment)
- Provider Format Capability默认 body_rules
- Model Fetcher (fixed catalog Codex has no /v1/models endpoint)
新增 provider 时参照此文件创建对应的 plugin.py 即可
"""
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
# ---------------------------------------------------------------------------
# Preset model catalog
# ---------------------------------------------------------------------------
# Codex upstream (chatgpt.com/backend-api/codex) has no /v1/models endpoint.
# 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")
# ---------------------------------------------------------------------------
# Transport Hook
# ---------------------------------------------------------------------------
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 _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
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(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""构建 Codex OAuth URL。
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
而非标准 OpenAI /v1/responsescompact 模式使用 /responses/compact
"""
_ = is_stream # Codex 不需要根据 stream 切换路径
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
from src.services.provider.adapters.codex.context import is_codex_compact_request
is_compact = is_codex_compact_request(endpoint_sig=endpoint_sig)
base = str(endpoint.base_url).rstrip("/")
# 如果用户已在 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}"
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],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
from src.core.provider_oauth_utils import (
fetch_openai_account_name,
parse_codex_id_token,
)
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
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
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
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,
}
)
logger.debug(
"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")),
list(token_response.keys()),
)
logger.debug("Codex direct token response values: {}", direct_snapshot)
token_candidates = [
("id_token", token_response.get("id_token")),
("idToken", token_response.get("idToken")),
("access_token", token_response.get("access_token")),
("accessToken", token_response.get("accessToken")),
]
for source_name, token_payload in token_candidates:
codex_info = parse_codex_id_token(token_payload)
if not codex_info:
continue
logger.debug(
"Codex parsed token values: source={} fields={} values={}",
source_name,
list(codex_info.keys()),
_build_safe_codex_debug_snapshot(codex_info),
)
for key, value in codex_info.items():
if not auth_config.get(key):
auth_config[key] = value
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,
)
logger.debug(
"Codex account_name lookup: account_id={} resolved_account_name={}",
account_id,
account_name,
)
if account_name:
auth_config["account_name"] = account_name
logger.debug(
"Codex enrich_auth_config final metadata: {}",
_build_safe_codex_debug_snapshot(auth_config),
)
return auth_config
# ---------------------------------------------------------------------------
# Unified Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""一次性注册 Codex 的所有 hooks 到各通用 registry。"""
from src.core.api_format.capabilities import register_provider_default_body_rules
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.transport import register_transport_hook
from src.services.provider.upstream_headers import register_upstream_headers_hook
# Transport
register_transport_hook("codex", "openai:cli", 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
register_auth_enricher("codex", enrich_codex)
# Provider Format Capability默认 body_rules
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
register_provider_default_body_rules("codex", "openai:cli", CODEX_DEFAULT_BODY_RULES)
# 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,
)