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.
- appkit_assistant/backend/{models.py → database/models.py} +32 -132
- appkit_assistant/backend/{repositories.py → database/repositories.py} +93 -1
- appkit_assistant/backend/model_manager.py +5 -5
- appkit_assistant/backend/models/__init__.py +28 -0
- appkit_assistant/backend/models/anthropic.py +31 -0
- appkit_assistant/backend/models/google.py +27 -0
- appkit_assistant/backend/models/openai.py +50 -0
- appkit_assistant/backend/models/perplexity.py +56 -0
- appkit_assistant/backend/processors/__init__.py +29 -0
- appkit_assistant/backend/processors/claude_responses_processor.py +205 -387
- appkit_assistant/backend/processors/gemini_responses_processor.py +231 -299
- appkit_assistant/backend/processors/lorem_ipsum_processor.py +6 -4
- appkit_assistant/backend/processors/mcp_mixin.py +297 -0
- appkit_assistant/backend/processors/openai_base.py +11 -125
- appkit_assistant/backend/processors/openai_chat_completion_processor.py +5 -3
- appkit_assistant/backend/processors/openai_responses_processor.py +480 -402
- appkit_assistant/backend/processors/perplexity_processor.py +156 -79
- appkit_assistant/backend/{processor.py → processors/processor_base.py} +7 -2
- appkit_assistant/backend/processors/streaming_base.py +188 -0
- appkit_assistant/backend/schemas.py +138 -0
- appkit_assistant/backend/services/auth_error_detector.py +99 -0
- appkit_assistant/backend/services/chunk_factory.py +273 -0
- appkit_assistant/backend/services/citation_handler.py +292 -0
- appkit_assistant/backend/services/file_cleanup_service.py +316 -0
- appkit_assistant/backend/services/file_upload_service.py +903 -0
- appkit_assistant/backend/services/file_validation.py +138 -0
- appkit_assistant/backend/{mcp_auth_service.py → services/mcp_auth_service.py} +4 -2
- appkit_assistant/backend/services/mcp_token_service.py +61 -0
- appkit_assistant/backend/services/message_converter.py +289 -0
- appkit_assistant/backend/services/openai_client_service.py +120 -0
- appkit_assistant/backend/{response_accumulator.py → services/response_accumulator.py} +163 -1
- appkit_assistant/backend/services/system_prompt_builder.py +89 -0
- appkit_assistant/backend/services/thread_service.py +5 -3
- appkit_assistant/backend/system_prompt_cache.py +3 -3
- appkit_assistant/components/__init__.py +8 -4
- appkit_assistant/components/composer.py +59 -24
- appkit_assistant/components/file_manager.py +623 -0
- appkit_assistant/components/mcp_server_dialogs.py +12 -20
- appkit_assistant/components/mcp_server_table.py +12 -2
- appkit_assistant/components/message.py +119 -2
- appkit_assistant/components/thread.py +1 -1
- appkit_assistant/components/threadlist.py +4 -2
- appkit_assistant/components/tools_modal.py +37 -20
- appkit_assistant/configuration.py +12 -0
- appkit_assistant/state/file_manager_state.py +697 -0
- appkit_assistant/state/mcp_oauth_state.py +3 -3
- appkit_assistant/state/mcp_server_state.py +47 -2
- appkit_assistant/state/system_prompt_state.py +1 -1
- appkit_assistant/state/thread_list_state.py +99 -5
- appkit_assistant/state/thread_state.py +88 -9
- {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/METADATA +8 -6
- appkit_assistant-1.0.0.dist-info/RECORD +58 -0
- appkit_assistant/backend/processors/claude_base.py +0 -178
- appkit_assistant/backend/processors/gemini_base.py +0 -84
- appkit_assistant-0.17.3.dist-info/RECORD +0 -39
- /appkit_assistant/backend/{file_manager.py → services/file_manager.py} +0 -0
- {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
|
-
|
|
16
|
+
from anthropic import AsyncAnthropic
|
|
17
17
|
|
|
18
|
-
from appkit_assistant.backend.
|
|
19
|
-
|
|
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.
|
|
30
|
-
from appkit_assistant.backend.
|
|
31
|
-
from appkit_assistant.backend.
|
|
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(
|
|
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
|
-
|
|
53
|
-
self
|
|
54
|
-
|
|
55
|
-
self.
|
|
56
|
-
self.
|
|
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
|
-
|
|
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.
|
|
85
|
-
self.
|
|
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.
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
144
|
-
"""
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
198
|
+
def _handle_message_stop(self, _: Any) -> Chunk | None:
|
|
187
199
|
"""Handle message_stop event."""
|
|
188
|
-
return self.
|
|
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,
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
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.
|
|
235
|
+
self.current_reasoning_session = thinking_id
|
|
228
236
|
self._needs_text_separator = True
|
|
229
|
-
return self.
|
|
230
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
tool_name
|
|
238
|
-
tool_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":
|
|
252
|
+
"server_label": server_label,
|
|
243
253
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
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.
|
|
294
|
-
ChunkType.TOOL_RESULT,
|
|
306
|
+
return self.chunk_factory.tool_result(
|
|
295
307
|
result_text or ("Werkzeugfehler" if is_error else "Erfolgreich"),
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return
|
|
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
|
|
331
|
-
citations = self.
|
|
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(
|
|
335
|
-
|
|
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.
|
|
340
|
-
ChunkType.THINKING,
|
|
351
|
+
return self.chunk_factory.thinking(
|
|
341
352
|
thinking_text,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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,
|
|
380
|
+
def _handle_content_block_stop(self, _: Any) -> Chunk | None:
|
|
373
381
|
"""Handle content_block_stop event."""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
392
|
+
ctx = self._current_tool_context
|
|
388
393
|
self._current_tool_context = None
|
|
389
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
454
|
+
media_type = self._file_validator.get_media_type(file_path)
|
|
534
455
|
|
|
535
|
-
if self.
|
|
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.
|
|
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":
|
|
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
|
-
|
|
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
|
-
#
|
|
740
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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.
|
|
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.
|
|
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
|
|
769
|
-
server_config["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
|
|
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
|
-
#
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
)
|