utim-cli 2.0.4__tar.gz → 2.1.0__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.
Files changed (113) hide show
  1. {utim_cli-2.0.4 → utim_cli-2.1.0}/CHANGELOG.md +22 -0
  2. {utim_cli-2.0.4/utim_cli.egg-info → utim_cli-2.1.0}/PKG-INFO +4 -1
  3. {utim_cli-2.0.4 → utim_cli-2.1.0}/pyproject.toml +11 -1
  4. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/__init__.py +1 -1
  5. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_version.py +1 -1
  6. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/agent.py +5 -0
  7. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/client_utils.py +82 -4
  8. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/config.py +150 -0
  9. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/orchestrator.py +65 -13
  10. utim_cli-2.1.0/utim_cli/server/attribution.py +62 -0
  11. utim_cli-2.1.0/utim_cli/server/audit_log.py +165 -0
  12. utim_cli-2.1.0/utim_cli/server/captcha.py +206 -0
  13. utim_cli-2.1.0/utim_cli/server/cli_auth.py +399 -0
  14. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/db.py +1188 -1049
  15. utim_cli-2.1.0/utim_cli/server/docs_md/SECRET_PROVISIONING.md +79 -0
  16. utim_cli-2.1.0/utim_cli/server/provision_build.py +184 -0
  17. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/router.py +78 -7
  18. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/auth_routes.py +849 -777
  19. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/completion_routes.py +1400 -1384
  20. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/security_routes.py +507 -427
  21. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/situational_scoring.py +278 -271
  22. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/publish_dialog.py +6 -6
  23. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/utim.py +2 -2
  24. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/vector_memory.py +8 -0
  25. {utim_cli-2.0.4 → utim_cli-2.1.0/utim_cli.egg-info}/PKG-INFO +4 -1
  26. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli.egg-info/SOURCES.txt +6 -0
  27. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli.egg-info/requires.txt +4 -0
  28. {utim_cli-2.0.4 → utim_cli-2.1.0}/LICENSE +0 -0
  29. {utim_cli-2.0.4 → utim_cli-2.1.0}/MANIFEST.in +0 -0
  30. {utim_cli-2.0.4 → utim_cli-2.1.0}/README.md +0 -0
  31. {utim_cli-2.0.4 → utim_cli-2.1.0}/setup.cfg +0 -0
  32. {utim_cli-2.0.4 → utim_cli-2.1.0}/setup.py +0 -0
  33. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_check_obj.py +0 -0
  34. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_find_mcp.py +0 -0
  35. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_find_plan.py +0 -0
  36. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_find_tool.py +0 -0
  37. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_patch_multimodal.py +0 -0
  38. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/_test_regex.py +0 -0
  39. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/auth.py +0 -0
  40. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/backup.py +0 -0
  41. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/billing.py +0 -0
  42. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/blender_agent.py +0 -0
  43. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/bootstrap.py +0 -0
  44. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/constants.py +0 -0
  45. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/context_pruner.py +0 -0
  46. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/doctor.py +0 -0
  47. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/harbor.py +0 -0
  48. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/knowledge_graph.py +0 -0
  49. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/local_db.py +0 -0
  50. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/logger.py +0 -0
  51. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/mcp_clean_wrapper.py +0 -0
  52. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/mcp_client.py +0 -0
  53. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/mcp_registry.json +0 -0
  54. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/reflection.py +0 -0
  55. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/report.py +0 -0
  56. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/scrapy_search.py +0 -0
  57. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/__init__.py +0 -0
  58. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/auth.py +0 -0
  59. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/about.md +0 -0
  60. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/changelog.md +0 -0
  61. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/docs.md +0 -0
  62. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/features.md +0 -0
  63. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/license.md +0 -0
  64. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/pricing.md +0 -0
  65. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/privacy.md +0 -0
  66. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/refund.md +0 -0
  67. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/support.md +0 -0
  68. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/docs_md/terms.md +0 -0
  69. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/email_utils.py +0 -0
  70. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/exchange_rate.py +0 -0
  71. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/firebase.py +0 -0
  72. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/history.py +0 -0
  73. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/logging_config.py +0 -0
  74. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/models.py +0 -0
  75. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/pricing_updater.py +0 -0
  76. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/rate_limit.py +0 -0
  77. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/__init__.py +0 -0
  78. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/credit_routes.py +0 -0
  79. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/feedback_routes.py +0 -0
  80. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/marketplace_routes.py +0 -0
  81. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/quota_routes.py +0 -0
  82. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/quota_share_routes.py +0 -0
  83. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/referral_routes.py +0 -0
  84. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/session_routes.py +0 -0
  85. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/routes/share_routes.py +0 -0
  86. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/server.py +0 -0
  87. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/server/storage_nodes.py +0 -0
  88. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/share.py +0 -0
  89. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/share_tui.py +0 -0
  90. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/state.py +0 -0
  91. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tools.py +0 -0
  92. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/__init__.py +0 -0
  93. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/feedback_dialog.py +0 -0
  94. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/history_dialog.py +0 -0
  95. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/marketplace_app_state.py +0 -0
  96. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/marketplace_dialog.py +0 -0
  97. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  98. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/mcp_dialog.py +0 -0
  99. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/miniagents_dialog.py +0 -0
  100. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/model_dialog.py +0 -0
  101. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/quota_dialog.py +0 -0
  102. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  103. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/quota_share_dialog.py +0 -0
  104. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/resume_dialog.py +0 -0
  105. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/skills_dialog.py +0 -0
  106. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/subagents_dialog.py +0 -0
  107. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/tools_dialog.py +0 -0
  108. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/tui/update_dialog.py +0 -0
  109. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/utilities.py +0 -0
  110. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli/workspace.py +0 -0
  111. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli.egg-info/dependency_links.txt +0 -0
  112. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli.egg-info/entry_points.txt +0 -0
  113. {utim_cli-2.0.4 → utim_cli-2.1.0}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0] - 2026-08-05
