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
|
@@ -4,33 +4,36 @@ Gemini responses processor for generating AI responses using Google's GenAI API.
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import copy
|
|
7
|
-
import json
|
|
8
7
|
import logging
|
|
9
8
|
import uuid
|
|
10
9
|
from collections.abc import AsyncGenerator
|
|
11
10
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
12
11
|
from dataclasses import dataclass, field
|
|
13
|
-
from typing import Any, Final
|
|
12
|
+
from typing import Any, Final, NamedTuple
|
|
14
13
|
|
|
15
|
-
import
|
|
14
|
+
import httpx
|
|
15
|
+
from google import genai
|
|
16
16
|
from google.genai import types
|
|
17
17
|
from mcp import ClientSession
|
|
18
|
-
from mcp.client.streamable_http import
|
|
18
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
19
19
|
|
|
20
|
-
from appkit_assistant.backend.
|
|
21
|
-
|
|
20
|
+
from appkit_assistant.backend.database.models import (
|
|
21
|
+
MCPServer,
|
|
22
|
+
)
|
|
23
|
+
from appkit_assistant.backend.processors.mcp_mixin import MCPCapabilities
|
|
24
|
+
from appkit_assistant.backend.processors.processor_base import (
|
|
25
|
+
ProcessorBase,
|
|
26
|
+
mcp_oauth_redirect_uri,
|
|
27
|
+
)
|
|
28
|
+
from appkit_assistant.backend.schemas import (
|
|
22
29
|
AIModel,
|
|
23
|
-
AssistantMCPUserToken,
|
|
24
30
|
Chunk,
|
|
25
|
-
ChunkType,
|
|
26
31
|
MCPAuthType,
|
|
27
|
-
MCPServer,
|
|
28
32
|
Message,
|
|
29
33
|
MessageType,
|
|
30
34
|
)
|
|
31
|
-
from appkit_assistant.backend.
|
|
32
|
-
from appkit_assistant.backend.
|
|
33
|
-
from appkit_assistant.backend.system_prompt_cache import get_system_prompt
|
|
35
|
+
from appkit_assistant.backend.services.chunk_factory import ChunkFactory
|
|
36
|
+
from appkit_assistant.backend.services.system_prompt_builder import SystemPromptBuilder
|
|
34
37
|
|
|
35
38
|
logger = logging.getLogger(__name__)
|
|
36
39
|
default_oauth_redirect_uri: Final[str] = mcp_oauth_redirect_uri()
|
|
@@ -38,6 +41,45 @@ default_oauth_redirect_uri: Final[str] = mcp_oauth_redirect_uri()
|
|
|
38
41
|
# Maximum characters to show in tool result preview
|
|
39
42
|
TOOL_RESULT_PREVIEW_LENGTH: Final[int] = 500
|
|
40
43
|
|
|
44
|
+
GEMINI_FORBIDDEN_SCHEMA_FIELDS: Final[set[str]] = {
|
|
45
|
+
"$schema",
|
|
46
|
+
"$id",
|
|
47
|
+
"$ref",
|
|
48
|
+
"$defs",
|
|
49
|
+
"definitions",
|
|
50
|
+
"$comment",
|
|
51
|
+
"examples",
|
|
52
|
+
"default",
|
|
53
|
+
"const",
|
|
54
|
+
"contentMediaType",
|
|
55
|
+
"contentEncoding",
|
|
56
|
+
"additionalProperties",
|
|
57
|
+
"additional_properties",
|
|
58
|
+
"patternProperties",
|
|
59
|
+
"unevaluatedProperties",
|
|
60
|
+
"unevaluatedItems",
|
|
61
|
+
"minItems",
|
|
62
|
+
"maxItems",
|
|
63
|
+
"minLength",
|
|
64
|
+
"maxLength",
|
|
65
|
+
"minimum",
|
|
66
|
+
"maximum",
|
|
67
|
+
"exclusiveMinimum",
|
|
68
|
+
"exclusiveMaximum",
|
|
69
|
+
"multipleOf",
|
|
70
|
+
"pattern",
|
|
71
|
+
"format",
|
|
72
|
+
"title",
|
|
73
|
+
"allOf",
|
|
74
|
+
"oneOf",
|
|
75
|
+
"not",
|
|
76
|
+
"if",
|
|
77
|
+
"then",
|
|
78
|
+
"else",
|
|
79
|
+
"dependentSchemas",
|
|
80
|
+
"dependentRequired",
|
|
81
|
+
}
|
|
82
|
+
|
|
41
83
|
|
|
42
84
|
@dataclass
|
|
43
85
|
class MCPToolContext:
|
|
@@ -48,7 +90,15 @@ class MCPToolContext:
|
|
|
48
90
|
tools: dict[str, Any] = field(default_factory=dict)
|
|
49
91
|
|
|
50
92
|
|
|
51
|
-
class
|
|
93
|
+
class MCPSessionWrapper(NamedTuple):
|
|
94
|
+
"""Wrapper to store MCP connection details before creating actual session."""
|
|
95
|
+
|
|
96
|
+
url: str
|
|
97
|
+
headers: dict[str, str]
|
|
98
|
+
name: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GeminiResponsesProcessor(ProcessorBase, MCPCapabilities):
|
|
52
102
|
"""Gemini processor using the GenAI API with native MCP support."""
|
|
53
103
|
|
|
54
104
|
def __init__(
|
|
@@ -57,14 +107,28 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
57
107
|
api_key: str | None = None,
|
|
58
108
|
oauth_redirect_uri: str = default_oauth_redirect_uri,
|
|
59
109
|
) -> None:
|
|
60
|
-
|
|
61
|
-
self.
|
|
62
|
-
self.
|
|
63
|
-
self.
|
|
64
|
-
self.
|
|
110
|
+
MCPCapabilities.__init__(self, oauth_redirect_uri, "gemini_responses")
|
|
111
|
+
self.models = models
|
|
112
|
+
self.client: genai.Client | None = None
|
|
113
|
+
self._chunk_factory = ChunkFactory(processor_name="gemini_responses")
|
|
114
|
+
self._system_prompt_builder = SystemPromptBuilder()
|
|
115
|
+
|
|
116
|
+
if api_key:
|
|
117
|
+
try:
|
|
118
|
+
self.client = genai.Client(
|
|
119
|
+
api_key=api_key, http_options={"api_version": "v1beta"}
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error("Failed to initialize Gemini client: %s", e)
|
|
123
|
+
else:
|
|
124
|
+
logger.warning("Gemini API key not found. Processor disabled.")
|
|
65
125
|
|
|
66
126
|
logger.debug("Using redirect URI for MCP OAuth: %s", oauth_redirect_uri)
|
|
67
127
|
|
|
128
|
+
def get_supported_models(self) -> dict[str, AIModel]:
|
|
129
|
+
"""Get supported models."""
|
|
130
|
+
return self.models
|
|
131
|
+
|
|
68
132
|
async def process(
|
|
69
133
|
self,
|
|
70
134
|
messages: list[Message],
|
|
@@ -84,9 +148,8 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
84
148
|
raise ValueError(msg)
|
|
85
149
|
|
|
86
150
|
model = self.models[model_id]
|
|
87
|
-
self.
|
|
88
|
-
self.
|
|
89
|
-
self._current_reasoning_session = None
|
|
151
|
+
self.current_user_id = user_id
|
|
152
|
+
self.clear_pending_auth_servers()
|
|
90
153
|
|
|
91
154
|
# Prepare configuration
|
|
92
155
|
config = self._create_generation_config(model, payload)
|
|
@@ -95,14 +158,13 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
95
158
|
mcp_sessions = []
|
|
96
159
|
mcp_prompt = ""
|
|
97
160
|
if mcp_servers:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
161
|
+
mcp_sessions, auth_required = await self._create_mcp_sessions(
|
|
162
|
+
mcp_servers, user_id
|
|
163
|
+
)
|
|
164
|
+
for server in auth_required:
|
|
165
|
+
self.add_pending_auth_server(server)
|
|
101
166
|
mcp_prompt = self._build_mcp_prompt(mcp_servers)
|
|
102
|
-
|
|
103
|
-
if mcp_sessions:
|
|
104
|
-
# Pass sessions directly to tools - SDK handles everything!
|
|
105
|
-
config.tools = mcp_sessions
|
|
167
|
+
# Note: tools are configured in _stream_with_mcp after connecting to MCP
|
|
106
168
|
|
|
107
169
|
# Prepare messages with MCP prompts for tool selection
|
|
108
170
|
contents, system_instruction = await self._convert_messages_to_gemini_format(
|
|
@@ -127,35 +189,35 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
127
189
|
yield chunk
|
|
128
190
|
|
|
129
191
|
# Handle any pending auth
|
|
130
|
-
for
|
|
131
|
-
yield
|
|
192
|
+
async for auth_chunk in self.yield_pending_auth_chunks():
|
|
193
|
+
yield auth_chunk
|
|
132
194
|
|
|
133
195
|
except Exception as e:
|
|
134
196
|
logger.exception("Error in Gemini processor: %s", str(e))
|
|
135
|
-
yield self.
|
|
197
|
+
yield self._chunk_factory.error(f"Error: {e!s}")
|
|
136
198
|
|
|
137
199
|
async def _create_mcp_sessions(
|
|
138
200
|
self, servers: list[MCPServer], user_id: int | None
|
|
139
|
-
) ->
|
|
201
|
+
) -> tuple[list[MCPSessionWrapper], list[MCPServer]]:
|
|
140
202
|
"""Create MCP ClientSession objects for each server.
|
|
141
203
|
|
|
142
204
|
Returns:
|
|
143
|
-
|
|
205
|
+
Tuple with (sessions, auth_required_servers)
|
|
144
206
|
"""
|
|
145
207
|
sessions = []
|
|
146
208
|
auth_required = []
|
|
147
209
|
|
|
148
210
|
for server in servers:
|
|
149
211
|
try:
|
|
150
|
-
# Parse headers
|
|
151
|
-
headers = self.
|
|
212
|
+
# Parse headers using MCPCapabilities
|
|
213
|
+
headers = self.parse_mcp_headers(server)
|
|
152
214
|
|
|
153
215
|
# Handle OAuth - inject token
|
|
154
216
|
if (
|
|
155
217
|
server.auth_type == MCPAuthType.OAUTH_DISCOVERY
|
|
156
218
|
and user_id is not None
|
|
157
219
|
):
|
|
158
|
-
token = await self.
|
|
220
|
+
token = await self.get_valid_token(server, user_id)
|
|
159
221
|
if token:
|
|
160
222
|
headers["Authorization"] = f"Bearer {token.access_token}"
|
|
161
223
|
else:
|
|
@@ -187,7 +249,7 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
187
249
|
"Failed to connect to MCP server %s: %s", server.name, str(e)
|
|
188
250
|
)
|
|
189
251
|
|
|
190
|
-
return
|
|
252
|
+
return sessions, auth_required
|
|
191
253
|
|
|
192
254
|
async def _stream_with_mcp(
|
|
193
255
|
self,
|
|
@@ -210,11 +272,14 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
210
272
|
async with self._mcp_context_manager(mcp_sessions) as tool_contexts:
|
|
211
273
|
if tool_contexts:
|
|
212
274
|
# Convert MCP tools to Gemini FunctionDeclarations
|
|
275
|
+
# Use unique naming: server_name__tool_name to avoid duplicates
|
|
213
276
|
function_declarations = []
|
|
214
277
|
for ctx in tool_contexts:
|
|
215
278
|
for tool_name, tool_def in ctx.tools.items():
|
|
279
|
+
# Create unique name: server_name__tool_name
|
|
280
|
+
unique_name = f"{ctx.server_name}__{tool_name}"
|
|
216
281
|
func_decl = self._mcp_tool_to_gemini_function(
|
|
217
|
-
|
|
282
|
+
unique_name, tool_def, original_name=tool_name
|
|
218
283
|
)
|
|
219
284
|
if func_decl:
|
|
220
285
|
function_declarations.append(func_decl)
|
|
@@ -275,27 +340,21 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
275
340
|
# Execute tool calls and collect results
|
|
276
341
|
function_responses = []
|
|
277
342
|
for fc in function_calls:
|
|
278
|
-
#
|
|
279
|
-
server_name =
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
server_name = ctx.server_name
|
|
283
|
-
break
|
|
343
|
+
# Parse unique tool name: server_name__tool_name
|
|
344
|
+
server_name, original_tool_name = self._parse_unique_tool_name(
|
|
345
|
+
fc.name
|
|
346
|
+
)
|
|
284
347
|
|
|
285
348
|
# Generate a unique tool call ID
|
|
286
349
|
tool_call_id = f"mcp_{uuid.uuid4().hex[:32]}"
|
|
287
350
|
|
|
288
|
-
# Yield TOOL_CALL chunk to show in UI
|
|
289
|
-
yield self.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"server_label": server_name,
|
|
296
|
-
"arguments": json.dumps(fc.args),
|
|
297
|
-
"status": "starting",
|
|
298
|
-
},
|
|
351
|
+
# Yield TOOL_CALL chunk to show in UI (use original name)
|
|
352
|
+
yield self._chunk_factory.tool_call(
|
|
353
|
+
f"Benutze Werkzeug: {server_name}.{original_tool_name}",
|
|
354
|
+
tool_name=original_tool_name,
|
|
355
|
+
tool_id=tool_call_id,
|
|
356
|
+
server_label=server_name,
|
|
357
|
+
status="starting",
|
|
299
358
|
)
|
|
300
359
|
|
|
301
360
|
result = await self._execute_mcp_tool(
|
|
@@ -308,16 +367,10 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
308
367
|
if len(result) > TOOL_RESULT_PREVIEW_LENGTH
|
|
309
368
|
else result
|
|
310
369
|
)
|
|
311
|
-
yield self.
|
|
312
|
-
ChunkType.TOOL_RESULT,
|
|
370
|
+
yield self._chunk_factory.tool_result(
|
|
313
371
|
preview,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
"tool_id": tool_call_id,
|
|
317
|
-
"server_label": server_name,
|
|
318
|
-
"status": "completed",
|
|
319
|
-
"result_length": str(len(result)),
|
|
320
|
-
},
|
|
372
|
+
tool_id=tool_call_id,
|
|
373
|
+
status="completed",
|
|
321
374
|
)
|
|
322
375
|
|
|
323
376
|
function_responses.append(
|
|
@@ -342,59 +395,75 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
342
395
|
# Continue to next round
|
|
343
396
|
continue
|
|
344
397
|
|
|
345
|
-
# No function calls - yield text response
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
yield self._create_chunk(
|
|
349
|
-
ChunkType.TEXT,
|
|
350
|
-
"".join(text_parts),
|
|
351
|
-
{"delta": "".join(text_parts)},
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
# Done - no more function calls
|
|
398
|
+
# No function calls - yield text response and finish
|
|
399
|
+
if text := self._extract_text_from_parts(content.parts):
|
|
400
|
+
yield self._chunk_factory.text(text, delta=text)
|
|
355
401
|
return
|
|
356
402
|
|
|
357
403
|
logger.warning("Max tool rounds (%d) exceeded", max_tool_rounds)
|
|
358
404
|
|
|
405
|
+
def _parse_unique_tool_name(self, unique_name: str) -> tuple[str, str]:
|
|
406
|
+
"""Parse unique tool name back to server name and original tool name.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
unique_name: Tool name in format 'server_name__tool_name'
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Tuple of (server_name, original_tool_name)
|
|
413
|
+
"""
|
|
414
|
+
if "__" in unique_name:
|
|
415
|
+
parts = unique_name.split("__", 1)
|
|
416
|
+
return parts[0], parts[1]
|
|
417
|
+
# Fallback for tools without prefix
|
|
418
|
+
return "unknown", unique_name
|
|
419
|
+
|
|
359
420
|
async def _execute_mcp_tool(
|
|
360
421
|
self,
|
|
361
|
-
|
|
422
|
+
unique_tool_name: str,
|
|
362
423
|
args: dict[str, Any],
|
|
363
424
|
tool_contexts: list[MCPToolContext],
|
|
364
425
|
) -> str:
|
|
365
426
|
"""Execute an MCP tool and return the result."""
|
|
366
|
-
|
|
427
|
+
server_name, tool_name = self._parse_unique_tool_name(unique_tool_name)
|
|
428
|
+
|
|
367
429
|
for ctx in tool_contexts:
|
|
368
|
-
if tool_name in ctx.tools:
|
|
430
|
+
if ctx.server_name == server_name and tool_name in ctx.tools:
|
|
369
431
|
try:
|
|
370
432
|
logger.debug(
|
|
371
433
|
"Executing tool %s on server %s with args: %s",
|
|
372
434
|
tool_name,
|
|
373
|
-
|
|
435
|
+
server_name,
|
|
374
436
|
args,
|
|
375
437
|
)
|
|
376
438
|
result = await ctx.session.call_tool(tool_name, args)
|
|
377
|
-
# Extract text from result
|
|
378
439
|
if hasattr(result, "content") and result.content:
|
|
379
|
-
texts = [
|
|
380
|
-
item.text
|
|
381
|
-
for item in result.content
|
|
382
|
-
if hasattr(item, "text")
|
|
383
|
-
]
|
|
440
|
+
texts = [i.text for i in result.content if hasattr(i, "text")]
|
|
384
441
|
return "\n".join(texts) if texts else str(result)
|
|
385
442
|
return str(result)
|
|
386
443
|
except Exception as e:
|
|
387
|
-
logger.exception("Error executing tool %s: %s", tool_name,
|
|
444
|
+
logger.exception("Error executing tool %s: %s", tool_name, e)
|
|
388
445
|
return f"Error executing tool: {e!s}"
|
|
389
446
|
|
|
390
|
-
return f"Tool {tool_name} not found
|
|
447
|
+
return f"Tool {tool_name} not found on server {server_name}"
|
|
391
448
|
|
|
392
449
|
def _mcp_tool_to_gemini_function(
|
|
393
|
-
self,
|
|
450
|
+
self,
|
|
451
|
+
name: str,
|
|
452
|
+
tool_def: dict[str, Any],
|
|
453
|
+
original_name: str | None = None,
|
|
394
454
|
) -> types.FunctionDeclaration | None:
|
|
395
|
-
"""Convert MCP tool definition to Gemini FunctionDeclaration.
|
|
455
|
+
"""Convert MCP tool definition to Gemini FunctionDeclaration.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
name: Unique function name (may include server prefix)
|
|
459
|
+
tool_def: MCP tool definition with description and inputSchema
|
|
460
|
+
original_name: Original tool name for description enhancement
|
|
461
|
+
"""
|
|
396
462
|
try:
|
|
397
463
|
description = tool_def.get("description", "")
|
|
464
|
+
# Enhance description with original name if using prefixed naming
|
|
465
|
+
if original_name and original_name != name:
|
|
466
|
+
description = f"[{original_name}] {description}"
|
|
398
467
|
input_schema = tool_def.get("inputSchema", {})
|
|
399
468
|
|
|
400
469
|
# Fix the schema for Gemini compatibility
|
|
@@ -410,97 +479,37 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
410
479
|
return None
|
|
411
480
|
|
|
412
481
|
def _fix_schema_for_gemini(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
413
|
-
"""Fix JSON schema for Gemini API compatibility.
|
|
414
|
-
|
|
415
|
-
Gemini requires 'items' field for array types and doesn't allow certain
|
|
416
|
-
JSON Schema fields like '$schema', '$id', 'definitions', etc.
|
|
417
|
-
This recursively fixes the schema.
|
|
418
|
-
"""
|
|
419
|
-
if not schema:
|
|
482
|
+
"""Fix JSON schema for Gemini API compatibility (recursive)."""
|
|
483
|
+
if not isinstance(schema, dict):
|
|
420
484
|
return schema
|
|
421
485
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
"
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
"
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
"
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
"patternProperties",
|
|
442
|
-
"unevaluatedProperties",
|
|
443
|
-
"unevaluatedItems",
|
|
444
|
-
"minItems",
|
|
445
|
-
"maxItems",
|
|
446
|
-
"minLength",
|
|
447
|
-
"maxLength",
|
|
448
|
-
"minimum",
|
|
449
|
-
"maximum",
|
|
450
|
-
"exclusiveMinimum",
|
|
451
|
-
"exclusiveMaximum",
|
|
452
|
-
"multipleOf",
|
|
453
|
-
"pattern",
|
|
454
|
-
"format",
|
|
455
|
-
"title",
|
|
456
|
-
# Composition keywords - Gemini doesn't support these
|
|
457
|
-
"allOf",
|
|
458
|
-
"oneOf",
|
|
459
|
-
"not",
|
|
460
|
-
"if",
|
|
461
|
-
"then",
|
|
462
|
-
"else",
|
|
463
|
-
"dependentSchemas",
|
|
464
|
-
"dependentRequired",
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
def fix_property(prop: dict[str, Any]) -> dict[str, Any]:
|
|
468
|
-
"""Recursively fix a property schema."""
|
|
469
|
-
if not isinstance(prop, dict):
|
|
470
|
-
return prop
|
|
471
|
-
|
|
472
|
-
# Remove forbidden fields
|
|
473
|
-
for forbidden in forbidden_fields:
|
|
474
|
-
prop.pop(forbidden, None)
|
|
475
|
-
|
|
476
|
-
prop_type = prop.get("type")
|
|
477
|
-
|
|
478
|
-
# Fix array without items
|
|
479
|
-
if prop_type == "array" and "items" not in prop:
|
|
480
|
-
prop["items"] = {"type": "string"}
|
|
481
|
-
logger.debug("Added missing 'items' to array property")
|
|
482
|
-
|
|
483
|
-
# Recurse into items
|
|
484
|
-
if "items" in prop and isinstance(prop["items"], dict):
|
|
485
|
-
prop["items"] = fix_property(prop["items"])
|
|
486
|
-
|
|
487
|
-
# Recurse into properties
|
|
488
|
-
if "properties" in prop and isinstance(prop["properties"], dict):
|
|
489
|
-
for key, val in prop["properties"].items():
|
|
490
|
-
prop["properties"][key] = fix_property(val)
|
|
491
|
-
|
|
492
|
-
# Recurse into anyOf/any_of arrays (Gemini accepts these but not
|
|
493
|
-
# forbidden fields inside them)
|
|
494
|
-
for any_of_key in ("anyOf", "any_of"):
|
|
495
|
-
if any_of_key in prop and isinstance(prop[any_of_key], list):
|
|
496
|
-
prop[any_of_key] = [
|
|
497
|
-
fix_property(item) if isinstance(item, dict) else item
|
|
498
|
-
for item in prop[any_of_key]
|
|
499
|
-
]
|
|
486
|
+
result = copy.deepcopy(schema)
|
|
487
|
+
|
|
488
|
+
# Remove forbidden fields
|
|
489
|
+
for key in GEMINI_FORBIDDEN_SCHEMA_FIELDS:
|
|
490
|
+
result.pop(key, None)
|
|
491
|
+
|
|
492
|
+
# Fix array without items
|
|
493
|
+
if result.get("type") == "array" and "items" not in result:
|
|
494
|
+
result["items"] = {"type": "string"}
|
|
495
|
+
|
|
496
|
+
# Recurse into nested schemas
|
|
497
|
+
if isinstance(result.get("items"), dict):
|
|
498
|
+
result["items"] = self._fix_schema_for_gemini(result["items"])
|
|
499
|
+
|
|
500
|
+
if isinstance(result.get("properties"), dict):
|
|
501
|
+
result["properties"] = {
|
|
502
|
+
k: self._fix_schema_for_gemini(v)
|
|
503
|
+
for k, v in result["properties"].items()
|
|
504
|
+
}
|
|
500
505
|
|
|
501
|
-
|
|
506
|
+
for key in ("anyOf", "any_of"):
|
|
507
|
+
if isinstance(result.get(key), list):
|
|
508
|
+
result[key] = [
|
|
509
|
+
self._fix_schema_for_gemini(item) for item in result[key]
|
|
510
|
+
]
|
|
502
511
|
|
|
503
|
-
return
|
|
512
|
+
return result
|
|
504
513
|
|
|
505
514
|
@asynccontextmanager
|
|
506
515
|
async def _mcp_context_manager(
|
|
@@ -516,11 +525,18 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
516
525
|
"Connecting to MCP server %s via streamablehttp_client",
|
|
517
526
|
wrapper.name,
|
|
518
527
|
)
|
|
528
|
+
# Create httpx client with headers and timeout
|
|
529
|
+
http_client = httpx.AsyncClient(
|
|
530
|
+
headers=wrapper.headers,
|
|
531
|
+
timeout=60.0,
|
|
532
|
+
)
|
|
533
|
+
# Register client for cleanup
|
|
534
|
+
await stack.enter_async_context(http_client)
|
|
535
|
+
|
|
519
536
|
read, write, _ = await stack.enter_async_context(
|
|
520
|
-
|
|
537
|
+
streamable_http_client(
|
|
521
538
|
url=wrapper.url,
|
|
522
|
-
|
|
523
|
-
timeout=60.0,
|
|
539
|
+
http_client=http_client,
|
|
524
540
|
)
|
|
525
541
|
)
|
|
526
542
|
|
|
@@ -590,151 +606,67 @@ class GeminiResponsesProcessor(BaseGeminiProcessor):
|
|
|
590
606
|
) -> types.GenerateContentConfig:
|
|
591
607
|
"""Create generation config from model and payload."""
|
|
592
608
|
# Default thinking level depends on model
|
|
593
|
-
|
|
594
|
-
thinking_level = "high"
|
|
595
|
-
if "flash" in model.model.lower():
|
|
596
|
-
thinking_level = "medium"
|
|
609
|
+
thinking_level = "medium" if "flash" in model.model.lower() else "high"
|
|
597
610
|
|
|
598
611
|
# Override from payload if present
|
|
599
|
-
if payload
|
|
600
|
-
thinking_level = payload.
|
|
612
|
+
if payload:
|
|
613
|
+
thinking_level = payload.get("thinking_level", thinking_level)
|
|
614
|
+
|
|
615
|
+
# Filter out fields not accepted by GenerateContentConfig
|
|
616
|
+
filtered_payload = {}
|
|
617
|
+
if payload:
|
|
618
|
+
ignored_fields = {"thread_uuid", "user_id", "thinking_level"}
|
|
619
|
+
filtered_payload = {
|
|
620
|
+
k: v for k, v in payload.items() if k not in ignored_fields
|
|
621
|
+
}
|
|
601
622
|
|
|
602
623
|
return types.GenerateContentConfig(
|
|
603
624
|
temperature=model.temperature,
|
|
604
625
|
thinking_config=types.ThinkingConfig(thinking_level=thinking_level),
|
|
605
|
-
**
|
|
626
|
+
**filtered_payload,
|
|
606
627
|
response_modalities=["TEXT"],
|
|
607
628
|
)
|
|
608
629
|
|
|
609
630
|
def _build_mcp_prompt(self, mcp_servers: list[MCPServer]) -> str:
|
|
610
631
|
"""Build MCP tool selection prompt from server prompts."""
|
|
611
|
-
|
|
612
|
-
return "\n".join(prompts) if prompts else ""
|
|
632
|
+
return "\n".join(f"- {s.prompt}" for s in mcp_servers if s.prompt)
|
|
613
633
|
|
|
614
634
|
async def _convert_messages_to_gemini_format(
|
|
615
635
|
self, messages: list[Message], mcp_prompt: str = ""
|
|
616
636
|
) -> tuple[list[types.Content], str | None]:
|
|
617
637
|
"""Convert app messages to Gemini Content objects."""
|
|
638
|
+
system_instruction = await self._system_prompt_builder.build(mcp_prompt)
|
|
618
639
|
contents: list[types.Content] = []
|
|
619
|
-
system_instruction: str | None = None
|
|
620
|
-
|
|
621
|
-
# Build MCP prompt section if tools are available
|
|
622
|
-
mcp_section = ""
|
|
623
|
-
if mcp_prompt:
|
|
624
|
-
mcp_section = (
|
|
625
|
-
"\n\n### Tool-Auswahlrichtlinien (Einbettung externer Beschreibungen)\n"
|
|
626
|
-
f"{mcp_prompt}"
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
# Get system prompt content first
|
|
630
|
-
system_prompt_template = await get_system_prompt()
|
|
631
|
-
if system_prompt_template:
|
|
632
|
-
# Format with MCP prompts placeholder
|
|
633
|
-
system_instruction = system_prompt_template.format(mcp_prompts=mcp_section)
|
|
634
640
|
|
|
635
641
|
for msg in messages:
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
642
|
+
match msg.type:
|
|
643
|
+
case MessageType.SYSTEM:
|
|
644
|
+
system_instruction = (
|
|
645
|
+
f"{system_instruction}\n{msg.text}"
|
|
646
|
+
if system_instruction
|
|
647
|
+
else msg.text
|
|
648
|
+
)
|
|
649
|
+
case MessageType.HUMAN | MessageType.ASSISTANT:
|
|
650
|
+
role = "user" if msg.type == MessageType.HUMAN else "model"
|
|
651
|
+
contents.append(
|
|
652
|
+
types.Content(role=role, parts=[types.Part(text=msg.text)])
|
|
653
|
+
)
|
|
647
654
|
|
|
648
655
|
return contents, system_instruction
|
|
649
656
|
|
|
657
|
+
def _extract_text_from_parts(self, parts: list[Any]) -> str:
|
|
658
|
+
"""Extract and join text from content parts."""
|
|
659
|
+
return "".join(p.text for p in parts if p.text)
|
|
660
|
+
|
|
650
661
|
def _handle_chunk(self, chunk: Any) -> Chunk | None:
|
|
651
662
|
"""Handle a single chunk from Gemini stream."""
|
|
652
|
-
|
|
653
|
-
|
|
663
|
+
if (
|
|
664
|
+
not chunk.candidates
|
|
665
|
+
or not chunk.candidates[0].content
|
|
666
|
+
or not chunk.candidates[0].content.parts
|
|
667
|
+
):
|
|
654
668
|
return None
|
|
655
669
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
# List comprehension for text parts
|
|
660
|
-
if not content.parts:
|
|
661
|
-
return None
|
|
662
|
-
|
|
663
|
-
text_parts = [part.text for part in content.parts if part.text]
|
|
664
|
-
|
|
665
|
-
if text_parts:
|
|
666
|
-
return self._create_chunk(
|
|
667
|
-
ChunkType.TEXT, "".join(text_parts), {"delta": "".join(text_parts)}
|
|
668
|
-
)
|
|
669
|
-
|
|
670
|
+
if text := self._extract_text_from_parts(chunk.candidates[0].content.parts):
|
|
671
|
+
return self._chunk_factory.text(text, delta=text)
|
|
670
672
|
return None
|
|
671
|
-
|
|
672
|
-
def _create_chunk(
|
|
673
|
-
self,
|
|
674
|
-
chunk_type: ChunkType,
|
|
675
|
-
content: str,
|
|
676
|
-
extra_metadata: dict[str, str] | None = None,
|
|
677
|
-
) -> Chunk:
|
|
678
|
-
"""Create a Chunk."""
|
|
679
|
-
metadata = {
|
|
680
|
-
"processor": "gemini_responses",
|
|
681
|
-
}
|
|
682
|
-
if extra_metadata:
|
|
683
|
-
metadata.update(extra_metadata)
|
|
684
|
-
|
|
685
|
-
return Chunk(
|
|
686
|
-
type=chunk_type,
|
|
687
|
-
text=content,
|
|
688
|
-
chunk_metadata=metadata,
|
|
689
|
-
)
|
|
690
|
-
|
|
691
|
-
async def _create_auth_required_chunk(self, server: MCPServer) -> Chunk:
|
|
692
|
-
"""Create an AUTH_REQUIRED chunk."""
|
|
693
|
-
# reusing logic from other processors, simplified here
|
|
694
|
-
return Chunk(
|
|
695
|
-
type=ChunkType.AUTH_REQUIRED,
|
|
696
|
-
text=f"{server.name} authentication required",
|
|
697
|
-
chunk_metadata={"server_name": server.name},
|
|
698
|
-
)
|
|
699
|
-
|
|
700
|
-
def _parse_mcp_headers(self, server: MCPServer) -> dict[str, str]:
|
|
701
|
-
"""Parse headers from server config.
|
|
702
|
-
|
|
703
|
-
Returns:
|
|
704
|
-
Dictionary of HTTP headers to send to the MCP server.
|
|
705
|
-
"""
|
|
706
|
-
if not server.headers or server.headers == "{}":
|
|
707
|
-
return {}
|
|
708
|
-
|
|
709
|
-
try:
|
|
710
|
-
headers_dict = json.loads(server.headers)
|
|
711
|
-
return dict(headers_dict)
|
|
712
|
-
except json.JSONDecodeError:
|
|
713
|
-
logger.warning("Invalid headers JSON for server %s", server.name)
|
|
714
|
-
return {}
|
|
715
|
-
|
|
716
|
-
async def _get_valid_token_for_server(
|
|
717
|
-
self, server: MCPServer, user_id: int
|
|
718
|
-
) -> AssistantMCPUserToken | None:
|
|
719
|
-
"""Get a valid OAuth token for the server/user."""
|
|
720
|
-
if server.id is None:
|
|
721
|
-
return None
|
|
722
|
-
|
|
723
|
-
with rx.session() as session:
|
|
724
|
-
token = self._mcp_auth_service.get_user_token(session, user_id, server.id)
|
|
725
|
-
|
|
726
|
-
if token is None:
|
|
727
|
-
return None
|
|
728
|
-
|
|
729
|
-
return await self._mcp_auth_service.ensure_valid_token(
|
|
730
|
-
session, server, token
|
|
731
|
-
)
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
class MCPSessionWrapper:
|
|
735
|
-
"""Wrapper to store MCP connection details before creating actual session."""
|
|
736
|
-
|
|
737
|
-
def __init__(self, url: str, headers: dict[str, str], name: str) -> None:
|
|
738
|
-
self.url = url
|
|
739
|
-
self.headers = headers
|
|
740
|
-
self.name = name
|