pascal-agent 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pascal/llm/codex.py ADDED
@@ -0,0 +1,331 @@
1
+ """pascal/llm/codex.py -- ChatGPT OAuth + Responses API provider.
2
+
3
+ Uses the same auth as Codex CLI (~/.codex/auth.json).
4
+ Calls https://chatgpt.com/backend-api/codex/responses (NOT standard OpenAI API).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import platform
12
+ import time
13
+ import urllib.parse
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from pascal.types import LLMResponse, Message, Role, TokenUsage, ToolCall
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
23
+ _TOKEN_URL = "https://auth.openai.com/oauth/token"
24
+ _CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
25
+ _AUTH_PATH = Path.home() / ".codex" / "auth.json"
26
+
27
+
28
+ class CodexProvider:
29
+ """ChatGPT Pro/Plus OAuth provider using Codex Responses API."""
30
+
31
+ def __init__(self, model: str = "gpt-5.4-mini") -> None:
32
+ self._model = model
33
+ self._access_token: str = ""
34
+ self._refresh_token: str = ""
35
+ self._account_id: str = ""
36
+ self._expires_at: float = 0.0
37
+ self._load_auth()
38
+
39
+ def _load_auth(self) -> None:
40
+ if not _AUTH_PATH.exists():
41
+ raise FileNotFoundError(
42
+ f"Codex auth not found at {_AUTH_PATH}. Run 'codex auth login' first."
43
+ )
44
+ data = json.loads(_AUTH_PATH.read_text(encoding="utf-8"))
45
+ tokens = data.get("tokens", {})
46
+ self._access_token = tokens.get("access_token", "")
47
+ self._refresh_token = tokens.get("refresh_token", "")
48
+ self._account_id = tokens.get("account_id", "")
49
+ if not self._account_id:
50
+ self._account_id = self._extract_account_id(tokens.get("id_token", ""))
51
+ # Decode expiry from access token JWT
52
+ self._expires_at = self._jwt_exp(self._access_token)
53
+
54
+ def _refresh_if_needed(self) -> None:
55
+ if time.time() < self._expires_at - 60:
56
+ return
57
+ if not self._refresh_token:
58
+ raise RuntimeError("No refresh token. Run 'codex auth login'.")
59
+ logger.info("Refreshing Codex OAuth token...")
60
+ body = urllib.parse.urlencode({
61
+ "grant_type": "refresh_token",
62
+ "refresh_token": self._refresh_token,
63
+ "client_id": _CLIENT_ID,
64
+ }).encode()
65
+ req = urllib.request.Request(
66
+ _TOKEN_URL, data=body,
67
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
68
+ )
69
+ with urllib.request.urlopen(req, timeout=15) as resp:
70
+ result = json.loads(resp.read())
71
+ self._access_token = result["access_token"]
72
+ if "refresh_token" in result:
73
+ self._refresh_token = result["refresh_token"]
74
+ self._expires_at = self._jwt_exp(self._access_token)
75
+ if not self._account_id:
76
+ self._account_id = self._extract_account_id(self._access_token)
77
+ # Save back to auth.json
78
+ self._save_auth()
79
+ logger.info("Token refreshed, expires at %s", time.ctime(self._expires_at))
80
+
81
+ def _save_auth(self) -> None:
82
+ data = json.loads(_AUTH_PATH.read_text(encoding="utf-8"))
83
+ data["tokens"]["access_token"] = self._access_token
84
+ if self._refresh_token:
85
+ data["tokens"]["refresh_token"] = self._refresh_token
86
+ if self._account_id:
87
+ data["tokens"]["account_id"] = self._account_id
88
+ _AUTH_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
89
+
90
+ async def chat(self, messages: list[Message], tools: list[dict] | None = None) -> LLMResponse:
91
+ self._refresh_if_needed()
92
+ body = self._build_body(messages, tools)
93
+ headers = self._build_headers()
94
+ return await self._stream_request(headers, body)
95
+
96
+ def _build_headers(self) -> dict[str, str]:
97
+ h = {
98
+ "Authorization": f"Bearer {self._access_token}",
99
+ "Content-Type": "application/json",
100
+ "Accept": "text/event-stream",
101
+ "OpenAI-Beta": "responses=experimental",
102
+ "originator": "pascal",
103
+ "User-Agent": f"pascal/0.2.0 ({platform.system()} {platform.release()}; {platform.machine()})",
104
+ }
105
+ if self._account_id:
106
+ h["ChatGPT-Account-Id"] = self._account_id
107
+ return h
108
+
109
+ def _build_body(self, messages: list[Message], tools: list[dict] | None) -> dict:
110
+ instructions = ""
111
+ input_items: list[dict] = []
112
+
113
+ for msg in messages:
114
+ if msg.role == Role.SYSTEM:
115
+ instructions += msg.content + "\n"
116
+ elif msg.role == Role.USER:
117
+ content = self._convert_user_content(msg)
118
+ input_items.append({"role": "user", "content": content})
119
+ elif msg.role == Role.ASSISTANT:
120
+ input_items.extend(self._convert_assistant_items(msg))
121
+ elif msg.role == Role.TOOL:
122
+ input_items.append(self._convert_tool_output(msg))
123
+
124
+ body: dict[str, Any] = {
125
+ "model": self._model,
126
+ "store": False,
127
+ "stream": True,
128
+ "instructions": instructions.strip(),
129
+ "input": input_items,
130
+ }
131
+ if tools:
132
+ body["tools"] = self._convert_tools(tools)
133
+ body["tool_choice"] = "auto"
134
+ return body
135
+
136
+ @staticmethod
137
+ def _convert_assistant_items(msg: Message) -> list[dict]:
138
+ items: list[dict] = []
139
+ tool_calls = list(getattr(msg, "tool_calls", None) or [])
140
+ content = [{"type": "output_text", "text": msg.content}] if msg.content else []
141
+
142
+ if content or not tool_calls:
143
+ items.append({
144
+ "type": "message",
145
+ "role": "assistant",
146
+ "content": content or [{"type": "output_text", "text": ""}],
147
+ "status": "completed",
148
+ })
149
+
150
+ for tc in tool_calls:
151
+ items.append({
152
+ "type": "function_call",
153
+ "call_id": tc.id,
154
+ "name": tc.name,
155
+ "arguments": json.dumps(tc.params),
156
+ })
157
+
158
+ return items
159
+
160
+ @staticmethod
161
+ def _convert_tool_output(msg: Message) -> dict:
162
+ return {
163
+ "type": "function_call_output",
164
+ "call_id": msg.tool_call_id,
165
+ "output": msg.content,
166
+ }
167
+
168
+ def _convert_user_content(self, msg: Message) -> list[dict]:
169
+ blocks: list[dict] = []
170
+ if msg.content:
171
+ blocks.append({"type": "input_text", "text": msg.content})
172
+ for att in msg.attachments:
173
+ if att.type == "image":
174
+ blocks.append({
175
+ "type": "input_image",
176
+ "image_url": f"data:{att.mime_type};base64,{att.data}",
177
+ "detail": att.detail,
178
+ })
179
+ return blocks or [{"type": "input_text", "text": ""}]
180
+
181
+ @staticmethod
182
+ def _convert_tools(tools: list[dict]) -> list[dict]:
183
+ converted = []
184
+ for t in tools:
185
+ fn = t.get("function", t)
186
+ converted.append({
187
+ "type": "function",
188
+ "name": fn.get("name", ""),
189
+ "description": fn.get("description", ""),
190
+ "parameters": fn.get("parameters", {}),
191
+ "strict": None,
192
+ })
193
+ return converted
194
+
195
+ async def _stream_request(self, headers: dict, body: dict) -> LLMResponse:
196
+ import httpx
197
+ text_parts: list[str] = []
198
+ usage: TokenUsage | None = None
199
+ # Track function calls being built: output_index -> {call_id, name, arg_parts}
200
+ fn_calls: dict[int, dict[str, Any]] = {}
201
+
202
+ async with httpx.AsyncClient(timeout=120.0) as client:
203
+ async with client.stream(
204
+ "POST", _CODEX_URL, headers=headers,
205
+ json=body,
206
+ ) as response:
207
+ if response.status_code != 200:
208
+ error_body = await response.aread()
209
+ raise RuntimeError(
210
+ f"Codex API {response.status_code}: {error_body.decode()[:500]}"
211
+ )
212
+ buffer = ""
213
+ async for chunk in response.aiter_text():
214
+ buffer += chunk
215
+ while "\n\n" in buffer:
216
+ event_str, buffer = buffer.split("\n\n", 1)
217
+ for line in event_str.split("\n"):
218
+ if line.startswith("data: "):
219
+ data = line[6:]
220
+ if data == "[DONE]":
221
+ continue
222
+ try:
223
+ evt = json.loads(data)
224
+ except json.JSONDecodeError:
225
+ continue
226
+ self._handle_event(evt, text_parts, fn_calls)
227
+ # Extract usage from completed event
228
+ if evt.get("type") in ("response.completed", "response.done"):
229
+ resp_obj = evt.get("response", {})
230
+ u = resp_obj.get("usage", {})
231
+ if u:
232
+ usage = TokenUsage(
233
+ prompt_tokens=u.get("input_tokens", 0),
234
+ completion_tokens=u.get("output_tokens", 0),
235
+ total_tokens=u.get("total_tokens", 0),
236
+ )
237
+
238
+ # Build ToolCall list from completed function calls
239
+ tool_calls: list[ToolCall] = []
240
+ for idx in sorted(fn_calls):
241
+ fc = fn_calls[idx]
242
+ args_str = "".join(fc.get("arg_parts", []))
243
+ try:
244
+ params = json.loads(args_str) if args_str else {}
245
+ except (json.JSONDecodeError, ValueError):
246
+ params = {}
247
+ tool_calls.append(ToolCall(
248
+ id=fc.get("call_id", ""),
249
+ name=fc.get("name", ""),
250
+ params=params,
251
+ ))
252
+
253
+ return LLMResponse(
254
+ text="".join(text_parts) or None,
255
+ tool_calls=tool_calls,
256
+ usage=usage or TokenUsage(),
257
+ )
258
+
259
+ @staticmethod
260
+ def _handle_event(
261
+ evt: dict,
262
+ text_parts: list[str],
263
+ fn_calls: dict[int, dict[str, Any]],
264
+ ) -> None:
265
+ evt_type = evt.get("type", "")
266
+ if evt_type == "response.output_text.delta":
267
+ delta = evt.get("delta", "")
268
+ if delta:
269
+ text_parts.append(delta)
270
+ elif evt_type == "response.output_item.added":
271
+ item = evt.get("item", {})
272
+ if item.get("type") == "function_call":
273
+ idx = evt.get("output_index", 0)
274
+ fn_calls[idx] = {
275
+ "call_id": item.get("call_id", ""),
276
+ "name": item.get("name", ""),
277
+ "arg_parts": [item.get("arguments", "")],
278
+ }
279
+ elif evt_type == "response.function_call_arguments.delta":
280
+ idx = evt.get("output_index", 0)
281
+ delta = evt.get("delta", "")
282
+ if idx in fn_calls and delta:
283
+ fn_calls[idx]["arg_parts"].append(delta)
284
+ elif evt_type == "response.output_item.done":
285
+ item = evt.get("item", {})
286
+ if item.get("type") == "function_call":
287
+ idx = evt.get("output_index", 0)
288
+ # If we missed the .added event, populate from the done event
289
+ if idx not in fn_calls:
290
+ fn_calls[idx] = {
291
+ "call_id": item.get("call_id", ""),
292
+ "name": item.get("name", ""),
293
+ "arg_parts": [item.get("arguments", "")],
294
+ }
295
+ else:
296
+ # Update call_id/name in case they were empty in the added event
297
+ fc = fn_calls[idx]
298
+ if not fc.get("call_id"):
299
+ fc["call_id"] = item.get("call_id", "")
300
+ if not fc.get("name"):
301
+ fc["name"] = item.get("name", "")
302
+ elif evt_type == "response.failed":
303
+ error = evt.get("response", {}).get("error", {})
304
+ raise RuntimeError(f"Codex API error: {error}")
305
+
306
+ @staticmethod
307
+ def _jwt_exp(token: str) -> float:
308
+ if not token:
309
+ return 0.0
310
+ try:
311
+ import base64
312
+ payload = token.split(".")[1]
313
+ payload += "=" * (4 - len(payload) % 4)
314
+ data = json.loads(base64.b64decode(payload))
315
+ return float(data.get("exp", 0))
316
+ except Exception:
317
+ return 0.0
318
+
319
+ @staticmethod
320
+ def _extract_account_id(token: str) -> str:
321
+ if not token:
322
+ return ""
323
+ try:
324
+ import base64
325
+ payload = token.split(".")[1]
326
+ payload += "=" * (4 - len(payload) % 4)
327
+ data = json.loads(base64.b64decode(payload))
328
+ auth = data.get("https://api.openai.com/auth", {})
329
+ return auth.get("chatgpt_account_id", "")
330
+ except Exception:
331
+ return ""
pascal/llm/openai.py ADDED
@@ -0,0 +1,224 @@
1
+ """pascal/llm/openai.py -- OpenAI 호환 프로바이더."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Literal, TypeAlias
8
+
9
+ import openai
10
+ from openai.types.chat import (
11
+ ChatCompletionAssistantMessageParam,
12
+ ChatCompletionContentPartImageParam,
13
+ ChatCompletionContentPartParam,
14
+ ChatCompletionContentPartTextParam,
15
+ ChatCompletionFunctionToolParam,
16
+ ChatCompletionMessageFunctionToolCallParam,
17
+ ChatCompletionMessageParam,
18
+ ChatCompletionSystemMessageParam,
19
+ ChatCompletionToolMessageParam,
20
+ ChatCompletionUserMessageParam,
21
+ )
22
+
23
+ from pascal.types import ContentBlock, LLMResponse, Message, Role, TokenUsage, ToolCall
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ OpenAIImageDetail = Literal["auto", "low", "high"]
28
+ OpenAITextContent: TypeAlias = str | list[ChatCompletionContentPartTextParam]
29
+
30
+
31
+ class OpenAIProvider:
32
+ """OpenAI Chat Completions API를 사용하는 LLM 프로바이더."""
33
+
34
+ def __init__(self, model: str = "gpt-5.4-mini", base_url: str = "") -> None:
35
+ if base_url:
36
+ self._client = openai.AsyncOpenAI(base_url=base_url)
37
+ else:
38
+ self._client = openai.AsyncOpenAI()
39
+ self._model = model
40
+
41
+ async def chat(
42
+ self,
43
+ messages: list[Message],
44
+ tools: list[dict] | None = None,
45
+ ) -> LLMResponse:
46
+ api_messages: list[ChatCompletionMessageParam] = [self._convert_message(m) for m in messages]
47
+ api_tools = self._convert_tools(tools) if tools else None
48
+
49
+ if api_tools is not None:
50
+ response = await self._client.chat.completions.create(
51
+ model=self._model,
52
+ messages=api_messages,
53
+ tools=api_tools,
54
+ )
55
+ else:
56
+ response = await self._client.chat.completions.create(
57
+ model=self._model,
58
+ messages=api_messages,
59
+ )
60
+ return self._parse_response(response)
61
+
62
+ def _convert_message(self, msg: Message) -> ChatCompletionMessageParam:
63
+ if msg.role == Role.TOOL:
64
+ tool_message: ChatCompletionToolMessageParam = {
65
+ "role": "tool",
66
+ "tool_call_id": msg.tool_call_id,
67
+ "content": msg.content,
68
+ }
69
+ return tool_message
70
+
71
+ if msg.role == Role.SYSTEM:
72
+ system_message: ChatCompletionSystemMessageParam = {
73
+ "role": "system",
74
+ "content": self._convert_text_content(msg),
75
+ }
76
+ return system_message
77
+
78
+ if msg.role == Role.USER:
79
+ user_message: ChatCompletionUserMessageParam = {
80
+ "role": "user",
81
+ "content": self._convert_content(msg),
82
+ }
83
+ return user_message
84
+
85
+ if msg.role == Role.ASSISTANT and getattr(msg, "tool_calls", None):
86
+ assistant_tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [
87
+ {
88
+ "id": tc.id,
89
+ "type": "function",
90
+ "function": {
91
+ "name": tc.name,
92
+ "arguments": json.dumps(tc.params),
93
+ },
94
+ }
95
+ for tc in msg.tool_calls
96
+ ]
97
+ assistant_message: ChatCompletionAssistantMessageParam = {
98
+ "role": "assistant",
99
+ "content": self._convert_text_content(msg) or None,
100
+ "tool_calls": assistant_tool_calls,
101
+ }
102
+ return assistant_message
103
+
104
+ plain_assistant_message: ChatCompletionAssistantMessageParam = {
105
+ "role": "assistant",
106
+ "content": self._convert_text_content(msg),
107
+ }
108
+ return plain_assistant_message
109
+
110
+ def _convert_content(self, msg: Message) -> str | list[ChatCompletionContentPartParam]:
111
+ if not msg.attachments:
112
+ return msg.content
113
+
114
+ blocks: list[ChatCompletionContentPartParam] = []
115
+ if msg.content:
116
+ blocks.append({"type": "text", "text": msg.content})
117
+ for attachment in msg.attachments:
118
+ block = self._convert_attachment(attachment)
119
+ if block is not None:
120
+ blocks.append(block)
121
+ return blocks
122
+
123
+ def _convert_text_content(self, msg: Message) -> OpenAITextContent:
124
+ if not msg.attachments:
125
+ return msg.content
126
+
127
+ blocks: list[ChatCompletionContentPartTextParam] = []
128
+ if msg.content:
129
+ blocks.append({"type": "text", "text": msg.content})
130
+ for attachment in msg.attachments:
131
+ if attachment.type == "text":
132
+ blocks.append({"type": "text", "text": attachment.data})
133
+ else:
134
+ logger.warning(
135
+ "Unsupported non-text content block for OpenAI %s message: %s",
136
+ msg.role.value,
137
+ attachment.type,
138
+ )
139
+ return blocks
140
+
141
+ def _convert_attachment(self, attachment: ContentBlock) -> ChatCompletionContentPartParam | None:
142
+ if attachment.type == "image":
143
+ image_block: ChatCompletionContentPartImageParam = {
144
+ "type": "image_url",
145
+ "image_url": {
146
+ "url": f"data:{attachment.mime_type};base64,{attachment.data}",
147
+ "detail": self._normalize_image_detail(attachment.detail),
148
+ },
149
+ }
150
+ return image_block
151
+ if attachment.type == "text":
152
+ text_block: ChatCompletionContentPartTextParam = {"type": "text", "text": attachment.data}
153
+ return text_block
154
+
155
+ logger.warning("Unsupported content block for OpenAI provider: %s", attachment.type)
156
+ return None
157
+
158
+ @staticmethod
159
+ def _normalize_image_detail(detail: str) -> OpenAIImageDetail:
160
+ if detail == "low":
161
+ return "low"
162
+ if detail == "high":
163
+ return "high"
164
+ return "auto"
165
+
166
+ @staticmethod
167
+ def _convert_tools(tools: list[dict]) -> list[ChatCompletionFunctionToolParam]:
168
+ default_parameters: dict[str, object] = {"type": "object", "properties": {}}
169
+ result: list[ChatCompletionFunctionToolParam] = []
170
+ for tool in tools:
171
+ func = tool.get("function", tool)
172
+ name: str = func["name"]
173
+ description: str = func.get("description", "")
174
+ parameters: dict[str, object] = func.get("parameters", default_parameters)
175
+ result.append(
176
+ {
177
+ "type": "function",
178
+ "function": {
179
+ "name": name,
180
+ "description": description,
181
+ "parameters": parameters,
182
+ },
183
+ }
184
+ )
185
+ return result
186
+
187
+ @classmethod
188
+ def _parse_response(cls, response) -> LLMResponse:
189
+ choice = response.choices[0]
190
+ msg = choice.message
191
+
192
+ return LLMResponse(
193
+ text=msg.content,
194
+ tool_calls=cls._parse_tool_calls(msg.tool_calls or []),
195
+ usage=cls._parse_usage(getattr(response, "usage", None)),
196
+ )
197
+
198
+ @staticmethod
199
+ def _parse_tool_calls(raw_tool_calls) -> list[ToolCall]:
200
+ tool_calls: list[ToolCall] = []
201
+ for tc in raw_tool_calls:
202
+ try:
203
+ params = json.loads(tc.function.arguments)
204
+ except json.JSONDecodeError:
205
+ params = {}
206
+ tool_calls.append(
207
+ ToolCall(
208
+ id=tc.id,
209
+ name=tc.function.name,
210
+ params=params,
211
+ )
212
+ )
213
+ return tool_calls
214
+
215
+ @staticmethod
216
+ def _parse_usage(raw_usage) -> TokenUsage:
217
+ if raw_usage is None:
218
+ return TokenUsage()
219
+ return TokenUsage(
220
+ prompt_tokens=int(getattr(raw_usage, "prompt_tokens", 0) or 0),
221
+ completion_tokens=int(getattr(raw_usage, "completion_tokens", 0) or 0),
222
+ total_tokens=int(getattr(raw_usage, "total_tokens", 0) or 0),
223
+ )
224
+