4
+
5
+ ### ⚡ Performance
6
+ - **Added `UTIM_LITE_MODE=1` for low-spec PCs**: Skip loading heavy ML models (ChromaDB, sentence-transformers, MiniLM embeddings, local Qwen/torch download). CLI now starts instantly with no model-download lag. Enabled by default for users on `bin\utimlite.cmd`.
7
+ - **New `utimlite` launcher** alongside `utim` (full mode) — `bin\utimlite.cmd` sets `UTIM_LITE_MODE=1` and runs the same app with a lightweight backend.
8
+ - **Heavy dependencies are now opt-in**: `chromadb` + `sentence-transformers` moved out of core deps into the new `memory` extra (`pip install "utim-cli[memory]"`). Core install is now 11 lightweight deps only.
9
+
10
+ ### 🛠️ Fixes
11
+ - **Fixed Parasail `400 'Expecting value: char 200'` error**: Python's `json.dumps` emits `NaN`/`Infinity` which strict providers (Parasail) reject with a JSON parse error. Added recursive `_sanitize_json()` in `client_utils.py` and wired it into `proxy_openrouter_request()` so every outbound LLM payload is now strict-JSON-safe (non-finite floats → `null`).
12
+ - **Fixed `Wrong color format 'dim'` crash in publish dialog** (carried from 2.0.4).
13
+
14
+ ### 🔐 Security Hardening
15
+ - **CLI signature middleware** with HMAC-SHA256 per-build secrets, install-id binding, and atomic nonce consumption (replay-attack resistant).
16
+ - **Pluggable CAPTCHA** (reCAPTCHA / Turnstile / hCaptcha) on `/auth/*` endpoints.
17
+ - **OpenRouter attribution centralized** via `OPENROUTER_HEADERS` and propagated to all client entry points (`proxy_openrouter_request`, `orchestrator.py` streaming path, `agent.py` ReActAgent, `server/routes/completion_routes.py` primary `/completions` server path, `server/router.py`, `security_routes.py`).
18
+ - **Audit logging** with automatic PII/sensitive-data sanitization.
19
+ - **Body-stream caching middleware** + atomic nonce DB updates bound to `(install_id, ip)`.
20
+ - **`provision_build.py`** for release-time secret generation/rotation.
21
+ - **Hardened CORS**: replaced permissive `*.utim.dev` regex with explicit allowlist (`www.utim.dev`, `api.utim.dev`).
22
+ - **Request size limits**: 4MB default, 16MB marketplace; **web search sandboxing** (64KB response cap, 500-char query limit, host allow-list).
23
+ - **Fixed abort-then-print bug** in `orchestrator.abort()`: removed premature `self.cancel_event.clear()` that re-enabled stream printing after abort — event now stays set until turn-reset paths clear it.
24
+
3
25
  ## [2.0.4] - 2026-08-05
4
26
 
5
27
  ### 🛠️ Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.0.4
