appkit-assistant 0.7.1__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.
- appkit_assistant/backend/model_manager.py +133 -0
- appkit_assistant/backend/models.py +103 -0
- appkit_assistant/backend/processor.py +46 -0
- appkit_assistant/backend/processors/ai_models.py +109 -0
- appkit_assistant/backend/processors/knowledgeai_processor.py +275 -0
- appkit_assistant/backend/processors/lorem_ipsum_processor.py +123 -0
- appkit_assistant/backend/processors/openai_base.py +73 -0
- appkit_assistant/backend/processors/openai_chat_completion_processor.py +117 -0
- appkit_assistant/backend/processors/openai_responses_processor.py +508 -0
- appkit_assistant/backend/processors/perplexity_processor.py +118 -0
- appkit_assistant/backend/repositories.py +96 -0
- appkit_assistant/backend/system_prompt.py +56 -0
- appkit_assistant/components/__init__.py +38 -0
- appkit_assistant/components/composer.py +154 -0
- appkit_assistant/components/composer_key_handler.py +38 -0
- appkit_assistant/components/mcp_server_dialogs.py +344 -0
- appkit_assistant/components/mcp_server_table.py +76 -0
- appkit_assistant/components/message.py +299 -0
- appkit_assistant/components/thread.py +252 -0
- appkit_assistant/components/threadlist.py +134 -0
- appkit_assistant/components/tools_modal.py +97 -0
- appkit_assistant/configuration.py +10 -0
- appkit_assistant/state/mcp_server_state.py +222 -0
- appkit_assistant/state/thread_state.py +874 -0
- appkit_assistant-0.7.1.dist-info/METADATA +8 -0
- appkit_assistant-0.7.1.dist-info/RECORD +27 -0
- appkit_assistant-0.7.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from appkit_assistant.backend.models import (
|
|
7
|
+
AIModel,
|
|
8
|
+
Chunk,
|
|
9
|
+
ChunkType,
|
|
10
|
+
MCPServer,
|
|
11
|
+
Message,
|
|
12
|
+
MessageType,
|
|
13
|
+
)
|
|
14
|
+
from appkit_assistant.backend.processors.openai_base import BaseOpenAIProcessor
|
|
15
|
+
from appkit_assistant.backend.system_prompt import SYSTEM_PROMPT
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAIResponsesProcessor(BaseOpenAIProcessor):
|
|
21
|
+
"""Simplified processor using content accumulator pattern."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
models: dict[str, AIModel],
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
base_url: str | None = None,
|
|
28
|
+
is_azure: bool = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(models, api_key, base_url, is_azure)
|
|
31
|
+
self._current_reasoning_session: str | None = None
|
|
32
|
+
|
|
33
|
+
async def process(
|
|
34
|
+
self,
|
|
35
|
+
messages: list[Message],
|
|
36
|
+
model_id: str,
|
|
37
|
+
files: list[str] | None = None, # noqa: ARG002
|
|
38
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
39
|
+
payload: dict[str, Any] | None = None,
|
|
40
|
+
) -> AsyncGenerator[Chunk, None]:
|
|
41
|
+
"""Process messages using simplified content accumulator pattern."""
|
|
42
|
+
if not self.client:
|
|
43
|
+
raise ValueError("OpenAI Client not initialized.")
|
|
44
|
+
|
|
45
|
+
if model_id not in self.models:
|
|
46
|
+
msg = f"Model {model_id} not supported by OpenAI processor"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
|
|
49
|
+
model = self.models[model_id]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
session = await self._create_responses_request(
|
|
53
|
+
messages, model, mcp_servers, payload
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if hasattr(session, "__aiter__"): # Streaming
|
|
57
|
+
async for event in session:
|
|
58
|
+
chunk = self._handle_event(event)
|
|
59
|
+
if chunk:
|
|
60
|
+
yield chunk
|
|
61
|
+
else: # Non-streaming
|
|
62
|
+
content = self._extract_responses_content(session)
|
|
63
|
+
if content:
|
|
64
|
+
yield Chunk(
|
|
65
|
+
type=ChunkType.TEXT,
|
|
66
|
+
text=content,
|
|
67
|
+
chunk_metadata={
|
|
68
|
+
"source": "responses_api",
|
|
69
|
+
"streaming": "false",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
raise e
|
|
74
|
+
|
|
75
|
+
def _handle_event(self, event: Any) -> Chunk | None:
|
|
76
|
+
"""Simplified event handler returning actual event content in chunks."""
|
|
77
|
+
if not hasattr(event, "type"):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
event_type = event.type
|
|
81
|
+
logger.debug("Event: %s", event)
|
|
82
|
+
|
|
83
|
+
# Try different handlers in order
|
|
84
|
+
handlers = [
|
|
85
|
+
self._handle_lifecycle_events,
|
|
86
|
+
lambda et: self._handle_text_events(et, event),
|
|
87
|
+
lambda et: self._handle_item_events(et, event),
|
|
88
|
+
lambda et: self._handle_mcp_events(et, event),
|
|
89
|
+
lambda et: self._handle_content_events(et, event),
|
|
90
|
+
lambda et: self._handle_completion_events(et, event),
|
|
91
|
+
lambda et: self._handle_image_events(et, event),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
for handler in handlers:
|
|
95
|
+
result = handler(event_type)
|
|
96
|
+
if result:
|
|
97
|
+
content_preview = result.text[:50] if result.text else ""
|
|
98
|
+
logger.info(
|
|
99
|
+
"Event %s → Chunk: type=%s, content=%s",
|
|
100
|
+
event_type,
|
|
101
|
+
result.type,
|
|
102
|
+
content_preview,
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
# Log unhandled events for debugging
|
|
107
|
+
logger.debug("Unhandled event type: %s", event_type)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _handle_lifecycle_events(self, event_type: str) -> Chunk | None:
|
|
111
|
+
"""Handle lifecycle events."""
|
|
112
|
+
lifecycle_events = {
|
|
113
|
+
"response.created": ("created", {"stage": "created"}),
|
|
114
|
+
"response.in_progress": ("in_progress", {"stage": "in_progress"}),
|
|
115
|
+
"response.done": ("done", {"stage": "done"}),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if event_type in lifecycle_events:
|
|
119
|
+
content, metadata = lifecycle_events[event_type]
|
|
120
|
+
chunk_type = (
|
|
121
|
+
ChunkType.LIFECYCLE
|
|
122
|
+
if event_type != "response.done"
|
|
123
|
+
else ChunkType.COMPLETION
|
|
124
|
+
)
|
|
125
|
+
return self._create_chunk(chunk_type, content, metadata)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def _handle_text_events(self, event_type: str, event: Any) -> Chunk | None:
|
|
129
|
+
"""Handle text-related events."""
|
|
130
|
+
if event_type == "response.output_text.delta":
|
|
131
|
+
return self._create_chunk(
|
|
132
|
+
ChunkType.TEXT, event.delta, {"delta": event.delta}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if event_type == "response.output_text.annotation.added":
|
|
136
|
+
return self._create_chunk(
|
|
137
|
+
ChunkType.ANNOTATION,
|
|
138
|
+
event.annotation,
|
|
139
|
+
{"annotation": event.annotation},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _handle_item_events(self, event_type: str, event: Any) -> Chunk | None:
|
|
145
|
+
"""Handle item added/done events for MCP calls and reasoning."""
|
|
146
|
+
if (
|
|
147
|
+
event_type == "response.output_item.added"
|
|
148
|
+
and hasattr(event, "item")
|
|
149
|
+
and hasattr(event.item, "type")
|
|
150
|
+
):
|
|
151
|
+
return self._handle_item_added(event.item)
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
event_type == "response.output_item.done"
|
|
155
|
+
and hasattr(event, "item")
|
|
156
|
+
and hasattr(event.item, "type")
|
|
157
|
+
):
|
|
158
|
+
return self._handle_item_done(event.item)
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _handle_item_added(self, item: Any) -> Chunk | None:
|
|
163
|
+
"""Handle when an item is added."""
|
|
164
|
+
if item.type == "mcp_call":
|
|
165
|
+
tool_name = getattr(item, "name", "unknown_tool")
|
|
166
|
+
tool_id = getattr(item, "id", "unknown_id")
|
|
167
|
+
server_label = getattr(item, "server_label", "unknown_server")
|
|
168
|
+
return self._create_chunk(
|
|
169
|
+
ChunkType.TOOL_CALL,
|
|
170
|
+
f"Benutze Werkzeug: {server_label}.{tool_name}",
|
|
171
|
+
{
|
|
172
|
+
"tool_name": tool_name,
|
|
173
|
+
"tool_id": tool_id,
|
|
174
|
+
"server_label": server_label,
|
|
175
|
+
"status": "starting",
|
|
176
|
+
"reasoning_session": self._current_reasoning_session,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if item.type == "reasoning":
|
|
181
|
+
reasoning_id = getattr(item, "id", "unknown_id")
|
|
182
|
+
# Track the current reasoning session
|
|
183
|
+
self._current_reasoning_session = reasoning_id
|
|
184
|
+
return self._create_chunk(
|
|
185
|
+
ChunkType.THINKING,
|
|
186
|
+
"Denke nach...",
|
|
187
|
+
{"reasoning_id": reasoning_id, "status": "starting"},
|
|
188
|
+
)
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def _handle_item_done(self, item: Any) -> Chunk | None:
|
|
192
|
+
"""Handle when an item is completed."""
|
|
193
|
+
if item.type == "mcp_call":
|
|
194
|
+
return self._handle_mcp_call_done(item)
|
|
195
|
+
|
|
196
|
+
if item.type == "reasoning":
|
|
197
|
+
reasoning_id = getattr(item, "id", "unknown_id")
|
|
198
|
+
summary = getattr(item, "summary", [])
|
|
199
|
+
summary_text = str(summary) if summary else "beendet."
|
|
200
|
+
return self._create_chunk(
|
|
201
|
+
ChunkType.THINKING_RESULT,
|
|
202
|
+
summary_text,
|
|
203
|
+
{"reasoning_id": reasoning_id, "status": "completed"},
|
|
204
|
+
)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _handle_mcp_call_done(self, item: Any) -> Chunk | None:
|
|
208
|
+
"""Handle MCP call completion."""
|
|
209
|
+
tool_id = getattr(item, "id", "unknown_id")
|
|
210
|
+
tool_name = getattr(item, "name", "unknown_tool")
|
|
211
|
+
error = getattr(item, "error", None)
|
|
212
|
+
output = getattr(item, "output", None)
|
|
213
|
+
|
|
214
|
+
if error:
|
|
215
|
+
error_text = self._extract_error_text(error)
|
|
216
|
+
return self._create_chunk(
|
|
217
|
+
ChunkType.TOOL_RESULT,
|
|
218
|
+
f"Werkzeugfehler: {error_text}",
|
|
219
|
+
{
|
|
220
|
+
"tool_id": tool_id,
|
|
221
|
+
"tool_name": tool_name,
|
|
222
|
+
"status": "error",
|
|
223
|
+
"error": True,
|
|
224
|
+
"error_details": str(error),
|
|
225
|
+
"reasoning_session": self._current_reasoning_session,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
output_text = str(output) if output else "Werkzeug erfolgreich aufgerufen"
|
|
230
|
+
return self._create_chunk(
|
|
231
|
+
ChunkType.TOOL_RESULT,
|
|
232
|
+
output_text,
|
|
233
|
+
{
|
|
234
|
+
"tool_id": tool_id,
|
|
235
|
+
"tool_name": tool_name,
|
|
236
|
+
"status": "completed",
|
|
237
|
+
"reasoning_session": self._current_reasoning_session,
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _extract_error_text(self, error: Any) -> str:
|
|
242
|
+
"""Extract readable error text from error object."""
|
|
243
|
+
if isinstance(error, dict) and "content" in error:
|
|
244
|
+
content = error["content"]
|
|
245
|
+
if isinstance(content, list) and content:
|
|
246
|
+
return content[0].get("text", str(error))
|
|
247
|
+
return "Unknown error"
|
|
248
|
+
|
|
249
|
+
def _handle_mcp_events(self, event_type: str, event: Any) -> Chunk | None:
|
|
250
|
+
"""Handle MCP-specific events."""
|
|
251
|
+
if event_type == "response.mcp_call_arguments.delta":
|
|
252
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
253
|
+
arguments_delta = getattr(event, "delta", "")
|
|
254
|
+
return self._create_chunk(
|
|
255
|
+
ChunkType.TOOL_CALL,
|
|
256
|
+
arguments_delta,
|
|
257
|
+
{
|
|
258
|
+
"tool_id": tool_id,
|
|
259
|
+
"status": "arguments_streaming",
|
|
260
|
+
"delta": arguments_delta,
|
|
261
|
+
"reasoning_session": self._current_reasoning_session,
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if event_type == "response.mcp_call_arguments.done":
|
|
266
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
267
|
+
arguments = getattr(event, "arguments", "")
|
|
268
|
+
return self._create_chunk(
|
|
269
|
+
ChunkType.TOOL_CALL,
|
|
270
|
+
f"Parameter: {arguments}",
|
|
271
|
+
{
|
|
272
|
+
"tool_id": tool_id,
|
|
273
|
+
"status": "arguments_complete",
|
|
274
|
+
"arguments": arguments,
|
|
275
|
+
"reasoning_session": self._current_reasoning_session,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if event_type == "response.mcp_call.failed":
|
|
280
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
281
|
+
return self._create_chunk(
|
|
282
|
+
ChunkType.TOOL_RESULT,
|
|
283
|
+
f"Werkzeugnutzung abgebrochen: {tool_id}",
|
|
284
|
+
{
|
|
285
|
+
"tool_id": tool_id,
|
|
286
|
+
"status": "failed",
|
|
287
|
+
"error": True,
|
|
288
|
+
"reasoning_session": self._current_reasoning_session,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if event_type == "response.mcp_call.in_progress":
|
|
293
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
294
|
+
return self._create_chunk(
|
|
295
|
+
ChunkType.TOOL_CALL,
|
|
296
|
+
"Tool call in progress...",
|
|
297
|
+
{"tool_id": tool_id, "status": "in_progress"},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if event_type == "response.mcp_list_tools.in_progress":
|
|
301
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
302
|
+
return self._create_chunk(
|
|
303
|
+
ChunkType.TOOL_CALL,
|
|
304
|
+
"Lade verfügbare Werkzeuge...",
|
|
305
|
+
{"tool_id": tool_id, "status": "listing_tools"},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if event_type == "response.mcp_list_tools.completed":
|
|
309
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
310
|
+
return self._create_chunk(
|
|
311
|
+
ChunkType.TOOL_RESULT,
|
|
312
|
+
"Verfügbare Werkzeuge geladen.",
|
|
313
|
+
{"tool_id": tool_id, "status": "tools_listed"},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if event_type == "response.mcp_list_tools.failed":
|
|
317
|
+
tool_id = getattr(event, "item_id", "unknown_id")
|
|
318
|
+
logger.error("MCP tool listing failed for tool_id: %s", str(event))
|
|
319
|
+
return self._create_chunk(
|
|
320
|
+
ChunkType.TOOL_RESULT,
|
|
321
|
+
f"Werkzeugliste konnte nicht geladen werden: {tool_id}",
|
|
322
|
+
{
|
|
323
|
+
"tool_id": tool_id,
|
|
324
|
+
"status": "listing_failed",
|
|
325
|
+
"error": True,
|
|
326
|
+
"reasoning_session": self._current_reasoning_session,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
def _handle_content_events(self, event_type: str, event: Any) -> Chunk | None: # noqa: ARG002
|
|
333
|
+
"""Handle content-related events."""
|
|
334
|
+
if event_type == "response.content_part.added":
|
|
335
|
+
# Content part added - this typically starts text streaming
|
|
336
|
+
return None # No need to show this as a separate chunk
|
|
337
|
+
|
|
338
|
+
if event_type == "response.content_part.done":
|
|
339
|
+
# Content part completed - this typically ends text streaming
|
|
340
|
+
return None # No need to show this as a separate chunk
|
|
341
|
+
|
|
342
|
+
if event_type == "response.output_text.done":
|
|
343
|
+
# Text output completed - already received via delta events
|
|
344
|
+
# Skip to avoid duplicate content
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def _handle_completion_events(self, event_type: str, event: Any) -> Chunk | None: # noqa: ARG002
|
|
350
|
+
"""Handle completion-related events."""
|
|
351
|
+
if event_type == "response.completed":
|
|
352
|
+
return self._create_chunk(
|
|
353
|
+
ChunkType.COMPLETION,
|
|
354
|
+
"Response generation completed",
|
|
355
|
+
{"status": "response_complete"},
|
|
356
|
+
)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _handle_image_events(self, event_type: str, event: Any) -> Chunk | None:
|
|
360
|
+
"""Handle image-related events."""
|
|
361
|
+
if "image" in event_type and (hasattr(event, "url") or hasattr(event, "data")):
|
|
362
|
+
image_data = {
|
|
363
|
+
"url": getattr(event, "url", ""),
|
|
364
|
+
"data": getattr(event, "data", ""),
|
|
365
|
+
}
|
|
366
|
+
image_str = str(image_data)
|
|
367
|
+
return self._create_chunk(ChunkType.IMAGE, image_str, image_data)
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
def _create_chunk(
|
|
371
|
+
self,
|
|
372
|
+
chunk_type: ChunkType,
|
|
373
|
+
content: str,
|
|
374
|
+
extra_metadata: dict[str, str] | None = None,
|
|
375
|
+
) -> Chunk:
|
|
376
|
+
"""Create a Chunk with actual content from the event"""
|
|
377
|
+
metadata = {
|
|
378
|
+
"processor": "openai_responses_simplified",
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if extra_metadata:
|
|
382
|
+
# Ensure all metadata values are strings
|
|
383
|
+
for key, value in extra_metadata.items():
|
|
384
|
+
if value is not None:
|
|
385
|
+
metadata[key] = str(value)
|
|
386
|
+
|
|
387
|
+
return Chunk(
|
|
388
|
+
type=chunk_type,
|
|
389
|
+
text=content,
|
|
390
|
+
chunk_metadata=metadata,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def _create_responses_request(
|
|
394
|
+
self,
|
|
395
|
+
messages: list[Message],
|
|
396
|
+
model: AIModel,
|
|
397
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
398
|
+
payload: dict[str, Any] | None = None,
|
|
399
|
+
) -> Any:
|
|
400
|
+
"""Create a simplified responses API request."""
|
|
401
|
+
# Configure MCP tools if provided
|
|
402
|
+
tools, mcp_prompt = (
|
|
403
|
+
self._configure_mcp_tools(mcp_servers) if mcp_servers else ([], "")
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Convert messages to responses format with system message
|
|
407
|
+
input_messages = self._convert_messages_to_responses_format(
|
|
408
|
+
messages, mcp_prompt=mcp_prompt
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
params = {
|
|
412
|
+
"model": model.model,
|
|
413
|
+
"input": input_messages,
|
|
414
|
+
"stream": model.stream,
|
|
415
|
+
"temperature": model.temperature,
|
|
416
|
+
"tools": tools,
|
|
417
|
+
"reasoning": {"effort": "medium"},
|
|
418
|
+
**(payload or {}),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
logger.debug("Responses API request params: %s", params)
|
|
422
|
+
return await self.client.responses.create(**params)
|
|
423
|
+
|
|
424
|
+
def _configure_mcp_tools(
|
|
425
|
+
self, mcp_servers: list[MCPServer] | None
|
|
426
|
+
) -> tuple[list[dict[str, Any]], str]:
|
|
427
|
+
"""Configure MCP servers as tools for the responses API.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
tuple: (tools list, concatenated prompts string)
|
|
431
|
+
"""
|
|
432
|
+
if not mcp_servers:
|
|
433
|
+
return [], ""
|
|
434
|
+
|
|
435
|
+
tools = []
|
|
436
|
+
prompts = []
|
|
437
|
+
for server in mcp_servers:
|
|
438
|
+
tool_config = {
|
|
439
|
+
"type": "mcp",
|
|
440
|
+
"server_label": server.name,
|
|
441
|
+
"server_url": server.url,
|
|
442
|
+
"require_approval": "never",
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if server.headers and server.headers != "{}":
|
|
446
|
+
tool_config["headers"] = json.loads(server.headers)
|
|
447
|
+
|
|
448
|
+
tools.append(tool_config)
|
|
449
|
+
|
|
450
|
+
if server.prompt:
|
|
451
|
+
prompts.append(f"- {server.prompt}")
|
|
452
|
+
|
|
453
|
+
prompt_string = "\n".join(prompts) if prompts else ""
|
|
454
|
+
return tools, prompt_string
|
|
455
|
+
|
|
456
|
+
def _convert_messages_to_responses_format(
|
|
457
|
+
self, messages: list[Message], mcp_prompt: str = ""
|
|
458
|
+
) -> list[dict[str, Any]]:
|
|
459
|
+
"""Convert messages to the responses API input format.
|
|
460
|
+
|
|
461
|
+
The system message is always prepended as the first message with role="system".
|
|
462
|
+
"""
|
|
463
|
+
input_messages = []
|
|
464
|
+
|
|
465
|
+
# Always add system message as first message
|
|
466
|
+
if mcp_prompt:
|
|
467
|
+
mcp_prompt = (
|
|
468
|
+
"### Tool-Auswahlrichtlinien (Einbettung externer Beschreibungen)\n"
|
|
469
|
+
f"{mcp_prompt}"
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
mcp_prompt = ""
|
|
473
|
+
|
|
474
|
+
system_text = SYSTEM_PROMPT.format(mcp_prompts=mcp_prompt)
|
|
475
|
+
input_messages.append(
|
|
476
|
+
{
|
|
477
|
+
"role": "system",
|
|
478
|
+
"content": [{"type": "input_text", "text": system_text}],
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Add conversation messages
|
|
483
|
+
for msg in messages:
|
|
484
|
+
if msg.type == MessageType.SYSTEM:
|
|
485
|
+
continue # System messages are handled above
|
|
486
|
+
|
|
487
|
+
role = "user" if msg.type == MessageType.HUMAN else "assistant"
|
|
488
|
+
content_type = "input_text" if role == "user" else "output_text"
|
|
489
|
+
input_messages.append(
|
|
490
|
+
{"role": role, "content": [{"type": content_type, "text": msg.text}]}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
return input_messages
|
|
494
|
+
|
|
495
|
+
def _extract_responses_content(self, session: Any) -> str | None:
|
|
496
|
+
"""Extract content from non-streaming responses."""
|
|
497
|
+
if (
|
|
498
|
+
hasattr(session, "output")
|
|
499
|
+
and session.output
|
|
500
|
+
and isinstance(session.output, list)
|
|
501
|
+
and session.output
|
|
502
|
+
):
|
|
503
|
+
first_output = session.output[0]
|
|
504
|
+
if hasattr(first_output, "content") and first_output.content:
|
|
505
|
+
if isinstance(first_output.content, list):
|
|
506
|
+
return first_output.content[0].get("text", "")
|
|
507
|
+
return str(first_output.content)
|
|
508
|
+
return None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from appkit_assistant.backend.models import AIModel, Chunk, MCPServer, Message
|
|
8
|
+
from appkit_assistant.backend.processors.openai_chat_completion_processor import (
|
|
9
|
+
OpenAIChatCompletionsProcessor,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextSize(enum.StrEnum):
|
|
16
|
+
"""Enum for context size options."""
|
|
17
|
+
|
|
18
|
+
LOW = "low"
|
|
19
|
+
MEDIUM = "medium"
|
|
20
|
+
HIGH = "high"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PerplexityAIModel(AIModel):
|
|
24
|
+
"""AI model for Perplexity API."""
|
|
25
|
+
|
|
26
|
+
search_context_size: ContextSize = ContextSize.MEDIUM
|
|
27
|
+
search_domain_filter: list[str] = []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SONAR = PerplexityAIModel(
|
|
31
|
+
id="sonar",
|
|
32
|
+
text="Perplexity Sonar",
|
|
33
|
+
icon="perplexity",
|
|
34
|
+
model="sonar",
|
|
35
|
+
stream=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SONAR_PRO = PerplexityAIModel(
|
|
39
|
+
id="sonar-pro",
|
|
40
|
+
text="Perplexity Sonar Pro",
|
|
41
|
+
icon="perplexity",
|
|
42
|
+
model="sonar-pro",
|
|
43
|
+
stream=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
SONAR_DEEP_RESEARCH = PerplexityAIModel(
|
|
47
|
+
id="sonar-deep-research",
|
|
48
|
+
text="Perplexity Deep Research",
|
|
49
|
+
icon="perplexity",
|
|
50
|
+
model="sonar-deep-research",
|
|
51
|
+
search_context_size=ContextSize.HIGH,
|
|
52
|
+
stream=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
SONAR_REASONING = PerplexityAIModel(
|
|
56
|
+
id="sonar-reasoning",
|
|
57
|
+
text="Perplexity Reasoning",
|
|
58
|
+
icon="perplexity",
|
|
59
|
+
model="sonar-reasoning",
|
|
60
|
+
search_context_size=ContextSize.HIGH,
|
|
61
|
+
stream=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
ALL_MODELS = {
|
|
65
|
+
SONAR.id: SONAR,
|
|
66
|
+
SONAR_PRO.id: SONAR_PRO,
|
|
67
|
+
SONAR_DEEP_RESEARCH.id: SONAR_DEEP_RESEARCH,
|
|
68
|
+
SONAR_REASONING.id: SONAR_REASONING,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PerplexityProcessor(OpenAIChatCompletionsProcessor):
|
|
73
|
+
"""Processor that generates text responses using the Perplexity API."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str | None = os.getenv("PERPLEXITY_API_KEY"),
|
|
78
|
+
models: dict[str, PerplexityAIModel] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.base_url = "https://api.perplexity.ai"
|
|
81
|
+
super().__init__(api_key=api_key, base_url=self.base_url, models=models)
|
|
82
|
+
|
|
83
|
+
async def process(
|
|
84
|
+
self,
|
|
85
|
+
messages: list[Message],
|
|
86
|
+
model_id: str,
|
|
87
|
+
files: list[str] | None = None,
|
|
88
|
+
mcp_servers: list[MCPServer] | None = None, # noqa: ARG002
|
|
89
|
+
payload: dict[str, Any] | None = None,
|
|
90
|
+
) -> AsyncGenerator[Chunk, None]:
|
|
91
|
+
if model_id not in self.models:
|
|
92
|
+
logger.error("Model %s not supported by Perplexity processor", model_id)
|
|
93
|
+
raise ValueError(f"Model {model_id} not supported by Perplexity processor")
|
|
94
|
+
|
|
95
|
+
model = self.models[model_id]
|
|
96
|
+
|
|
97
|
+
# Create Perplexity-specific payload
|
|
98
|
+
perplexity_payload = {
|
|
99
|
+
"search_domain_filter": model.search_domain_filter,
|
|
100
|
+
"return_images": True,
|
|
101
|
+
"return_related_questions": True,
|
|
102
|
+
"web_search_options": {
|
|
103
|
+
"search_context_size": model.search_context_size,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Merge with any additional payload
|
|
108
|
+
if payload:
|
|
109
|
+
perplexity_payload.update(payload)
|
|
110
|
+
|
|
111
|
+
async for response in super().process(
|
|
112
|
+
messages=messages,
|
|
113
|
+
model_id=model_id,
|
|
114
|
+
files=files,
|
|
115
|
+
mcp_servers=None,
|
|
116
|
+
payload=perplexity_payload,
|
|
117
|
+
):
|
|
118
|
+
yield response
|