appkit-assistant 0.16.3__py3-none-any.whl → 0.17.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.
- appkit_assistant/backend/file_manager.py +117 -0
- appkit_assistant/backend/models.py +9 -0
- appkit_assistant/backend/processors/claude_base.py +178 -0
- appkit_assistant/backend/processors/claude_responses_processor.py +923 -0
- appkit_assistant/backend/processors/gemini_base.py +84 -0
- appkit_assistant/backend/processors/gemini_responses_processor.py +723 -0
- appkit_assistant/backend/processors/lorem_ipsum_processor.py +2 -0
- appkit_assistant/backend/processors/openai_base.py +10 -10
- appkit_assistant/backend/processors/openai_responses_processor.py +22 -15
- appkit_assistant/{logic → backend}/response_accumulator.py +50 -11
- appkit_assistant/components/__init__.py +2 -0
- appkit_assistant/components/composer.py +99 -12
- appkit_assistant/components/message.py +50 -17
- appkit_assistant/components/thread.py +2 -1
- appkit_assistant/configuration.py +2 -0
- appkit_assistant/state/thread_state.py +103 -5
- {appkit_assistant-0.16.3.dist-info → appkit_assistant-0.17.0.dist-info}/METADATA +4 -1
- {appkit_assistant-0.16.3.dist-info → appkit_assistant-0.17.0.dist-info}/RECORD +19 -14
- {appkit_assistant-0.16.3.dist-info → appkit_assistant-0.17.0.dist-info}/WHEEL +0 -0
|
@@ -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
|
+
)
|