appkit-assistant 0.17.3__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. appkit_assistant/backend/{models.py → database/models.py} +32 -132
  2. appkit_assistant/backend/{repositories.py → database/repositories.py} +93 -1
  3. appkit_assistant/backend/model_manager.py +5 -5
  4. appkit_assistant/backend/models/__init__.py +28 -0
  5. appkit_assistant/backend/models/anthropic.py +31 -0
  6. appkit_assistant/backend/models/google.py +27 -0
  7. appkit_assistant/backend/models/openai.py +50 -0
  8. appkit_assistant/backend/models/perplexity.py +56 -0
  9. appkit_assistant/backend/processors/__init__.py +29 -0
  10. appkit_assistant/backend/processors/claude_responses_processor.py +205 -387
  11. appkit_assistant/backend/processors/gemini_responses_processor.py +231 -299
  12. appkit_assistant/backend/processors/lorem_ipsum_processor.py +6 -4
  13. appkit_assistant/backend/processors/mcp_mixin.py +297 -0
  14. appkit_assistant/backend/processors/openai_base.py +11 -125
  15. appkit_assistant/backend/processors/openai_chat_completion_processor.py +5 -3
  16. appkit_assistant/backend/processors/openai_responses_processor.py +480 -402
  17. appkit_assistant/backend/processors/perplexity_processor.py +156 -79
  18. appkit_assistant/backend/{processor.py → processors/processor_base.py} +7 -2
  19. appkit_assistant/backend/processors/streaming_base.py +188 -0
  20. appkit_assistant/backend/schemas.py +138 -0
  21. appkit_assistant/backend/services/auth_error_detector.py +99 -0
  22. appkit_assistant/backend/services/chunk_factory.py +273 -0
  23. appkit_assistant/backend/services/citation_handler.py +292 -0
  24. appkit_assistant/backend/services/file_cleanup_service.py +316 -0
  25. appkit_assistant/backend/services/file_upload_service.py +903 -0
  26. appkit_assistant/backend/services/file_validation.py +138 -0
  27. appkit_assistant/backend/{mcp_auth_service.py → services/mcp_auth_service.py} +4 -2
  28. appkit_assistant/backend/services/mcp_token_service.py +61 -0
  29. appkit_assistant/backend/services/message_converter.py +289 -0
  30. appkit_assistant/backend/services/openai_client_service.py +120 -0
  31. appkit_assistant/backend/{response_accumulator.py → services/response_accumulator.py} +163 -1
  32. appkit_assistant/backend/services/system_prompt_builder.py +89 -0
  33. appkit_assistant/backend/services/thread_service.py +5 -3
  34. appkit_assistant/backend/system_prompt_cache.py +3 -3
  35. appkit_assistant/components/__init__.py +8 -4
  36. appkit_assistant/components/composer.py +59 -24
  37. appkit_assistant/components/file_manager.py +623 -0
  38. appkit_assistant/components/mcp_server_dialogs.py +12 -20
  39. appkit_assistant/components/mcp_server_table.py +12 -2
  40. appkit_assistant/components/message.py +119 -2
  41. appkit_assistant/components/thread.py +1 -1
  42. appkit_assistant/components/threadlist.py +4 -2
  43. appkit_assistant/components/tools_modal.py +37 -20
  44. appkit_assistant/configuration.py +12 -0
  45. appkit_assistant/state/file_manager_state.py +697 -0
  46. appkit_assistant/state/mcp_oauth_state.py +3 -3
  47. appkit_assistant/state/mcp_server_state.py +47 -2
  48. appkit_assistant/state/system_prompt_state.py +1 -1
  49. appkit_assistant/state/thread_list_state.py +99 -5
  50. appkit_assistant/state/thread_state.py +88 -9
  51. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/METADATA +8 -6
  52. appkit_assistant-1.0.0.dist-info/RECORD +58 -0
  53. appkit_assistant/backend/processors/claude_base.py +0 -178
  54. appkit_assistant/backend/processors/gemini_base.py +0 -84
  55. appkit_assistant-0.17.3.dist-info/RECORD +0 -39
  56. /appkit_assistant/backend/{file_manager.py → services/file_manager.py} +0 -0
  57. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/WHEEL +0 -0
@@ -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 reflex as rx
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 streamablehttp_client
18
+ from mcp.client.streamable_http import streamable_http_client
19
19
 
