utim-cli 1.44.1__tar.gz → 1.44.3__tar.gz
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.
- {utim_cli-1.44.1 → utim_cli-1.44.3}/CHANGELOG.md +37 -29
- {utim_cli-1.44.1/utim_cli.egg-info → utim_cli-1.44.3}/PKG-INFO +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/pyproject.toml +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/agent.py +13 -13
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/auth.py +11 -2
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/blender_agent.py +29 -8
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/config.py +94 -35
- utim_cli-1.44.3/utim_cli/constants.py +13 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/context_pruner.py +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/mcp_client.py +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/mcp_registry.json +1 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/orchestrator.py +25 -14
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/reflection.py +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/tools.py +149 -66
- utim_cli-1.44.3/utim_cli/tui/__init__.py +1 -0
- utim_cli-1.44.3/utim_cli/tui/history_dialog.py +242 -0
- utim_cli-1.44.3/utim_cli/tui/mcp_dialog.py +417 -0
- utim_cli-1.44.3/utim_cli/tui/model_dialog.py +1235 -0
- utim_cli-1.44.3/utim_cli/tui/resume_dialog.py +137 -0
- utim_cli-1.44.3/utim_cli/tui/tools_dialog.py +112 -0
- utim_cli-1.44.3/utim_cli/tui/update_dialog.py +152 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/utim.py +122 -2129
- {utim_cli-1.44.1 → utim_cli-1.44.3/utim_cli.egg-info}/PKG-INFO +2 -1
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli.egg-info/SOURCES.txt +9 -2
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli.egg-info/requires.txt +1 -0
- utim_cli-1.44.1/utim_cli/billing.py +0 -40
- {utim_cli-1.44.1 → utim_cli-1.44.3}/LICENSE +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/MANIFEST.in +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/README.md +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/setup.cfg +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/setup.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/__init__.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/backup.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/bootstrap.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/doctor.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/logger.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/report.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/share.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/share_tui.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/state.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/vector_memory.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli/workspace.py +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-1.44.1 → utim_cli-1.44.3}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,29 +1,37 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to the UTIM CLI Agent will be documented in this file.
|
|
4
|
-
|
|
5
|
-
## [1.
|
|
6
|
-
|
|
7
|
-
###
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the UTIM CLI Agent will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.44.2] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **MCP Server Hang:** Fixed an issue where installing MCP servers (like Figma) without the `--stdio` flag would hang the CLI indefinitely. Added a 60-second initialization timeout and updated the preset registry to include required flags automatically.
|
|
9
|
+
- **Model Selection Crash:** Fixed an issue where the CLI would crash instantly when trying to open the `/model` selector on a production npm install due to an excluded server module.
|
|
10
|
+
- **Image Subagent Config:** Separated the Image Generation model and the Prompt Expander LLM in the subagent configuration UI, allowing users to configure both independently.
|
|
11
|
+
- **Minor:** Minor Bug Fixes
|
|
12
|
+
|
|
13
|
+
## [1.43.20] - 2026-07-01
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Local-Global Experience Vector DB Sync**: UTIM now carries the ChromaDB experience database across project folders automatically. On startup, the previous project's `experiences.json`, `vector_db`, and vector metadata files are copied to the current folder — deleted from the old folder only after a successful paste. Last active folder is persisted server-side so the sync survives PC restarts.
|
|
17
|
+
- **BYOK Provider Disconnection (key `x`)**: Press `x` in the model selector to view a list of connected custom providers and disconnect them. All associated models are removed from the model list instantly.
|
|
18
|
+
- **Bring Your Own Key (BYOK) for Free Users**: Free-tier users can now connect any OpenAI-compatible provider using their own API key. BYOK models bypass UTIM quota limits and plan gating entirely.
|
|
19
|
+
- **Dynamic Changelog & Version API**: `/api/changelog` parses `CHANGELOG.md` at request time and returns structured JSON. `/health` version field is also derived from `CHANGELOG.md` dynamically — deploying a new changelog auto-updates both the website changelog page and version badge.
|
|
20
|
+
- **Changelog Tab on Website**: The terminal website now includes a `↻ Changelog` tab that fetches live release notes from the Railway server on load.
|
|
21
|
+
- **Features Page Redesign**: The Features tab now displays a 9-card animated grid with per-feature glow icons, numbered labels, colored tags, and spring entrance animations — matching the full terminal aesthetic of the site.
|
|
22
|
+
- **Last Project Folder Persistence**: Server stores each user's last active project directory in the database. Survives PC restarts and is used to drive the cross-folder experience DB sync.
|
|
23
|
+
- **`GET /api/auth/last-folder` and `POST /api/auth/last-folder` Endpoints**: New authenticated REST endpoints to read/write a user's last active project folder path.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Plan Upgrade Credit Transition**: Free plan credit allowance ($1.00) is zeroed out instead of rolling over to the new paid plan's quota bank on upgrade.
|
|
27
|
+
- **Hobby Plan Model Access**: Hobby plan `allowed_models` corrected to `"all"` so users can access the full model registry (not just free-tier models).
|
|
28
|
+
- **BYOK Model Gating Bypass**: Orchestrator pre-flight checks now skip plan gating and UTIM credit checks for custom BYOK models since they use user-owned keys.
|
|
29
|
+
- **Pricing Page Claims Cleaned**: Removed unsubstantiated marketing claims (`Dedicated elite endpoints`, `Autonomous team agents access`, `High-speed dedicated endpoints`, `All premium models (GPT-4o, Claude)`, `Synthetic Eye & VSIX tools`, `Claude Sonnet 4.6 (1 RPM)`, `10-session history storage`) from all pricing tier feature lists.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **BYOK Flow Display Bug**: Rewrote Add/Delete/BYOK-import dialogs to run on `sys.__stdout__` with `prompt_toolkit.prompt()` so they are never swallowed by the alt-screen buffer.
|
|
33
|
+
- **Double-Exit Crash (TUI)**: Wrapped all keyboard exit callbacks (`ENTER`/`ESC`/`q`/`CTRL+C`) in `try...except` blocks to prevent double-fire tracebacks.
|
|
34
|
+
- **`/changelog` Route No Longer Shows Legacy Page**: All website routes (including `/changelog`) now correctly map to the `<PowershellUI />` terminal component. Legacy standalone page removed.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: utim-cli
|
|
3
|
-
Version: 1.44.
|
|
3
|
+
Version: 1.44.3
|
|
4
4
|
Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://utim.dev
|
|
@@ -37,6 +37,7 @@ Requires-Dist: tree-sitter-typescript>=0.21.0
|
|
|
37
37
|
Requires-Dist: mcp>=0.1.0
|
|
38
38
|
Requires-Dist: nest-asyncio>=1.5.0
|
|
39
39
|
Requires-Dist: python-multipart>=0.0.7
|
|
40
|
+
Requires-Dist: slowapi>=0.1.9
|
|
40
41
|
Provides-Extra: search
|
|
41
42
|
Requires-Dist: scrapy>=2.11.0; extra == "search"
|
|
42
43
|
Requires-Dist: scrapy-playwright>=0.0.34; extra == "search"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.44.
|
|
7
|
+
version = "1.44.3"
|
|
8
8
|
description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -39,6 +39,7 @@ dependencies = [
|
|
|
39
39
|
"mcp>=0.1.0",
|
|
40
40
|
"nest-asyncio>=1.5.0",
|
|
41
41
|
"python-multipart>=0.0.7",
|
|
42
|
+
"slowapi>=0.1.9",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[project.urls]
|
|
@@ -3,7 +3,7 @@ import json
|
|
|
3
3
|
import sys
|
|
4
4
|
import time
|
|
5
5
|
from openai import OpenAI
|
|
6
|
-
from .tools import
|
|
6
|
+
from .tools import get_tools
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.live import Live
|
|
9
9
|
from rich.spinner import Spinner
|
|
@@ -193,11 +193,15 @@ class ReActAgent:
|
|
|
193
193
|
except Exception as e:
|
|
194
194
|
return f"Error executing MCP tool {tool_name}: {str(e)}"
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
utim_tools, tool_functions = get_tools()
|
|
197
|
+
if tool_name not in tool_functions:
|
|
198
|
+
# If not a standard tool, check MCP
|
|
199
|
+
if hasattr(self, 'orchestrator') and self.orchestrator and tool_name in self.orchestrator.mcp_tool_names:
|
|
200
|
+
return self.orchestrator._execute_mcp_tool(tool_name, arguments)
|
|
201
|
+
return f"Error: Tool '{tool_name}' not found."
|
|
202
|
+
|
|
199
203
|
try:
|
|
200
|
-
result =
|
|
204
|
+
result = tool_functions[tool_name](**arguments)
|
|
201
205
|
self._render_tool_panel(tool_name, arguments, result)
|
|
202
206
|
return str(result)
|
|
203
207
|
except Exception as e:
|
|
@@ -212,17 +216,12 @@ class ReActAgent:
|
|
|
212
216
|
tool_calls = []
|
|
213
217
|
current_tool_call = None
|
|
214
218
|
|
|
215
|
-
|
|
216
|
-
try:
|
|
217
|
-
from utim_cli.mcp_client import mcp_manager
|
|
218
|
-
mcp_tools = mcp_manager.get_tools()
|
|
219
|
-
except Exception:
|
|
220
|
-
pass
|
|
219
|
+
utim_tools, tool_functions = get_tools()
|
|
221
220
|
|
|
222
221
|
# Filter disabled tools
|
|
223
222
|
from utim_cli.config import config
|
|
224
223
|
disabled = config.get("disabled_tools", [])
|
|
225
|
-
all_tools = [t for t in
|
|
224
|
+
all_tools = [t for t in utim_tools if t["function"]["name"] not in disabled]
|
|
226
225
|
|
|
227
226
|
kwargs = {
|
|
228
227
|
"model": self.model_id,
|
|
@@ -314,7 +313,8 @@ class ReActAgent:
|
|
|
314
313
|
|
|
315
314
|
headers = ["Tool", "Description"]
|
|
316
315
|
all_tools = []
|
|
317
|
-
|
|
316
|
+
utim_tools, _ = get_tools()
|
|
317
|
+
for tool_def in utim_tools:
|
|
318
318
|
fn = tool_def["function"]
|
|
319
319
|
all_tools.append((fn["name"], fn["description"], "standard"))
|
|
320
320
|
|
|
@@ -112,8 +112,17 @@ def login() -> None:
|
|
|
112
112
|
console.print(f" [bold {BLUE}]{auth_url}[/bold {BLUE}]\n")
|
|
113
113
|
webbrowser.open(auth_url)
|
|
114
114
|
|
|
115
|
-
# Block until the callback arrives
|
|
116
|
-
|
|
115
|
+
# Block until the callback arrives with a 5-minute timeout
|
|
116
|
+
import time
|
|
117
|
+
httpd.timeout = 1.0
|
|
118
|
+
start_time = time.time()
|
|
119
|
+
while not _AuthCallbackHandler.received:
|
|
120
|
+
if time.time() - start_time > 300:
|
|
121
|
+
console.print(f" [bold red]✗ Authentication timed out (5 minutes).[/bold red]\n")
|
|
122
|
+
httpd.server_close()
|
|
123
|
+
return
|
|
124
|
+
httpd.handle_request()
|
|
125
|
+
|
|
117
126
|
httpd.server_close()
|
|
118
127
|
|
|
119
128
|
data = _AuthCallbackHandler.received
|
|
@@ -46,6 +46,7 @@ import subprocess
|
|
|
46
46
|
import time
|
|
47
47
|
import uuid
|
|
48
48
|
from typing import Any, Dict, List, Optional
|
|
49
|
+
from utim_cli.constants import DEFAULT_MODEL
|
|
49
50
|
|
|
50
51
|
# ---------------------------------------------------------------------------
|
|
51
52
|
# Configuration
|
|
@@ -56,8 +57,6 @@ _OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
56
57
|
# Vision models (must support image_url content parts)
|
|
57
58
|
_VISION_MODELS: List[str] = [
|
|
58
59
|
"google/gemma-4-31b-it:free",
|
|
59
|
-
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
60
|
-
"nvidia/nemotron-nano-12b-v2-vl:free",
|
|
61
60
|
"google/gemma-4-26b-a4b-it:free",
|
|
62
61
|
"nvidia/llama-nemotron-embed-vl-1b-v2:free",
|
|
63
62
|
"nvidia/llama-nemotron-rerank-vl-1b-v2:free",
|
|
@@ -66,12 +65,34 @@ _VISION_MODELS: List[str] = [
|
|
|
66
65
|
|
|
67
66
|
_CODE_MODELS: List[str] = [
|
|
68
67
|
"poolside/laguna-m.1:free",
|
|
69
|
-
|
|
70
|
-
"qwen/qwen3-coder:free",
|
|
71
|
-
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
68
|
+
DEFAULT_MODEL,
|
|
72
69
|
"openrouter/free"
|
|
73
70
|
]
|
|
74
71
|
|
|
72
|
+
|
|
73
|
+
def _get_vision_models() -> List[str]:
|
|
74
|
+
"""Return vision model list, with user override prepended if configured."""
|
|
75
|
+
try:
|
|
76
|
+
from utim_cli.config import config
|
|
77
|
+
override = config.get("subagent_model_blender_vision")
|
|
78
|
+
if override and override not in ("__non_agent__", "__none__"):
|
|
79
|
+
return [override] + [m for m in _VISION_MODELS if m != override]
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return _VISION_MODELS
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_code_models() -> List[str]:
|
|
86
|
+
"""Return code model list, with user override prepended if configured."""
|
|
87
|
+
try:
|
|
88
|
+
from utim_cli.config import config
|
|
89
|
+
override = config.get("subagent_model_blender_code")
|
|
90
|
+
if override and override not in ("__non_agent__", "__none__"):
|
|
91
|
+
return [override] + [m for m in _CODE_MODELS if m != override]
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return _CODE_MODELS
|
|
95
|
+
|
|
75
96
|
MAX_RETRIES = 3 # Script fix-and-retry attempts
|
|
76
97
|
|
|
77
98
|
# ---------------------------------------------------------------------------
|
|
@@ -562,7 +583,7 @@ def _phase1_vision(image_path: str, scene_brief: Dict[str, Any]) -> Dict[str, An
|
|
|
562
583
|
raw = _llm_call(
|
|
563
584
|
system=_VISION_SYSTEM_PROMPT,
|
|
564
585
|
user_text=brief_text,
|
|
565
|
-
models=
|
|
586
|
+
models=_get_vision_models(),
|
|
566
587
|
image_b64=image_b64,
|
|
567
588
|
image_mime=mime_type,
|
|
568
589
|
max_tokens=8192,
|
|
@@ -674,7 +695,7 @@ def _phase2_generate_script(
|
|
|
674
695
|
raw_script = _llm_call(
|
|
675
696
|
system=_CODE_SYSTEM_PROMPT,
|
|
676
697
|
user_text=user_prompt,
|
|
677
|
-
models=
|
|
698
|
+
models=_get_code_models(),
|
|
678
699
|
max_tokens=16384,
|
|
679
700
|
)
|
|
680
701
|
|
|
@@ -854,7 +875,7 @@ def _phase3_execute(
|
|
|
854
875
|
raw_fixed = _llm_call(
|
|
855
876
|
system=_FIX_SYSTEM_PROMPT,
|
|
856
877
|
user_text=fix_prompt,
|
|
857
|
-
models=
|
|
878
|
+
models=_get_code_models(),
|
|
858
879
|
max_tokens=16384,
|
|
859
880
|
)
|
|
860
881
|
current_script = _clean_and_patch_script(
|
|
@@ -1,35 +1,48 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import
|
|
2
|
+
import logging
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
5
|
from typing import Any, Dict, Optional
|
|
6
6
|
|
|
7
|
+
logger = logging.getLogger("utim.config")
|
|
8
|
+
|
|
7
9
|
class Config:
|
|
8
10
|
def __init__(self):
|
|
9
11
|
self.global_dir = pathlib.Path.home() / ".utim"
|
|
10
12
|
self.local_dir = pathlib.Path(".utim").resolve()
|
|
11
|
-
self.
|
|
13
|
+
self._global_data: Dict[str, Any] = {}
|
|
14
|
+
self._local_data: Dict[str, Any] = {}
|
|
15
|
+
self._load()
|
|
12
16
|
|
|
13
|
-
def _load(self)
|
|
14
|
-
data = {}
|
|
17
|
+
def _load(self):
|
|
15
18
|
# Load global config
|
|
16
19
|
global_config = self.global_dir / "config.json"
|
|
17
20
|
if global_config.exists():
|
|
18
21
|
try:
|
|
19
22
|
with open(global_config, "r", encoding="utf-8") as f:
|
|
20
|
-
|
|
21
|
-
except
|
|
22
|
-
|
|
23
|
+
self._global_data = json.load(f)
|
|
24
|
+
except json.JSONDecodeError as e:
|
|
25
|
+
logger.warning(f"Global config corrupted at {global_config}: {e}. Using defaults.")
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.error(f"Cannot read global config: {e}")
|
|
23
28
|
|
|
24
29
|
# Load local config if exists
|
|
25
30
|
local_config = self.local_dir / "config.json"
|
|
26
31
|
if local_config.exists():
|
|
27
32
|
try:
|
|
28
33
|
with open(local_config, "r", encoding="utf-8") as f:
|
|
29
|
-
|
|
30
|
-
except
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
self._local_data = json.load(f)
|
|
35
|
+
except json.JSONDecodeError as e:
|
|
36
|
+
logger.warning(f"Local config corrupted at {local_config}: {e}. Using defaults.")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Cannot read local config: {e}")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def _data(self) -> Dict[str, Any]:
|
|
42
|
+
"""Returns a merged view (local overrides global)."""
|
|
43
|
+
merged = dict(self._global_data)
|
|
44
|
+
merged.update(self._local_data)
|
|
45
|
+
return merged
|
|
33
46
|
|
|
34
47
|
def save(self):
|
|
35
48
|
# Save to global config directory
|
|
@@ -37,24 +50,28 @@ class Config:
|
|
|
37
50
|
global_config = self.global_dir / "config.json"
|
|
38
51
|
try:
|
|
39
52
|
with open(global_config, "w", encoding="utf-8") as f:
|
|
40
|
-
json.dump(self.
|
|
41
|
-
except Exception:
|
|
42
|
-
|
|
53
|
+
json.dump(self._global_data, f, indent=4)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f"Cannot write global config: {e}")
|
|
43
56
|
|
|
44
|
-
# Save to local config directory if
|
|
45
|
-
if self.
|
|
57
|
+
# Save to local config directory if we have local data
|
|
58
|
+
if self._local_data:
|
|
59
|
+
self.local_dir.mkdir(parents=True, exist_ok=True)
|
|
46
60
|
local_config = self.local_dir / "config.json"
|
|
47
61
|
try:
|
|
48
62
|
with open(local_config, "w", encoding="utf-8") as f:
|
|
49
|
-
json.dump(self.
|
|
50
|
-
except Exception:
|
|
51
|
-
|
|
63
|
+
json.dump(self._local_data, f, indent=4)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Cannot write local config: {e}")
|
|
52
66
|
|
|
53
67
|
def get(self, key: str, default: Any = None) -> Any:
|
|
54
68
|
return self._data.get(key, default)
|
|
55
69
|
|
|
56
|
-
def set(self, key: str, value: Any):
|
|
57
|
-
|
|
70
|
+
def set(self, key: str, value: Any, local: bool = False):
|
|
71
|
+
if local:
|
|
72
|
+
self._local_data[key] = value
|
|
73
|
+
else:
|
|
74
|
+
self._global_data[key] = value
|
|
58
75
|
self.save()
|
|
59
76
|
|
|
60
77
|
@property
|
|
@@ -70,7 +87,8 @@ class Config:
|
|
|
70
87
|
return self.get("name")
|
|
71
88
|
|
|
72
89
|
def clear(self):
|
|
73
|
-
self.
|
|
90
|
+
self._global_data = {}
|
|
91
|
+
self._local_data = {}
|
|
74
92
|
self.save()
|
|
75
93
|
|
|
76
94
|
# ── Custom / Bring-Your-Own-Model support ─────────────────────────────────
|
|
@@ -92,14 +110,28 @@ class Config:
|
|
|
92
110
|
|
|
93
111
|
@custom_models.setter
|
|
94
112
|
def custom_models(self, value: list):
|
|
95
|
-
self.
|
|
96
|
-
self.save()
|
|
113
|
+
self.set("custom_models", value)
|
|
97
114
|
|
|
98
115
|
def add_custom_model(self, entry: dict) -> None:
|
|
99
116
|
"""Append or replace a custom model entry keyed on model_id."""
|
|
100
117
|
models = self.custom_models
|
|
101
118
|
models = [m for m in models if m.get("model_id") != entry["model_id"]]
|
|
102
|
-
|
|
119
|
+
|
|
120
|
+
api_key = entry.get("api_key")
|
|
121
|
+
use_keyring = False
|
|
122
|
+
if api_key:
|
|
123
|
+
try:
|
|
124
|
+
import keyring
|
|
125
|
+
keyring.set_password("utim_cli", entry["model_id"], api_key)
|
|
126
|
+
use_keyring = True
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.warning(f"Keyring not available, storing key in plain text: {e}")
|
|
129
|
+
|
|
130
|
+
entry_to_save = dict(entry)
|
|
131
|
+
if use_keyring:
|
|
132
|
+
entry_to_save.pop("api_key", None)
|
|
133
|
+
|
|
134
|
+
models.append(entry_to_save)
|
|
103
135
|
self.custom_models = models
|
|
104
136
|
|
|
105
137
|
def remove_custom_model(self, model_id: str) -> bool:
|
|
@@ -108,26 +140,55 @@ class Config:
|
|
|
108
140
|
new = [m for m in models if m.get("model_id") != model_id]
|
|
109
141
|
if len(new) == len(models):
|
|
110
142
|
return False
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
import keyring
|
|
146
|
+
keyring.delete_password("utim_cli", model_id)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
111
150
|
self.custom_models = new
|
|
112
151
|
return True
|
|
113
152
|
|
|
114
153
|
def remove_custom_provider(self, provider_name: str, base_url: str) -> int:
|
|
115
154
|
"""Remove all custom models associated with a provider. Returns count removed."""
|
|
116
155
|
models = self.custom_models
|
|
117
|
-
new = [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
156
|
+
new = []
|
|
157
|
+
removed = 0
|
|
158
|
+
try:
|
|
159
|
+
import keyring
|
|
160
|
+
has_keyring = True
|
|
161
|
+
except ImportError:
|
|
162
|
+
has_keyring = False
|
|
163
|
+
|
|
164
|
+
for m in models:
|
|
165
|
+
if m.get("provider_name") == provider_name and m.get("base_url") == base_url:
|
|
166
|
+
removed += 1
|
|
167
|
+
if has_keyring:
|
|
168
|
+
try:
|
|
169
|
+
keyring.delete_password("utim_cli", m["model_id"])
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
else:
|
|
173
|
+
new.append(m)
|
|
174
|
+
|
|
175
|
+
if removed > 0:
|
|
123
176
|
self.custom_models = new
|
|
124
|
-
return
|
|
177
|
+
return removed
|
|
125
178
|
|
|
126
179
|
def get_custom_model(self, model_id: str) -> Optional[dict]:
|
|
127
180
|
"""Return the custom model entry for *model_id*, or None."""
|
|
128
181
|
for m in self.custom_models:
|
|
129
182
|
if m.get("model_id") == model_id:
|
|
130
|
-
|
|
183
|
+
m_ret = dict(m)
|
|
184
|
+
try:
|
|
185
|
+
import keyring
|
|
186
|
+
key = keyring.get_password("utim_cli", model_id)
|
|
187
|
+
if key:
|
|
188
|
+
m_ret["api_key"] = key
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
return m_ret
|
|
131
192
|
return None
|
|
132
193
|
|
|
133
194
|
@property
|
|
@@ -147,8 +208,6 @@ class Config:
|
|
|
147
208
|
return [m.strip() for m in models.split(",")]
|
|
148
209
|
return [
|
|
149
210
|
"poolside/laguna-m.1:free",
|
|
150
|
-
"qwen/qwen3-coder:free",
|
|
151
|
-
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
152
211
|
"openrouter/free"
|
|
153
212
|
]
|
|
154
213
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DEFAULT_MODEL = "cohere/north-mini-code:free"
|
|
4
|
+
SERVER_URL = os.getenv("UTIM_SERVER_URL", "https://api.utim.dev")
|
|
5
|
+
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
6
|
+
|
|
7
|
+
SUBAGENT_DEFAULTS = {
|
|
8
|
+
"web_search": DEFAULT_MODEL,
|
|
9
|
+
"plan_project": DEFAULT_MODEL,
|
|
10
|
+
"project_res": DEFAULT_MODEL,
|
|
11
|
+
"generate_image": "nvidia/nemotron-3-nano-30b-a3b:free",
|
|
12
|
+
"blender_create_object": DEFAULT_MODEL,
|
|
13
|
+
}
|
|
@@ -11,12 +11,13 @@ from typing import List, Dict, Tuple, Optional, Set
|
|
|
11
11
|
import requests
|
|
12
12
|
import json
|
|
13
13
|
import hashlib
|
|
14
|
+
from utim_cli.constants import DEFAULT_MODEL
|
|
14
15
|
|
|
15
16
|
# Fallback models for compression operations - ordered by reliability and quality
|
|
16
17
|
COMPRESSION_FALLBACK_MODELS = [
|
|
17
18
|
"openai/gpt-oss-20b:free",
|
|
18
19
|
"poolside/laguna-xs.2:free",
|
|
19
|
-
|
|
20
|
+
DEFAULT_MODEL,
|
|
20
21
|
"openrouter/free"
|
|
21
22
|
]
|
|
22
23
|
|
|
@@ -114,9 +114,10 @@ class MCPManager:
|
|
|
114
114
|
stack = AsyncExitStack()
|
|
115
115
|
self._exit_stacks[name] = stack
|
|
116
116
|
|
|
117
|
+
import asyncio
|
|
117
118
|
read, write = await stack.enter_async_context(stdio_client(server_params))
|
|
118
119
|
session = await stack.enter_async_context(ClientSession(read, write))
|
|
119
|
-
await session.initialize()
|
|
120
|
+
await asyncio.wait_for(session.initialize(), timeout=60.0)
|
|
120
121
|
|
|
121
122
|
self.sessions[name] = session
|
|
122
123
|
|
|
@@ -22,10 +22,12 @@ import subprocess
|
|
|
22
22
|
import sys
|
|
23
23
|
import threading
|
|
24
24
|
import time
|
|
25
|
+
from datetime import datetime
|
|
25
26
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
26
27
|
from typing import Any, Dict, List, Optional, Tuple
|
|
27
28
|
|
|
28
29
|
import requests
|
|
30
|
+
from utim_cli.constants import DEFAULT_MODEL
|
|
29
31
|
# openai SDK removed — we call OpenRouter directly via requests (no Rust/jiter needed)
|
|
30
32
|
from rich.console import Console
|
|
31
33
|
from rich.live import Live
|
|
@@ -35,8 +37,7 @@ from rich.rule import Rule
|
|
|
35
37
|
from rich.spinner import Spinner
|
|
36
38
|
from rich.text import Text
|
|
37
39
|
|
|
38
|
-
from .
|
|
39
|
-
from .tools import TOOL_FUNCTIONS, UTIM_TOOLS
|
|
40
|
+
from .tools import get_tools
|
|
40
41
|
import utim_cli.tools as _tools_module # for injecting cancel_event
|
|
41
42
|
from .config import config
|
|
42
43
|
|
|
@@ -261,11 +262,10 @@ class Orchestrator:
|
|
|
261
262
|
mcp_manager.start()
|
|
262
263
|
except Exception:
|
|
263
264
|
pass
|
|
264
|
-
self.credits = CreditManager()
|
|
265
265
|
self.server_url = "https://api.utim.dev"
|
|
266
266
|
self.session_id: Optional[str] = None
|
|
267
267
|
# Primary model — falls back through config.fallback_models on failure
|
|
268
|
-
self.model_id: str =
|
|
268
|
+
self.model_id: str = DEFAULT_MODEL
|
|
269
269
|
self._current_line_len = 0
|
|
270
270
|
self.tool_results: List[Dict[str, Any]] = []
|
|
271
271
|
self.turn_step_timings: List[Dict[str, Any]] = []
|
|
@@ -499,6 +499,7 @@ class Orchestrator:
|
|
|
499
499
|
timeout=5,
|
|
500
500
|
)
|
|
501
501
|
if resp.status_code == 200:
|
|
502
|
+
quota = resp.json()
|
|
502
503
|
chosen_model = override_model if override_model else self.model_id
|
|
503
504
|
is_custom = bool(config.get_custom_model(chosen_model))
|
|
504
505
|
|
|
@@ -521,7 +522,7 @@ class Orchestrator:
|
|
|
521
522
|
is_custom = bool(config.get_custom_model(chosen_model))
|
|
522
523
|
|
|
523
524
|
if not is_custom and models_allowed != ["all"] and chosen_model not in models_allowed:
|
|
524
|
-
fallback_model =
|
|
525
|
+
fallback_model = DEFAULT_MODEL # default free fallback
|
|
525
526
|
self.console.print(f"\n[bold yellow]⚠ Model '{chosen_model}' is gated under your current '{quota['display_name']}' plan.[/bold yellow]")
|
|
526
527
|
self.console.print(f" Downgrading to default allowed model: '{fallback_model}' for this request.")
|
|
527
528
|
if override_model:
|
|
@@ -568,7 +569,9 @@ class Orchestrator:
|
|
|
568
569
|
except Exception:
|
|
569
570
|
pass
|
|
570
571
|
|
|
571
|
-
|
|
572
|
+
from utim_cli.tools import get_tools
|
|
573
|
+
utim_tools, _ = get_tools()
|
|
574
|
+
all_tools = (override_tools if override_tools is not None else utim_tools) + mcp_tools
|
|
572
575
|
# Filter disabled tools
|
|
573
576
|
disabled = config.get("disabled_tools", [])
|
|
574
577
|
all_tools = [t for t in all_tools if t["function"]["name"] not in disabled]
|
|
@@ -1362,12 +1365,13 @@ class Orchestrator:
|
|
|
1362
1365
|
))
|
|
1363
1366
|
return f"Error executing MCP tool {func_name}: {str(e)}"
|
|
1364
1367
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1368
|
+
from utim_cli.tools import get_tools
|
|
1369
|
+
utim_tools, tool_functions = get_tools()
|
|
1370
|
+
if func_name not in tool_functions:
|
|
1371
|
+
# Check if it's an MCP tool
|
|
1372
|
+
if func_name in self.mcp_tool_names:
|
|
1373
|
+
return self._execute_mcp_tool(func_name, call_args)
|
|
1374
|
+
return f"Error: The tool '{func_name}' does not exist or is not available in the current context."
|
|
1371
1375
|
|
|
1372
1376
|
display_arg = self._get_display_arg(func_name, arguments)
|
|
1373
1377
|
|
|
@@ -1508,7 +1512,8 @@ class Orchestrator:
|
|
|
1508
1512
|
_SILENT_TOOLS = {"manage_memory", "recall_experience", "store_experience"}
|
|
1509
1513
|
if func_name in _SILENT_TOOLS:
|
|
1510
1514
|
try:
|
|
1511
|
-
|
|
1515
|
+
utim_tools, tool_functions = get_tools()
|
|
1516
|
+
result = tool_functions[func_name](**call_args)
|
|
1512
1517
|
except Exception as exc:
|
|
1513
1518
|
result = f"Error executing {func_name}: {exc}"
|
|
1514
1519
|
self.tool_results.append({
|
|
@@ -1560,7 +1565,13 @@ class Orchestrator:
|
|
|
1560
1565
|
except Exception:
|
|
1561
1566
|
pass
|
|
1562
1567
|
|
|
1563
|
-
|
|
1568
|
+
try:
|
|
1569
|
+
utim_tools, tool_functions = get_tools()
|
|
1570
|
+
result = tool_functions[func_name](**call_args)
|
|
1571
|
+
return result
|
|
1572
|
+
except Exception as e:
|
|
1573
|
+
import traceback
|
|
1574
|
+
result = f"Error executing tool {func_name}: {e}\n{traceback.format_exc()}"
|
|
1564
1575
|
|
|
1565
1576
|
# Restore the indicator to evaluating logic
|
|
1566
1577
|
try:
|