appkit-assistant 0.16.3__py3-none-any.whl → 0.17.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,923 @@
1
+ """
2
+ Claude responses processor for generating AI responses using Anthropic's Claude API.
3
+
4
+ Supports MCP tools, file uploads (images and documents), extended thinking,
5
+ and automatic citation extraction.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import logging
11
+ from collections.abc import AsyncGenerator
12
+ from pathlib import Path
13
+ from typing import Any, Final
14
+
15
+ import reflex as rx
16
+
17
+ from appkit_assistant.backend.mcp_auth_service import MCPAuthService
18
+ from appkit_assistant.backend.models import (
19
+ AIModel,
20
+ AssistantMCPUserToken,
21
+ Chunk,
22
+ ChunkType,
23
+ MCPAuthType,
24
+ MCPServer,
25
+ Message,
26
+ MessageType,
27
+ )
28
+ from appkit_assistant.backend.processor import mcp_oauth_redirect_uri
29
+ from appkit_assistant.backend.processors.claude_base import BaseClaudeProcessor
30
+ from appkit_assistant.backend.system_prompt_cache import get_system_prompt
31
+ from appkit_commons.database.session import get_session_manager
32
+
33
+ logger = logging.getLogger(__name__)
34
+ default_oauth_redirect_uri: Final[str] = mcp_oauth_redirect_uri()
35
+
36
+ # Beta headers required for MCP and files API
37
+ MCP_BETA_HEADER: Final[str] = "mcp-client-2025-11-20"
38
+ FILES_BETA_HEADER: Final[str] = "files-api-2025-04-14"
39
+
40
+
41
+ class ClaudeResponsesProcessor(BaseClaudeProcessor):
42
+ """Claude processor using the Messages API with MCP tools and file uploads."""
43
+
44
+ def __init__(
45
+ self,
46
+ models: dict[str, AIModel],
47
+ api_key: str | None = None,
48
+ base_url: str | None = None,
49
+ oauth_redirect_uri: str = default_oauth_redirect_uri,
50
+ ) -> None:
51
+ super().__init__(models, api_key, base_url)
52
+ self._current_reasoning_session: str | None = None
53
+ self._current_user_id: int | None = None
54
+ self._mcp_auth_service = MCPAuthService(redirect_uri=oauth_redirect_uri)
55
+ self._pending_auth_servers: list[MCPServer] = []
56
+ self._uploaded_file_ids: list[str] = []
57
+ # Track current tool context for streaming
58
+ self._current_tool_context: dict[str, Any] | None = None
59
+ # Track if we need a newline before next text block
60
+ self._needs_text_separator: bool = False
61
+
62
+ logger.debug("Using redirect URI for MCP OAuth: %s", oauth_redirect_uri)
63
+
64
+ async def process(
65
+ self,
66
+ messages: list[Message],
67
+ model_id: str,
68
+ files: list[str] | None = None,
69
+ mcp_servers: list[MCPServer] | None = None,
70
+ payload: dict[str, Any] | None = None,
71
+ user_id: int | None = None,
72
+ ) -> AsyncGenerator[Chunk, None]:
73
+ """Process messages using Claude Messages API with streaming."""
74
+ if not self.client:
75
+ raise ValueError("Claude Client not initialized.")
76
+
77
+ if model_id not in self.models:
78
+ msg = f"Model {model_id} not supported by Claude processor"
79
+ raise ValueError(msg)
80
+
81
+ model = self.models[model_id]
82
+ self._current_user_id = user_id
83
+ self._pending_auth_servers = []
84
+ self._uploaded_file_ids = []
85
+
86
+ try:
87
+ # Upload files if provided
88
+ file_content_blocks = []
89
+ if files:
90
+ file_content_blocks = await self._process_files(files)
91
+
92
+ # Create the request
93
+ stream = await self._create_messages_request(
94
+ messages,
95
+ model,
96
+ mcp_servers,
97
+ payload,
98
+ user_id,
99
+ file_content_blocks,
100
+ )
101
+
102
+ try:
103
+ # Process streaming events
104
+ async with stream as response:
105
+ async for event in response:
106
+ chunk = self._handle_event(event)
107
+ if chunk:
108
+ yield chunk
109
+ except Exception as e:
110
+ error_msg = str(e)
111
+ logger.error("Error during Claude response processing: %s", error_msg)
112
+ # Only yield error chunk if NOT an auth error
113
+ is_auth_related = (
114
+ self._is_auth_error(error_msg) or self._pending_auth_servers
115
+ )
116
+ if not is_auth_related:
117
+ yield Chunk(
118
+ type=ChunkType.ERROR,
119
+ text=f"Ein Fehler ist aufgetreten: {error_msg}",
120
+ chunk_metadata={
121
+ "source": "claude_api",
122
+ "error_type": type(e).__name__,
123
+ },
124
+ )
125
+
126
+ # Yield any pending auth requirements
127
+ logger.debug(
128
+ "Processing pending auth servers: %d", len(self._pending_auth_servers)
129
+ )
130
+ for server in self._pending_auth_servers:
131
+ logger.debug("Yielding auth chunk for server: %s", server.name)
132
+ yield await self._create_auth_required_chunk(server)
133
+
134
+ except Exception as e:
135
+ logger.error("Critical error in Claude processor: %s", e)
136
+ raise
137
+
138
+ def _handle_event(self, event: Any) -> Chunk | None:
139
+ """Handle streaming events from Claude API."""
140
+ event_type = getattr(event, "type", None)
141
+ if not event_type:
142
+ return None
143
+
144
+ handlers = {
145
+ "message_start": self._handle_message_start,
146
+ "message_delta": self._handle_message_delta,
147
+ "message_stop": self._handle_message_stop,
148
+ "content_block_start": self._handle_content_block_start,
149
+ "content_block_delta": self._handle_content_block_delta,
150
+ "content_block_stop": self._handle_content_block_stop,
151
+ }
152
+
153
+ handler = handlers.get(event_type)
154
+ if handler:
155
+ return handler(event)
156
+
157
+ logger.debug("Unhandled Claude event type: %s", event_type)
158
+ return None
159
+
160
+ def _handle_message_start(self, event: Any) -> Chunk | None: # noqa: ARG002
161
+ """Handle message_start event."""
162
+ return self._create_chunk(
163
+ ChunkType.LIFECYCLE,
164
+ "created",
165
+ {"stage": "created"},
166
+ )
167
+
168
+ def _handle_message_delta(self, event: Any) -> Chunk | None:
169
+ """Handle message_delta event (contains stop_reason)."""
170
+ delta = getattr(event, "delta", None)
171
+ if delta:
172
+ stop_reason = getattr(delta, "stop_reason", None)
173
+ if stop_reason:
174
+ return self._create_chunk(
175
+ ChunkType.LIFECYCLE,
176
+ f"stop_reason: {stop_reason}",
177
+ {"stop_reason": stop_reason},
178
+ )
179
+ return None
180
+
181
+ def _handle_message_stop(self, event: Any) -> Chunk | None: # noqa: ARG002
182
+ """Handle message_stop event."""
183
+ return self._create_chunk(
184
+ ChunkType.COMPLETION,
185
+ "Response generation completed",
186
+ {"status": "response_complete"},
187
+ )
188
+
189
+ def _handle_content_block_start(self, event: Any) -> Chunk | None:
190
+ """Handle content_block_start event."""
191
+ content_block = getattr(event, "content_block", None)
192
+ if not content_block:
193
+ return None
194
+
195
+ block_type = getattr(content_block, "type", None)
196
+
197
+ # Use dispatch map to reduce branches
198
+ handlers = {
199
+ "text": self._handle_text_block_start,
200
+ "thinking": self._handle_thinking_block_start,
201
+ "tool_use": self._handle_tool_use_block_start,
202
+ "mcp_tool_use": self._handle_mcp_tool_use_block_start,
203
+ "mcp_tool_result": self._handle_mcp_tool_result_block_start,
204
+ }
205
+
206
+ handler = handlers.get(block_type)
207
+ if handler:
208
+ return handler(content_block)
209
+
210
+ return None
211
+
212
+ def _handle_text_block_start(self, content_block: Any) -> Chunk | None: # noqa: ARG002
213
+ """Handle start of text content block."""
214
+ if self._needs_text_separator:
215
+ self._needs_text_separator = False
216
+ return self._create_chunk(ChunkType.TEXT, "\n\n", {"separator": "true"})
217
+ return None
218
+
219
+ def _handle_thinking_block_start(self, content_block: Any) -> Chunk:
220
+ """Handle start of thinking content block."""
221
+ thinking_id = getattr(content_block, "id", "thinking")
222
+ self._current_reasoning_session = thinking_id
223
+ self._needs_text_separator = True
224
+ return self._create_chunk(
225
+ ChunkType.THINKING,
226
+ "Denke nach...",
227
+ {"reasoning_id": thinking_id, "status": "starting"},
228
+ )
229
+
230
+ def _handle_tool_use_block_start(self, content_block: Any) -> Chunk:
231
+ """Handle start of tool_use content block."""
232
+ tool_name = getattr(content_block, "name", "unknown_tool")
233
+ tool_id = getattr(content_block, "id", "unknown_id")
234
+ self._current_tool_context = {
235
+ "tool_name": tool_name,
236
+ "tool_id": tool_id,
237
+ "server_label": None,
238
+ }
239
+ return self._create_chunk(
240
+ ChunkType.TOOL_CALL,
241
+ f"Benutze Werkzeug: {tool_name}",
242
+ {
243
+ "tool_name": tool_name,
244
+ "tool_id": tool_id,
245
+ "status": "starting",
246
+ "reasoning_session": self._current_reasoning_session,
247
+ },
248
+ )
249
+
250
+ def _handle_mcp_tool_use_block_start(self, content_block: Any) -> Chunk:
251
+ """Handle start of mcp_tool_use content block."""
252
+ tool_name = getattr(content_block, "name", "unknown_tool")
253
+ tool_id = getattr(content_block, "id", "unknown_id")
254
+ server_name = getattr(content_block, "server_name", "unknown_server")
255
+ self._current_tool_context = {
256
+ "tool_name": tool_name,
257
+ "tool_id": tool_id,
258
+ "server_label": server_name,
259
+ }
260
+ return self._create_chunk(
261
+ ChunkType.TOOL_CALL,
262
+ f"Benutze Werkzeug: {server_name}.{tool_name}",
263
+ {
264
+ "tool_name": tool_name,
265
+ "tool_id": tool_id,
266
+ "server_label": server_name,
267
+ "status": "starting",
268
+ "reasoning_session": self._current_reasoning_session,
269
+ },
270
+ )
271
+
272
+ def _handle_mcp_tool_result_block_start(self, content_block: Any) -> Chunk:
273
+ """Handle start of mcp_tool_result content block."""
274
+ self._needs_text_separator = True
275
+ tool_use_id = getattr(content_block, "tool_use_id", "unknown_id")
276
+ is_error = bool(getattr(content_block, "is_error", False))
277
+ content = getattr(content_block, "content", "")
278
+
279
+ logger.debug(
280
+ "MCP tool result - tool_use_id: %s, is_error: %s, content type: %s",
281
+ tool_use_id,
282
+ is_error,
283
+ type(content).__name__,
284
+ )
285
+
286
+ result_text = self._extract_mcp_result_text(content)
287
+ status = "error" if is_error else "completed"
288
+ return self._create_chunk(
289
+ ChunkType.TOOL_RESULT,
290
+ result_text or ("Werkzeugfehler" if is_error else "Erfolgreich"),
291
+ {
292
+ "tool_id": tool_use_id,
293
+ "status": status,
294
+ "error": is_error,
295
+ "reasoning_session": self._current_reasoning_session,
296
+ },
297
+ )
298
+
299
+ def _extract_mcp_result_text(self, content: Any) -> str:
300
+ """Extract text from MCP tool result content."""
301
+ if isinstance(content, list):
302
+ parts = []
303
+ for item in content:
304
+ if isinstance(item, dict):
305
+ parts.append(item.get("text", str(item)))
306
+ elif hasattr(item, "text"):
307
+ parts.append(getattr(item, "text", str(item)))
308
+ else:
309
+ parts.append(str(item))
310
+ return "".join(parts)
311
+ return str(content) if content else ""
312
+
313
+ return None
314
+
315
+ def _handle_content_block_delta(self, event: Any) -> Chunk | None:
316
+ """Handle content_block_delta event."""
317
+ delta = getattr(event, "delta", None)
318
+ if not delta:
319
+ return None
320
+
321
+ delta_type = getattr(delta, "type", None)
322
+
323
+ if delta_type == "text_delta":
324
+ text = getattr(delta, "text", "")
325
+ # Extract citations from text if present
326
+ citations = self._extract_citations_from_delta(delta)
327
+ metadata = {"delta": text}
328
+ if citations:
329
+ metadata["citations"] = json.dumps(citations)
330
+ return self._create_chunk(ChunkType.TEXT, text, metadata)
331
+
332
+ if delta_type == "thinking_delta":
333
+ thinking_text = getattr(delta, "thinking", "")
334
+ return self._create_chunk(
335
+ ChunkType.THINKING,
336
+ thinking_text,
337
+ {
338
+ "reasoning_id": self._current_reasoning_session,
339
+ "status": "in_progress",
340
+ "delta": thinking_text,
341
+ },
342
+ )
343
+
344
+ if delta_type == "input_json_delta":
345
+ partial_json = getattr(delta, "partial_json", "")
346
+ # Include tool context in streaming chunks
347
+ metadata: dict[str, Any] = {
348
+ "status": "arguments_streaming",
349
+ "delta": partial_json,
350
+ "reasoning_session": self._current_reasoning_session,
351
+ }
352
+ if self._current_tool_context:
353
+ metadata["tool_name"] = self._current_tool_context.get("tool_name")
354
+ metadata["tool_id"] = self._current_tool_context.get("tool_id")
355
+ if self._current_tool_context.get("server_label"):
356
+ metadata["server_label"] = self._current_tool_context[
357
+ "server_label"
358
+ ]
359
+ return self._create_chunk(
360
+ ChunkType.TOOL_CALL,
361
+ partial_json,
362
+ metadata,
363
+ )
364
+
365
+ return None
366
+
367
+ def _handle_content_block_stop(self, event: Any) -> Chunk | None: # noqa: ARG002
368
+ """Handle content_block_stop event."""
369
+ # Check if this was a thinking block ending
370
+ if self._current_reasoning_session:
371
+ # Reset reasoning session after thinking completes
372
+ reasoning_id = self._current_reasoning_session
373
+ self._current_reasoning_session = None
374
+ return self._create_chunk(
375
+ ChunkType.THINKING_RESULT,
376
+ "beendet.",
377
+ {"reasoning_id": reasoning_id, "status": "completed"},
378
+ )
379
+
380
+ # Check if this was a tool block ending
381
+ if self._current_tool_context:
382
+ tool_context = self._current_tool_context
383
+ self._current_tool_context = None
384
+ metadata: dict[str, Any] = {
385
+ "tool_name": tool_context.get("tool_name"),
386
+ "tool_id": tool_context.get("tool_id"),
387
+ "status": "arguments_complete",
388
+ }
389
+ if tool_context.get("server_label"):
390
+ metadata["server_label"] = tool_context["server_label"]
391
+ return self._create_chunk(
392
+ ChunkType.TOOL_CALL,
393
+ "Werkzeugargumente vollständig",
394
+ metadata,
395
+ )
396
+
397
+ return None
398
+
399
+ def _extract_citations_from_delta(self, delta: Any) -> list[dict[str, Any]]:
400
+ """Extract citation information from a text delta."""
401
+ citations = []
402
+
403
+ # Claude provides citations in the text block's citations field
404
+ text_block_citations = getattr(delta, "citations", None)
405
+ if text_block_citations:
406
+ for citation in text_block_citations:
407
+ citation_data = {
408
+ "cited_text": getattr(citation, "cited_text", ""),
409
+ "document_index": getattr(citation, "document_index", 0),
410
+ "document_title": getattr(citation, "document_title", None),
411
+ }
412
+
413
+ # Handle different citation location types
414
+ citation_type = getattr(citation, "type", None)
415
+ if citation_type == "char_location":
416
+ citation_data["start_char_index"] = getattr(
417
+ citation, "start_char_index", 0
418
+ )
419
+ citation_data["end_char_index"] = getattr(
420
+ citation, "end_char_index", 0
421
+ )
422
+ elif citation_type == "page_location":
423
+ citation_data["start_page_number"] = getattr(
424
+ citation, "start_page_number", 0
425
+ )
426
+ citation_data["end_page_number"] = getattr(
427
+ citation, "end_page_number", 0
428
+ )
429
+ elif citation_type == "content_block_location":
430
+ citation_data["start_block_index"] = getattr(
431
+ citation, "start_block_index", 0
432
+ )
433
+ citation_data["end_block_index"] = getattr(
434
+ citation, "end_block_index", 0
435
+ )
436
+
437
+ citations.append(citation_data)
438
+
439
+ return citations
440
+
441
+ def _is_auth_error(self, error: Any) -> bool:
442
+ """Check if an error indicates authentication failure (401/403)."""
443
+ error_str = str(error).lower()
444
+ auth_indicators = [
445
+ "401",
446
+ "403",
447
+ "unauthorized",
448
+ "forbidden",
449
+ "authentication required",
450
+ "access denied",
451
+ "invalid token",
452
+ "token expired",
453
+ ]
454
+ return any(indicator in error_str for indicator in auth_indicators)
455
+
456
+ def _create_chunk(
457
+ self,
458
+ chunk_type: ChunkType,
459
+ content: str,
460
+ extra_metadata: dict[str, Any] | None = None,
461
+ ) -> Chunk:
462
+ """Create a Chunk with content from the event."""
463
+ metadata: dict[str, str] = {
464
+ "processor": "claude_responses",
465
+ }
466
+
467
+ if extra_metadata:
468
+ for key, value in extra_metadata.items():
469
+ if value is not None:
470
+ metadata[key] = str(value)
471
+
472
+ return Chunk(
473
+ type=chunk_type,
474
+ text=content,
475
+ chunk_metadata=metadata,
476
+ )
477
+
478
+ async def _process_files(self, files: list[str]) -> list[dict[str, Any]]:
479
+ """Process and upload files for use in messages.
480
+
481
+ Args:
482
+ files: List of file paths to process
483
+
484
+ Returns:
485
+ List of content blocks for file attachments
486
+ """
487
+ content_blocks = []
488
+
489
+ for file_path in files:
490
+ is_valid, error_msg = self._validate_file(file_path)
491
+ if not is_valid:
492
+ logger.warning("Skipping invalid file %s: %s", file_path, error_msg)
493
+ continue
494
+
495
+ try:
496
+ content_block = await self._create_file_content_block(file_path)
497
+ if content_block:
498
+ content_blocks.append(content_block)
499
+ except Exception as e:
500
+ logger.error("Failed to process file %s: %s", file_path, e)
501
+ continue
502
+ finally:
503
+ # Clean up local file after upload attempt
504
+ try:
505
+ Path(file_path).unlink(missing_ok=True)
506
+ except Exception as e:
507
+ logger.warning("Failed to delete local file %s: %s", file_path, e)
508
+
509
+ return content_blocks
510
+
511
+ async def _create_file_content_block(self, file_path: str) -> dict[str, Any] | None:
512
+ """Create a content block for a file.
513
+
514
+ For images, uses base64 encoding directly.
515
+ For documents, uploads via Files API.
516
+
517
+ Args:
518
+ file_path: Path to the file
519
+
520
+ Returns:
521
+ Content block dict or None if failed
522
+ """
523
+ path = Path(file_path)
524
+
525
+ # Read file content
526
+ file_data = path.read_bytes()
527
+
528
+ media_type = self._get_media_type(file_path)
529
+
530
+ if self._is_image_file(file_path):
531
+ # For images, use base64 encoding directly in the message
532
+ base64_data = base64.standard_b64encode(file_data).decode("utf-8")
533
+ return {
534
+ "type": "image",
535
+ "source": {
536
+ "type": "base64",
537
+ "media_type": media_type,
538
+ "data": base64_data,
539
+ },
540
+ }
541
+
542
+ # For documents, upload via Files API and reference
543
+ try:
544
+ file_upload = await self.client.beta.files.upload(
545
+ file=(path.name, file_data, media_type),
546
+ )
547
+ self._uploaded_file_ids.append(file_upload.id)
548
+ return {
549
+ "type": "document",
550
+ "source": {
551
+ "type": "file",
552
+ "file_id": file_upload.id,
553
+ },
554
+ "citations": {"enabled": True},
555
+ }
556
+ except Exception as e:
557
+ logger.error("Failed to upload file %s: %s", file_path, e)
558
+ return None
559
+
560
+ async def _create_messages_request(
561
+ self,
562
+ messages: list[Message],
563
+ model: AIModel,
564
+ mcp_servers: list[MCPServer] | None = None,
565
+ payload: dict[str, Any] | None = None,
566
+ user_id: int | None = None,
567
+ file_content_blocks: list[dict[str, Any]] | None = None,
568
+ ) -> Any:
569
+ """Create a Claude Messages API request with streaming.
570
+
571
+ Args:
572
+ messages: List of conversation messages
573
+ model: AI model configuration
574
+ mcp_servers: Optional list of MCP servers for tools
575
+ payload: Optional additional parameters
576
+ user_id: Optional user ID for OAuth token lookup
577
+ file_content_blocks: Optional list of file content blocks
578
+
579
+ Returns:
580
+ Streaming response object
581
+ """
582
+ # Configure MCP tools and servers
583
+ tools, mcp_server_configs, mcp_prompt = await self._configure_mcp_tools(
584
+ mcp_servers, user_id
585
+ )
586
+
587
+ # Convert messages to Claude format
588
+ claude_messages = await self._convert_messages_to_claude_format(
589
+ messages, file_content_blocks
590
+ )
591
+
592
+ # Build system prompt
593
+ system_prompt = await self._build_system_prompt(mcp_prompt)
594
+
595
+ # Determine which beta features to enable
596
+ betas = []
597
+ if mcp_servers:
598
+ betas.append(MCP_BETA_HEADER)
599
+ if file_content_blocks:
600
+ betas.append(FILES_BETA_HEADER)
601
+
602
+ # Build request parameters
603
+ # max_tokens must be > thinking.budget_tokens when thinking is enabled
604
+ params: dict[str, Any] = {
605
+ "model": model.model,
606
+ "max_tokens": 32000,
607
+ "messages": claude_messages,
608
+ }
609
+
610
+ # Add system prompt
611
+ if system_prompt:
612
+ params["system"] = system_prompt
613
+
614
+ # Add MCP servers if configured
615
+ if mcp_server_configs:
616
+ params["mcp_servers"] = mcp_server_configs
617
+
618
+ # Add tools if configured
619
+ if tools:
620
+ params["tools"] = tools
621
+
622
+ # Add extended thinking (always enabled with fixed budget)
623
+ params["thinking"] = {
624
+ "type": "enabled",
625
+ "budget_tokens": self.THINKING_BUDGET_TOKENS,
626
+ }
627
+
628
+ # Add temperature
629
+ if model.temperature is not None:
630
+ params["temperature"] = model.temperature
631
+
632
+ # Merge any additional payload
633
+ if payload:
634
+ params.update(payload)
635
+
636
+ # Create streaming request
637
+ if betas:
638
+ return self.client.beta.messages.stream(
639
+ betas=betas,
640
+ **params,
641
+ )
642
+
643
+ return self.client.messages.stream(**params)
644
+
645
+ def _parse_mcp_headers(
646
+ self,
647
+ server: MCPServer,
648
+ ) -> tuple[str | None, str]:
649
+ """Parse MCP server headers and extract auth token + query params.
650
+
651
+ Claude's MCP connector only supports authorization_token (Bearer token).
652
+ Custom headers like X-Project-ID are converted to URL query parameters.
653
+
654
+ Args:
655
+ server: MCP server configuration
656
+
657
+ Returns:
658
+ Tuple of (authorization_token, url_suffix_with_query_params)
659
+ """
660
+ auth_token = None
661
+ query_suffix = ""
662
+
663
+ if not server.headers or server.headers == "{}":
664
+ return auth_token, query_suffix
665
+
666
+ try:
667
+ headers = json.loads(server.headers)
668
+ except json.JSONDecodeError as e:
669
+ logger.error(
670
+ "Failed to parse headers JSON for server %s: %s",
671
+ server.name,
672
+ e,
673
+ )
674
+ return auth_token, query_suffix
675
+
676
+ # Extract Bearer token from Authorization header
677
+ auth_header = headers.get("Authorization", "")
678
+ if auth_header.startswith("Bearer "):
679
+ auth_token = auth_header[7:] # Remove "Bearer " prefix
680
+ logger.debug(
681
+ "Extracted Bearer token from headers for server %s", server.name
682
+ )
683
+ elif auth_header:
684
+ auth_token = auth_header
685
+ logger.debug("Using raw Authorization header for server %s", server.name)
686
+
687
+ # Convert non-auth headers to URL query parameters
688
+ query_params = []
689
+ for key, value in headers.items():
690
+ if key.lower() == "authorization":
691
+ continue
692
+ # Convert header name: X-Project-ID -> project_id
693
+ param_name = key.lower()
694
+ if param_name.startswith("x-"):
695
+ param_name = param_name[2:]
696
+ param_name = param_name.replace("-", "_")
697
+ query_params.append(f"{param_name}={value}")
698
+
699
+ if query_params:
700
+ query_suffix = "&".join(query_params)
701
+ logger.info(
702
+ "Converted headers to query params for server %s: %s",
703
+ server.name,
704
+ query_params,
705
+ )
706
+
707
+ return auth_token, query_suffix
708
+
709
+ async def _configure_mcp_tools(
710
+ self,
711
+ mcp_servers: list[MCPServer] | None,
712
+ user_id: int | None = None,
713
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]:
714
+ """Configure MCP servers and tools for the request.
715
+
716
+ Args:
717
+ mcp_servers: List of MCP server configurations
718
+ user_id: Optional user ID for OAuth token lookup
719
+
720
+ Returns:
721
+ Tuple of (tools list, mcp_servers list, concatenated prompts)
722
+ """
723
+ if not mcp_servers:
724
+ return [], [], ""
725
+
726
+ tools = []
727
+ server_configs = []
728
+ prompts = []
729
+
730
+ for server in mcp_servers:
731
+ # Parse headers to get auth token and query params
732
+ auth_token, query_suffix = self._parse_mcp_headers(server)
733
+
734
+ # Build URL with query params if needed
735
+ server_url = server.url
736
+ if query_suffix:
737
+ separator = "&" if "?" in server_url else "?"
738
+ server_url = f"{server_url}{separator}{query_suffix}"
739
+
740
+ # Build MCP server configuration
741
+ server_config: dict[str, Any] = {
742
+ "type": "url",
743
+ "name": server.name,
744
+ }
745
+
746
+ if auth_token:
747
+ server_config["authorization_token"] = auth_token
748
+
749
+ # Inject OAuth token if required (overrides static header token)
750
+ if server.auth_type == MCPAuthType.OAUTH_DISCOVERY and user_id is not None:
751
+ token = await self._get_valid_token_for_server(server, user_id)
752
+ if token:
753
+ server_config["authorization_token"] = token.access_token
754
+ logger.debug("Injected OAuth token for server %s", server.name)
755
+ else:
756
+ # Track for potential auth flow
757
+ self._pending_auth_servers.append(server)
758
+ logger.debug(
759
+ "No valid token for OAuth server %s, auth may be required",
760
+ server.name,
761
+ )
762
+
763
+ # Set the final URL (may include query params from headers)
764
+ server_config["url"] = server_url
765
+ server_configs.append(server_config)
766
+
767
+ # Add MCP toolset for this server
768
+ tools.append(
769
+ {
770
+ "type": "mcp_toolset",
771
+ "mcp_server_name": server.name,
772
+ }
773
+ )
774
+
775
+ # Collect prompts
776
+ if server.prompt:
777
+ prompts.append(f"- {server.prompt}")
778
+
779
+ prompt_string = "\n".join(prompts) if prompts else ""
780
+ return tools, server_configs, prompt_string
781
+
782
+ async def _convert_messages_to_claude_format(
783
+ self,
784
+ messages: list[Message],
785
+ file_content_blocks: list[dict[str, Any]] | None = None,
786
+ ) -> list[dict[str, Any]]:
787
+ """Convert messages to Claude API format.
788
+
789
+ Args:
790
+ messages: List of conversation messages
791
+ file_content_blocks: Optional file content blocks to attach
792
+
793
+ Returns:
794
+ List of Claude-formatted messages
795
+ """
796
+ claude_messages = []
797
+
798
+ for i, msg in enumerate(messages):
799
+ if msg.type == MessageType.SYSTEM:
800
+ continue # System messages handled separately
801
+
802
+ role = "user" if msg.type == MessageType.HUMAN else "assistant"
803
+
804
+ # Build content
805
+ content: list[dict[str, Any]] = []
806
+
807
+ # For the last user message, attach files if present
808
+ is_last_user = (
809
+ role == "user" and i == len(messages) - 1 and file_content_blocks
810
+ )
811
+
812
+ if is_last_user and file_content_blocks:
813
+ content.extend(file_content_blocks)
814
+
815
+ # Add text content
816
+ content.append(
817
+ {
818
+ "type": "text",
819
+ "text": msg.text,
820
+ }
821
+ )
822
+
823
+ claude_messages.append(
824
+ {
825
+ "role": role,
826
+ "content": content if len(content) > 1 else msg.text,
827
+ }
828
+ )
829
+
830
+ return claude_messages
831
+
832
+ async def _build_system_prompt(self, mcp_prompt: str = "") -> str:
833
+ """Build the system prompt with optional MCP tool descriptions.
834
+
835
+ Args:
836
+ mcp_prompt: Optional MCP tool prompts
837
+
838
+ Returns:
839
+ Complete system prompt string
840
+ """
841
+ # Get base system prompt
842
+ system_prompt_template = await get_system_prompt()
843
+
844
+ # Format with MCP prompts
845
+ if mcp_prompt:
846
+ mcp_section = (
847
+ "### Tool-Auswahlrichtlinien (Einbettung externer Beschreibungen)\n"
848
+ f"{mcp_prompt}"
849
+ )
850
+ else:
851
+ mcp_section = ""
852
+
853
+ return system_prompt_template.format(mcp_prompts=mcp_section)
854
+
855
+ async def _get_valid_token_for_server(
856
+ self,
857
+ server: MCPServer,
858
+ user_id: int,
859
+ ) -> AssistantMCPUserToken | None:
860
+ """Get a valid OAuth token for the given server and user.
861
+
862
+ Args:
863
+ server: The MCP server configuration
864
+ user_id: The user's ID
865
+
866
+ Returns:
867
+ A valid token or None if not available
868
+ """
869
+ if server.id is None:
870
+ return None
871
+
872
+ with rx.session() as session:
873
+ token = self._mcp_auth_service.get_user_token(session, user_id, server.id)
874
+
875
+ if token is None:
876
+ return None
877
+
878
+ return await self._mcp_auth_service.ensure_valid_token(
879
+ session, server, token
880
+ )
881
+
882
+ async def _create_auth_required_chunk(self, server: MCPServer) -> Chunk:
883
+ """Create an AUTH_REQUIRED chunk for a server that needs authentication.
884
+
885
+ Args:
886
+ server: The MCP server requiring authentication
887
+
888
+ Returns:
889
+ A chunk signaling auth is required with the auth URL
890
+ """
891
+ try:
892
+ with get_session_manager().session() as session:
893
+ auth_service = self._mcp_auth_service
894
+ (
895
+ auth_url,
896
+ state,
897
+ ) = await auth_service.build_authorization_url_with_registration(
898
+ server,
899
+ session=session,
900
+ user_id=self._current_user_id,
901
+ )
902
+ logger.info(
903
+ "Built auth URL for server %s, state=%s, url=%s",
904
+ server.name,
905
+ state,
906
+ auth_url[:100] if auth_url else "None",
907
+ )
908
+ except (ValueError, Exception) as e:
909
+ logger.error("Cannot build auth URL for server %s: %s", server.name, str(e))
910
+ auth_url = ""
911
+ state = ""
912
+
913
+ return Chunk(
914
+ type=ChunkType.AUTH_REQUIRED,
915
+ text=f"{server.name} benötigt Ihre Autorisierung",
916
+ chunk_metadata={
917
+ "server_id": str(server.id) if server.id else "",
918
+ "server_name": server.name,
919
+ "auth_url": auth_url,
920
+ "state": state,
921
+ "processor": "claude_responses",
922
+ },
923
+ )