3
+ Version: 2.1.0
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
@@ -57,6 +57,9 @@ Requires-Dist: mcp>=0.1.0; (platform_system != "Linux" or (platform_machine != "
57
57
  Requires-Dist: openai>=1.30.0; (platform_system != "Linux" or (platform_machine != "aarch64" and platform_machine != "arm64" and platform_machine != "armv7l" and platform_machine != "armv8l" and platform_machine != "arm" and platform_machine != "armv7")) and extra == "full"
58
58
  Provides-Extra: legacy-agent
59
59
  Requires-Dist: openai>=1.30.0; extra == "legacy-agent"
60
+ Provides-Extra: memory
61
+ Requires-Dist: chromadb>=0.4.0; extra == "memory"
62
+ Requires-Dist: sentence-transformers>=2.2.0; extra == "memory"
60
63
 
61
64
  # UTIM CLI Agent: Enterprise Coder Assistant
62
65
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.0.4"
7
+ version = "2.1.0"
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"
@@ -75,6 +75,16 @@ full = [
75
75
  legacy-agent = [
76
76
  "openai>=1.30.0",
77
77
  ]
78
+ # Heavy / memory-heavy features — intentionally NOT in core dependencies.
79
+ # Installing these downloads several hundred MB (chromadb + sentence-transformers
80
+ # + the MiniLM model) and can lag low-spec PCs. The CLI works fully without them:
81
+ # - Set UTIM_LITE_MODE=1 to force lightweight fallbacks (deterministic mock
82
+ # embeddings, no local Qwen/torch download).
83
+ # - Use `pip install "utim-cli[memory]"` on machines that can afford it.
84
+ memory = [
85
+ "chromadb>=0.4.0",
86
+ "sentence-transformers>=2.2.0",
87
+ ]
78
88
 
79
89
  [project.scripts]
80
90
  utim = "utim_cli.utim:main_cli_entry"
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.0.4"
8
+ __version__ = "2.1.0"
9
9
 
10
10
  import builtins, os, pathlib, urllib.parse, re
11
11
 
@@ -11,4 +11,4 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.0.4"
14
+ VERSION = "2.1.0"
@@ -61,6 +61,11 @@ class ReActAgent:
61
61
  self.client = OpenAI(
62
62
  base_url="https://openrouter.ai/api/v1",
63
63
  api_key=api_key or "sk-fake-key-for-init",
64
+ default_headers={
65
+ "HTTP-Referer": "https://utim.dev",
66
+ "X-Title": "UTIM CLI Agent",
67
+ "User-Agent": "UTIM-CLI/2.0 (+https://utim.dev)",
68
+ },
64
69
  )
65
70
  self.messages = [{"role": "system", "content": system_prompt}]
66
71
  self.start_time = None
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import json
3
+ import math
3
4
  import requests
4
5
  from utim_cli.config import config
5
6
 
@@ -37,6 +38,32 @@ def get_server_url() -> str:
37
38
  return "https://api.utim.dev"
38
39
 
39
40
 
41
+ def _sanitize_json(obj):
42
+ """Recursively scrub a payload for values that break strict JSON parsers.
43
+
44
+ Python's json.dumps emits NaN/Infinity as literal `NaN` / `Infinity`,
45
+ which is NOT valid JSON — strict providers (e.g. Parasail) reject the
46
+ whole request with 'Expecting value' at the exact offending character.
47
+ This replaces non-finite floats with None (safe in API payloads) and
48
+ drops anything that cannot round-trip through JSON.
49
+ """
50
+ if isinstance(obj, dict):
51
+ return {k: _sanitize_json(v) for k, v in obj.items()
52
+ if isinstance(k, (str, int, float, bool)) or k is None}
53
+ if isinstance(obj, (list, tuple)):
54
+ return [_sanitize_json(v) for v in obj]
55
+ if isinstance(obj, float):
56
+ return obj if math.isfinite(obj) else None
57
+ if isinstance(obj, (str, int, bool)) or obj is None:
58
+ return obj
59
+ # Anything else (bytes, objects, sets…) can't survive JSON serialisation.
60
+ try:
61
+ json.dumps(obj)
62
+ return obj
63
+ except (TypeError, ValueError):
64
+ return None
65
+
66
+
40
67
  # ─── Model catalog cache ──────────────────────────────────────────────────────
41
68
  _catalog_cache: dict | None = None
42
69
  _catalog_cache_ts: float = 0.0
@@ -84,6 +111,10 @@ def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None
84
111
  """Routes LLM request to UTIM server /completions, or falls back to direct OpenRouter."""
85
112
  if timeout is None:
86
113
  timeout = (15, 900)
114
+ # Scrub payload for NaN/Infinity/non-JSON types BEFORE it leaves the
115
+ # machine. Providers like Parasail do a strict parse and reject the whole
116
+ # request (400 'Expecting value') if even one value is malformed.
117
+ json_data = _sanitize_json(json_data)
87
118
  # Ensure environment variables are loaded (orchestrator handles this, but tools.py is independent)
88
119
  _cwd_env = os.path.join(os.getcwd(), ".env")
89
120
  if os.path.isfile(_cwd_env):
@@ -98,19 +129,63 @@ def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None
98
129
  llm_key = os.getenv("OPENROUTER_API_KEY")
99
130
 
100
131
  if llm_key:
132
+ # Direct path to OpenRouter — must always carry canonical attribution
133
+ # headers so OpenRouter shows "UTIM CLI Agent" (NOT "unknown") in its
134
+ # analytics. The user-agent and install-id are added by the global
135
+ # session patcher in config.py.
101
136
  headers = {
102
137
  "Authorization": f"Bearer {llm_key}",
103
138
  "Content-Type": "application/json",
104
139
  "HTTP-Referer": "https://utim.dev",
105
140
  "X-Title": "UTIM CLI Agent",
141
+ "User-Agent": "UTIM-CLI/2.0 (+https://utim.dev)",
106
142
  }
107
- return requests.post("https://openrouter.ai/api/v1/chat/completions", json=json_data, headers=headers, stream=stream or json_data.get("stream"), timeout=timeout, verify=config.verify_ssl)
143
+ return requests.post(
144
+ "https://openrouter.ai/api/v1/chat/completions",
145
+ json=json_data,
146
+ headers=headers,
147
+ stream=stream or json_data.get("stream"),
148
+ timeout=timeout,
149
+ verify=config.verify_ssl,
150
+ )
108
151
 
109
152
  if api_key:
153
+ # Path via UTIM server. We attach CLI identity headers (version,
154
+ # install-id) so the server can correlate calls and (when enforcement
155
+ # is on) verify the request signature. The session-level UA patch in
156
+ # config.py adds the User-Agent too.
157
+ try:
158
+ from utim_cli._version import VERSION as _CLI_VERSION
159
+ except Exception:
160
+ _CLI_VERSION = "unknown"
161
+
162
+ try:
163
+ from utim_cli.config import get_install_id
164
+ _INSTALL_ID = get_install_id()
165
+ except Exception:
166
+ _INSTALL_ID = ""
167
+
110
168
  headers = {
111
169
  "X-API-Key": api_key,
112
- "Content-Type": "application/json"
170
+ "Content-Type": "application/json",
171
+ "X-UTIM-CLI-Version": _CLI_VERSION,
172
+ "X-UTIM-Install-ID": _INSTALL_ID,
173
+ "User-Agent": f"UTIM-CLI/{_CLI_VERSION} (+https://utim.dev)",
113
174
  }
175
+
176
+ # Optional: attach CLI signature if the server is enforcing
177
+ # signatures. The signing flow (challenge → sign) is implemented in
178
+ # config.py's global session patcher so it works for every request,
179
+ # not just this one. If unavailable, we still send the headers above
180
+ # so the server can at least identify the caller.
181
+ try:
182
+ from utim_cli.config import sign_request_for_server
183
+ signed = sign_request_for_server(method="POST", path="/completions", body=json_data)
184
+ if signed:
185
+ headers.update(signed)
186
+ except Exception:
187
+ pass
188
+
114
189
  payload = {
115
190
  "messages": json_data.get("messages"),
116
191
  "model_id": json_data.get("model"),
@@ -237,7 +312,10 @@ def parse_xml_tool_calls(content: str) -> tuple[str | None, list[dict]]:
237
312
  import re
238
313
  import uuid
239
314
  tool_calls = []
240
- tool_call_pattern = re.compile(r'<tool_call>([\s\S]*?)</tool_call>')
315
+ # Match BOTH <tool_call> and the pipe-delimited <|tool_call|> variant.
316
+ # Some models emit <|tool_call|> (Claude/Ollama style); the old regex only
317
+ # matched <tool_call>, so those calls leaked raw into chat as text.
318
+ tool_call_pattern = re.compile(r'<\|?tool_call\|?>([\s\S]*?)<\|?/tool_call\|?>')
241
319
 
242
320
  matches = list(tool_call_pattern.finditer(content))
243
321
  if not matches:
@@ -506,4 +584,4 @@ def update_session_usage(
506
584
  entry["tools_credits"] = entry.get("tools_credits", 0.0) + credits_used
507
585
 
508
586
  all_sessions[eff_id] = entry
509
- _write_all_usage(all_sessions)
587
+ _write_all_usage(all_sessions)
@@ -3,10 +3,160 @@ import logging
3
3
  import os
4
4
  import pathlib
5
5
  import shutil
6
+ import time
7
+ import hashlib
8
+ import hmac
6
9
  from typing import Any, Dict, Optional
7
10
 
8
11
  logger = logging.getLogger("utim.config")
9
12
 
13
+
14
+ # ── CLI install identity & request signing ───────────────────────────────────
15
+ # Used by client_utils.py to attach X-UTIM-* headers to every server-bound
16
+ # request and (when the server enforces signing) to compute X-UTIM-CLI-Signature.
17
+ # The server defines the canonical string in utim_cli/server/cli_auth.py.
18
+
19
+ _INSTALL_ID: str = ""
20
+ _HMAC_SECRET: bytes = b"" # populated lazily from environment / config
21
+ _LAST_CHALLENGE_FETCH: float = 0.0
22
+ _CHALLENGE_CACHE: dict = {"nonce": "", "expires_at": 0}
23
+
24
+
25
+ def _load_install_id() -> str:
26
+ """Read or generate the persistent install ID (uuid4 stored on disk)."""
27
+ global _INSTALL_ID
28
+ if _INSTALL_ID:
29
+ return _INSTALL_ID
30
+ try:
31
+ uid_path = get_utim_dir() / "install_id"
32
+ if uid_path.exists():
33
+ _INSTALL_ID = uid_path.read_text(encoding="utf-8").strip() or _generate_install_id()
34
+ else:
35
+ _INSTALL_ID = _generate_install_id()
36
+ uid_path.parent.mkdir(parents=True, exist_ok=True)
37
+ uid_path.write_text(_INSTALL_ID, encoding="utf-8")
38
+ except Exception:
39
+ _INSTALL_ID = _generate_install_id()
40
+ return _INSTALL_ID
41
+
42
+
43
+ def _generate_install_id() -> str:
44
+ import uuid
45
+ return f"inst-{uuid.uuid4().hex}"
46
+
47
+
48
+ def get_install_id() -> str:
49
+ """Public accessor — returns the per-install UUID."""
50
+ return _load_install_id()
51
+
52
+
53
+ def get_cli_version() -> str:
54
+ try:
55
+ from utim_cli._version import VERSION # type: ignore
56
+ return VERSION
57
+ except Exception:
58
+ return "unknown"
59
+
60
+
61
+ def get_hmac_secret() -> bytes:
62
+ """Return the build-embedded HMAC secret used to sign requests.
63
+
64
+ Source priority:
65
+ 1. UTIM_CLI_HMAC_SECRET environment variable (dev / override)
66
+ 2. Persisted config value (set by the CLI on first launch from a baked-in default)
67
+ 3. A stable derived default — sufficient for the server to log but NOT to
68
+ authenticate (server treats unknown secrets as "unsigned legacy client")
69
+ """
70
+ global _HMAC_SECRET
71
+ if _HMAC_SECRET:
72
+ return _HMAC_SECRET
73
+ env_secret = os.environ.get("UTIM_CLI_HMAC_SECRET", "").strip()
74
+ if env_secret:
75
+ _HMAC_SECRET = env_secret.encode("utf-8")
76
+ return _HMAC_SECRET
77
+ try:
78
+ stored = config.get("cli_hmac_secret")
79
+ if stored:
80
+ _HMAC_SECRET = stored.encode("utf-8")
81
+ return _HMAC_SECRET
82
+ except Exception:
83
+ pass
84
+ # Stable fallback derived from install_id — server rejects but client code
85
+ # still works without raising. The server logs these as "unsigned legacy".
86
+ _HMAC_SECRET = hashlib.sha256(("utim-legacy:" + get_install_id()).encode("utf-8")).digest()
87
+ return _HMAC_SECRET
88
+
89
+
90
+ def _fetch_challenge(server_url: str) -> dict:
91
+ """Ask the server for a fresh nonce. Cached for 30s to avoid spamming."""
92
+ global _LAST_CHALLENGE_FETCH, _CHALLENGE_CACHE
93
+ now = time.time()
94
+ if _CHALLENGE_CACHE.get("nonce") and _CHALLENGE_CACHE.get("expires_at", 0) > now + 5:
95
+ if (now - _LAST_CHALLENGE_FETCH) < 30:
96
+ return _CHALLENGE_CACHE
97
+ try:
98
+ import requests as _req
99
+ r = _req.get(
100
+ f"{server_url.rstrip('/')}/auth/cli-challenge",
101
+ headers={
102
+ "X-UTIM-Install-ID": get_install_id(),
103
+ "User-Agent": f"UTIM-CLI/{get_cli_version()} (+https://utim.dev)",
104
+ },
105
+ timeout=5,
106
+ )
107
+ if r.status_code == 200:
108
+ data = r.json()
109
+ _CHALLENGE_CACHE = {
110
+ "nonce": data.get("nonce", ""),
111
+ "expires_at": int(data.get("expires_at", 0)),
112
+ "enforcement_enabled": bool(data.get("enforcement_enabled", False)),
113
+ }
114
+ _LAST_CHALLENGE_FETCH = now
115
+ except Exception:
116
+ pass
117
+ return _CHALLENGE_CACHE
118
+
119
+
120
+ def sign_request_for_server(method: str, path: str, body: Any) -> dict:
121
+ """Build the X-UTIM-* signature headers for a server-bound request.
122
+
123
+ Returns {} silently if signing is not available (no network, no secret).
124
+ """
125
+ try:
126
+ from utim_cli.client_utils import get_server_url
127
+ server_url = get_server_url()
128
+ except Exception:
129
+ return {}
130
+
131
+ challenge = _fetch_challenge(server_url)
132
+ nonce = (challenge or {}).get("nonce") or ""
133
+ if not nonce:
134
+ return {}
135
+
136
+ if body is None:
137
+ body_bytes = b""
138
+ elif isinstance(body, (bytes, bytearray)):
139
+ body_bytes = bytes(body)
140
+ else:
141
+ try:
142
+ body_bytes = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
143
+ except Exception:
144
+ body_bytes = str(body).encode("utf-8")
145
+
146
+ body_hash = hashlib.sha256(body_bytes).hexdigest()
147
+ install_id = get_install_id()
148
+ canonical = f"{method.upper()}\n{path}\n{nonce}\n{install_id}\n{body_hash}"
149
+
150
+ secret = get_hmac_secret()
151
+ signature = hmac.new(secret, canonical.encode("utf-8"), hashlib.sha256).hexdigest()
152
+
153
+ return {
154
+ "X-UTIM-Nonce": nonce,
155
+ "X-UTIM-CLI-Signature": signature,
156
+ "X-UTIM-CLI-Version": get_cli_version(),
157
+ "X-UTIM-Install-ID": install_id,
158
+ }
159
+
10
160
  # ── SSL session patch ─────────────────────────────────────────────────────────
11
161
  # Keeps a reference to the true original so toggling works correctly in both
12
162
  # directions without chaining patches on top of each other.
@@ -490,6 +490,10 @@ _qwen_downloading = False
490
490
  def _trigger_qwen_download_bg():
491
491
  """Trigger background download and caching of Qwen local model if not already loaded or downloading."""
492
492
  global _qwen_model, _qwen_tokenizer, _qwen_downloading
493
+ # LITE MODE: never download/load the local Qwen model (torch + ~1GB RAM).
494
+ # Falls back to the cloud API path (proxy_openrouter_request) instead.
495
+ if os.environ.get("UTIM_LITE_MODE", "0") in ("1", "true", "TRUE", "yes"):
496
+ return
493
497
  if _qwen_model is None and not _qwen_downloading:
494
498
  import sys
495
499
  if "pytest" in sys.modules:
@@ -758,16 +762,28 @@ def _get_dynamic_directives(is_compressed: bool, messages: Optional[List[Dict]]
758
762
  if not enabled_tools:
759
763
  return "### TOOL STATUS: ALL TOOLS DISABLED\nAll tools are disabled. You may only respond in text."
760
764
 
761
- directives = [
762
- "### CORE DIRECTIVES:",
763
- "1. **Targeted Search**: Use `grep_search` to locate exact symbol definitions, imports, or error text across codebase files.",
764
- "2. **Scoped File Reading**: Use `read_file` with `symbol_name` or line ranges (`start_line`/`end_line`) to inspect target function/class definitions. Avoid reading un-sliced large files.",
765
- "3. **Images & Screenshots**: If the prompt includes an image or screenshot path (.png, .jpg, .jpeg, etc.), execute `read_file` immediately on Turn 1 with that exact path. Absolute file paths anywhere on the system are valid — do not question file locations.",
766
- "4. **Project & Feature Planning**: Proactively invoke `plan_project` (with `plan_part` set to 'design', 'architecture', 'security', 'database', 'testing', 'deployment', or 'features') for non-trivial tasks, new features, system refactors, or new application builds before implementing.",
767
- "5. **Think-Create-Verify**: Execute tools directly without conversational preambles or text planning. Verify edits with `run_command` (compile/build/test).",
768
- "6. **Execution**: Invoke native function-calling schemas directly. Never output raw JSON, <think> tags, or conversational text before tool calls.",
769
- "7. **Output & Summary**: Be concise, professional, and clear. Upon finishing, provide a comprehensive summary of completion.",
770
- ]
765
+ if is_compressed:
766
+ # Short, token-efficient directives for steady-state turns. The full
767
+ # version is only injected on iteration 0 (first turn).
768
+ directives = [
769
+ "### CORE DIRECTIVES:",
770
+ "1. **Targeted Search**: Use `grep_search` for symbols/errors; `read_file` with symbol_name or line ranges no un-sliced large reads.",
771
+ "2. **Images**: If the prompt includes an image path (.png/.jpg/.jpeg), `read_file` it immediately on Turn 1 with that exact path.",
772
+ "3. **Planning**: Invoke `plan_project` for non-trivial tasks/new features/refactors before implementing.",
773
+ "4. **Think-Create-Verify**: Execute tools directly (no preamble). Verify edits with `run_command` (build/test).",
774
+ "5. **Output**: Native function-calling only — no raw JSON, <think> tags, or tool markup in text. Finish with a concise summary.",
775
+ ]
776
+ else:
777
+ directives = [
778
+ "### CORE DIRECTIVES:",
779
+ "1. **Targeted Search**: Use `grep_search` to locate exact symbol definitions, imports, or error text across codebase files.",
780
+ "2. **Scoped File Reading**: Use `read_file` with `symbol_name` or line ranges (`start_line`/`end_line`) to inspect target function/class definitions. Avoid reading un-sliced large files.",
781
+ "3. **Images & Screenshots**: If the prompt includes an image or screenshot path (.png, .jpg, .jpeg, etc.), execute `read_file` immediately on Turn 1 with that exact path. Absolute file paths anywhere on the system are valid — do not question file locations.",
782
+ "4. **Project & Feature Planning**: Proactively invoke `plan_project` (with `plan_part` set to 'design', 'architecture', 'security', 'database', 'testing', 'deployment', or 'features') for non-trivial tasks, new features, system refactors, or new application builds before implementing.",
783
+ "5. **Think-Create-Verify**: Execute tools directly without conversational preambles or text planning. Verify edits with `run_command` (compile/build/test).",
784
+ "6. **Execution**: Invoke native function-calling schemas directly. Never output raw JSON, <think> tags, or conversational text before tool calls.",
785
+ "7. **Output & Summary**: Be concise, professional, and clear. Upon finishing, provide a comprehensive summary of completion.",
786
+ ]
771
787
  # NOTE: 'Iteration Budget' directive intentionally removed.
772
788
  # Iteration limits are now enforced dynamically (window-aware) at runtime
773
789
  # via milestone warnings, not hardcoded into the system prompt.
@@ -1278,8 +1294,12 @@ class Orchestrator:
1278
1294
  abort_active_command()
1279
1295
  except Exception:
1280
1296
  pass
1281
- # Clear cancel_event after abort cleanup so future calls do not hang
1282
- self.cancel_event.clear()
1297
+ # NOTE: We deliberately do NOT clear cancel_event here. The stream
1298
+ # reader thread may still be draining a socket with buffered chunks,
1299
+ # and the render loops check `cancel_event.is_set()` before printing.
1300
+ # Clearing it here would let those buffered chunks print after abort.
1301
+ # The event is cleared at the START of the next turn (see the
1302
+ # `_run`/turn-reset paths that call self.cancel_event.clear()).
1283
1303
 
1284
1304
 
1285
1305
  # LLM calling
@@ -1755,10 +1775,22 @@ class Orchestrator:
1755
1775
  current_model,
1756
1776
  is_primary=current_is_primary and not override_model,
1757
1777
  )
1778
+ # Attribution headers — OpenRouter shows "UTIM CLI Agent"
1779
+ # in its activity log ONLY when HTTP-Referer + X-Title are
1780
+ # present on every outbound call. Without them the app
1781
+ # shows as "unknown".
1782
+ try:
1783
+ from utim_cli.server.attribution import OPENROUTER_HEADERS as _OR_ATTR
1784
+ except Exception:
1785
+ _OR_ATTR = {
1786
+ "HTTP-Referer": "https://utim.dev",
1787
+ "X-Title": "UTIM CLI Agent",
1788
+ }
1758
1789
  _headers = {
1759
1790
  "Authorization": f"Bearer {_endpoint_key}",
1760
1791
  "Content-Type": "application/json",
1761
1792
  "User-Agent": _browser_ua,
1793
+ **_OR_ATTR,
1762
1794
  }
1763
1795
  request_payload = payload
1764
1796
  else:
@@ -1805,6 +1837,9 @@ class Orchestrator:
1805
1837
  was_cut_off = False
1806
1838
  _stream_finish_reason: str = ""
1807
1839
  _stream_completion_tokens: int = 0
1840
+ # Buffers raw <tool_call>/<|tool_call|> XML streamed as text
1841
+ # so it never renders in the live chat output.
1842
+ _tool_xml_buf: list = [""]
1808
1843
 
1809
1844
  try:
1810
1845
  # Dynamic thinking phases — cycle through contextual messages
@@ -1938,7 +1973,18 @@ class Orchestrator:
1938
1973
  if t == "content_delta":
1939
1974
  text = chunk.get("text", "")
1940
1975
  final_content += text
1941
- if not silent and text and not self.cancel_event.is_set():
1976
+ # Never print raw tool-call XML tokens live. If the
1977
+ # model emits <tool_call>/<|tool_call|> as text (some
1978
+ # models do), we buffer it out of the visible stream
1979
+ # so schemas don't leak into chat; it's parsed into
1980
+ # real tool calls at end-of-stream (see below).
1981
+ _looks_like_tool_xml = (
1982
+ "<tool_call" in text or "</tool_call" in text
1983
+ or "<|tool_call" in text or "</|tool_call" in text
1984
+ )
1985
+ if _looks_like_tool_xml:
1986
+ _tool_xml_buf[0] += text
1987
+ elif not silent and text and not self.cancel_event.is_set():
1942
1988
  display_buf += text
1943
1989
  _line_buf += text
1944
1990
  if "\n" in _line_buf:
@@ -2139,6 +2185,12 @@ class Orchestrator:
2139
2185
  # Content was already streamed live — just add trailing newline
2140
2186
  self.console.print()
2141
2187
 
2188
+ # Merge any buffered tool-call XML back into final_content so
2189
+ # parse_xml_tool_calls (which now handles <|tool_call|>) can
2190
+ # turn it into real tool calls instead of leaked chat text.
2191
+ if _tool_xml_buf[0]:
2192
+ final_content += _tool_xml_buf[0]
2193
+
2142
2194
  clean_content = re.sub(
2143
2195
  r"<think(?:ing)?>.*?</think(?:ing)?>", "", final_content, flags=re.DOTALL
2144
2196
  ).strip()
@@ -0,0 +1,62 @@
1
+ """
2
+ OpenRouter Attribution Helper
3
+ =============================
4
+
5
+ Centralised injection of the HTTP-Referer / X-Title / User-Agent headers that
6
+ OpenRouter uses to identify the calling app. OpenRouter shows "UTIM CLI Agent"
7
+ in its logs only when these are present on every outbound call. Previously
8
+ some code paths (the server's /completions proxy, the CLI's direct fallback)
9
+ were inconsistent — leading to the app showing as "unknown" in analytics.
10
+
11
+ This module exposes:
12
+
13
+ OPENROUTER_HEADERS : dict of headers every OpenRouter-bound request MUST carry
14
+ attach_openrouter_headers(headers=None) -> dict
15
+ attach_openrouter_headers_to_session(session)
16
+ install_urllib3_user_agent() # one-time patch for clients that bypass `requests`
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Optional
21
+
22
+ # ── Constants ────────────────────────────────────────────────────────────────
23
+
24
+ OPENROUTER_HEADERS = {
25
+ "HTTP-Referer": "https://utim.dev",
26
+ "X-Title": "UTIM CLI Agent",
27
+ "User-Agent": "UTIM-CLI/2.0 (+https://utim.dev)",
28
+ }
29
+
30
+
31
+ def attach_openrouter_headers(headers: Optional[dict] = None) -> dict:
32
+ """Return a new dict with the canonical OpenRouter attribution headers merged in.
33
+
34
+ Caller-supplied headers take precedence over the defaults (so a test harness
35
+ can still override), except HTTP-Referer and X-Title which are forced to
36
+ the canonical values (otherwise OpenRouter shows the app as 'unknown').
37
+ """
38
+ merged = dict(headers or {})
39
+ for key, val in OPENROUTER_HEADERS.items():
40
+ merged[key] = val
41
+ return merged
42
+
43
+
44
+ def attach_openrouter_headers_to_session(session) -> None:
45
+ """Mutate a requests.Session in place to always carry the headers."""
46
+ session.headers.update(OPENROUTER_HEADERS)
47
+
48
+
49
+ def install_urllib3_user_agent() -> None:
50
+ """Patch urllib3's default User-Agent pool header. Useful for code that
51
+ goes through the `httpx` or `urllib3` lower layers and bypasses `requests`.
52
+ """
53
+ try:
54
+ import urllib3
55
+ urllib3.util.make_headers( # noqa: F841 - intentional side-effect
56
+ user_agent=OPENROUTER_HEADERS["User-Agent"]
57
+ )
58
+ # Note: urllib3 doesn't actually store this globally; this is a stub
59
+ # for the rare direct-urllib3 caller. Most code uses `requests` and
60
+ # should be patched via attach_openrouter_headers_to_session().
61
+ except Exception:
62
+ pass