kollabor 0.4.9__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 (128) hide show
  1. core/__init__.py +18 -0
  2. core/application.py +578 -0
  3. core/cli.py +193 -0
  4. core/commands/__init__.py +43 -0
  5. core/commands/executor.py +277 -0
  6. core/commands/menu_renderer.py +319 -0
  7. core/commands/parser.py +186 -0
  8. core/commands/registry.py +331 -0
  9. core/commands/system_commands.py +479 -0
  10. core/config/__init__.py +7 -0
  11. core/config/llm_task_config.py +110 -0
  12. core/config/loader.py +501 -0
  13. core/config/manager.py +112 -0
  14. core/config/plugin_config_manager.py +346 -0
  15. core/config/plugin_schema.py +424 -0
  16. core/config/service.py +399 -0
  17. core/effects/__init__.py +1 -0
  18. core/events/__init__.py +12 -0
  19. core/events/bus.py +129 -0
  20. core/events/executor.py +154 -0
  21. core/events/models.py +258 -0
  22. core/events/processor.py +176 -0
  23. core/events/registry.py +289 -0
  24. core/fullscreen/__init__.py +19 -0
  25. core/fullscreen/command_integration.py +290 -0
  26. core/fullscreen/components/__init__.py +12 -0
  27. core/fullscreen/components/animation.py +258 -0
  28. core/fullscreen/components/drawing.py +160 -0
  29. core/fullscreen/components/matrix_components.py +177 -0
  30. core/fullscreen/manager.py +302 -0
  31. core/fullscreen/plugin.py +204 -0
  32. core/fullscreen/renderer.py +282 -0
  33. core/fullscreen/session.py +324 -0
  34. core/io/__init__.py +52 -0
  35. core/io/buffer_manager.py +362 -0
  36. core/io/config_status_view.py +272 -0
  37. core/io/core_status_views.py +410 -0
  38. core/io/input_errors.py +313 -0
  39. core/io/input_handler.py +2655 -0
  40. core/io/input_mode_manager.py +402 -0
  41. core/io/key_parser.py +344 -0
  42. core/io/layout.py +587 -0
  43. core/io/message_coordinator.py +204 -0
  44. core/io/message_renderer.py +601 -0
  45. core/io/modal_interaction_handler.py +315 -0
  46. core/io/raw_input_processor.py +946 -0
  47. core/io/status_renderer.py +845 -0
  48. core/io/terminal_renderer.py +586 -0
  49. core/io/terminal_state.py +551 -0
  50. core/io/visual_effects.py +734 -0
  51. core/llm/__init__.py +26 -0
  52. core/llm/api_communication_service.py +863 -0
  53. core/llm/conversation_logger.py +473 -0
  54. core/llm/conversation_manager.py +414 -0
  55. core/llm/file_operations_executor.py +1401 -0
  56. core/llm/hook_system.py +402 -0
  57. core/llm/llm_service.py +1629 -0
  58. core/llm/mcp_integration.py +386 -0
  59. core/llm/message_display_service.py +450 -0
  60. core/llm/model_router.py +214 -0
  61. core/llm/plugin_sdk.py +396 -0
  62. core/llm/response_parser.py +848 -0
  63. core/llm/response_processor.py +364 -0
  64. core/llm/tool_executor.py +520 -0
  65. core/logging/__init__.py +19 -0
  66. core/logging/setup.py +208 -0
  67. core/models/__init__.py +5 -0
  68. core/models/base.py +23 -0
  69. core/plugins/__init__.py +13 -0
  70. core/plugins/collector.py +212 -0
  71. core/plugins/discovery.py +386 -0
  72. core/plugins/factory.py +263 -0
  73. core/plugins/registry.py +152 -0
  74. core/storage/__init__.py +5 -0
  75. core/storage/state_manager.py +84 -0
  76. core/ui/__init__.py +6 -0
  77. core/ui/config_merger.py +176 -0
  78. core/ui/config_widgets.py +369 -0
  79. core/ui/live_modal_renderer.py +276 -0
  80. core/ui/modal_actions.py +162 -0
  81. core/ui/modal_overlay_renderer.py +373 -0
  82. core/ui/modal_renderer.py +591 -0
  83. core/ui/modal_state_manager.py +443 -0
  84. core/ui/widget_integration.py +222 -0
  85. core/ui/widgets/__init__.py +27 -0
  86. core/ui/widgets/base_widget.py +136 -0
  87. core/ui/widgets/checkbox.py +85 -0
  88. core/ui/widgets/dropdown.py +140 -0
  89. core/ui/widgets/label.py +78 -0
  90. core/ui/widgets/slider.py +185 -0
  91. core/ui/widgets/text_input.py +224 -0
  92. core/utils/__init__.py +11 -0
  93. core/utils/config_utils.py +656 -0
  94. core/utils/dict_utils.py +212 -0
  95. core/utils/error_utils.py +275 -0
  96. core/utils/key_reader.py +171 -0
  97. core/utils/plugin_utils.py +267 -0
  98. core/utils/prompt_renderer.py +151 -0
  99. kollabor-0.4.9.dist-info/METADATA +298 -0
  100. kollabor-0.4.9.dist-info/RECORD +128 -0
  101. kollabor-0.4.9.dist-info/WHEEL +5 -0
  102. kollabor-0.4.9.dist-info/entry_points.txt +2 -0
  103. kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
  104. kollabor-0.4.9.dist-info/top_level.txt +4 -0
  105. kollabor_cli_main.py +20 -0
  106. plugins/__init__.py +1 -0
  107. plugins/enhanced_input/__init__.py +18 -0
  108. plugins/enhanced_input/box_renderer.py +103 -0
  109. plugins/enhanced_input/box_styles.py +142 -0
  110. plugins/enhanced_input/color_engine.py +165 -0
  111. plugins/enhanced_input/config.py +150 -0
  112. plugins/enhanced_input/cursor_manager.py +72 -0
  113. plugins/enhanced_input/geometry.py +81 -0
  114. plugins/enhanced_input/state.py +130 -0
  115. plugins/enhanced_input/text_processor.py +115 -0
  116. plugins/enhanced_input_plugin.py +385 -0
  117. plugins/fullscreen/__init__.py +9 -0
  118. plugins/fullscreen/example_plugin.py +327 -0
  119. plugins/fullscreen/matrix_plugin.py +132 -0
  120. plugins/hook_monitoring_plugin.py +1299 -0
  121. plugins/query_enhancer_plugin.py +350 -0
  122. plugins/save_conversation_plugin.py +502 -0
  123. plugins/system_commands_plugin.py +93 -0
  124. plugins/tmux_plugin.py +795 -0
  125. plugins/workflow_enforcement_plugin.py +629 -0
  126. system_prompt/default.md +1286 -0
  127. system_prompt/default_win.md +265 -0
  128. system_prompt/example_with_trender.md +47 -0
