appkit-assistant 0.17.3__py3-none-any.whl → 1.0.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.
Files changed (57) hide show
  1. appkit_assistant/backend/{models.py → database/models.py} +32 -132
  2. appkit_assistant/backend/{repositories.py → database/repositories.py} +93 -1
  3. appkit_assistant/backend/model_manager.py +5 -5
  4. appkit_assistant/backend/models/__init__.py +28 -0
  5. appkit_assistant/backend/models/anthropic.py +31 -0
  6. appkit_assistant/backend/models/google.py +27 -0
  7. appkit_assistant/backend/models/openai.py +50 -0
  8. appkit_assistant/backend/models/perplexity.py +56 -0
  9. appkit_assistant/backend/processors/__init__.py +29 -0
  10. appkit_assistant/backend/processors/claude_responses_processor.py +205 -387
  11. appkit_assistant/backend/processors/gemini_responses_processor.py +231 -299
  12. appkit_assistant/backend/processors/lorem_ipsum_processor.py +6 -4
  13. appkit_assistant/backend/processors/mcp_mixin.py +297 -0
  14. appkit_assistant/backend/processors/openai_base.py +11 -125
  15. appkit_assistant/backend/processors/openai_chat_completion_processor.py +5 -3
  16. appkit_assistant/backend/processors/openai_responses_processor.py +480 -402
  17. appkit_assistant/backend/processors/perplexity_processor.py +156 -79
  18. appkit_assistant/backend/{processor.py → processors/processor_base.py} +7 -2
  19. appkit_assistant/backend/processors/streaming_base.py +188 -0
  20. appkit_assistant/backend/schemas.py +138 -0
  21. appkit_assistant/backend/services/auth_error_detector.py +99 -0
  22. appkit_assistant/backend/services/chunk_factory.py +273 -0
  23. appkit_assistant/backend/services/citation_handler.py +292 -0
  24. appkit_assistant/backend/services/file_cleanup_service.py +316 -0
  25. appkit_assistant/backend/services/file_upload_service.py +903 -0
  26. appkit_assistant/backend/services/file_validation.py +138 -0
  27. appkit_assistant/backend/{mcp_auth_service.py → services/mcp_auth_service.py} +4 -2
  28. appkit_assistant/backend/services/mcp_token_service.py +61 -0
  29. appkit_assistant/backend/services/message_converter.py +289 -0
  30. appkit_assistant/backend/services/openai_client_service.py +120 -0
  31. appkit_assistant/backend/{response_accumulator.py → services/response_accumulator.py} +163 -1
  32. appkit_assistant/backend/services/system_prompt_builder.py +89 -0
  33. appkit_assistant/backend/services/thread_service.py +5 -3
  34. appkit_assistant/backend/system_prompt_cache.py +3 -3
  35. appkit_assistant/components/__init__.py +8 -4
  36. appkit_assistant/components/composer.py +59 -24
  37. appkit_assistant/components/file_manager.py +623 -0
  38. appkit_assistant/components/mcp_server_dialogs.py +12 -20
  39. appkit_assistant/components/mcp_server_table.py +12 -2
  40. appkit_assistant/components/message.py +119 -2
  41. appkit_assistant/components/thread.py +1 -1
  42. appkit_assistant/components/threadlist.py +4 -2
  43. appkit_assistant/components/tools_modal.py +37 -20
  44. appkit_assistant/configuration.py +12 -0
  45. appkit_assistant/state/file_manager_state.py +697 -0
  46. appkit_assistant/state/mcp_oauth_state.py +3 -3
  47. appkit_assistant/state/mcp_server_state.py +47 -2
  48. appkit_assistant/state/system_prompt_state.py +1 -1
  49. appkit_assistant/state/thread_list_state.py +99 -5
  50. appkit_assistant/state/thread_state.py +88 -9
  51. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/METADATA +8 -6
  52. appkit_assistant-1.0.0.dist-info/RECORD +58 -0
  53. appkit_assistant/backend/processors/claude_base.py +0 -178
  54. appkit_assistant/backend/processors/gemini_base.py +0 -84
  55. appkit_assistant-0.17.3.dist-info/RECORD +0 -39
  56. /appkit_assistant/backend/{file_manager.py → services/file_manager.py} +0 -0
  57. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/WHEEL +0 -0
@@ -13,23 +13,25 @@ from collections.abc import AsyncGenerator
13
13
  from pathlib import Path
14
14
  from typing import Any, Final
15
15
 
16
- import reflex as rx
16
+ from anthropic import AsyncAnthropic
17
17
 
