mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-27 10:03:50 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Pre-commit / pre-commit (push) Has been cancelled
Test / Run Web + Local Brain Smoke (push) Has been cancelled
Test / Run Frontend Guardrails (push) Has been cancelled
Test / Run Python Tests (push) Has been cancelled
Co-authored-by: Douglas <douglas.ym.lai@gmail.com> Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com> Co-authored-by: Tao Sun <168447269+fengju0213@users.noreply.github.com> Co-authored-by: Weijie Bai <happy.regina.bai@gmail.com>
270 lines
8.7 KiB
Python
270 lines
8.7 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.model.chat import Chat
|
|
|
|
RESOLVER_URL_ENV = "CODEX_RESOLVER_URL"
|
|
RESOLVER_SECRET_ENV = "CODEX_RESOLVER_SECRET"
|
|
MODEL_API_URL_ENV = "CODEX_MODEL_API_URL"
|
|
MODEL_DEFAULT_HEADERS_ENV = "CODEX_MODEL_DEFAULT_HEADERS_JSON"
|
|
RESOLVER_SECRET_HEADER = "x-eigent-resolver-secret"
|
|
DEFAULT_CODEX_API_URL = "https://chatgpt.com/backend-api/codex"
|
|
_CAMEL_RESPONSES_MULTIMODAL_PATCHED = False
|
|
|
|
|
|
class CodexSubscriptionAuthError(RuntimeError):
|
|
"""Raised when the backend cannot get a local-only Codex token."""
|
|
|
|
|
|
def is_codex_subscription_auth(options: Chat) -> bool:
|
|
return options.auth_source == "codex_subscription"
|
|
|
|
|
|
def _resolver_endpoint() -> tuple[str, str]:
|
|
resolver_url = os.getenv(RESOLVER_URL_ENV, "").strip().rstrip("/")
|
|
resolver_secret = os.getenv(RESOLVER_SECRET_ENV, "").strip()
|
|
if not resolver_url or not resolver_secret:
|
|
raise CodexSubscriptionAuthError(
|
|
"Codex subscription resolver is not available. "
|
|
"Restart the desktop app and try again."
|
|
)
|
|
return resolver_url, resolver_secret
|
|
|
|
|
|
def _resolve_access_token(
|
|
email: str, force_refresh: bool = False
|
|
) -> dict[str, Any]:
|
|
resolver_url, resolver_secret = _resolver_endpoint()
|
|
try:
|
|
response = httpx.post(
|
|
f"{resolver_url}/codex/token",
|
|
headers={RESOLVER_SECRET_HEADER: resolver_secret},
|
|
json={"email": email, "force_refresh": force_refresh},
|
|
timeout=5.0,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise CodexSubscriptionAuthError(
|
|
"Codex subscription resolver is unreachable."
|
|
) from exc
|
|
|
|
if response.status_code != 200:
|
|
error_code = "unknown_error"
|
|
try:
|
|
payload = response.json()
|
|
if isinstance(payload, dict):
|
|
error_code = str(payload.get("error_code") or error_code)
|
|
except ValueError:
|
|
pass
|
|
raise CodexSubscriptionAuthError(
|
|
f"Codex subscription token is unavailable: {error_code}"
|
|
)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
raise CodexSubscriptionAuthError(
|
|
"Codex subscription resolver returned an invalid response."
|
|
) from exc
|
|
|
|
access_token = payload.get("access_token")
|
|
if not isinstance(access_token, str) or not access_token.strip():
|
|
raise CodexSubscriptionAuthError(
|
|
"Codex subscription resolver returned an empty token."
|
|
)
|
|
return payload
|
|
|
|
|
|
def _configured_default_headers() -> dict[str, str] | None:
|
|
raw = os.getenv(MODEL_DEFAULT_HEADERS_ENV, "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except ValueError as exc:
|
|
raise CodexSubscriptionAuthError(
|
|
f"{MODEL_DEFAULT_HEADERS_ENV} must be valid JSON."
|
|
) from exc
|
|
if not isinstance(parsed, dict) or not all(
|
|
isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
|
|
):
|
|
raise CodexSubscriptionAuthError(
|
|
f"{MODEL_DEFAULT_HEADERS_ENV} must be a JSON object of strings."
|
|
)
|
|
return parsed
|
|
|
|
|
|
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
parts = token.split(".")
|
|
if len(parts) < 2:
|
|
return {}
|
|
payload = parts[1]
|
|
payload += "=" * (-len(payload) % 4)
|
|
try:
|
|
parsed = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")))
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def _codex_runtime_headers(access_token: str) -> dict[str, str]:
|
|
headers = {
|
|
"User-Agent": "codex_cli_rs/0.0.0 (Eigent)",
|
|
"originator": "codex_cli_rs",
|
|
}
|
|
payload = _decode_jwt_payload(access_token)
|
|
auth = payload.get("https://api.openai.com/auth")
|
|
if isinstance(auth, dict):
|
|
account_id = auth.get("chatgpt_account_id")
|
|
if isinstance(account_id, str) and account_id:
|
|
headers["ChatGPT-Account-ID"] = account_id
|
|
return headers
|
|
|
|
|
|
def _to_responses_content_part(part: Any) -> Any:
|
|
if not isinstance(part, dict):
|
|
return part
|
|
|
|
part_type = part.get("type")
|
|
if part_type == "text":
|
|
return {
|
|
**part,
|
|
"type": "input_text",
|
|
}
|
|
if part_type == "image_url":
|
|
image_url = part.get("image_url")
|
|
if isinstance(image_url, dict):
|
|
converted = {
|
|
"type": "input_image",
|
|
"image_url": image_url.get("url"),
|
|
}
|
|
detail = image_url.get("detail")
|
|
if detail in {"low", "high", "auto"}:
|
|
converted["detail"] = detail
|
|
return converted
|
|
return {
|
|
"type": "input_image",
|
|
"image_url": image_url,
|
|
}
|
|
|
|
return part
|
|
|
|
|
|
def _normalize_responses_multimodal_content(
|
|
input_items: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
normalized_items: list[dict[str, Any]] = []
|
|
for item in input_items:
|
|
if not isinstance(item, dict):
|
|
normalized_items.append(item)
|
|
continue
|
|
|
|
content = item.get("content")
|
|
if not isinstance(content, list):
|
|
normalized_items.append(item)
|
|
continue
|
|
|
|
normalized_items.append(
|
|
{
|
|
**item,
|
|
"content": [
|
|
_to_responses_content_part(part) for part in content
|
|
],
|
|
}
|
|
)
|
|
|
|
return normalized_items
|
|
|
|
|
|
def _install_camel_responses_multimodal_patch() -> None:
|
|
global _CAMEL_RESPONSES_MULTIMODAL_PATCHED
|
|
if _CAMEL_RESPONSES_MULTIMODAL_PATCHED:
|
|
return
|
|
|
|
from camel.models.openai_compatible_model import OpenAICompatibleModel
|
|
from camel.models.openai_model import OpenAIModel
|
|
|
|
for model_class in (OpenAIModel, OpenAICompatibleModel):
|
|
original_converter = model_class._convert_messages_to_responses_input
|
|
|
|
def patched_converter(
|
|
messages,
|
|
_original_converter=original_converter,
|
|
):
|
|
return _normalize_responses_multimodal_content(
|
|
_original_converter(messages)
|
|
)
|
|
|
|
model_class._convert_messages_to_responses_input = staticmethod(
|
|
patched_converter
|
|
)
|
|
|
|
_CAMEL_RESPONSES_MULTIMODAL_PATCHED = True
|
|
|
|
|
|
def apply_codex_subscription_runtime(
|
|
options: Chat,
|
|
effective_config: dict[str, Any],
|
|
extra_params: dict[str, Any],
|
|
force_refresh: bool = False,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
if not is_codex_subscription_auth(options):
|
|
return effective_config, extra_params
|
|
|
|
_install_camel_responses_multimodal_patch()
|
|
|
|
token_payload = _resolve_access_token(
|
|
options.email, force_refresh=force_refresh
|
|
)
|
|
updated_config = dict(effective_config)
|
|
updated_extra_params = dict(extra_params or {})
|
|
|
|
updated_config["api_key"] = token_payload["access_token"]
|
|
updated_config["model_platform"] = (
|
|
updated_config.get("model_platform") or "openai"
|
|
)
|
|
access_token = token_payload["access_token"]
|
|
updated_config["api_url"] = (
|
|
updated_config.get("api_url")
|
|
or os.getenv(MODEL_API_URL_ENV)
|
|
or DEFAULT_CODEX_API_URL
|
|
)
|
|
|
|
default_headers = {
|
|
**_codex_runtime_headers(access_token),
|
|
**(_configured_default_headers() or {}),
|
|
}
|
|
updated_extra_params.setdefault("api_mode", "responses")
|
|
# ChatGPT Codex requires streaming Responses calls and rejects stored
|
|
# Responses calls. CAMEL otherwise chooses streaming from model_config and
|
|
# defaults store=True when api_mode="responses".
|
|
updated_extra_params["stream"] = True
|
|
updated_extra_params["store"] = False
|
|
if default_headers:
|
|
existing_headers = updated_extra_params.get("default_headers")
|
|
if isinstance(existing_headers, dict):
|
|
updated_extra_params["default_headers"] = {
|
|
**default_headers,
|
|
**existing_headers,
|
|
}
|
|
else:
|
|
updated_extra_params["default_headers"] = default_headers
|
|
|
|
return updated_config, updated_extra_params
|