返回 JoyAI-Echo
openai_codex_provider.py
根目录 / echo_longvideo / Director_Agent / nanobot / providers / openai_codex_provider.py
1 """OpenAI Codex Responses Provider."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import hashlib
7 import json
8 from collections.abc import Awaitable, Callable
9 from typing import Any
10
11 import httpx
12 from loguru import logger
13 from oauth_cli_kit import get_token as get_codex_token
14
15 from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
16 from nanobot.providers.openai_responses import (
17 consume_sse,
18 convert_messages,
19 convert_tools,
20 )
21
22 DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
23 DEFAULT_ORIGINATOR = "nanobot"
24
25
26 class OpenAICodexProvider(LLMProvider):
27 """Use Codex OAuth to call the Responses API."""
28
29 def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
30 super().__init__(api_key=None, api_base=None)
31 self.default_model = default_model
32
33 async def _call_codex(
34 self,
35 messages: list[dict[str, Any]],
36 tools: list[dict[str, Any]] | None,
37 model: str | None,
38 reasoning_effort: str | None,
39 tool_choice: str | dict[str, Any] | None,
40 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
41 ) -> LLMResponse:
42 """Shared request logic for both chat() and chat_stream()."""
43 model = model or self.default_model
44 system_prompt, input_items = convert_messages(messages)
45
46 token = await asyncio.to_thread(get_codex_token)
47 headers = _build_headers(token.account_id, token.access)
48
49 body: dict[str, Any] = {
50 "model": _strip_model_prefix(model),
51 "store": False,
52 "stream": True,
53 "instructions": system_prompt,
54 "input": input_items,
55 "text": {"verbosity": "medium"},
56 "include": ["reasoning.encrypted_content"],
57 "prompt_cache_key": _prompt_cache_key(messages),
58 "tool_choice": tool_choice or "auto",
59 "parallel_tool_calls": True,
60 }
61 if reasoning_effort:
62 body["reasoning"] = {"effort": reasoning_effort}
63 if tools:
64 body["tools"] = convert_tools(tools)
65
66 try:
67 try:
68 content, tool_calls, finish_reason = await _request_codex(
69 DEFAULT_CODEX_URL, headers, body, verify=True,
70 on_content_delta=on_content_delta,
71 )
72 except Exception as e:
73 if "CERTIFICATE_VERIFY_FAILED" not in str(e):
74 raise
75 logger.warning("SSL verification failed for Codex API; retrying with verify=False")
76 content, tool_calls, finish_reason = await _request_codex(
77 DEFAULT_CODEX_URL, headers, body, verify=False,
78 on_content_delta=on_content_delta,
79 )
80 return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
81 except Exception as e:
82 msg = f"Error calling Codex: {e}"
83 retry_after = getattr(e, "retry_after", None) or self._extract_retry_after(msg)
84 return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
85
86 async def chat(
87 self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
88 model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
89 reasoning_effort: str | None = None,
90 tool_choice: str | dict[str, Any] | None = None,
91 ) -> LLMResponse:
92 return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice)
93
94 async def chat_stream(
95 self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
96 model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
97 reasoning_effort: str | None = None,
98 tool_choice: str | dict[str, Any] | None = None,
99 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
100 ) -> LLMResponse:
101 return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
102
103 def get_default_model(self) -> str:
104 return self.default_model
105
106
107 def _strip_model_prefix(model: str) -> str:
108 if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
109 return model.split("/", 1)[1]
110 return model
111
112
113 def _build_headers(account_id: str, token: str) -> dict[str, str]:
114 return {
115 "Authorization": f"Bearer {token}",
116 "chatgpt-account-id": account_id,
117 "OpenAI-Beta": "responses=experimental",
118 "originator": DEFAULT_ORIGINATOR,
119 "User-Agent": "nanobot (python)",
120 "accept": "text/event-stream",
121 "content-type": "application/json",
122 }
123
124
125 class _CodexHTTPError(RuntimeError):
126 def __init__(self, message: str, retry_after: float | None = None):
127 super().__init__(message)
128 self.retry_after = retry_after
129
130
131 async def _request_codex(
132 url: str,
133 headers: dict[str, str],
134 body: dict[str, Any],
135 verify: bool,
136 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
137 ) -> tuple[str, list[ToolCallRequest], str]:
138 async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
139 async with client.stream("POST", url, headers=headers, json=body) as response:
140 if response.status_code != 200:
141 text = await response.aread()
142 retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
143 raise _CodexHTTPError(
144 _friendly_error(response.status_code, text.decode("utf-8", "ignore")),
145 retry_after=retry_after,
146 )
147 return await consume_sse(response, on_content_delta)
148
149
150 def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
151 raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
152 return hashlib.sha256(raw.encode("utf-8")).hexdigest()
153
154
155 def _friendly_error(status_code: int, raw: str) -> str:
156 if status_code == 429:
157 return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
158 return f"HTTP {status_code}: {raw}"
159
159 lines PYTHON