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/__init__.py +3 -0
- pascal/__main__.py +880 -0
- pascal/actions.py +1066 -0
- pascal/capability.py +218 -0
- pascal/channels/__init__.py +0 -0
- pascal/channels/telegram.py +108 -0
- pascal/clipboard.py +38 -0
- pascal/config.py +134 -0
- pascal/daemon.py +211 -0
- pascal/desk.py +633 -0
- pascal/effect.py +155 -0
- pascal/eval/__init__.py +1 -0
- pascal/eval/smoke.py +213 -0
- pascal/llm/__init__.py +1 -0
- pascal/llm/anthropic.py +225 -0
- pascal/llm/codex.py +331 -0
- pascal/llm/openai.py +224 -0
- pascal/loop.py +1037 -0
- pascal/mcp.py +206 -0
- pascal/prompt.py +141 -0
- pascal/receipts.py +147 -0
- pascal/sandbox.py +287 -0
- pascal/scheduler.py +243 -0
- pascal/schemas.py +183 -0
- pascal/state.py +790 -0
- pascal/tools.py +672 -0
- pascal/trust.py +150 -0
- pascal/types.py +337 -0
- pascal/uia.py +316 -0
- pascal_agent-0.3.0.dist-info/METADATA +262 -0
- pascal_agent-0.3.0.dist-info/RECORD +33 -0
- pascal_agent-0.3.0.dist-info/WHEEL +4 -0
- pascal_agent-0.3.0.dist-info/entry_points.txt +2 -0
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
|
+
|