@@ -0,0 +1,450 @@
1
+ """Message Display Service for LLM responses.
2
+
3
+ Handles unified message display coordination, eliminating duplicated
4
+ display logic throughout the LLM service. Follows KISS principle with
5
+ single responsibility for message display orchestration.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class MessageDisplayService:
15
+ """Unified service for coordinating LLM message display.
16
+
17
+ Eliminates code duplication by providing a single point of control
18
+ for all message display operations including thinking duration,
19
+ assistant responses, and tool execution results.
20
+
21
+ Follows KISS principle: Single responsibility for message display coordination.
22
+ Implements DRY principle: Eliminates ~90 lines of duplicated display code.
23
+ """
24
+
25
+ def __init__(self, renderer):
26
+ """Initialize message display service.
27
+
28
+ Args:
29
+ renderer: Terminal renderer with message_coordinator
30
+ """
31
+ self.renderer = renderer
32
+ self.message_coordinator = renderer.message_coordinator
33
+ self._streaming_active = False
34
+
35
+ logger.info("Message display service initialized")
36
+
37
+
38
+ def display_thinking_and_response(self,
39
+ thinking_duration: float,
40
+ response: str,
41
+ show_thinking_threshold: float = 0.1) -> None:
42
+ """Display thinking duration and assistant response atomically.
43
+
44
+ Args:
45
+ thinking_duration: Time spent thinking in seconds
46
+ response: Assistant response content
47
+ show_thinking_threshold: Minimum duration to show thinking message
48
+ """
49
+ # Use the unified display method for consistency
50
+ self.display_complete_response(
51
+ thinking_duration=thinking_duration,
52
+ response=response,
53
+ tool_results=None,
54
+ original_tools=None,
55
+ show_thinking_threshold=show_thinking_threshold
56
+ )
57
+
58
+ def display_tool_results(self, tool_results: List[Any], original_tools: List[Dict] = None) -> None:
59
+ """Display tool execution results with consistent formatting.
60
+
61
+ Args:
62
+ tool_results: List of tool execution result objects
63
+ original_tools: List of original tool data for command extraction
64
+ """
65
+ for i, result in enumerate(tool_results):
66
+ # Get original tool data for display
67
+ tool_data = original_tools[i] if original_tools and i < len(original_tools) else {}
68
+
69
+ # Format tool execution display with consistent styling
70
+ tool_display = self._format_tool_header(result, tool_data)
71
+ result_display = self._format_tool_result(result)
72
+
73
+ # Build combined tool display message
74
+ combined_output = [tool_display, result_display]
75
+
76
+ # Add actual output if appropriate
77
+ if self._should_show_output(result):
78
+ output_lines = self._format_tool_output(result)
79
+ combined_output.extend(output_lines)
80
+
81
+ # Create message sequence for this tool
82
+ tool_messages = [
83
+ ("error" if not result.success else "system",
84
+ "\n".join(combined_output), {})
85
+ ]
86
+
87
+ # Add spacing between tools if multiple
88
+ if len(tool_results) > 1 and i < len(tool_results) - 1:
89
+ tool_messages.append(("system", "", {}))
90
+
91
+ # Display tool messages using coordinator
92
+ self.message_coordinator.display_message_sequence(tool_messages)
93
+
94
+ logger.debug(f"Displayed {len(tool_results)} tool results")
95
+
96
+ def display_user_message(self, message: str) -> None:
97
+ """Display user message through coordinator.
98
+
99
+ Args:
100
+ message: User's input message
101
+ """
102
+ # Don't display user messages in pipe mode
103
+ if getattr(self.renderer, 'pipe_mode', False):
104
+ logger.debug(f"Suppressing user message in pipe mode: {len(message)} chars")
105
+ return
106
+
107
+ message_sequence = [("user", message, {})]
108
+ self.message_coordinator.display_message_sequence(message_sequence)
109
+ logger.debug(f"Displayed user message: {len(message)} chars")
110
+
111
+ def display_system_message(self, message: str) -> None:
112
+ """Display system message through coordinator.
113
+
114
+ Args:
115
+ message: System message to display
116
+ """
117
+ message_sequence = [("system", message, {})]
118
+ self.message_coordinator.display_message_sequence(message_sequence)
119
+ logger.debug(f"Displayed system message: {message[:50]}...")
120
+
121
+ def display_error_message(self, error: str) -> None:
122
+ """Display error message through coordinator.
123
+
124
+ Args:
125
+ error: Error message to display
126
+ """
127
+ message_sequence = [("error", f"Error: {error}", {})]
128
+ self.message_coordinator.display_message_sequence(message_sequence)
129
+ logger.debug(f"Displayed error message: {error[:50]}...")
130
+
131
+ def display_cancellation_message(self) -> None:
132
+ """Display request cancellation message."""
133
+ # Don't display cancellation message in pipe mode (it's expected during cleanup)
134
+ pipe_mode = getattr(self.renderer, 'pipe_mode', False)
135
+
136
+ if hasattr(self.renderer, 'pipe_mode') and pipe_mode:
137
+ logger.debug("Suppressing cancellation message in pipe mode")
138
+ return
139
+
140
+ message_sequence = [
141
+ ("system", "Request cancelled", {}),
142
+ ("system", "", {}) # Empty line for spacing
143
+ ]
144
+ self.message_coordinator.display_message_sequence(message_sequence)
145
+ logger.debug("Displayed cancellation message")
146
+
147
+ def _format_tool_header(self, result, tool_data: Dict = None) -> str:
148
+ """Format tool execution header with consistent styling.
149
+
150
+ Args:
151
+ result: Tool execution result
152
+ tool_data: Original tool data for command/name extraction
153
+
154
+ Returns:
155
+ Formatted tool header string
156
+ """
157
+ if result.tool_type == "terminal":
158
+ # Extract actual command from original tool data
159
+ command = tool_data.get("command", "unknown") if tool_data else result.tool_id
160
+ return f"\033[1;33m⟣\033[0m terminal({command})"
161
+ elif result.tool_type == "mcp_tool":
162
+ # Extract tool name and arguments from original tool data
163
+ tool_name = tool_data.get("name", "unknown") if tool_data else result.tool_id
164
+ arguments = tool_data.get("arguments", {}) if tool_data else {}
165
+ return f"\033[1;33m⟣\033[0m {tool_name}({arguments})"
166
+ elif result.tool_type.startswith("file_"):
167
+ # Extract filename/path from file operation data
168
+ display_info = self._extract_file_display_info(tool_data, result.tool_type)
169
+ return f"\033[1;33m⟣\033[0m {result.tool_type}({display_info})"
170
+ else:
171
+ return f"\033[1;33m⟣\033[0m {result.tool_type}({result.tool_id})"
172
+
173
+ def _extract_file_display_info(self, tool_data: Dict, tool_type: str) -> str:
174
+ """Extract display information from file operation data.
175
+
176
+ Args:
177
+ tool_data: Original tool data
178
+ tool_type: Type of file operation
179
+
180
+ Returns:
181
+ Filename or path to display
182
+ """
183
+ if not tool_data:
184
+ return "unknown"
185
+
186
+ # Most file operations use 'file' key
187
+ if "file" in tool_data:
188
+ return tool_data["file"]
189
+
190
+ # Move/copy operations use 'from' and 'to'
191
+ if "from" in tool_data and "to" in tool_data:
192
+ return f"{tool_data['from']} → {tool_data['to']}"
193
+
194
+ # mkdir/rmdir use 'path'
195
+ if "path" in tool_data:
196
+ return tool_data["path"]
197
+
198
+ return "unknown"
199
+
200
+ def _format_tool_result(self, result) -> str:
201
+ """Format tool execution result summary.
202
+
203
+ Args:
204
+ result: Tool execution result
205
+
206
+ Returns:
207
+ Formatted result summary string
208
+ """
209
+ if result.success:
210
+ # Count output characteristics for summary
211
+ output_lines = result.output.count('\n') + 1 if result.output else 0
212
+ output_chars = len(result.output) if result.output else 0
213
+
214
+ if result.tool_type == "terminal" and result.output:
215
+ return f"\033[32m ▮ Read {output_lines} lines ({output_chars} chars)\033[0m"
216
+ elif result.tool_type == "file_read" and result.output:
217
+ # Extract line count from output message "✓ Read X lines from..."
218
+ import re
219
+ match = re.search(r'Read (\d+) lines', result.output)
220
+ if match:
221
+ line_count = match.group(1)
222
+ return f"\033[32m ▮ Read {line_count} lines\033[0m"
223
+ return f"\033[32m ▮ Success\033[0m"
224
+ elif result.tool_type == "mcp_tool" and result.output:
225
+ preview = result.output[:50] + "..." if len(result.output) > 50 else result.output
226
+ return f"\033[32m ▮ {preview}\033[0m"
227
+ else:
228
+ return f"\033[32m ▮ Success\033[0m"
229
+ else:
230
+ return f"\033[31m ▮ Error: {result.error}\033[0m"
231
+
232
+ def _should_show_output(self, result) -> bool:
233
+ """Determine if tool output should be displayed inline.
234
+
235
+ Args:
236
+ result: Tool execution result
237
+
238
+ Returns:
239
+ True if output should be shown
240
+ """
241
+ return (result.success and
242
+ result.output and
243
+ len(result.output) < 500)
244
+
245
+ def _format_tool_output(self, result) -> List[str]:
246
+ """Format tool output for inline display.
247
+
248
+ Args:
249
+ result: Tool execution result
250
+
251
+ Returns:
252
+ List of formatted output lines
253
+ """
254
+ # Special formatting for file_edit with diff info
255
+ if (result.tool_type == "file_edit" and
256
+ hasattr(result, 'metadata') and
257
+ result.metadata and
258
+ 'diff_info' in result.metadata):
259
+ return self._format_edit_diff(result)
260
+
261
+ # Default formatting for other outputs
262
+ output_lines = result.output.strip().split('\n')
263
+ formatted_lines = []
264
+
265
+ # Show first 20 lines with indentation
266
+ for line in output_lines[:20]:
267
+ formatted_lines.append(f" {line}")
268
+
269
+ # Add truncation message if needed
270
+ if len(output_lines) > 20:
271
+ remaining = len(output_lines) - 20
272
+ formatted_lines.append(f" ... ({remaining} more lines)")
273
+
274
+ return formatted_lines
275
+
276
+ def _format_edit_diff(self, result) -> List[str]:
277
+ """Format file edit as a pretty condensed diff.
278
+
279
+ Args:
280
+ result: Tool execution result with diff_info
281
+
282
+ Returns:
283
+ List of formatted diff lines
284
+ """
285
+ diff_info = result.metadata.get('diff_info', {})
286
+ find_text = diff_info.get('find', '')
287
+ replace_text = diff_info.get('replace', '')
288
+ count = diff_info.get('count', 0)
289
+
290
+ formatted_lines = []
291
+
292
+ # Show the first line of output (✅ Replaced...)
293
+ first_line = result.output.split('\n')[0]
294
+ formatted_lines.append(f" {first_line}")
295
+
296
+ # Add pretty diff visualization
297
+ formatted_lines.append("")
298
+
299
+ # Removed lines (red with -)
300
+ removed_lines = find_text.split('\n')
301
+ for line in removed_lines[:3]: # Show max 3 lines
302
+ formatted_lines.append(f" \033[31m│- {line}\033[0m")
303
+
304
+ if len(removed_lines) > 3:
305
+ formatted_lines.append(f" \033[31m│ ... ({len(removed_lines) - 3} more lines)\033[0m")
306
+
307
+ # Separator
308
+ formatted_lines.append(" \033[90m│\033[0m")
309
+
310
+ # Added lines (green with +)
311
+ added_lines = replace_text.split('\n')
312
+ for line in added_lines[:3]: # Show max 3 lines
313
+ formatted_lines.append(f" \033[32m│+ {line}\033[0m")
314
+
315
+ if len(added_lines) > 3:
316
+ formatted_lines.append(f" \033[32m│ ... ({len(added_lines) - 3} more lines)\033[0m")
317
+
318
+ formatted_lines.append("")
319
+
320
+ # Add backup info if present
321
+ output_lines = result.output.split('\n')
322
+ for line in output_lines[1:]: # Skip first line (already shown)
323
+ if line.strip():
324
+ formatted_lines.append(f" {line}")
325
+
326
+ return formatted_lines
327
+
328
+ def display_generating_progress(self, estimated_tokens: int) -> None:
329
+ """Display generating progress with token estimate.
330
+
331
+ Args:
332
+ estimated_tokens: Estimated number of tokens being generated
333
+ """
334
+ if estimated_tokens > 0:
335
+ self.renderer.update_thinking(True, f"Generating... ({estimated_tokens} tokens)")
336
+ else:
337
+ self.renderer.update_thinking(True, "Generating...")
338
+ logger.debug(f"Displaying generating progress: {estimated_tokens} tokens")
339
+
340
+ def clear_thinking_display(self) -> None:
341
+ """Clear thinking/generating display."""
342
+ self.renderer.update_thinking(False)
343
+ logger.debug("Cleared thinking display")
344
+
345
+ def start_streaming_response(self) -> None:
346
+ """Start a streaming response session.
347
+
348
+ This method initializes streaming mode, disabling atomic batching
349
+ for the duration of the response to allow real-time display.
350
+ """
351
+ self._streaming_active = True
352
+ logger.debug("Started streaming response session")
353
+
354
+ def end_streaming_response(self) -> None:
355
+ """End a streaming response session.
356
+
357
+ This method disables streaming mode and returns to normal
358
+ atomic batching behavior.
359
+ """
360
+ self._streaming_active = False
361
+ logger.debug("Ended streaming response session")
362
+
363
+ def is_streaming_active(self) -> bool:
364
+ """Check if streaming mode is currently active.
365
+
366
+ Returns:
367
+ True if streaming is active, False otherwise
368
+ """
369
+ return self._streaming_active
370
+
371
+ def display_complete_response(self,
372
+ thinking_duration: float,
373
+ response: str,
374
+ tool_results: List[Any] = None,
375
+ original_tools: List[Dict] = None,
376
+ show_thinking_threshold: float = 0.1,
377
+ skip_response_content: bool = False) -> None:
378
+ """Display complete response with thinking, content, and tools atomically.
379
+
380
+ This unified method ensures that thinking duration, assistant response,
381
+ and tool execution results all display together in a single atomic
382
+ operation, preventing commands from appearing after the response.
383
+
384
+ Args:
385
+ thinking_duration: Time spent thinking in seconds
386
+ response: Assistant response content
387
+ tool_results: List of tool execution result objects (optional)
388
+ original_tools: List of original tool data for command extraction (optional)
389
+ show_thinking_threshold: Minimum duration to show thinking message
390
+ skip_response_content: Skip displaying response content (for streaming mode)
391
+ """
392
+ message_sequence = []
393
+ pipe_mode = getattr(self.renderer, 'pipe_mode', False)
394
+
395
+ # Add thinking duration if meaningful (suppress in pipe mode)
396
+ if thinking_duration > show_thinking_threshold and not pipe_mode:
397
+ thought_message = f"Thought for {thinking_duration:.1f} seconds"
398
+ message_sequence.append(("system", thought_message, {}))
399
+
400
+ # Add assistant response if present and not skipped (for streaming mode)
401
+ if response.strip() and not skip_response_content:
402
+ message_sequence.append(("assistant", response, {}))
403
+
404
+ # Add tool results if present (suppress in pipe mode)
405
+ if tool_results and not pipe_mode:
406
+ for i, result in enumerate(tool_results):
407
+ # Get original tool data for display
408
+ tool_data = original_tools[i] if original_tools and i < len(original_tools) else {}
409
+
410
+ # Format tool execution display with consistent styling
411
+ tool_display = self._format_tool_header(result, tool_data)
412
+ result_display = self._format_tool_result(result)
413
+
414
+ # Build combined tool display message
415
+ combined_output = [tool_display, result_display]
416
+
417
+ # Add actual output if appropriate
418
+ if self._should_show_output(result):
419
+ output_lines = self._format_tool_output(result)
420
+ combined_output.extend(output_lines)
421
+
422
+ # Add tool message to sequence
423
+ message_sequence.append((
424
+ "error" if not result.success else "system",
425
+ "\n".join(combined_output),
426
+ {}
427
+ ))
428
+
429
+ # Add spacing between tools if multiple
430
+ if len(tool_results) > 1 and i < len(tool_results) - 1:
431
+ message_sequence.append(("system", "", {}))
432
+
433
+ # Display everything atomically to prevent race conditions
434
+ if message_sequence:
435
+ self.message_coordinator.display_message_sequence(message_sequence)
436
+ logger.debug(f"Displayed complete response with {len(message_sequence)} messages atomically")
437
+
438
+ def get_display_stats(self) -> Dict[str, int]:
439
+ """Get display operation statistics.
440
+
441
+ Returns:
442
+ Dictionary with display operation counts
443
+ """
444
+ # This could be enhanced with actual counters if needed
445
+ return {
446
+ "messages_displayed": 0, # Placeholder - could track actual counts
447
+ "tool_results_displayed": 0,
448
+ "thinking_displays": 0,
449
+ "streaming_sessions": 1 if self._streaming_active else 0
450
+ }
@@ -0,0 +1,214 @@
1
+ """Model routing system for intelligent model selection.
2
+
3
+ Routes queries to appropriate models based on task requirements
4
+ and configured model capabilities.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ModelRouter:
14
+ """Intelligent model routing based on query analysis.
15
+
16
+ Routes different types of queries to appropriate models
17
+ for optimal performance and cost efficiency.
18
+ """
19
+
20
+ def __init__(self, config):
21
+ """Initialize model router.
22
+
23
+ Args:
24
+ config: Configuration manager
25
+ """
26
+ self.config = config
27
+
28
+ # Model configurations
29
+ self.models = {
30
+ "fast": config.get("core.llm.models.fast", {
31
+ "name": "qwen/qwen3-0.6b",
32
+ "api_url": "http://localhost:1234",
33
+ "temperature": 0.3,
34
+ "max_tokens": 500
35
+ }),
36
+ "reasoning": config.get("core.llm.models.reasoning", {
37
+ "name": "qwen/qwen3-4b",
38
+ "api_url": "http://localhost:1234",
39
+ "temperature": 0.7,
40
+ "max_tokens": 2000
41
+ }),
42
+ "coding": config.get("core.llm.models.coding", {
43
+ "name": "qwen/qwen3-4b",
44
+ "api_url": "http://localhost:1234",
45
+ "temperature": 0.5,
46
+ "max_tokens": 3000
47
+ }),
48
+ "documentation": config.get("core.llm.models.documentation", {
49
+ "name": "qwen/qwen3-4b",
50
+ "api_url": "http://localhost:1234",
51
+ "temperature": 0.6,
52
+ "max_tokens": 2000
53
+ })
54
+ }
55
+
56
+ # Default model
57
+ self.default_model = "reasoning"
58
+
59
+ logger.info(f"Model router initialized with {len(self.models)} model types")
60
+
61
+ def analyze_query(self, query: str) -> str:
62
+ """Analyze query to determine appropriate model type.
63
+
64
+ Args:
65
+ query: User query to analyze
66
+
67
+ Returns:
68
+ Model type to use
69
+ """
70
+ query_lower = query.lower()
71
+
72
+ # Quick responses
73
+ if len(query) < 50 and "?" in query:
74
+ if any(word in query_lower for word in ["what", "when", "where", "who"]):
75
+ return "fast"
76
+
77
+ # Coding tasks
78
+ if any(word in query_lower for word in ["code", "function", "class", "debug", "implement", "fix"]):
79
+ return "coding"
80
+
81
+ # Documentation tasks
82
+ if any(word in query_lower for word in ["document", "explain", "describe", "readme", "comment"]):
83
+ return "documentation"
84
+
85
+ # Complex reasoning
86
+ if any(word in query_lower for word in ["analyze", "compare", "evaluate", "design", "architect"]):
87
+ return "reasoning"
88
+
89
+ # Short greetings or simple responses
90
+ if len(query) < 20:
91
+ return "fast"
92
+
93
+ # Default to reasoning for everything else
94
+ return self.default_model
95
+
96
+ def get_model_config(self, model_type: Optional[str] = None) -> Dict[str, Any]:
97
+ """Get configuration for a specific model type.
98
+
99
+ Args:
100
+ model_type: Type of model (fast, reasoning, coding, documentation)
101
+
102
+ Returns:
103
+ Model configuration dictionary
104
+ """
105
+ if model_type is None:
106
+ model_type = self.default_model
107
+
108
+ if model_type not in self.models:
109
+ logger.warning(f"Unknown model type: {model_type}, using default")
110
+ model_type = self.default_model
111
+
112
+ return self.models[model_type]
113
+
114
+ def route_query(self, query: str, force_model: Optional[str] = None) -> Dict[str, Any]:
115
+ """Route a query to the appropriate model.
116
+
117
+ Args:
118
+ query: User query to route
119
+ force_model: Optional model type to force
120
+
121
+ Returns:
122
+ Routing decision with model configuration
123
+ """
124
+ # Allow forcing a specific model
125
+ if force_model and force_model in self.models:
126
+ model_type = force_model
127
+ reason = "forced"
128
+ else:
129
+ model_type = self.analyze_query(query)
130
+ reason = "analyzed"
131
+
132
+ model_config = self.get_model_config(model_type)
133
+
134
+ routing_decision = {
135
+ "model_type": model_type,
136
+ "model_config": model_config,
137
+ "reason": reason,
138
+ "query_length": len(query),
139
+ "query_complexity": self._assess_complexity(query)
140
+ }
141
+
142
+ logger.info(f"Routed query to {model_type} model ({reason})")
143
+ return routing_decision
144
+
145
+ def _assess_complexity(self, query: str) -> str:
146
+ """Assess query complexity.
147
+
148
+ Args:
149
+ query: Query to assess
150
+
151
+ Returns:
152
+ Complexity level (simple, moderate, complex)
153
+ """
154
+ # Simple heuristics for complexity
155
+ word_count = len(query.split())
156
+ has_code = "```" in query
157
+ has_multiple_questions = query.count("?") > 1
158
+
159
+ if word_count < 10 and not has_code:
160
+ return "simple"
161
+ elif word_count > 50 or has_code or has_multiple_questions:
162
+ return "complex"
163
+ else:
164
+ return "moderate"
165
+
166
+ def update_model_config(self, model_type: str, config: Dict[str, Any]) -> bool:
167
+ """Update configuration for a model type.
168
+
169
+ Args:
170
+ model_type: Type of model to update
171
+ config: New configuration
172
+
173
+ Returns:
174
+ True if update successful
175
+ """
176
+ if model_type not in self.models:
177
+ logger.warning(f"Creating new model type: {model_type}")
178
+
179
+ self.models[model_type] = config
180
+ logger.info(f"Updated configuration for {model_type} model")
181
+ return True
182
+
183
+ def list_available_models(self) -> List[Dict[str, Any]]:
184
+ """List all available model configurations.
185
+
186
+ Returns:
187
+ List of model configurations
188
+ """
189
+ models = []
190
+ for model_type, config in self.models.items():
191
+ models.append({
192
+ "type": model_type,
193
+ "name": config.get("name", "unknown"),
194
+ "api_url": config.get("api_url", "unknown"),
195
+ "is_default": model_type == self.default_model
196
+ })
197
+ return models
198
+
199
+ def set_default_model(self, model_type: str) -> bool:
200
+ """Set the default model type.
201
+
202
+ Args:
203
+ model_type: Model type to set as default
204
+
205
+ Returns:
206
+ True if successful
207
+ """
208
+ if model_type not in self.models:
209
+ logger.error(f"Cannot set unknown model type as default: {model_type}")
210
+ return False
211
+
212
+ self.default_model = model_type
213
+ logger.info(f"Set default model to: {model_type}")
214
+ return True