20
- from appkit_assistant.backend.mcp_auth_service import MCPAuthService
21
- from appkit_assistant.backend.models import (
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.processor import mcp_oauth_redirect_uri
32
- from appkit_assistant.backend.processors.gemini_base import BaseGeminiProcessor
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 GeminiResponsesProcessor(BaseGeminiProcessor):
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
- super().__init__(models, api_key)
61
- self._current_reasoning_session: str | None = None
62
- self._current_user_id: int | None = None
63
- self._mcp_auth_service = MCPAuthService(redirect_uri=oauth_redirect_uri)
64
- self._pending_auth_servers: list[MCPServer] = []
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._current_user_id = user_id
88
- self._pending_auth_servers = []
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
- sessions_result = await self._create_mcp_sessions(mcp_servers, user_id)
99
- mcp_sessions = sessions_result["sessions"]
100
- self._pending_auth_servers = sessions_result["auth_required"]
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 server in self._pending_auth_servers:
131
- yield await self._create_auth_required_chunk(server)
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._create_chunk(ChunkType.ERROR, f"Error: {e!s}")
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
- ) -> dict[str, Any]:
201
+ ) -> tuple[list[MCPSessionWrapper], list[MCPServer]]:
140
202
  """Create MCP ClientSession objects for each server.
141
203
 
142
204
  Returns:
143
- Dict with 'sessions' and 'auth_required' lists
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._parse_mcp_headers(server)
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._get_valid_token_for_server(server, user_id)
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 {"sessions": sessions, "auth_required": auth_required}
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
- tool_name, tool_def
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
- # Find server name for this tool
279
- server_name = "unknown"
280
- for ctx in tool_contexts:
281
- if fc.name in ctx.tools:
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._create_chunk(
290
- ChunkType.TOOL_CALL,
291
- f"Werkzeug: {server_name}.{fc.name}",
292
- {
293
- "tool_name": fc.name,
294
- "tool_id": tool_call_id,
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._create_chunk(
312
- ChunkType.TOOL_RESULT,
370
+ yield self._chunk_factory.tool_result(
313
371
  preview,
314
- {
315
- "tool_name": fc.name,
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
- text_parts = [part.text for part in content.parts if part.text]
347
- if text_parts:
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
- tool_name: str,
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
- # Find which context has this tool
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
- ctx.server_name,
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, str(e))
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 in any MCP server"
447
+ return f"Tool {tool_name} not found on server {server_name}"
391
448
 
392
449
  def _mcp_tool_to_gemini_function(
393
- self, name: str, tool_def: dict[str, Any]
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
- # Deep copy to avoid modifying original
423
- schema = copy.deepcopy(schema)
424
-
425
- # Fields that Gemini doesn't allow in FunctionDeclaration parameters
426
- # Note: additionalProperties gets converted to additional_properties by SDK
427
- forbidden_fields = {
428
- "$schema",
429
- "$id",
430
- "$ref",
431
- "$defs",
432
- "definitions",
433
- "$comment",
434
- "examples",
435
- "default",
436
- "const",
437
- "contentMediaType",
438
- "contentEncoding",
439
- "additionalProperties",
440
- "additional_properties",
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
- return prop
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 fix_property(schema)
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
- streamablehttp_client(
537
+ streamable_http_client(
521
538
  url=wrapper.url,
522
- headers=wrapper.headers,
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
- # "medium" is only supported by Flash, Pro uses "high" (default dynamic)
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 and "thinking_level" in payload:
600
- thinking_level = payload.pop("thinking_level")
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
- **(payload or {}),
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
- prompts = [f"- {server.prompt}" for server in mcp_servers if server.prompt]
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
- if msg.type == MessageType.SYSTEM:
637
- # Append to system instruction
638
- if system_instruction:
639
- system_instruction += f"\n{msg.text}"
640
- else:
641
- system_instruction = msg.text
642
- elif msg.type in (MessageType.HUMAN, MessageType.ASSISTANT):
643
- role = "user" if msg.type == MessageType.HUMAN else "model"
644
- contents.append(
645
- types.Content(role=role, parts=[types.Part(text=msg.text)])
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
- # Gemini chunks contain candidates. First candidate.
653
- if not chunk.candidates or not chunk.candidates[0].content:
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
- candidate = chunk.candidates[0]
657
- content = candidate.content
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