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.
@@ -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