kollabor 0.4.9__py3-none-any.whl → 0.4.15__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.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Profile Manager.
|
|
3
|
+
|
|
4
|
+
Manages named LLM configuration profiles that define:
|
|
5
|
+
- API endpoint URL
|
|
6
|
+
- Model name
|
|
7
|
+
- Temperature and other parameters
|
|
8
|
+
- Tool calling format (OpenAI vs Anthropic)
|
|
9
|
+
- API token environment variable
|
|
10
|
+
|
|
11
|
+
Profiles can be defined in config.json under core.llm.profiles
|
|
12
|
+
or use built-in defaults.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from .api_adapters import BaseAPIAdapter, OpenAIAdapter, AnthropicAdapter
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class EnvVarHint:
|
|
30
|
+
"""Information about a profile's env var."""
|
|
31
|
+
name: str # e.g., "KOLLABOR_CLAUDE_TOKEN"
|
|
32
|
+
is_set: bool # True if env var exists and is non-empty
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LLMProfile:
|
|
37
|
+
"""
|
|
38
|
+
Configuration profile for LLM settings.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
name: Profile identifier
|
|
42
|
+
api_url: Base URL for the LLM API
|
|
43
|
+
model: Model name/identifier
|
|
44
|
+
temperature: Sampling temperature (0.0-1.0)
|
|
45
|
+
max_tokens: Maximum tokens to generate (None = no limit)
|
|
46
|
+
tool_format: Tool calling format ("openai" or "anthropic")
|
|
47
|
+
native_tool_calling: Enable native API tool calling (True) or XML-only mode (False)
|
|
48
|
+
timeout: Request timeout in milliseconds (0 = no timeout)
|
|
49
|
+
description: Human-readable description
|
|
50
|
+
extra_headers: Additional HTTP headers to include
|
|
51
|
+
|
|
52
|
+
API tokens are now resolved via environment variables using the pattern:
|
|
53
|
+
KOLLABOR_{PROFILE_NAME}_TOKEN (e.g., KOLLABOR_CLAUDE_TOKEN)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
api_url: str
|
|
58
|
+
model: str
|
|
59
|
+
temperature: float = 0.7
|
|
60
|
+
max_tokens: Optional[int] = None
|
|
61
|
+
tool_format: str = "openai"
|
|
62
|
+
native_tool_calling: bool = True # True = native API tools, False = XML tags only
|
|
63
|
+
timeout: int = 0
|
|
64
|
+
description: str = ""
|
|
65
|
+
extra_headers: Dict[str, str] = field(default_factory=dict)
|
|
66
|
+
# Internal storage for API token from config (not from env var)
|
|
67
|
+
api_token: str = field(default="", repr=False)
|
|
68
|
+
|
|
69
|
+
def _get_env_key(self, field: str) -> str:
|
|
70
|
+
"""Generate env var key for this profile and field.
|
|
71
|
+
|
|
72
|
+
Normalizes profile name: strip whitespace, all non-alphanumeric chars become underscore.
|
|
73
|
+
Examples:
|
|
74
|
+
my-local-llm -> KOLLABOR_MY_LOCAL_LLM_{FIELD}
|
|
75
|
+
my.profile -> KOLLABOR_MY_PROFILE_{FIELD}
|
|
76
|
+
My Profile! -> KOLLABOR_MY_PROFILE__{FIELD}
|
|
77
|
+
" fast " -> KOLLABOR_FAST_{FIELD}
|
|
78
|
+
"""
|
|
79
|
+
# Strip whitespace, replace all non-alphanumeric with underscore, then uppercase
|
|
80
|
+
name_stripped = self.name.strip()
|
|
81
|
+
name_normalized = re.sub(r'[^a-zA-Z0-9]', '_', name_stripped).upper()
|
|
82
|
+
return f"KOLLABOR_{name_normalized}_{field}"
|
|
83
|
+
|
|
84
|
+
def _get_env_value(self, field: str) -> Optional[str]:
|
|
85
|
+
"""Get env var value, treating empty/whitespace-only as unset.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The env var value if set and non-empty, None otherwise.
|
|
89
|
+
Note: "0" is a valid value and will be returned (not treated as falsy).
|
|
90
|
+
"""
|
|
91
|
+
env_key = self._get_env_key(field)
|
|
92
|
+
env_val = os.environ.get(env_key)
|
|
93
|
+
# Check for None (unset) or empty/whitespace-only
|
|
94
|
+
if env_val is None or not env_val.strip():
|
|
95
|
+
return None
|
|
96
|
+
return env_val
|
|
97
|
+
|
|
98
|
+
def get_endpoint(self) -> str:
|
|
99
|
+
"""Get API endpoint, checking env var first. REQUIRED field."""
|
|
100
|
+
env_val = self._get_env_value("ENDPOINT")
|
|
101
|
+
if env_val:
|
|
102
|
+
return env_val
|
|
103
|
+
if self.api_url:
|
|
104
|
+
return self.api_url
|
|
105
|
+
# Both sources empty - warn user
|
|
106
|
+
logger.warning(f"Profile '{self.name}': No endpoint configured. "
|
|
107
|
+
f"Set {self._get_env_key('ENDPOINT')} or configure in config.json")
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
def get_token(self) -> Optional[str]:
|
|
111
|
+
"""Get API token from env var or config. REQUIRED field."""
|
|
112
|
+
env_val = self._get_env_value("TOKEN")
|
|
113
|
+
if env_val:
|
|
114
|
+
return env_val
|
|
115
|
+
if self.api_token:
|
|
116
|
+
return self.api_token
|
|
117
|
+
# Both sources empty - warn user
|
|
118
|
+
logger.warning(f"Profile '{self.name}': No API token configured. "
|
|
119
|
+
f"Set {self._get_env_key('TOKEN')} in your environment")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def get_model(self) -> str:
|
|
123
|
+
"""Get model, checking env var first. REQUIRED field."""
|
|
124
|
+
env_val = self._get_env_value("MODEL")
|
|
125
|
+
if env_val:
|
|
126
|
+
return env_val
|
|
127
|
+
if self.model:
|
|
128
|
+
return self.model
|
|
129
|
+
# Both sources empty - warn user
|
|
130
|
+
logger.warning(f"Profile '{self.name}': No model configured. "
|
|
131
|
+
f"Set {self._get_env_key('MODEL')} or configure in config.json")
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
def get_max_tokens(self) -> Optional[int]:
|
|
135
|
+
"""Get max tokens, checking env var first. OPTIONAL field."""
|
|
136
|
+
env_key = self._get_env_key("MAX_TOKENS")
|
|
137
|
+
env_val = self._get_env_value("MAX_TOKENS")
|
|
138
|
+
if env_val:
|
|
139
|
+
try:
|
|
140
|
+
return int(env_val)
|
|
141
|
+
except ValueError:
|
|
142
|
+
logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid integer, "
|
|
143
|
+
f"using config value")
|
|
144
|
+
return self.max_tokens # Returns None if not configured (uses API default)
|
|
145
|
+
|
|
146
|
+
def get_temperature(self) -> float:
|
|
147
|
+
"""Get temperature, checking env var first. OPTIONAL field (default: 0.7)."""
|
|
148
|
+
env_key = self._get_env_key("TEMPERATURE")
|
|
149
|
+
env_val = self._get_env_value("TEMPERATURE")
|
|
150
|
+
if env_val:
|
|
151
|
+
try:
|
|
152
|
+
return float(env_val)
|
|
153
|
+
except ValueError:
|
|
154
|
+
logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid float, "
|
|
155
|
+
f"using config value")
|
|
156
|
+
return self.temperature if self.temperature is not None else 0.7
|
|
157
|
+
|
|
158
|
+
def get_timeout(self) -> int:
|
|
159
|
+
"""Get timeout, checking env var first. OPTIONAL field (default: 30000ms).
|
|
160
|
+
|
|
161
|
+
Note: 0 means no timeout (infinity), not a fallback value.
|
|
162
|
+
"""
|
|
163
|
+
env_key = self._get_env_key("TIMEOUT")
|
|
164
|
+
env_val = self._get_env_value("TIMEOUT")
|
|
165
|
+
if env_val is not None:
|
|
166
|
+
try:
|
|
167
|
+
return int(env_val)
|
|
168
|
+
except ValueError:
|
|
169
|
+
logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid integer, "
|
|
170
|
+
f"using config value")
|
|
171
|
+
# 0 is valid (no timeout), only use default if truly None
|
|
172
|
+
if self.timeout is not None:
|
|
173
|
+
return self.timeout
|
|
174
|
+
return 30000
|
|
175
|
+
|
|
176
|
+
def get_tool_format(self) -> str:
|
|
177
|
+
"""Get tool format, checking env var first. OPTIONAL field (default: openai)."""
|
|
178
|
+
env_key = self._get_env_key("TOOL_FORMAT")
|
|
179
|
+
env_val = self._get_env_value("TOOL_FORMAT")
|
|
180
|
+
valid_formats = ("openai", "anthropic")
|
|
181
|
+
if env_val:
|
|
182
|
+
if env_val in valid_formats:
|
|
183
|
+
return env_val
|
|
184
|
+
logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is invalid "
|
|
185
|
+
f"(must be one of {valid_formats}), using 'openai'")
|
|
186
|
+
config_val = self.tool_format
|
|
187
|
+
if config_val and config_val in valid_formats:
|
|
188
|
+
return config_val
|
|
189
|
+
return "openai"
|
|
190
|
+
|
|
191
|
+
def get_native_tool_calling(self) -> bool:
|
|
192
|
+
"""Get native_tool_calling, checking env var first. OPTIONAL field (default: True).
|
|
193
|
+
|
|
194
|
+
When True, tools are passed to the API for native function calling.
|
|
195
|
+
When False, the LLM uses XML tags (<terminal>, <tool>, etc.) instead.
|
|
196
|
+
"""
|
|
197
|
+
env_val = self._get_env_value("NATIVE_TOOL_CALLING")
|
|
198
|
+
if env_val is not None:
|
|
199
|
+
# Accept common truthy/falsy values
|
|
200
|
+
return env_val.lower() in ("true", "1", "yes", "on")
|
|
201
|
+
return self.native_tool_calling
|
|
202
|
+
|
|
203
|
+
def get_env_var_hints(self) -> Dict[str, EnvVarHint]:
|
|
204
|
+
"""Get env var names and status for this profile."""
|
|
205
|
+
fields = ["ENDPOINT", "TOKEN", "MODEL", "MAX_TOKENS", "TEMPERATURE", "TIMEOUT", "TOOL_FORMAT", "NATIVE_TOOL_CALLING"]
|
|
206
|
+
return {
|
|
207
|
+
field.lower(): EnvVarHint(
|
|
208
|
+
name=self._get_env_key(field),
|
|
209
|
+
is_set=self._get_env_value(field) is not None
|
|
210
|
+
)
|
|
211
|
+
for field in fields
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def get_api_token(self) -> Optional[str]:
|
|
215
|
+
"""
|
|
216
|
+
Get API token from environment variable.
|
|
217
|
+
|
|
218
|
+
DEPRECATED: Use get_token() instead. Tokens are now resolved via
|
|
219
|
+
KOLLABOR_{PROFILE_NAME}_TOKEN environment variables.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
None (deprecated method, use get_token() instead)
|
|
223
|
+
"""
|
|
224
|
+
# Deprecated - use get_token() which follows the new env var pattern
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
228
|
+
"""Convert profile to dictionary representation."""
|
|
229
|
+
result = {
|
|
230
|
+
"name": self.name,
|
|
231
|
+
"api_url": self.api_url,
|
|
232
|
+
"model": self.model,
|
|
233
|
+
"temperature": self.temperature,
|
|
234
|
+
"max_tokens": self.max_tokens,
|
|
235
|
+
"tool_format": self.tool_format,
|
|
236
|
+
"native_tool_calling": self.native_tool_calling,
|
|
237
|
+
"timeout": self.timeout,
|
|
238
|
+
"description": self.description,
|
|
239
|
+
"extra_headers": self.extra_headers,
|
|
240
|
+
}
|
|
241
|
+
# Only include api_token if set (to avoid empty string in config)
|
|
242
|
+
if self.api_token:
|
|
243
|
+
result["api_token"] = self.api_token
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_dict(cls, name: str, data: Dict[str, Any]) -> "LLMProfile":
|
|
248
|
+
"""
|
|
249
|
+
Create profile from dictionary.
|
|
250
|
+
|
|
251
|
+
Silently ignores unknown fields for forward compatibility.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
name: Profile name
|
|
255
|
+
data: Profile configuration dictionary
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
LLMProfile instance
|
|
259
|
+
"""
|
|
260
|
+
return cls(
|
|
261
|
+
name=name,
|
|
262
|
+
api_url=data.get("api_url", "http://localhost:1234"),
|
|
263
|
+
model=data.get("model", "default"),
|
|
264
|
+
temperature=data.get("temperature", 0.7),
|
|
265
|
+
max_tokens=data.get("max_tokens"),
|
|
266
|
+
tool_format=data.get("tool_format", "openai"),
|
|
267
|
+
native_tool_calling=data.get("native_tool_calling", True),
|
|
268
|
+
timeout=data.get("timeout", 0),
|
|
269
|
+
description=data.get("description", ""),
|
|
270
|
+
extra_headers=data.get("extra_headers", {}),
|
|
271
|
+
api_token=data.get("api_token", ""),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class ProfileManager:
|
|
276
|
+
"""
|
|
277
|
+
Manages LLM configuration profiles.
|
|
278
|
+
|
|
279
|
+
Features:
|
|
280
|
+
- Built-in default profiles (default, fast, claude, openai)
|
|
281
|
+
- User-defined profiles from config.json
|
|
282
|
+
- Active profile switching
|
|
283
|
+
- Adapter instantiation for profiles
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
# Built-in default profiles
|
|
287
|
+
DEFAULT_PROFILES: Dict[str, Dict[str, Any]] = {
|
|
288
|
+
"default": {
|
|
289
|
+
"api_url": "http://localhost:1234",
|
|
290
|
+
"model": "qwen/qwen3-4b",
|
|
291
|
+
"temperature": 0.7,
|
|
292
|
+
"tool_format": "openai",
|
|
293
|
+
"description": "Local LLM for general use",
|
|
294
|
+
},
|
|
295
|
+
"fast": {
|
|
296
|
+
"api_url": "http://localhost:1234",
|
|
297
|
+
"model": "qwen/qwen3-0.6b",
|
|
298
|
+
"temperature": 0.3,
|
|
299
|
+
"tool_format": "openai",
|
|
300
|
+
"description": "Fast local model for quick queries",
|
|
301
|
+
},
|
|
302
|
+
"claude": {
|
|
303
|
+
"api_url": "https://api.anthropic.com",
|
|
304
|
+
"model": "claude-sonnet-4-20250514",
|
|
305
|
+
"temperature": 0.7,
|
|
306
|
+
"max_tokens": 4096,
|
|
307
|
+
"tool_format": "anthropic",
|
|
308
|
+
"description": "Anthropic Claude for complex tasks",
|
|
309
|
+
},
|
|
310
|
+
"openai": {
|
|
311
|
+
"api_url": "https://api.openai.com",
|
|
312
|
+
"model": "gpt-4-turbo",
|
|
313
|
+
"temperature": 0.7,
|
|
314
|
+
"max_tokens": 4096,
|
|
315
|
+
"tool_format": "openai",
|
|
316
|
+
"description": "OpenAI GPT-4 for general tasks",
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
def __init__(self, config=None):
|
|
321
|
+
"""
|
|
322
|
+
Initialize profile manager.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
config: Configuration object with get() method
|
|
326
|
+
"""
|
|
327
|
+
self.config = config
|
|
328
|
+
self._profiles: Dict[str, LLMProfile] = {}
|
|
329
|
+
self._active_profile_name: str = "default"
|
|
330
|
+
self._load_profiles()
|
|
331
|
+
# Note: Default profile initialization is now handled by config_utils.initialize_config()
|
|
332
|
+
# which runs earlier in app startup and creates global/local config with profiles
|
|
333
|
+
|
|
334
|
+
def _load_profiles(self) -> None:
|
|
335
|
+
"""Load profiles from defaults and config file.
|
|
336
|
+
|
|
337
|
+
Reads directly from config FILE (not cached config object) to ensure
|
|
338
|
+
we always get the latest saved values.
|
|
339
|
+
"""
|
|
340
|
+
# Start with built-in defaults
|
|
341
|
+
for name, data in self.DEFAULT_PROFILES.items():
|
|
342
|
+
self._profiles[name] = LLMProfile.from_dict(name, data)
|
|
343
|
+
|
|
344
|
+
# Read profiles directly from config file (not cached config object)
|
|
345
|
+
# This ensures we get the latest saved values after save_profile_values_to_config
|
|
346
|
+
user_profiles, active_profile, default_profile = self._read_profiles_from_file()
|
|
347
|
+
|
|
348
|
+
if user_profiles:
|
|
349
|
+
for name, data in user_profiles.items():
|
|
350
|
+
if isinstance(data, dict):
|
|
351
|
+
self._profiles[name] = LLMProfile.from_dict(name, data)
|
|
352
|
+
logger.debug(f"Loaded user profile: {name}")
|
|
353
|
+
|
|
354
|
+
# Load active profile (last used) - takes priority
|
|
355
|
+
if active_profile and active_profile in self._profiles:
|
|
356
|
+
self._active_profile_name = active_profile
|
|
357
|
+
elif default_profile and default_profile in self._profiles:
|
|
358
|
+
self._active_profile_name = default_profile
|
|
359
|
+
|
|
360
|
+
logger.info(
|
|
361
|
+
f"Loaded {len(self._profiles)} profiles, active: {self._active_profile_name}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _read_profiles_from_file(self) -> tuple:
|
|
365
|
+
"""Read profiles directly from global config file.
|
|
366
|
+
|
|
367
|
+
Profiles are user-level settings and only stored globally.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple of (profiles_dict, active_profile, default_profile)
|
|
371
|
+
"""
|
|
372
|
+
global_config = Path.home() / ".kollabor-cli" / "config.json"
|
|
373
|
+
|
|
374
|
+
if global_config.exists():
|
|
375
|
+
try:
|
|
376
|
+
config_data = json.loads(global_config.read_text(encoding="utf-8"))
|
|
377
|
+
llm_config = config_data.get("core", {}).get("llm", {})
|
|
378
|
+
profiles = llm_config.get("profiles", {})
|
|
379
|
+
active = llm_config.get("active_profile")
|
|
380
|
+
default = llm_config.get("default_profile", "default")
|
|
381
|
+
|
|
382
|
+
if profiles:
|
|
383
|
+
logger.debug(f"Loaded profiles from: {global_config}")
|
|
384
|
+
return profiles, active, default
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.warning(f"Failed to read profiles from {global_config}: {e}")
|
|
387
|
+
|
|
388
|
+
# Fallback to config object if file read fails
|
|
389
|
+
if self.config:
|
|
390
|
+
return (
|
|
391
|
+
self.config.get("core.llm.profiles", {}),
|
|
392
|
+
self.config.get("core.llm.active_profile"),
|
|
393
|
+
self.config.get("core.llm.default_profile", "default")
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return {}, None, "default"
|
|
397
|
+
|
|
398
|
+
def get_profile(self, name: str) -> Optional[LLMProfile]:
|
|
399
|
+
"""
|
|
400
|
+
Get a profile by name.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
name: Profile name
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
LLMProfile or None if not found
|
|
407
|
+
"""
|
|
408
|
+
return self._profiles.get(name)
|
|
409
|
+
|
|
410
|
+
def get_active_profile(self) -> LLMProfile:
|
|
411
|
+
"""
|
|
412
|
+
Get the currently active profile.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Active LLMProfile (falls back to "default" if needed)
|
|
416
|
+
"""
|
|
417
|
+
profile = self._profiles.get(self._active_profile_name)
|
|
418
|
+
if not profile:
|
|
419
|
+
logger.warning(
|
|
420
|
+
f"Active profile '{self._active_profile_name}' not found, "
|
|
421
|
+
"falling back to 'default'"
|
|
422
|
+
)
|
|
423
|
+
profile = self._profiles.get("default")
|
|
424
|
+
if not profile:
|
|
425
|
+
# Create minimal default profile
|
|
426
|
+
profile = LLMProfile(
|
|
427
|
+
name="default",
|
|
428
|
+
api_url="http://localhost:1234",
|
|
429
|
+
model="default",
|
|
430
|
+
)
|
|
431
|
+
return profile
|
|
432
|
+
|
|
433
|
+
def set_active_profile(self, name: str, persist: bool = True) -> bool:
|
|
434
|
+
"""
|
|
435
|
+
Set the active profile.
|
|
436
|
+
|
|
437
|
+
If profile doesn't exist but env vars are set (KOLLABOR_{NAME}_ENDPOINT
|
|
438
|
+
and KOLLABOR_{NAME}_TOKEN), auto-creates the profile from env vars.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
name: Profile name to activate
|
|
442
|
+
persist: If True, save the selection to config for next startup
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True if successful, False if profile not found and can't be created
|
|
446
|
+
"""
|
|
447
|
+
if name not in self._profiles:
|
|
448
|
+
# Try to auto-create from env vars
|
|
449
|
+
if self._try_create_profile_from_env(name):
|
|
450
|
+
logger.info(f"Auto-created profile '{name}' from environment variables")
|
|
451
|
+
else:
|
|
452
|
+
logger.error(f"Profile not found: {name}")
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
old_profile = self._active_profile_name
|
|
456
|
+
self._active_profile_name = name
|
|
457
|
+
logger.info(f"Switched profile: {old_profile} -> {name}")
|
|
458
|
+
|
|
459
|
+
# Persist to config so it survives restart
|
|
460
|
+
if persist:
|
|
461
|
+
self._save_active_profile_to_config(name)
|
|
462
|
+
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
def _try_create_profile_from_env(self, name: str) -> bool:
|
|
466
|
+
"""
|
|
467
|
+
Try to create a profile from environment variables.
|
|
468
|
+
|
|
469
|
+
Checks for KOLLABOR_{NAME}_ENDPOINT and KOLLABOR_{NAME}_TOKEN.
|
|
470
|
+
If both are set, creates a minimal profile that will read all
|
|
471
|
+
values from env vars at runtime.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
name: Profile name to create
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
True if profile was created, False if required env vars missing
|
|
478
|
+
"""
|
|
479
|
+
# Normalize name for env var lookup (same logic as LLMProfile._get_env_key)
|
|
480
|
+
name_normalized = re.sub(r'[^a-zA-Z0-9]', '_', name.strip()).upper()
|
|
481
|
+
endpoint_key = f"KOLLABOR_{name_normalized}_ENDPOINT"
|
|
482
|
+
token_key = f"KOLLABOR_{name_normalized}_TOKEN"
|
|
483
|
+
|
|
484
|
+
endpoint = os.environ.get(endpoint_key, "").strip()
|
|
485
|
+
token = os.environ.get(token_key, "").strip()
|
|
486
|
+
|
|
487
|
+
# Require at least endpoint to create profile
|
|
488
|
+
if not endpoint:
|
|
489
|
+
logger.debug(f"Cannot auto-create profile '{name}': {endpoint_key} not set")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
# Create minimal profile - it will read all values from env vars at runtime
|
|
493
|
+
profile = LLMProfile(
|
|
494
|
+
name=name,
|
|
495
|
+
api_url=endpoint, # Fallback if env var unset later
|
|
496
|
+
model="", # Will be read from env var
|
|
497
|
+
description=f"Auto-created from environment variables",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
self._profiles[name] = profile
|
|
501
|
+
logger.info(f"Created profile '{name}' from env vars ({endpoint_key})")
|
|
502
|
+
return True
|
|
503
|
+
|
|
504
|
+
def _save_active_profile_to_config(self, name: str) -> bool:
|
|
505
|
+
"""
|
|
506
|
+
Save the active profile name to global config.json.
|
|
507
|
+
|
|
508
|
+
Profiles are user-wide settings, so they're saved to global config
|
|
509
|
+
(~/.kollabor-cli/config.json) to be available across all projects.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
name: Profile name to save as active
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
True if saved successfully
|
|
516
|
+
"""
|
|
517
|
+
try:
|
|
518
|
+
# Profiles are user-wide, always save to global config
|
|
519
|
+
config_path = Path.home() / ".kollabor-cli" / "config.json"
|
|
520
|
+
|
|
521
|
+
if not config_path.exists():
|
|
522
|
+
logger.warning(f"Config file not found: {config_path}")
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
526
|
+
|
|
527
|
+
# Ensure core.llm exists
|
|
528
|
+
if "core" not in config_data:
|
|
529
|
+
config_data["core"] = {}
|
|
530
|
+
if "llm" not in config_data["core"]:
|
|
531
|
+
config_data["core"]["llm"] = {}
|
|
532
|
+
|
|
533
|
+
# Save active profile
|
|
534
|
+
config_data["core"]["llm"]["active_profile"] = name
|
|
535
|
+
|
|
536
|
+
config_path.write_text(
|
|
537
|
+
json.dumps(config_data, indent=2, ensure_ascii=False),
|
|
538
|
+
encoding="utf-8"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
logger.debug(f"Saved active profile to config: {name}")
|
|
542
|
+
return True
|
|
543
|
+
|
|
544
|
+
except Exception as e:
|
|
545
|
+
logger.error(f"Failed to save active profile to config: {e}")
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
def list_profiles(self) -> List[LLMProfile]:
|
|
549
|
+
"""
|
|
550
|
+
List all available profiles.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
List of LLMProfile instances
|
|
554
|
+
"""
|
|
555
|
+
return list(self._profiles.values())
|
|
556
|
+
|
|
557
|
+
def get_profile_names(self) -> List[str]:
|
|
558
|
+
"""
|
|
559
|
+
Get list of profile names.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
List of profile name strings
|
|
563
|
+
"""
|
|
564
|
+
return list(self._profiles.keys())
|
|
565
|
+
|
|
566
|
+
def add_profile(self, profile: LLMProfile) -> bool:
|
|
567
|
+
"""
|
|
568
|
+
Add a new profile.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
profile: LLMProfile to add
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
True if added, False if name already exists
|
|
575
|
+
"""
|
|
576
|
+
if profile.name in self._profiles:
|
|
577
|
+
logger.warning(f"Profile already exists: {profile.name}")
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
self._profiles[profile.name] = profile
|
|
581
|
+
logger.info(f"Added profile: {profile.name}")
|
|
582
|
+
return True
|
|
583
|
+
|
|
584
|
+
def remove_profile(self, name: str) -> bool:
|
|
585
|
+
"""
|
|
586
|
+
Remove a profile.
|
|
587
|
+
|
|
588
|
+
Cannot remove built-in profiles or the current active profile.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
name: Profile name to remove
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
True if removed, False if protected or not found
|
|
595
|
+
"""
|
|
596
|
+
if name in self.DEFAULT_PROFILES:
|
|
597
|
+
logger.error(f"Cannot remove built-in profile: {name}")
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
if name == self._active_profile_name:
|
|
601
|
+
logger.error(f"Cannot remove active profile: {name}")
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
if name not in self._profiles:
|
|
605
|
+
logger.error(f"Profile not found: {name}")
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
del self._profiles[name]
|
|
609
|
+
logger.info(f"Removed profile: {name}")
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
def delete_profile(self, name: str) -> bool:
|
|
613
|
+
"""
|
|
614
|
+
Delete a profile from memory and config file.
|
|
615
|
+
|
|
616
|
+
Cannot delete built-in profiles or the current active profile.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
name: Profile name to delete
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
True if deleted successfully, False otherwise
|
|
623
|
+
"""
|
|
624
|
+
if name in self.DEFAULT_PROFILES:
|
|
625
|
+
logger.error(f"Cannot delete built-in profile: {name}")
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
if name == self._active_profile_name:
|
|
629
|
+
logger.error(f"Cannot delete active profile: {name}")
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
if name not in self._profiles:
|
|
633
|
+
logger.error(f"Profile not found: {name}")
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
# Remove from memory
|
|
637
|
+
del self._profiles[name]
|
|
638
|
+
|
|
639
|
+
# Remove from config file
|
|
640
|
+
self._delete_profile_from_config(name)
|
|
641
|
+
|
|
642
|
+
logger.info(f"Deleted profile: {name}")
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
def _delete_profile_from_config(self, name: str) -> bool:
|
|
646
|
+
"""
|
|
647
|
+
Delete a profile from global config.json.
|
|
648
|
+
|
|
649
|
+
Profiles are user-wide settings, so they're deleted from global config
|
|
650
|
+
(~/.kollabor-cli/config.json).
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
name: Profile name to delete
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
True if deleted successfully from config
|
|
657
|
+
"""
|
|
658
|
+
try:
|
|
659
|
+
# Profiles are user-wide, always use global config
|
|
660
|
+
config_path = Path.home() / ".kollabor-cli" / "config.json"
|
|
661
|
+
|
|
662
|
+
if not config_path.exists():
|
|
663
|
+
logger.warning(f"Config file not found: {config_path}")
|
|
664
|
+
return True # No config file, nothing to delete
|
|
665
|
+
|
|
666
|
+
# Load current config
|
|
667
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
668
|
+
|
|
669
|
+
# Check if profile exists in config
|
|
670
|
+
profiles = config_data.get("core", {}).get("llm", {}).get("profiles", {})
|
|
671
|
+
if name not in profiles:
|
|
672
|
+
logger.debug(f"Profile '{name}' not in config file")
|
|
673
|
+
return True # Not in config, nothing to delete
|
|
674
|
+
|
|
675
|
+
# Remove profile from config
|
|
676
|
+
del config_data["core"]["llm"]["profiles"][name]
|
|
677
|
+
|
|
678
|
+
# Write back
|
|
679
|
+
config_path.write_text(
|
|
680
|
+
json.dumps(config_data, indent=2, ensure_ascii=False),
|
|
681
|
+
encoding="utf-8"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
logger.info(f"Deleted profile from config: {name}")
|
|
685
|
+
return True
|
|
686
|
+
|
|
687
|
+
except Exception as e:
|
|
688
|
+
logger.error(f"Failed to delete profile from config: {e}")
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
def get_adapter_for_profile(
|
|
692
|
+
self, profile: Optional[LLMProfile] = None
|
|
693
|
+
) -> BaseAPIAdapter:
|
|
694
|
+
"""
|
|
695
|
+
Get the appropriate API adapter for a profile.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
profile: Profile to get adapter for (default: active profile)
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Configured API adapter instance
|
|
702
|
+
"""
|
|
703
|
+
if profile is None:
|
|
704
|
+
profile = self.get_active_profile()
|
|
705
|
+
|
|
706
|
+
if profile.tool_format == "anthropic":
|
|
707
|
+
return AnthropicAdapter(base_url=profile.api_url)
|
|
708
|
+
else:
|
|
709
|
+
return OpenAIAdapter(base_url=profile.api_url)
|
|
710
|
+
|
|
711
|
+
def get_active_adapter(self) -> BaseAPIAdapter:
|
|
712
|
+
"""
|
|
713
|
+
Get adapter for the active profile.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
Configured API adapter instance
|
|
717
|
+
"""
|
|
718
|
+
return self.get_adapter_for_profile(self.get_active_profile())
|
|
719
|
+
|
|
720
|
+
def is_active(self, name: str) -> bool:
|
|
721
|
+
"""
|
|
722
|
+
Check if a profile is the active one.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
name: Profile name
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
True if this is the active profile
|
|
729
|
+
"""
|
|
730
|
+
return name == self._active_profile_name
|
|
731
|
+
|
|
732
|
+
@property
|
|
733
|
+
def active_profile_name(self) -> str:
|
|
734
|
+
"""Get the name of the active profile."""
|
|
735
|
+
return self._active_profile_name
|
|
736
|
+
|
|
737
|
+
def _get_normalized_name(self, name: str) -> str:
|
|
738
|
+
"""Get normalized profile name for env var prefix.
|
|
739
|
+
|
|
740
|
+
Strips whitespace and replaces all non-alphanumeric characters with
|
|
741
|
+
underscores, then uppercases the result.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
name: The profile name to normalize
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Normalized name suitable for env var prefix
|
|
748
|
+
|
|
749
|
+
Examples:
|
|
750
|
+
"my-profile" -> "MY_PROFILE"
|
|
751
|
+
"my.profile" -> "MY_PROFILE"
|
|
752
|
+
"My Profile!" -> "MY_PROFILE_"
|
|
753
|
+
" fast " -> "FAST"
|
|
754
|
+
"""
|
|
755
|
+
return re.sub(r'[^a-zA-Z0-9]', '_', name.strip()).upper()
|
|
756
|
+
|
|
757
|
+
def _check_name_collision(self, new_name: str, exclude_name: Optional[str] = None) -> Optional[str]:
|
|
758
|
+
"""Check if new profile name would collide with existing profiles.
|
|
759
|
+
|
|
760
|
+
Two profile names collide if they normalize to the same env var prefix,
|
|
761
|
+
which would cause them to share the same environment variables.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
new_name: The proposed profile name
|
|
765
|
+
exclude_name: Profile name to exclude from check (for renames)
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
Name of colliding profile if collision found, None otherwise.
|
|
769
|
+
"""
|
|
770
|
+
new_normalized = self._get_normalized_name(new_name)
|
|
771
|
+
for existing_name in self._profiles:
|
|
772
|
+
if existing_name == exclude_name:
|
|
773
|
+
continue
|
|
774
|
+
if self._get_normalized_name(existing_name) == new_normalized:
|
|
775
|
+
return existing_name
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
def get_profile_summary(self, name: Optional[str] = None) -> str:
|
|
779
|
+
"""
|
|
780
|
+
Get a human-readable summary of a profile.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
name: Profile name (default: active profile)
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Formatted summary string
|
|
787
|
+
"""
|
|
788
|
+
profile = self._profiles.get(name) if name else self.get_active_profile()
|
|
789
|
+
if not profile:
|
|
790
|
+
return f"Profile '{name}' not found"
|
|
791
|
+
|
|
792
|
+
hints = profile.get_env_var_hints()
|
|
793
|
+
token_status = "[set]" if hints["token"].is_set else "[not set]"
|
|
794
|
+
|
|
795
|
+
native_mode = "native" if profile.get_native_tool_calling() else "xml"
|
|
796
|
+
lines = [
|
|
797
|
+
f"Profile: {profile.name}",
|
|
798
|
+
f" Endpoint: {profile.get_endpoint() or '(not configured)'}",
|
|
799
|
+
f" Model: {profile.get_model() or '(not configured)'}",
|
|
800
|
+
f" Token: {hints['token'].name} {token_status}",
|
|
801
|
+
f" Temperature: {profile.get_temperature()}",
|
|
802
|
+
f" Max Tokens: {profile.get_max_tokens() or '(API default)'}",
|
|
803
|
+
f" Timeout: {profile.get_timeout()}ms",
|
|
804
|
+
f" Tool Format: {profile.get_tool_format()}",
|
|
805
|
+
f" Tool Calling: {native_mode}",
|
|
806
|
+
]
|
|
807
|
+
if profile.description:
|
|
808
|
+
lines.append(f" Description: {profile.description}")
|
|
809
|
+
|
|
810
|
+
return "\n".join(lines)
|
|
811
|
+
|
|
812
|
+
def create_profile(
|
|
813
|
+
self,
|
|
814
|
+
name: str,
|
|
815
|
+
api_url: str,
|
|
816
|
+
model: str,
|
|
817
|
+
api_token: Optional[str] = None,
|
|
818
|
+
temperature: float = 0.7,
|
|
819
|
+
max_tokens: Optional[int] = None,
|
|
820
|
+
tool_format: str = "openai",
|
|
821
|
+
native_tool_calling: bool = True,
|
|
822
|
+
timeout: int = 0,
|
|
823
|
+
description: str = "",
|
|
824
|
+
save_to_config: bool = True,
|
|
825
|
+
) -> Optional[LLMProfile]:
|
|
826
|
+
"""
|
|
827
|
+
Create a new profile and optionally save to config.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
name: Profile name
|
|
831
|
+
api_url: API endpoint URL
|
|
832
|
+
model: Model identifier
|
|
833
|
+
api_token: API token (optional, can use env var instead)
|
|
834
|
+
temperature: Sampling temperature
|
|
835
|
+
max_tokens: Max tokens (None for unlimited)
|
|
836
|
+
tool_format: Tool calling format (openai/anthropic)
|
|
837
|
+
native_tool_calling: Enable native API tool calling (True) or XML mode (False)
|
|
838
|
+
timeout: Request timeout
|
|
839
|
+
description: Human-readable description
|
|
840
|
+
save_to_config: Whether to persist to config.json
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Created LLMProfile or None on failure
|
|
844
|
+
"""
|
|
845
|
+
if name in self._profiles:
|
|
846
|
+
logger.warning(f"Profile already exists: {name}")
|
|
847
|
+
return None
|
|
848
|
+
|
|
849
|
+
# Check for env var prefix collision
|
|
850
|
+
collision = self._check_name_collision(name)
|
|
851
|
+
if collision:
|
|
852
|
+
logger.error(f"Cannot create profile '{name}': env var prefix collides with "
|
|
853
|
+
f"existing profile '{collision}' (both normalize to "
|
|
854
|
+
f"KOLLABOR_{self._get_normalized_name(name)}_*)")
|
|
855
|
+
return None
|
|
856
|
+
|
|
857
|
+
profile = LLMProfile(
|
|
858
|
+
name=name,
|
|
859
|
+
api_url=api_url,
|
|
860
|
+
model=model,
|
|
861
|
+
api_token=api_token,
|
|
862
|
+
temperature=temperature,
|
|
863
|
+
max_tokens=max_tokens,
|
|
864
|
+
tool_format=tool_format,
|
|
865
|
+
native_tool_calling=native_tool_calling,
|
|
866
|
+
timeout=timeout,
|
|
867
|
+
description=description,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
self._profiles[name] = profile
|
|
871
|
+
logger.info(f"Created profile: {name}")
|
|
872
|
+
|
|
873
|
+
if save_to_config:
|
|
874
|
+
self._save_profile_to_config(profile)
|
|
875
|
+
|
|
876
|
+
return profile
|
|
877
|
+
|
|
878
|
+
def _save_profile_to_config(self, profile: LLMProfile) -> bool:
|
|
879
|
+
"""
|
|
880
|
+
Save a profile to global config.json.
|
|
881
|
+
|
|
882
|
+
Profiles are user-wide settings, so they're saved to global config
|
|
883
|
+
(~/.kollabor-cli/config.json) to be available across all projects.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
profile: Profile to save
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
True if saved successfully
|
|
890
|
+
"""
|
|
891
|
+
try:
|
|
892
|
+
# Profiles are user-wide, always save to global config
|
|
893
|
+
config_path = Path.home() / ".kollabor-cli" / "config.json"
|
|
894
|
+
|
|
895
|
+
if not config_path.exists():
|
|
896
|
+
logger.error(f"Config file not found: {config_path}")
|
|
897
|
+
return False
|
|
898
|
+
|
|
899
|
+
# Load current config
|
|
900
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
901
|
+
|
|
902
|
+
# Ensure core.llm.profiles exists
|
|
903
|
+
if "core" not in config_data:
|
|
904
|
+
config_data["core"] = {}
|
|
905
|
+
if "llm" not in config_data["core"]:
|
|
906
|
+
config_data["core"]["llm"] = {}
|
|
907
|
+
if "profiles" not in config_data["core"]["llm"]:
|
|
908
|
+
config_data["core"]["llm"]["profiles"] = {}
|
|
909
|
+
|
|
910
|
+
# Add profile (without name field, as it's the key)
|
|
911
|
+
profile_data = profile.to_dict()
|
|
912
|
+
del profile_data["name"] # Name is the key
|
|
913
|
+
config_data["core"]["llm"]["profiles"][profile.name] = profile_data
|
|
914
|
+
|
|
915
|
+
# Write back
|
|
916
|
+
config_path.write_text(
|
|
917
|
+
json.dumps(config_data, indent=2, ensure_ascii=False),
|
|
918
|
+
encoding="utf-8"
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
logger.info(f"Saved profile to config: {profile.name}")
|
|
922
|
+
return True
|
|
923
|
+
|
|
924
|
+
except Exception as e:
|
|
925
|
+
logger.error(f"Failed to save profile to config: {e}")
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
def save_profile_values_to_config(self, profile: LLMProfile) -> Dict[str, bool]:
|
|
929
|
+
"""
|
|
930
|
+
Save a profile's RESOLVED values (from env vars) to global config.
|
|
931
|
+
|
|
932
|
+
Profiles are user-level settings and stored globally only.
|
|
933
|
+
This reads current values using the profile's getter methods
|
|
934
|
+
(which resolve env vars first) and saves them to config.
|
|
935
|
+
|
|
936
|
+
Args:
|
|
937
|
+
profile: Profile whose resolved values to save
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
Dict with "global" key indicating success
|
|
941
|
+
"""
|
|
942
|
+
# Build profile data from resolved getters (reads env vars)
|
|
943
|
+
profile_data = {
|
|
944
|
+
"api_url": profile.get_endpoint(),
|
|
945
|
+
"model": profile.get_model(),
|
|
946
|
+
"temperature": profile.get_temperature(),
|
|
947
|
+
"max_tokens": profile.get_max_tokens(),
|
|
948
|
+
"timeout": profile.get_timeout(),
|
|
949
|
+
"tool_format": profile.get_tool_format(),
|
|
950
|
+
"native_tool_calling": profile.get_native_tool_calling(),
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
# Only include token if it's set (don't save None)
|
|
954
|
+
token = profile.get_token()
|
|
955
|
+
if token:
|
|
956
|
+
profile_data["api_token"] = token
|
|
957
|
+
|
|
958
|
+
# Include description if set
|
|
959
|
+
if profile.description:
|
|
960
|
+
profile_data["description"] = profile.description
|
|
961
|
+
|
|
962
|
+
# Include extra_headers if set
|
|
963
|
+
if profile.extra_headers:
|
|
964
|
+
profile_data["extra_headers"] = profile.extra_headers
|
|
965
|
+
|
|
966
|
+
# Profiles are user-level settings, always save to global
|
|
967
|
+
global_config = Path.home() / ".kollabor-cli" / "config.json"
|
|
968
|
+
|
|
969
|
+
result = {"global": False, "local": False}
|
|
970
|
+
result["global"] = self._save_profile_data_to_file(
|
|
971
|
+
global_config, profile.name, profile_data
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
if result["global"]:
|
|
975
|
+
logger.info(f"Saved profile '{profile.name}' to global config")
|
|
976
|
+
else:
|
|
977
|
+
logger.error(f"Failed to save profile '{profile.name}' to config")
|
|
978
|
+
|
|
979
|
+
return result
|
|
980
|
+
|
|
981
|
+
def _save_profile_data_to_file(self, config_path: Path, profile_name: str,
|
|
982
|
+
profile_data: Dict[str, Any]) -> bool:
|
|
983
|
+
"""
|
|
984
|
+
Save profile data to a specific config file.
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
config_path: Path to config.json file
|
|
988
|
+
profile_name: Name of the profile (used as key)
|
|
989
|
+
profile_data: Profile data dictionary to save
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
True if saved successfully
|
|
993
|
+
"""
|
|
994
|
+
try:
|
|
995
|
+
if not config_path.exists():
|
|
996
|
+
logger.debug(f"Config file not found, skipping: {config_path}")
|
|
997
|
+
return False
|
|
998
|
+
|
|
999
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1000
|
+
|
|
1001
|
+
# Ensure core.llm.profiles exists
|
|
1002
|
+
if "core" not in config_data:
|
|
1003
|
+
config_data["core"] = {}
|
|
1004
|
+
if "llm" not in config_data["core"]:
|
|
1005
|
+
config_data["core"]["llm"] = {}
|
|
1006
|
+
if "profiles" not in config_data["core"]["llm"]:
|
|
1007
|
+
config_data["core"]["llm"]["profiles"] = {}
|
|
1008
|
+
|
|
1009
|
+
config_data["core"]["llm"]["profiles"][profile_name] = profile_data
|
|
1010
|
+
|
|
1011
|
+
config_path.write_text(
|
|
1012
|
+
json.dumps(config_data, indent=2, ensure_ascii=False),
|
|
1013
|
+
encoding="utf-8"
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
logger.debug(f"Saved profile to: {config_path}")
|
|
1017
|
+
return True
|
|
1018
|
+
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
logger.error(f"Failed to save profile to {config_path}: {e}")
|
|
1021
|
+
return False
|
|
1022
|
+
|
|
1023
|
+
def reload(self) -> None:
|
|
1024
|
+
"""Reload profiles from config file, preserving current active profile."""
|
|
1025
|
+
# Preserve current active profile name
|
|
1026
|
+
current_active = self._active_profile_name
|
|
1027
|
+
self._profiles.clear()
|
|
1028
|
+
self._load_profiles()
|
|
1029
|
+
# Restore active profile if it still exists, otherwise keep what _load_profiles set
|
|
1030
|
+
if current_active in self._profiles:
|
|
1031
|
+
self._active_profile_name = current_active
|
|
1032
|
+
logger.debug(f"Reloaded {len(self._profiles)} profiles, active: {self._active_profile_name}")
|
|
1033
|
+
|
|
1034
|
+
def update_profile(
|
|
1035
|
+
self,
|
|
1036
|
+
original_name: str,
|
|
1037
|
+
new_name: str = None,
|
|
1038
|
+
api_url: str = None,
|
|
1039
|
+
model: str = None,
|
|
1040
|
+
api_token: str = None,
|
|
1041
|
+
temperature: float = None,
|
|
1042
|
+
max_tokens: Optional[int] = None,
|
|
1043
|
+
tool_format: str = None,
|
|
1044
|
+
native_tool_calling: bool = None,
|
|
1045
|
+
timeout: int = None,
|
|
1046
|
+
description: str = None,
|
|
1047
|
+
save_to_config: bool = True,
|
|
1048
|
+
) -> bool:
|
|
1049
|
+
"""
|
|
1050
|
+
Update an existing profile.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
original_name: Current name of the profile to update
|
|
1054
|
+
new_name: New name for the profile (optional, for renaming)
|
|
1055
|
+
api_url: New API endpoint URL
|
|
1056
|
+
model: New model identifier
|
|
1057
|
+
api_token: New API token
|
|
1058
|
+
temperature: New sampling temperature
|
|
1059
|
+
max_tokens: New max tokens
|
|
1060
|
+
tool_format: New tool calling format
|
|
1061
|
+
native_tool_calling: Enable native API tool calling (True) or XML mode (False)
|
|
1062
|
+
timeout: New request timeout
|
|
1063
|
+
description: New description
|
|
1064
|
+
save_to_config: Whether to persist to config.json
|
|
1065
|
+
|
|
1066
|
+
Returns:
|
|
1067
|
+
True if updated successfully, False otherwise
|
|
1068
|
+
"""
|
|
1069
|
+
if original_name not in self._profiles:
|
|
1070
|
+
logger.error(f"Profile not found: {original_name}")
|
|
1071
|
+
return False
|
|
1072
|
+
|
|
1073
|
+
profile = self._profiles[original_name]
|
|
1074
|
+
target_name = new_name or original_name
|
|
1075
|
+
|
|
1076
|
+
# If renaming, check for collision and warn about env var change
|
|
1077
|
+
if new_name and new_name != original_name:
|
|
1078
|
+
collision = self._check_name_collision(new_name, exclude_name=original_name)
|
|
1079
|
+
if collision:
|
|
1080
|
+
logger.error(f"Cannot rename to '{new_name}': env var prefix collides with "
|
|
1081
|
+
f"existing profile '{collision}'")
|
|
1082
|
+
return False
|
|
1083
|
+
|
|
1084
|
+
# Warn user about env var change
|
|
1085
|
+
old_prefix = self._get_normalized_name(original_name)
|
|
1086
|
+
new_prefix = self._get_normalized_name(new_name)
|
|
1087
|
+
if old_prefix != new_prefix:
|
|
1088
|
+
logger.warning(f"Profile renamed: env vars changed from KOLLABOR_{old_prefix}_* "
|
|
1089
|
+
f"to KOLLABOR_{new_prefix}_*. Update your environment variables.")
|
|
1090
|
+
|
|
1091
|
+
# Update profile fields
|
|
1092
|
+
if api_url is not None:
|
|
1093
|
+
profile.api_url = api_url
|
|
1094
|
+
if model is not None:
|
|
1095
|
+
profile.model = model
|
|
1096
|
+
if api_token is not None:
|
|
1097
|
+
profile.api_token = api_token
|
|
1098
|
+
if temperature is not None:
|
|
1099
|
+
profile.temperature = temperature
|
|
1100
|
+
if max_tokens is not None:
|
|
1101
|
+
profile.max_tokens = max_tokens
|
|
1102
|
+
if tool_format is not None:
|
|
1103
|
+
profile.tool_format = tool_format
|
|
1104
|
+
if native_tool_calling is not None:
|
|
1105
|
+
profile.native_tool_calling = native_tool_calling
|
|
1106
|
+
if timeout is not None:
|
|
1107
|
+
profile.timeout = timeout
|
|
1108
|
+
if description is not None:
|
|
1109
|
+
profile.description = description
|
|
1110
|
+
|
|
1111
|
+
# Handle renaming
|
|
1112
|
+
if new_name and new_name != original_name:
|
|
1113
|
+
profile.name = new_name
|
|
1114
|
+
del self._profiles[original_name]
|
|
1115
|
+
self._profiles[new_name] = profile
|
|
1116
|
+
|
|
1117
|
+
# Update active profile name if this was the active one
|
|
1118
|
+
if self._active_profile_name == original_name:
|
|
1119
|
+
self._active_profile_name = new_name
|
|
1120
|
+
|
|
1121
|
+
logger.info(f"Renamed profile: {original_name} -> {new_name}")
|
|
1122
|
+
|
|
1123
|
+
logger.info(f"Updated profile: {target_name}")
|
|
1124
|
+
|
|
1125
|
+
if save_to_config:
|
|
1126
|
+
self._update_profile_in_config(original_name, profile)
|
|
1127
|
+
|
|
1128
|
+
return True
|
|
1129
|
+
|
|
1130
|
+
def _update_profile_in_config(self, original_name: str, profile: LLMProfile) -> bool:
|
|
1131
|
+
"""
|
|
1132
|
+
Update a profile in global config.json.
|
|
1133
|
+
|
|
1134
|
+
Profiles are user-wide settings, so they're saved to global config
|
|
1135
|
+
(~/.kollabor-cli/config.json) to be available across all projects.
|
|
1136
|
+
|
|
1137
|
+
Args:
|
|
1138
|
+
original_name: Original profile name (for removal if renamed)
|
|
1139
|
+
profile: Updated profile to save
|
|
1140
|
+
|
|
1141
|
+
Returns:
|
|
1142
|
+
True if saved successfully
|
|
1143
|
+
"""
|
|
1144
|
+
try:
|
|
1145
|
+
# Profiles are user-wide, always save to global config
|
|
1146
|
+
config_path = Path.home() / ".kollabor-cli" / "config.json"
|
|
1147
|
+
|
|
1148
|
+
if not config_path.exists():
|
|
1149
|
+
logger.error(f"Config file not found: {config_path}")
|
|
1150
|
+
return False
|
|
1151
|
+
|
|
1152
|
+
# Load current config
|
|
1153
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1154
|
+
|
|
1155
|
+
# Ensure core.llm.profiles exists
|
|
1156
|
+
if "core" not in config_data:
|
|
1157
|
+
config_data["core"] = {}
|
|
1158
|
+
if "llm" not in config_data["core"]:
|
|
1159
|
+
config_data["core"]["llm"] = {}
|
|
1160
|
+
if "profiles" not in config_data["core"]["llm"]:
|
|
1161
|
+
config_data["core"]["llm"]["profiles"] = {}
|
|
1162
|
+
|
|
1163
|
+
# Remove old profile if it was renamed
|
|
1164
|
+
if original_name != profile.name and original_name in config_data["core"]["llm"]["profiles"]:
|
|
1165
|
+
del config_data["core"]["llm"]["profiles"][original_name]
|
|
1166
|
+
|
|
1167
|
+
# Add/update profile (without name field, as it's the key)
|
|
1168
|
+
profile_data = profile.to_dict()
|
|
1169
|
+
del profile_data["name"] # Name is the key
|
|
1170
|
+
config_data["core"]["llm"]["profiles"][profile.name] = profile_data
|
|
1171
|
+
|
|
1172
|
+
# Write back
|
|
1173
|
+
config_path.write_text(
|
|
1174
|
+
json.dumps(config_data, indent=2, ensure_ascii=False),
|
|
1175
|
+
encoding="utf-8"
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
logger.info(f"Updated profile in config: {profile.name}")
|
|
1179
|
+
return True
|
|
1180
|
+
|
|
1181
|
+
except Exception as e:
|
|
1182
|
+
logger.error(f"Failed to update profile in config: {e}")
|
|
1183
|
+
return False
|