18
- from appkit_assistant.backend.mcp_auth_service import MCPAuthService
19
- from appkit_assistant.backend.models import (
18
+ from appkit_assistant.backend.database.models import (
19
+ MCPServer,
20
+ )
21
+ from appkit_assistant.backend.processors.mcp_mixin import MCPCapabilities
22
+ from appkit_assistant.backend.processors.processor_base import mcp_oauth_redirect_uri
23
+ from appkit_assistant.backend.processors.streaming_base import StreamingProcessorBase
24
+ from appkit_assistant.backend.schemas import (
20
25
  AIModel,
21
- AssistantMCPUserToken,
22
26
  Chunk,
23
27
  ChunkType,
24
28
  MCPAuthType,
25
- MCPServer,
26
29
  Message,
27
30
  MessageType,
28
31
  )
29
- from appkit_assistant.backend.processor import mcp_oauth_redirect_uri
30
- from appkit_assistant.backend.processors.claude_base import BaseClaudeProcessor
31
- from appkit_assistant.backend.system_prompt_cache import get_system_prompt
32
- from appkit_commons.database.session import get_session_manager
32
+ from appkit_assistant.backend.services.citation_handler import ClaudeCitationHandler
33
+ from appkit_assistant.backend.services.file_validation import FileValidationService
34
+ from appkit_assistant.backend.services.system_prompt_builder import SystemPromptBuilder
33
35
 
34
36
  logger = logging.getLogger(__name__)
35
37
  default_oauth_redirect_uri: Final[str] = mcp_oauth_redirect_uri()
@@ -38,8 +40,11 @@ default_oauth_redirect_uri: Final[str] = mcp_oauth_redirect_uri()
38
40
  MCP_BETA_HEADER: Final[str] = "mcp-client-2025-11-20"
39
41
  FILES_BETA_HEADER: Final[str] = "files-api-2025-04-14"
40
42
 
43
+ # Extended thinking budget (fixed at 10k tokens)
44
+ THINKING_BUDGET_TOKENS: Final[int] = 10000
45
+
41
46
 
42
- class ClaudeResponsesProcessor(BaseClaudeProcessor):
47
+ class ClaudeResponsesProcessor(StreamingProcessorBase, MCPCapabilities):
43
48
  """Claude processor using the Messages API with MCP tools and file uploads."""
44
49
 
45
50
  def __init__(
@@ -49,18 +54,41 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
49
54
  base_url: str | None = None,
50
55
  oauth_redirect_uri: str = default_oauth_redirect_uri,
51
56
  ) -> None:
52
- super().__init__(models, api_key, base_url)
53
- self._current_reasoning_session: str | None = None
54
- self._current_user_id: int | None = None
55
- self._mcp_auth_service = MCPAuthService(redirect_uri=oauth_redirect_uri)
56
- self._pending_auth_servers: list[MCPServer] = []
57
+ StreamingProcessorBase.__init__(self, models, "claude_responses")
58
+ MCPCapabilities.__init__(self, oauth_redirect_uri, "claude_responses")
59
+
60
+ self.api_key = api_key
61
+ self.base_url = base_url
62
+ self.client: AsyncAnthropic | None = None
63
+
64
+ if self.api_key:
65
+ if self.base_url:
66
+ self.client = AsyncAnthropic(
67
+ api_key=self.api_key,
68
+ base_url=self.base_url,
69
+ )
70
+ else:
71
+ self.client = AsyncAnthropic(api_key=self.api_key)
72
+ else:
73
+ logger.warning("No Claude API key found. Processor will not work.")
74
+
75
+ # Services
76
+ self._file_validator = FileValidationService()
77
+ self._citation_handler = ClaudeCitationHandler()
78
+ self._system_prompt_builder = SystemPromptBuilder()
79
+
80
+ # State
57
81
  self._uploaded_file_ids: list[str] = []
58
- # Track current tool context for streaming
59
82
  self._current_tool_context: dict[str, Any] | None = None
60
- # Track if we need a newline before next text block
61
83
  self._needs_text_separator: bool = False
84
+ # Tool name tracking: tool_id -> (tool_name, server_label)
85
+ self._tool_name_map: dict[str, tuple[str, str | None]] = {}
86
+ # Warnings to display to the user (e.g. disabled tools)
87
+ self._mcp_warnings: list[str] = []
62
88
 
63
- logger.debug("Using redirect URI for MCP OAuth: %s", oauth_redirect_uri)
89
+ def get_supported_models(self) -> dict[str, AIModel]:
90
+ """Return supported models if API key is available."""
91
+ return self.models if self.api_key else {}
64
92
 
65
93
  async def process(
66
94
  self,
@@ -81,9 +109,11 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
81
109
  raise ValueError(msg)
82
110
 
83
111
  model = self.models[model_id]
84
- self._current_user_id = user_id
85
- self._pending_auth_servers = []
112
+ self.current_user_id = user_id
113
+ self.clear_pending_auth_servers()
86
114
  self._uploaded_file_ids = []
115
+ self._tool_name_map.clear() # Clear tool tracking for new request
116
+ self._mcp_warnings = [] # Clear warnings for new request
87
117
 
88
118
  try:
89
119
  # Upload files if provided
@@ -101,6 +131,11 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
101
131
  file_content_blocks,
102
132
  )
103
133
 
134
+ # Yield warnings if any (e.g. disabled tools)
135
+ if self._mcp_warnings:
136
+ for warning in self._mcp_warnings:
137
+ yield self.chunk_factory.text(f"⚠️ {warning}\n\n")
138
+
104
139
  try:
105
140
  # Process streaming events
106
141
  async with stream as response:
@@ -116,37 +151,26 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
116
151
  logger.error("Error during Claude response processing: %s", error_msg)
117
152
  # Only yield error chunk if NOT an auth error
118
153
  is_auth_related = (
119
- self._is_auth_error(error_msg) or self._pending_auth_servers
154
+ self.auth_detector.is_auth_error(error_msg)
155
+ or self.pending_auth_servers
120
156
  )
121
157
  if not is_auth_related:
122
- yield Chunk(
123
- type=ChunkType.ERROR,
124
- text=f"Ein Fehler ist aufgetreten: {error_msg}",
125
- chunk_metadata={
126
- "source": "claude_api",
127
- "error_type": type(e).__name__,
128
- },
158
+ yield self.chunk_factory.error(
159
+ f"Ein Fehler ist aufgetreten: {error_msg}",
160
+ error_type=type(e).__name__,
129
161
  )
130
162
 
131
163
  # Yield any pending auth requirements
132
- logger.debug(
133
- "Processing pending auth servers: %d", len(self._pending_auth_servers)
134
- )
135
- for server in self._pending_auth_servers:
136
- logger.debug("Yielding auth chunk for server: %s", server.name)
137
- yield await self._create_auth_required_chunk(server)
164
+ async for auth_chunk in self.yield_pending_auth_chunks():
165
+ yield auth_chunk
138
166
 
139
167
  except Exception as e:
140
168
  logger.error("Critical error in Claude processor: %s", e)
141
169
  raise
142
170
 
143
- def _handle_event(self, event: Any) -> Chunk | None:
144
- """Handle streaming events from Claude API."""
145
- event_type = getattr(event, "type", None)
146
- if not event_type:
147
- return None
148
-
149
- handlers = {
171
+ def _get_event_handlers(self) -> dict[str, Any]:
172
+ """Get the event handler mapping for Claude API events."""
173
+ return {
150
174
  "message_start": self._handle_message_start,
151
175
  "message_delta": self._handle_message_delta,
152
176
  "message_stop": self._handle_message_stop,
@@ -155,41 +179,25 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
155
179
  "content_block_stop": self._handle_content_block_stop,
156
180
  }
157
181
 
158
- handler = handlers.get(event_type)
159
- if handler:
160
- return handler(event)
161
-
162
- logger.debug("Unhandled Claude event type: %s", event_type)
163
- return None
164
-
165
- def _handle_message_start(self, event: Any) -> Chunk | None: # noqa: ARG002
182
+ def _handle_message_start(self, _: Any) -> Chunk | None:
166
183
  """Handle message_start event."""
167
- return self._create_chunk(
168
- ChunkType.LIFECYCLE,
169
- "created",
170
- {"stage": "created"},
171
- )
184
+ return self.chunk_factory.lifecycle("created", {"stage": "created"})
172
185
 
173
186
  def _handle_message_delta(self, event: Any) -> Chunk | None:
174
187
  """Handle message_delta event (contains stop_reason)."""
175
188
  delta = getattr(event, "delta", None)
176
- if delta:
177
- stop_reason = getattr(delta, "stop_reason", None)
178
- if stop_reason:
179
- return self._create_chunk(
180
- ChunkType.LIFECYCLE,
181
- f"stop_reason: {stop_reason}",
182
- {"stop_reason": stop_reason},
183
- )
184
- return None
189
+ if not delta:
190
+ return None
191
+ stop_reason = getattr(delta, "stop_reason", None)
192
+ if not stop_reason:
193
+ return None
194
+ return self.chunk_factory.lifecycle(
195
+ f"stop_reason: {stop_reason}", {"stop_reason": stop_reason}
196
+ )
185
197
 
186
- def _handle_message_stop(self, event: Any) -> Chunk | None: # noqa: ARG002
198
+ def _handle_message_stop(self, _: Any) -> Chunk | None:
187
199
  """Handle message_stop event."""
188
- return self._create_chunk(
189
- ChunkType.COMPLETION,
190
- "Response generation completed",
191
- {"status": "response_complete"},
192
- )
200
+ return self.chunk_factory.completion(status="response_complete")
193
201
 
194
202
  def _handle_content_block_start(self, event: Any) -> Chunk | None:
195
203
  """Handle content_block_start event."""
@@ -214,42 +222,52 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
214
222
 
215
223
  return None
216
224
 
217
- def _handle_text_block_start(self, content_block: Any) -> Chunk | None: # noqa: ARG002
225
+ def _handle_text_block_start(self, _: Any) -> Chunk | None:
218
226
  """Handle start of text content block."""
219
- if self._needs_text_separator:
220
- self._needs_text_separator = False
221
- return self._create_chunk(ChunkType.TEXT, "\n\n", {"separator": "true"})
222
- return None
227
+ if not self._needs_text_separator:
228
+ return None
229
+ self._needs_text_separator = False
230
+ return self.chunk_factory.text("\n\n", {"separator": "true"})
223
231
 
224
232
  def _handle_thinking_block_start(self, content_block: Any) -> Chunk:
225
233
  """Handle start of thinking content block."""
226
234
  thinking_id = getattr(content_block, "id", "thinking")
227
- self._current_reasoning_session = thinking_id
235
+ self.current_reasoning_session = thinking_id
228
236
  self._needs_text_separator = True
229
- return self._create_chunk(
230
- ChunkType.THINKING,
231
- "Denke nach...",
232
- {"reasoning_id": thinking_id, "status": "starting"},
237
+ return self.chunk_factory.thinking(
238
+ "Denke nach...", reasoning_id=thinking_id, status="starting"
233
239
  )
234
240
 
235
- def _handle_tool_use_block_start(self, content_block: Any) -> Chunk:
236
- """Handle start of tool_use content block."""
237
- tool_name = getattr(content_block, "name", "unknown_tool")
238
- tool_id = getattr(content_block, "id", "unknown_id")
241
+ def _handle_tool_use_common(
242
+ self,
243
+ tool_name: str,
244
+ tool_id: str,
245
+ server_label: str | None,
246
+ tool_display_name: str,
247
+ ) -> Chunk:
248
+ """Common handler for tool use start."""
239
249
  self._current_tool_context = {
240
250
  "tool_name": tool_name,
241
251
  "tool_id": tool_id,
242
- "server_label": None,
252
+ "server_label": server_label,
243
253
  }
244
- return self._create_chunk(
245
- ChunkType.TOOL_CALL,
246
- f"Benutze Werkzeug: {tool_name}",
247
- {
248
- "tool_name": tool_name,
249
- "tool_id": tool_id,
250
- "status": "starting",
251
- "reasoning_session": self._current_reasoning_session,
252
- },
254
+ # Store for result lookup
255
+ self._tool_name_map[tool_id] = (tool_name, server_label)
256
+ return self.chunk_factory.tool_call(
257
+ tool_display_name,
258
+ tool_name=tool_name,
259
+ tool_id=tool_id,
260
+ server_label=server_label,
261
+ status="starting",
262
+ reasoning_session=self.current_reasoning_session,
263
+ )
264
+
265
+ def _handle_tool_use_block_start(self, content_block: Any) -> Chunk:
266
+ """Handle start of tool_use content block."""
267
+ tool_name = getattr(content_block, "name", "unknown_tool")
268
+ tool_id = getattr(content_block, "id", "unknown_id")
269
+ return self._handle_tool_use_common(
270
+ tool_name, tool_id, None, f"Benutze Werkzeug: {tool_name}"
253
271
  )
254
272
 
255
273
  def _handle_mcp_tool_use_block_start(self, content_block: Any) -> Chunk:
@@ -257,21 +275,11 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
257
275
  tool_name = getattr(content_block, "name", "unknown_tool")
258
276
  tool_id = getattr(content_block, "id", "unknown_id")
259
277
  server_name = getattr(content_block, "server_name", "unknown_server")
260
- self._current_tool_context = {
261
- "tool_name": tool_name,
262
- "tool_id": tool_id,
263
- "server_label": server_name,
264
- }
265
- return self._create_chunk(
266
- ChunkType.TOOL_CALL,
278
+ return self._handle_tool_use_common(
279
+ tool_name,
280
+ tool_id,
281
+ server_name,
267
282
  f"Benutze Werkzeug: {server_name}.{tool_name}",
268
- {
269
- "tool_name": tool_name,
270
- "tool_id": tool_id,
271
- "server_label": server_name,
272
- "status": "starting",
273
- "reasoning_session": self._current_reasoning_session,
274
- },
275
283
  )
276
284
 
277
285
  def _handle_mcp_tool_result_block_start(self, content_block: Any) -> Chunk:
@@ -281,41 +289,43 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
281
289
  is_error = bool(getattr(content_block, "is_error", False))
282
290
  content = getattr(content_block, "content", "")
283
291
 
292
+ # Look up tool name and server from map
293
+ tool_info = self._tool_name_map.get(tool_use_id, ("unknown_tool", None))
294
+ tool_name, server_label = tool_info
295
+
284
296
  logger.debug(
285
- "MCP tool result - tool_use_id: %s, is_error: %s, content type: %s",
297
+ "MCP tool result - tool_use_id: %s, tool: %s, server: %s, is_error: %s",
286
298
  tool_use_id,
299
+ tool_name,
300
+ server_label,
287
301
  is_error,
288
- type(content).__name__,
289
302
  )
290
303
 
291
304
  result_text = self._extract_mcp_result_text(content)
292
305
  status = "error" if is_error else "completed"
293
- return self._create_chunk(
294
- ChunkType.TOOL_RESULT,
306
+ return self.chunk_factory.tool_result(
295
307
  result_text or ("Werkzeugfehler" if is_error else "Erfolgreich"),
296
- {
297
- "tool_id": tool_use_id,
298
- "status": status,
299
- "error": is_error,
300
- "reasoning_session": self._current_reasoning_session,
301
- },
308
+ tool_id=tool_use_id,
309
+ tool_name=tool_name,
310
+ server_label=server_label,
311
+ status=status,
312
+ is_error=is_error,
313
+ reasoning_session=self.current_reasoning_session,
302
314
  )
303
315
 
304
316
  def _extract_mcp_result_text(self, content: Any) -> str:
305
317
  """Extract text from MCP tool result content."""
306
- if isinstance(content, list):
307
- parts = []
308
- for item in content:
309
- if isinstance(item, dict):
310
- parts.append(item.get("text", str(item)))
311
- elif hasattr(item, "text"):
312
- parts.append(getattr(item, "text", str(item)))
313
- else:
314
- parts.append(str(item))
315
- return "".join(parts)
316
- return str(content) if content else ""
317
-
318
- return None
318
+ if not content:
319
+ return ""
320
+ if not isinstance(content, list):
321
+ return str(content)
322
+ parts = []
323
+ for item in content:
324
+ if isinstance(item, dict):
325
+ parts.append(item.get("text", str(item)))
326
+ else:
327
+ parts.append(getattr(item, "text", str(item)))
328
+ return "".join(parts)
319
329
 
320
330
  def _handle_content_block_delta(self, event: Any) -> Chunk | None:
321
331
  """Handle content_block_delta event."""
@@ -327,159 +337,70 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
327
337
 
328
338
  if delta_type == "text_delta":
329
339
  text = getattr(delta, "text", "")
330
- # Extract citations from text if present
331
- citations = self._extract_citations_from_delta(delta)
332
- metadata = {"delta": text}
340
+ # Extract citations using the citation handler
341
+ citations = self._citation_handler.extract_citations(delta)
342
+ metadata: dict[str, Any] = {"delta": text}
333
343
  if citations:
334
- metadata["citations"] = json.dumps(citations)
335
- return self._create_chunk(ChunkType.TEXT, text, metadata)
344
+ metadata["citations"] = json.dumps(
345
+ [self._citation_handler.to_dict(c) for c in citations]
346
+ )
347
+ return self.chunk_factory.text(text, metadata)
336
348
 
337
349
  if delta_type == "thinking_delta":
338
350
  thinking_text = getattr(delta, "thinking", "")
339
- return self._create_chunk(
340
- ChunkType.THINKING,
351
+ return self.chunk_factory.thinking(
341
352
  thinking_text,
342
- {
343
- "reasoning_id": self._current_reasoning_session,
344
- "status": "in_progress",
345
- "delta": thinking_text,
346
- },
353
+ reasoning_id=self.current_reasoning_session,
354
+ status="in_progress",
355
+ delta=thinking_text,
347
356
  )
348
357
 
349
358
  if delta_type == "input_json_delta":
350
359
  partial_json = getattr(delta, "partial_json", "")
351
360
  # Include tool context in streaming chunks
352
- metadata: dict[str, Any] = {
353
- "status": "arguments_streaming",
354
- "delta": partial_json,
355
- "reasoning_session": self._current_reasoning_session,
356
- }
361
+ tool_name = "unknown_tool"
362
+ tool_id = "unknown_id"
363
+ server_label = None
357
364
  if self._current_tool_context:
358
- metadata["tool_name"] = self._current_tool_context.get("tool_name")
359
- metadata["tool_id"] = self._current_tool_context.get("tool_id")
360
- if self._current_tool_context.get("server_label"):
361
- metadata["server_label"] = self._current_tool_context[
362
- "server_label"
363
- ]
364
- return self._create_chunk(
365
- ChunkType.TOOL_CALL,
365
+ tool_name = self._current_tool_context.get("tool_name", "unknown_tool")
366
+ tool_id = self._current_tool_context.get("tool_id", "unknown_id")
367
+ server_label = self._current_tool_context.get("server_label")
368
+ return self.chunk_factory.tool_call(
366
369
  partial_json,
367
- metadata,
370
+ tool_name=tool_name,
371
+ tool_id=tool_id,
372
+ server_label=server_label,
373
+ status="arguments_streaming",
374
+ reasoning_session=self.current_reasoning_session,
368
375
  )
369
376
 
377
+ logger.debug("Unhandled delta type in stream: %s", delta_type)
370
378
  return None
371
379
 
372
- def _handle_content_block_stop(self, event: Any) -> Chunk | None: # noqa: ARG002
380
+ def _handle_content_block_stop(self, _: Any) -> Chunk | None:
373
381
  """Handle content_block_stop event."""
374
- # Check if this was a thinking block ending
375
- if self._current_reasoning_session:
376
- # Reset reasoning session after thinking completes
377
- reasoning_id = self._current_reasoning_session
378
- self._current_reasoning_session = None
379
- return self._create_chunk(
382
+ if self.current_reasoning_session:
383
+ reasoning_id = self.current_reasoning_session
384
+ self.current_reasoning_session = None
385
+ return self.chunk_factory.create(
380
386
  ChunkType.THINKING_RESULT,
381
387
  "beendet.",
382
388
  {"reasoning_id": reasoning_id, "status": "completed"},
383
389
  )
384
390
 
385
- # Check if this was a tool block ending
386
391
  if self._current_tool_context:
387
- tool_context = self._current_tool_context
392
+ ctx = self._current_tool_context
388
393
  self._current_tool_context = None
389
- metadata: dict[str, Any] = {
390
- "tool_name": tool_context.get("tool_name"),
391
- "tool_id": tool_context.get("tool_id"),
392
- "status": "arguments_complete",
393
- }
394
- if tool_context.get("server_label"):
395
- metadata["server_label"] = tool_context["server_label"]
396
- return self._create_chunk(
397
- ChunkType.TOOL_CALL,
394
+ return self.chunk_factory.tool_call(
398
395
  "Werkzeugargumente vollständig",
399
- metadata,
396
+ tool_name=ctx.get("tool_name"),
397
+ tool_id=ctx.get("tool_id"),
398
+ server_label=ctx.get("server_label"),
399
+ status="arguments_complete",
400
400
  )
401
401
 
402
402
  return None
403
403
 
404
- def _extract_citations_from_delta(self, delta: Any) -> list[dict[str, Any]]:
405
- """Extract citation information from a text delta."""
406
- citations = []
407
-
408
- # Claude provides citations in the text block's citations field
409
- text_block_citations = getattr(delta, "citations", None)
410
- if text_block_citations:
411
- for citation in text_block_citations:
412
- citation_data = {
413
- "cited_text": getattr(citation, "cited_text", ""),
414
- "document_index": getattr(citation, "document_index", 0),
415
- "document_title": getattr(citation, "document_title", None),
416
- }
417
-
418
- # Handle different citation location types
419
- citation_type = getattr(citation, "type", None)
420
- if citation_type == "char_location":
421
- citation_data["start_char_index"] = getattr(
422
- citation, "start_char_index", 0
423
- )
424
- citation_data["end_char_index"] = getattr(
425
- citation, "end_char_index", 0
426
- )
427
- elif citation_type == "page_location":
428
- citation_data["start_page_number"] = getattr(
429
- citation, "start_page_number", 0
430
- )
431
- citation_data["end_page_number"] = getattr(
432
- citation, "end_page_number", 0
433
- )
434
- elif citation_type == "content_block_location":
435
- citation_data["start_block_index"] = getattr(
436
- citation, "start_block_index", 0
437
- )
438
- citation_data["end_block_index"] = getattr(
439
- citation, "end_block_index", 0
440
- )
441
-
442
- citations.append(citation_data)
443
-
444
- return citations
445
-
446
- def _is_auth_error(self, error: Any) -> bool:
447
- """Check if an error indicates authentication failure (401/403)."""
448
- error_str = str(error).lower()
449
- auth_indicators = [
450
- "401",
451
- "403",
452
- "unauthorized",
453
- "forbidden",
454
- "authentication required",
455
- "access denied",
456
- "invalid token",
457
- "token expired",
458
- ]
459
- return any(indicator in error_str for indicator in auth_indicators)
460
-
461
- def _create_chunk(
462
- self,
463
- chunk_type: ChunkType,
464
- content: str,
465
- extra_metadata: dict[str, Any] | None = None,
466
- ) -> Chunk:
467
- """Create a Chunk with content from the event."""
468
- metadata: dict[str, str] = {
469
- "processor": "claude_responses",
470
- }
471
-
472
- if extra_metadata:
473
- for key, value in extra_metadata.items():
474
- if value is not None:
475
- metadata[key] = str(value)
476
-
477
- return Chunk(
478
- type=chunk_type,
479
- text=content,
480
- chunk_metadata=metadata,
481
- )
482
-
483
404
  async def _process_files(self, files: list[str]) -> list[dict[str, Any]]:
484
405
  """Process and upload files for use in messages.
485
406
 
@@ -492,7 +413,7 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
492
413
  content_blocks = []
493
414
 
494
415
  for file_path in files:
495
- is_valid, error_msg = self._validate_file(file_path)
416
+ is_valid, error_msg = self._file_validator.validate_file(file_path)
496
417
  if not is_valid:
497
418
  logger.warning("Skipping invalid file %s: %s", file_path, error_msg)
498
419
  continue
@@ -530,9 +451,9 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
530
451
  # Read file content
531
452
  file_data = path.read_bytes()
532
453
 
533
- media_type = self._get_media_type(file_path)
454
+ media_type = self._file_validator.get_media_type(file_path)
534
455
 
535
- if self._is_image_file(file_path):
456
+ if self._file_validator.is_image_file(file_path):
536
457
  # For images, use base64 encoding directly in the message
537
458
  base64_data = base64.standard_b64encode(file_data).decode("utf-8")
538
459
  return {
@@ -595,7 +516,7 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
595
516
  )
596
517
 
597
518
  # Build system prompt
598
- system_prompt = await self._build_system_prompt(mcp_prompt)
519
+ system_prompt = await self._system_prompt_builder.build(mcp_prompt)
599
520
 
600
521
  # Determine which beta features to enable
601
522
  betas = []
@@ -627,16 +548,20 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
627
548
  # Add extended thinking (always enabled with fixed budget)
628
549
  params["thinking"] = {
629
550
  "type": "enabled",
630
- "budget_tokens": self.THINKING_BUDGET_TOKENS,
551
+ "budget_tokens": THINKING_BUDGET_TOKENS,
631
552
  }
632
553
 
633
554
  # Add temperature
634
555
  if model.temperature is not None:
635
556
  params["temperature"] = model.temperature
636
557
 
637
- # Merge any additional payload
558
+ # Merge any additional payload (excluding internal keys)
638
559
  if payload:
639
- params.update(payload)
560
+ internal_keys = {"thread_uuid"}
561
+ filtered_payload = {
562
+ k: v for k, v in payload.items() if k not in internal_keys
563
+ }
564
+ params.update(filtered_payload)
640
565
 
641
566
  # Create streaming request
642
567
  if betas:
@@ -736,11 +661,16 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
736
661
  # Parse headers to get auth token and query params
737
662
  auth_token, query_suffix = self._parse_mcp_headers(server)
738
663
 
739
- # Build URL with query params if needed
740
- server_url = server.url
664
+ # Check if tool requires unsupported headers (converted to query suffix).
665
+ # Claude currently does not support custom headers for MCP servers.
741
666
  if query_suffix:
742
- separator = "&" if "?" in server_url else "?"
743
- server_url = f"{server_url}{separator}{query_suffix}"
667
+ warning_msg = (
668
+ f"Der MCP-Server '{server.name}' wurde deaktiviert, "
669
+ "da er HTTP-Header benötigt, die von der Claude API "
670
+ "nicht unterstützt werden."
671
+ )
672
+ self._mcp_warnings.append(warning_msg)
673
+ continue
744
674
 
745
675
  # Build MCP server configuration
746
676
  server_config: dict[str, Any] = {
@@ -753,20 +683,20 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
753
683
 
754
684
  # Inject OAuth token if required (overrides static header token)
755
685
  if server.auth_type == MCPAuthType.OAUTH_DISCOVERY and user_id is not None:
756
- token = await self._get_valid_token_for_server(server, user_id)
686
+ token = await self.get_valid_token(server, user_id)
757
687
  if token:
758
688
  server_config["authorization_token"] = token.access_token
759
689
  logger.debug("Injected OAuth token for server %s", server.name)
760
690
  else:
761
691
  # Track for potential auth flow
762
- self._pending_auth_servers.append(server)
692
+ self.add_pending_auth_server(server)
763
693
  logger.debug(
764
694
  "No valid token for OAuth server %s, auth may be required",
765
695
  server.name,
766
696
  )
767
697
 
768
- # Set the final URL (may include query params from headers)
769
- server_config["url"] = server_url
698
+ # Set the final URL
699
+ server_config["url"] = server.url
770
700
  server_configs.append(server_config)
771
701
 
772
702
  # Add MCP toolset for this server
@@ -789,41 +719,22 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
789
719
  messages: list[Message],
790
720
  file_content_blocks: list[dict[str, Any]] | None = None,
791
721
  ) -> list[dict[str, Any]]:
792
- """Convert messages to Claude API format.
793
-
794
- Args:
795
- messages: List of conversation messages
796
- file_content_blocks: Optional file content blocks to attach
797
-
798
- Returns:
799
- List of Claude-formatted messages
800
- """
722
+ """Convert messages to Claude API format."""
801
723
  claude_messages = []
724
+ last_idx = len(messages) - 1
802
725
 
803
726
  for i, msg in enumerate(messages):
804
727
  if msg.type == MessageType.SYSTEM:
805
- continue # System messages handled separately
728
+ continue
806
729
 
807
730
  role = "user" if msg.type == MessageType.HUMAN else "assistant"
808
-
809
- # Build content
810
731
  content: list[dict[str, Any]] = []
811
732
 
812
- # For the last user message, attach files if present
813
- is_last_user = (
814
- role == "user" and i == len(messages) - 1 and file_content_blocks
815
- )
816
-
817
- if is_last_user and file_content_blocks:
733
+ # Attach files to last user message
734
+ if role == "user" and i == last_idx and file_content_blocks:
818
735
  content.extend(file_content_blocks)
819
736
 
820
- # Add text content
821
- content.append(
822
- {
823
- "type": "text",
824
- "text": msg.text,
825
- }
826
- )
737
+ content.append({"type": "text", "text": msg.text})
827
738
 
828
739
  claude_messages.append(
829
740
  {
@@ -833,96 +744,3 @@ class ClaudeResponsesProcessor(BaseClaudeProcessor):
833
744
  )
834
745
 
835
746
  return claude_messages
836
-
837
- async def _build_system_prompt(self, mcp_prompt: str = "") -> str:
838
- """Build the system prompt with optional MCP tool descriptions.
839
-
840
- Args:
841
- mcp_prompt: Optional MCP tool prompts
842
-
843
- Returns:
844
- Complete system prompt string
845
- """
846
- # Get base system prompt
847
- system_prompt_template = await get_system_prompt()
848
-
849
- # Format with MCP prompts
850
- if mcp_prompt:
851
- mcp_section = (
852
- "### Tool-Auswahlrichtlinien (Einbettung externer Beschreibungen)\n"
853
- f"{mcp_prompt}"
854
- )
855
- else:
856
- mcp_section = ""
857
-
858
- return system_prompt_template.format(mcp_prompts=mcp_section)
859
-
860
- async def _get_valid_token_for_server(
861
- self,
862
- server: MCPServer,
863
- user_id: int,
864
- ) -> AssistantMCPUserToken | None:
865
- """Get a valid OAuth token for the given server and user.
866
-
867
- Args:
868
- server: The MCP server configuration
869
- user_id: The user's ID
870
-
871
- Returns:
872
- A valid token or None if not available
873
- """
874
- if server.id is None:
875
- return None
876
-
877
- with rx.session() as session:
878
- token = self._mcp_auth_service.get_user_token(session, user_id, server.id)
879
-
880
- if token is None:
881
- return None
882
-
883
- return await self._mcp_auth_service.ensure_valid_token(
884
- session, server, token
885
- )
886
-
887
- async def _create_auth_required_chunk(self, server: MCPServer) -> Chunk:
888
- """Create an AUTH_REQUIRED chunk for a server that needs authentication.
889
-
890
- Args:
891
- server: The MCP server requiring authentication
892
-
893
- Returns:
894
- A chunk signaling auth is required with the auth URL
895
- """
896
- try:
897
- with get_session_manager().session() as session:
898
- auth_service = self._mcp_auth_service
899
- (
900
- auth_url,
901
- state,
902
- ) = await auth_service.build_authorization_url_with_registration(
903
- server,
904
- session=session,
905
- user_id=self._current_user_id,
906
- )
907
- logger.info(
908
- "Built auth URL for server %s, state=%s, url=%s",
909
- server.name,
910
- state,
911
- auth_url[:100] if auth_url else "None",
912
- )
913
- except (ValueError, Exception) as e:
914
- logger.error("Cannot build auth URL for server %s: %s", server.name, str(e))
915
- auth_url = ""
916
- state = ""
917
-
918
- return Chunk(
919
- type=ChunkType.AUTH_REQUIRED,
920
- text=f"{server.name} benötigt Ihre Autorisierung",
921
- chunk_metadata={
922
- "server_id": str(server.id) if server.id else "",
923
- "server_name": server.name,
924
- "auth_url": auth_url,
925
- "state": state,
926
- "processor": "claude_responses",
927
- },
928
- )