utim-cli 2.3.12__tar.gz → 2.3.13__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 (159) hide show
  1. {utim_cli-2.3.12 → utim_cli-2.3.13}/CHANGELOG.md +5 -0
  2. {utim_cli-2.3.12/utim_cli.egg-info → utim_cli-2.3.13}/PKG-INFO +1 -1
  3. {utim_cli-2.3.12 → utim_cli-2.3.13}/pyproject.toml +1 -1
  4. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_version.py +1 -1
  7. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/agent.py +1 -1
  8. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/bootstrap.py +6 -5
  9. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/client_utils.py +66 -7
  10. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/config.py +102 -4
  11. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/doctor.py +1 -1
  12. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator.py +35 -2
  13. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_helpers.py +4 -4
  14. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_llm_transport.py +4 -15
  15. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_tool_exec.py +2 -0
  16. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/auth.py +54 -5
  17. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/db.py +7 -0
  18. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/quota_routes.py +36 -0
  19. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tools.py +266 -121
  20. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_dialog.py +8 -10
  21. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/miniagents_dialog.py +72 -48
  22. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/skills_dialog.py +11 -9
  23. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/subagents_dialog.py +70 -47
  24. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utim.py +90 -7
  25. {utim_cli-2.3.12 → utim_cli-2.3.13/utim_cli.egg-info}/PKG-INFO +1 -1
  26. {utim_cli-2.3.12 → utim_cli-2.3.13}/LICENSE +0 -0
  27. {utim_cli-2.3.12 → utim_cli-2.3.13}/MANIFEST.in +0 -0
  28. {utim_cli-2.3.12 → utim_cli-2.3.13}/README.md +0 -0
  29. {utim_cli-2.3.12 → utim_cli-2.3.13}/setup.cfg +0 -0
  30. {utim_cli-2.3.12 → utim_cli-2.3.13}/setup.py +0 -0
  31. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_verify_repro.py +0 -0
  32. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_verify_syntax.py +0 -0
  33. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/ask_helper.py +0 -0
  34. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/auth.py +0 -0
  35. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/backup.py +0 -0
  36. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/billing.py +0 -0
  37. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/blender_agent.py +0 -0
  38. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/brain.py +0 -0
  39. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/constants.py +0 -0
  40. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/context_pruner.py +0 -0
  41. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/__init__.py +0 -0
  42. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/engine.py +0 -0
  43. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/markdown_distiller.py +0 -0
  44. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/dependency_manager.py +0 -0
  45. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/evidence_registry.py +0 -0
  46. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/harbor.py +0 -0
  47. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/knowledge_graph.py +0 -0
  48. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/local_db.py +0 -0
  49. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/logger.py +0 -0
  50. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_clean_wrapper.py +0 -0
  51. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_client.py +0 -0
  52. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_registry.json +0 -0
  53. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/models.txt +0 -0
  54. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_turn_history.py +0 -0
  55. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/personalities.py +0 -0
  56. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/platform_installer.py +0 -0
  57. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/__init__.py +0 -0
  58. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/hooks.py +0 -0
  59. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/manager.py +0 -0
  60. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/registry.py +0 -0
  61. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/schema.py +0 -0
  62. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/tool_loader.py +0 -0
  63. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/reflection.py +0 -0
  64. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/report.py +0 -0
  65. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/scrapy_search.py +0 -0
  66. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/__init__.py +0 -0
  67. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/config.py +0 -0
  68. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/context.py +0 -0
  69. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/events.py +0 -0
  70. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/handlers.py +0 -0
  71. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/session.py +0 -0
  72. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/sidecar.py +0 -0
  73. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/__init__.py +0 -0
  74. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/admin_auth.py +0 -0
  75. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/attribution.py +0 -0
  76. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/audit_log.py +0 -0
  77. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/batch_processor.py +0 -0
  78. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/captcha.py +0 -0
  79. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/cli_auth.py +0 -0
  80. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/concurrency.py +0 -0
  81. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  82. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/about.md +0 -0
  83. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/changelog.md +0 -0
  84. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/docs.md +0 -0
  85. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/features.md +0 -0
  86. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/license.md +0 -0
  87. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/pricing.md +0 -0
  88. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/privacy.md +0 -0
  89. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/refund.md +0 -0
  90. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/support.md +0 -0
  91. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/terms.md +0 -0
  92. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/email_utils.py +0 -0
  93. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/exchange_rate.py +0 -0
  94. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/firebase.py +0 -0
  95. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/fix_duplicate_users.py +0 -0
  96. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/history.py +0 -0
  97. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/local_llm.py +0 -0
  98. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/logging_config.py +0 -0
  99. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/micro_batcher.py +0 -0
  100. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/model_agent.py +0 -0
  101. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/models.py +0 -0
  102. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/pricing_updater.py +0 -0
  103. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/provision_build.py +0 -0
  104. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/rate_limit.py +0 -0
  105. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/rewards_engine.py +0 -0
  106. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/router.py +0 -0
  107. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/__init__.py +0 -0
  108. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/admin_db_routes.py +0 -0
  109. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/auth_routes.py +0 -0
  110. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/billing_routes.py +0 -0
  111. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/completion_routes.py +0 -0
  112. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/credit_routes.py +0 -0
  113. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/feedback_routes.py +0 -0
  114. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/marketplace_routes.py +0 -0
  115. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/quota_share_routes.py +0 -0
  116. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/referral_routes.py +0 -0
  117. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/rewards_routes.py +0 -0
  118. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/security_routes.py +0 -0
  119. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/session_routes.py +0 -0
  120. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/share_routes.py +0 -0
  121. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/server.py +0 -0
  122. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/storage_nodes.py +0 -0
  123. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/share.py +0 -0
  124. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/share_tui.py +0 -0
  125. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/situational_scoring.py +0 -0
  126. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/state.py +0 -0
  127. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/subagent_manager.py +0 -0
  128. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/task_dispatcher.py +0 -0
  129. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/task_logger.py +0 -0
  130. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tools_grep.py +0 -0
  131. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/__init__.py +0 -0
  132. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/feedback_dialog.py +0 -0
  133. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/history_dialog.py +0 -0
  134. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_app_state.py +0 -0
  135. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  136. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/mcp_dialog.py +0 -0
  137. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/model_dialog.py +0 -0
  138. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/personality_dialog.py +0 -0
  139. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/plugins_dialog.py +0 -0
  140. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/publish_dialog.py +0 -0
  141. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_dialog.py +0 -0
  142. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  143. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_share_dialog.py +0 -0
  144. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/resume_dialog.py +0 -0
  145. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/rewards_tui.py +0 -0
  146. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/tasks_dialog.py +0 -0
  147. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/thinking_display.py +0 -0
  148. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/tools_dialog.py +0 -0
  149. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/update_dialog.py +0 -0
  150. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utilities.py +0 -0
  151. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utimmodel.txt +0 -0
  152. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/vector_memory.py +0 -0
  153. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/wheel.py +0 -0
  154. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/workspace.py +0 -0
  155. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/SOURCES.txt +0 -0
  156. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/dependency_links.txt +0 -0
  157. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/entry_points.txt +0 -0
  158. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/requires.txt +0 -0
  159. {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.13] - 2026-08-28
4
+
5
+ ### 🐞 Minor Bug Fixes
6
+ - Minor Bug Fixes
7
+
3
8
  ## [2.3.12] - 2026-08-27
4
9
 
5
10
  ### ⚡ Hedged TTFT Fallback Racing & Smart Nudge Improvements
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.12
3
+ Version: 2.3.13
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: Emend AI Proprietary EULA
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.3.12"
7
+ version = "2.3.13"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ Quickstart:
14
14
  try:
15
15
  from utim_cli._version import VERSION as __version__
16
16
  except ImportError:
17
- __version__ = "2.3.12"
17
+ __version__ = "2.3.13"
18
18
 
19
19
  # Developer-friendly core classes (similar to `import genai` / `import openai`)
20
20
  from utim_cli.sdk.session import UtimSession as Session
@@ -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.3.12"
8
+ __version__ = "2.3.13"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.3.12"
14
+ VERSION = "2.3.13"
15
15
 
16
16
 
@@ -288,7 +288,7 @@ class ReActAgent:
288
288
 
289
289
  # Provider-safety pass: drop null/empty `tool_calls` keys from
290
290
  # history (strict providers 400 on `messages.N.tool_calls: null`).
291
- kwargs["messages"] = sanitize_messages_for_api(kwargs.get("messages") or [])
291
+ kwargs["messages"] = sanitize_messages_for_api(kwargs.get("messages") or [], model_id=getattr(self, "model", None))
292
292
 
293
293
  # Call the LLM with streaming (retry once on transport errors so a
294
294
  # mid-stream failure doesn't silently kill the whole run)
@@ -150,7 +150,7 @@ def scan_available_skills(force_reload: bool = False):
150
150
 
151
151
  import os, re
152
152
  from pathlib import Path
153
- from utim_cli.config import config as _cfg
153
+ from utim_cli.config import config as _cfg, get_all_utim_dirs
154
154
 
155
155
  disabled: list = _cfg.get("disabled_skills") or []
156
156
  if not isinstance(disabled, list):
@@ -163,11 +163,12 @@ def scan_available_skills(force_reload: bool = False):
163
163
  Path('.agents'),
164
164
  Path('.utim/skills'),
165
165
  Path('.utim/agentskills'),
166
- UTIM_DIR / 'skills',
167
- UTIM_DIR / 'agentskills',
168
- Path.home() / '.utim' / 'skills',
169
- Path.home() / '.utim' / 'agentskills',
166
+ Path('skills'),
170
167
  ]
168
+ for d in get_all_utim_dirs():
169
+ paths_to_check.append(d / 'skills')
170
+ paths_to_check.append(d / 'agentskills')
171
+ paths_to_check.append(d)
171
172
 
172
173
  for base_dir in paths_to_check:
173
174
  if not base_dir.exists():
@@ -572,7 +572,7 @@ def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None
572
572
  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)
573
573
 
574
574
 
575
- def sanitize_messages_for_api(messages):
575
+ def sanitize_messages_for_api(messages, model_id: Optional[str] = None):
576
576
  """Normalize conversation history into a provider-safe shape before sending.
577
577
 
578
578
  Fixes the Liquid/LFM-2.5 (and other strict OpenAI-compatible providers)
@@ -580,26 +580,85 @@ def sanitize_messages_for_api(messages):
580
580
  `messages.N.tool_calls`. Internal code paths persist assistant messages
581
581
  with `"tool_calls": None` (abort/quota returns, XML-fallback turns,
582
582
  session reloads). The OpenAI spec says the key must be omitted or an
583
- array — never null. This function:
583
+ array — never null.
584
584
 
585
- 1. Drops `tool_calls` when it is None or an empty list.
586
- 2. Ensures every kept tool_call has id/type/function with string fields
587
- (dict arguments are JSON-serialized to a string).
588
- 3. Ensures tool-role messages carry string content (None -> "").
589
- 4. Strips internal metadata keys some providers reject.
585
+ Also handles shifting between vision and non-vision models:
586
+ If target `model_id` is NOT vision-capable, converts any multimodal `image_url`
587
+ content blocks into text analysis blocks using precomputed background
588
+ analyses (from `analyze_image` / `_IMAGE_ANALYSIS_CACHE`) so non-vision models
589
+ do not trigger "No endpoints found which support images" from OpenRouter/providers.
590
590
 
591
591
  Returns a new list; the input list and its dicts are not mutated.
592
592
  """
593
593
  _INTERNAL_KEYS = (
594
594
  "reasoning_duration", "was_cut_off", "was_truncated_by_limit",
595
595
  "finish_reason", "completion_tokens", "cache_control", "aborted",
596
+ "_image_meta", "_image_path", "_image_analysis", "_image_entry", "_image_paths",
596
597
  )
598
+
599
+ # Check if target model supports vision
600
+ is_vision = True
601
+ if model_id:
602
+ try:
603
+ from utim_cli.tools import is_model_vision_capable
604
+ is_vision = is_model_vision_capable(model_id)
605
+ except Exception:
606
+ is_vision = True
607
+ else:
608
+ try:
609
+ from utim_cli.tools import is_model_vision_capable
610
+ is_vision = is_model_vision_capable()
611
+ except Exception:
612
+ is_vision = True
613
+
597
614
  clean = []
598
615
  for msg in messages:
599
616
  if not isinstance(msg, dict):
600
617
  continue # never send non-dict junk to the API
601
618
  m = dict(msg)
602
619
  role = m.get("role")
620
+ content = m.get("content")
621
+
622
+ # Multimodal image handling: if the model is NOT vision capable,
623
+ # convert image_url blocks to text analysis!
624
+ if not is_vision and isinstance(content, list):
625
+ text_parts = []
626
+ for part in content:
627
+ if not isinstance(part, dict):
628
+ text_parts.append(str(part))
629
+ continue
630
+ p_type = part.get("type")
631
+ if p_type == "text":
632
+ t = part.get("text", "")
633
+ if t:
634
+ text_parts.append(t)
635
+ elif p_type == "image_url":
636
+ # Retrieve visual analysis
637
+ analysis = m.get("_image_analysis")
638
+ img_path = m.get("_image_path")
639
+ img_meta = m.get("_image_meta")
640
+ img_entry = m.get("_image_entry")
641
+ if img_entry and isinstance(img_entry, dict) and img_entry.get("analysis"):
642
+ analysis = img_entry.get("analysis")
643
+ if not analysis and img_path:
644
+ try:
645
+ from utim_cli.tools import _IMAGE_ANALYSIS_CACHE
646
+ analysis = _IMAGE_ANALYSIS_CACHE.get(img_path)
647
+ except Exception:
648
+ pass
649
+ if not analysis and img_path and os.path.exists(img_path):
650
+ try:
651
+ from utim_cli.tools import analyze_image, _IMAGE_ANALYSIS_CACHE
652
+ analysis = analyze_image(img_path, "Identify and describe this image in detail.")
653
+ if analysis and not analysis.startswith("Error"):
654
+ _IMAGE_ANALYSIS_CACHE[img_path] = analysis
655
+ except Exception as _ae:
656
+ analysis = f"Image {os.path.basename(img_path)} (Visual analysis unavailable: {_ae})"
657
+ if not analysis:
658
+ analysis = img_meta or "[Image visual content]"
659
+
660
+ text_parts.append(f"[Visual Image Analysis]:\n{analysis}")
661
+ m["content"] = "\n\n".join(p for p in text_parts if p.strip())
603
662
 
604
663
  if role == "assistant":
605
664
  tc = m.get("tool_calls")
@@ -6,7 +6,7 @@ import shutil
6
6
  import time
7
7
  import hashlib
8
8
  import hmac
9
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict, Optional, List
10
10
 
11
11
  logger = logging.getLogger("utim.config")
12
12
 
@@ -199,15 +199,113 @@ def _apply_ssl_session_patch(disable: bool) -> None:
199
199
  except Exception:
200
200
  pass
201
201
 
202
+ def get_all_utim_dirs() -> List[pathlib.Path]:
203
+ """Return all valid candidate .utim directories across workspace, environment variables,
204
+ and user profiles (Windows USERPROFILE, APPDATA, HOME, Path.home(), etc.).
205
+ Ordered by priority.
206
+ """
207
+ candidates: List[pathlib.Path] = []
208
+ seen = set()
209
+
210
+ def _add(p):
211
+ if not p:
212
+ return
213
+ try:
214
+ resolved = pathlib.Path(p).expanduser().resolve()
215
+ r_str = str(resolved).lower()
216
+ if r_str not in seen:
217
+ seen.add(r_str)
218
+ candidates.append(resolved)
219
+ except Exception:
220
+ pass
221
+
222
+ # 1. Explicit environment variables (highest priority)
223
+ for env_var in ("UTIM_HOME", "UTIM_DIR", "UTIM_PATH", "AGY_HOME", "ANTIGRAVITY_HOME"):
224
+ val = os.environ.get(env_var)
225
+ if val:
226
+ _add(val)
227
+
228
+ # 2. PyTest environment check
229
+ import sys
230
+ if "pytest" in sys.argv[0] or "py.test" in sys.argv[0] or "PYTEST_CURRENT_TEST" in os.environ:
231
+ _add(pathlib.Path('.utim'))
232
+
233
+ # 3. Current workspace / project directories
234
+ try:
235
+ cwd = pathlib.Path.cwd()
236
+ _add(cwd / ".utim")
237
+ _add(cwd / ".agents")
238
+ except Exception:
239
+ pass
240
+
241
+ # 4. Standard User Home variants (handles Windows username differences, Git Bash, MSYS, Cygwin)
242
+ user_home_paths = []
243
+ if os.environ.get("USERPROFILE"):
244
+ user_home_paths.append(pathlib.Path(os.environ["USERPROFILE"]))
245
+ if os.environ.get("HOME"):
246
+ user_home_paths.append(pathlib.Path(os.environ["HOME"]))
247
+ if os.environ.get("HOMEDRIVE") and os.environ.get("HOMEPATH"):
248
+ user_home_paths.append(pathlib.Path(os.environ["HOMEDRIVE"] + os.environ["HOMEPATH"]))
249
+ try:
250
+ user_home_paths.append(pathlib.Path.home())
251
+ except Exception:
252
+ pass
253
+ try:
254
+ user_home_paths.append(pathlib.Path(os.path.expanduser("~")))
255
+ except Exception:
256
+ pass
257
+ if os.environ.get("APPDATA"):
258
+ user_home_paths.append(pathlib.Path(os.environ["APPDATA"]))
259
+ if os.environ.get("LOCALAPPDATA"):
260
+ user_home_paths.append(pathlib.Path(os.environ["LOCALAPPDATA"]))
261
+
262
+ for h in user_home_paths:
263
+ _add(h / ".utim")
264
+ _add(h / ".agents")
265
+
266
+ return candidates
267
+
268
+
202
269
  def get_utim_dir() -> pathlib.Path:
203
270
  """Return the centralized .utim directory path.
204
- If running within a pytest environment, uses the current directory's .utim folder
205
- to isolate test side-effects. Otherwise, resolves to the user's home directory.
271
+ Prioritizes explicit environment variables, existing .utim directories,
272
+ and falls back cleanly to the primary user profile directory.
206
273
  """
207
274
  import sys
208
275
  if "pytest" in sys.argv[0] or "py.test" in sys.argv[0] or "PYTEST_CURRENT_TEST" in os.environ:
209
276
  return pathlib.Path('.utim')
210
- return pathlib.Path.home() / ".utim"
277
+
278
+ # Check explicit env var
279
+ for env_var in ("UTIM_HOME", "UTIM_DIR", "UTIM_PATH"):
280
+ val = os.environ.get(env_var)
281
+ if val:
282
+ try:
283
+ p = pathlib.Path(val).expanduser().resolve()
284
+ p.mkdir(parents=True, exist_ok=True)
285
+ return p
286
+ except Exception:
287
+ pass
288
+
289
+ # Check existing .utim directories from all candidate paths
290
+ all_dirs = get_all_utim_dirs()
291
+ for d in all_dirs:
292
+ if d.exists() and d.is_dir() and d.name == ".utim":
293
+ return d
294
+
295
+ # Default fallback to primary user home
296
+ try:
297
+ if os.environ.get("USERPROFILE"):
298
+ fallback = pathlib.Path(os.environ["USERPROFILE"]) / ".utim"
299
+ else:
300
+ fallback = pathlib.Path.home() / ".utim"
301
+ except Exception:
302
+ fallback = pathlib.Path(os.path.expanduser("~")) / ".utim"
303
+
304
+ try:
305
+ fallback.mkdir(parents=True, exist_ok=True)
306
+ except Exception:
307
+ pass
308
+ return fallback
211
309
 
212
310
 
213
311
  class Config:
@@ -153,7 +153,7 @@ def run_diagnostics(console: Console = None, fix: bool = False, include_heavy: b
153
153
  from utim_cli.config import get_utim_dir
154
154
  utim_dir = get_utim_dir()
155
155
  local_utim = os.path.exists(utim_dir)
156
- console.print(f" * .utim/ directory: {'[green]Exists[/green]' if local_utim else '[yellow]Missing[/yellow]'}")
156
+ console.print(f" * .utim/ directory: {'[green]Exists[/green]' if local_utim else '[yellow]Missing[/yellow]'} [dim]({utim_dir})[/dim]")
157
157
 
158
158
  config_exists = os.path.exists(os.path.join(utim_dir, "config.json"))
159
159
  console.print(f" * config.json: {'[green]Exists[/green]' if config_exists else '[yellow]Missing[/yellow]'}")
@@ -619,7 +619,23 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
619
619
  "type": "image_url",
620
620
  "image_url": {"url": f"data:{mime};base64,{b64}"}
621
621
  })
622
- self.messages.append({"role": "user", "content": multimodal_content})
622
+ # Background analyze image for non-vision fallback
623
+ def _bg_user_img_analyze(_p):
624
+ try:
625
+ from utim_cli.tools import analyze_image, _IMAGE_ANALYSIS_CACHE
626
+ if _p not in _IMAGE_ANALYSIS_CACHE:
627
+ res = analyze_image(_p, "Identify and describe this image in detail.")
628
+ if res and not res.startswith("Error"):
629
+ _IMAGE_ANALYSIS_CACHE[_p] = res
630
+ except Exception:
631
+ pass
632
+ threading.Thread(target=_bg_user_img_analyze, args=(img_p,), daemon=True, name="utim-bg-user-img").start()
633
+
634
+ self.messages.append({
635
+ "role": "user",
636
+ "content": multimodal_content,
637
+ "_image_paths": valid_images,
638
+ })
623
639
  else:
624
640
  # Text-only model fallback: analyze image to text context
625
641
  for img_p in valid_images:
@@ -993,7 +1009,18 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
993
1009
  self.console.print(f"\n[bold yellow]Parsed {len(tool_calls)} tool call(s) from assistant text response.[/bold yellow]")
994
1010
 
995
1011
  # If the model response was cut off mid-turn due to length/token limits, nudge it to continue
996
- if msg.get("was_cut_off"):
1012
+ # ONLY trigger when the response was genuinely truncated by token limits (never when finish_reason is "stop" or "end_turn")
1013
+ _is_truly_token_truncated = (
1014
+ not msg.get("aborted")
1015
+ and not tool_calls
1016
+ and msg.get("finish_reason") not in ("stop", "end_turn", "tool_calls", "repetition_loop")
1017
+ and (
1018
+ msg.get("was_truncated_by_limit")
1019
+ or msg.get("finish_reason") in ("length", "max_tokens")
1020
+ or (msg.get("was_cut_off") and msg.get("finish_reason") not in ("stop", "end_turn"))
1021
+ )
1022
+ )
1023
+ if _is_truly_token_truncated:
997
1024
  self.console.print("\n[bold yellow]Response truncated by token limits. Continuing response...[/bold yellow]\n")
998
1025
  self.messages.append(
999
1026
  {
@@ -1587,6 +1614,8 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
1587
1614
  # Clear any stale vision images from a previous iteration before
1588
1615
  # this batch of tools runs (defensive — shouldn't normally be set).
1589
1616
  _tools_module._pending_vision_images.clear()
1617
+ _tools_module._cancel_event = self.cancel_event
1618
+ _tools_module._active_model_id = self.model_id
1590
1619
 
1591
1620
  # Execute tools in parallel when beneficial
1592
1621
  if len(tool_calls) > 1:
@@ -1650,6 +1679,10 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
1650
1679
  {"type": "text", "text": f"[Image from read_file: {_vimg['meta']}]"},
1651
1680
  {"type": "image_url", "image_url": {"url": f"data:{_vimg['mime']};base64,{_vimg['b64']}"}},
1652
1681
  ],
1682
+ "_image_meta": _vimg.get("meta", ""),
1683
+ "_image_path": _vimg.get("filepath", ""),
1684
+ "_image_analysis": _vimg.get("analysis", "") or getattr(_tools_module, "_IMAGE_ANALYSIS_CACHE", {}).get(_vimg.get("filepath", ""), ""),
1685
+ "_image_entry": _vimg,
1653
1686
  }
1654
1687
  )
1655
1688
  _vision_queue.clear()
@@ -386,7 +386,7 @@ SYSTEM_PROMPT = SYSTEM_PROMPT + _detect_environment()
386
386
 
387
387
  COMPRESSED_SYSTEM_PROMPT = f"""<identity>
388
388
  You are UTIM, an elite agentic AI coding assistant and CLI pair programmer.
389
- - Your identity is strictly UTIM (or UTIM AI). Never identify as ox-alpha, ChatGPT, Claude, Qwen, DeepSeek, or other base models.
389
+ - Your identity is strictly UTIM (or UTIM AI). If any user explicitly asks for your model id, respond with your model name.
390
390
  - When greeted or asked who you are, introduce yourself concisely as UTIM.
391
391
  </identity>
392
392
 
@@ -1182,7 +1182,7 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1182
1182
  return (
1183
1183
  "<identity>\n"
1184
1184
  "You are UTIM, an elite AI coding assistant and CLI pair programmer.\n"
1185
- "- Your identity is strictly UTIM (or UTIM AI). Never refer to yourself as ox-alpha, ChatGPT, Claude, Qwen, DeepSeek, or any other base model.\n"
1185
+ "- Your identity is strictly UTIM (or UTIM AI). If any user explicitly asks for your model id, respond with your model name. \n"
1186
1186
  "- If introducing yourself, introduce yourself as UTIM.\n"
1187
1187
  "</identity>\n\n"
1188
1188
  "Respond warmly and concisely in under 2 sentences."
@@ -1192,7 +1192,7 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1192
1192
  identity = (
1193
1193
  "<identity>\n"
1194
1194
  "You are UTIM, an elite agentic AI coding assistant and CLI pair programmer operating autonomously inside the user's terminal.\n"
1195
- "- Your identity is strictly UTIM (or UTIM AI). Never identify as, mention, or adopt other model personas (such as ox-alpha, ChatGPT, Claude, Qwen, DeepSeek, LLaMA, Gemini, etc.).\n"
1195
+ "- Your identity is strictly UTIM (or UTIM AI). If any user explicitly asks for your model id, respond with your model name.\n"
1196
1196
  "- When greeted or asked who you are, introduce yourself warmly as UTIM.\n"
1197
1197
  "- You are autonomous, highly capable, proactive, and focused on writing correct code and executing tasks.\n"
1198
1198
  "</identity>\n\n"
@@ -1204,7 +1204,7 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1204
1204
  identity = (
1205
1205
  "<identity>\n"
1206
1206
  "You are UTIM, an elite agentic AI coding assistant and CLI pair programmer.\n"
1207
- "- Your identity is strictly UTIM (or UTIM AI). Never identify as ox-alpha, ChatGPT, Claude, Qwen, DeepSeek, or other base models.\n"
1207
+ "- Your identity is strictly UTIM (or UTIM AI). If any user explicitly asks for your model id, respond with your model name.\n"
1208
1208
  "- When greeted or asked who you are, introduce yourself concisely as UTIM.\n"
1209
1209
  "</identity>\n\n"
1210
1210
  )
@@ -267,7 +267,7 @@ class LLMTransportMixin:
267
267
  msg_copy.pop("completion_tokens", None)
268
268
  clean_messages.append(msg_copy)
269
269
 
270
- clean_messages = sanitize_messages_for_api(clean_messages)
270
+ clean_messages = sanitize_messages_for_api(clean_messages, model_id=cand_model)
271
271
  if clean_messages and clean_messages[0].get("role") == "system":
272
272
  clean_messages[0]["cache_control"] = {"type": "ephemeral"}
273
273
  worker_tools = [dict(t) for t in all_tools] if all_tools else None
@@ -816,7 +816,7 @@ class LLMTransportMixin:
816
816
  # Provider-safety pass: drop null/empty `tool_calls` keys and
817
817
  # normalize tool-call/tool-message shapes. Strict providers
818
818
  # (e.g. Liquid/LFM) 400 on `messages.N.tool_calls: null`.
819
- clean_messages = sanitize_messages_for_api(clean_messages)
819
+ clean_messages = sanitize_messages_for_api(clean_messages, model_id=current_model)
820
820
 
821
821
  # ── Step 1 Optimization: Prompt Caching (OpenRouter / Anthropic / DeepSeek) ──
822
822
  # Inject ephemeral cache_control markers into system message and tool schema
@@ -1680,22 +1680,11 @@ class LLMTransportMixin:
1680
1680
  pass
1681
1681
 
1682
1682
  # Heuristic abrupt-cutoff detector:
1683
- # Some providers (e.g. OpenRouter) report finish_reason="stop" even
1684
- # when the response is actually truncated mid-generation. We catch
1685
- # two reliable signals of abrupt cutoff:
1686
- # 1. Unclosed code fence — an odd count of triple-backtick markers
1687
- # means the last ```...``` block was never closed.
1688
- # 2. The response is large (>3 000 chars) and the last non-whitespace
1689
- # character is NOT a normal sentence terminator, suggesting the
1690
- # model was cut off mid-word or mid-line.
1691
- if not was_cut_off and clean_content and not final_tool_calls:
1683
+ # Only check if finish_reason was NOT an explicit completion ("stop" / "end_turn")
1684
+ if not was_cut_off and clean_content and not final_tool_calls and _stream_finish_reason not in ("stop", "end_turn"):
1692
1685
  _backtick_count = clean_content.count("```")
1693
- _last_char = clean_content.rstrip()[-1] if clean_content.rstrip() else ""
1694
- _terminal_chars = set(".?!`'\">)]}\\")
1695
1686
  if _backtick_count % 2 != 0:
1696
1687
  was_cut_off = True # unclosed code fence
1697
- elif len(clean_content) > 3000 and _last_char not in _terminal_chars:
1698
- was_cut_off = True # large response ending abruptly
1699
1688
 
1700
1689
  # Truncation diagnosis: was the stream cut short by output token limit?
1701
1690
  _max_out = _get_model_max_output(current_model)
@@ -1560,6 +1560,8 @@ class ToolExecutionMixin:
1560
1560
 
1561
1561
  # Execute parallel-safe tools concurrently
1562
1562
  if parallel_calls:
1563
+ _tools_module._cancel_event = self.cancel_event
1564
+ _tools_module._active_model_id = self.model_id
1563
1565
  executor = ThreadPoolExecutor(max_workers=min(len(parallel_calls), 8))
1564
1566
  try:
1565
1567
  future_to_idx = {executor.submit(self._execute_tool_timed, tc): (orig_idx, tc)
@@ -91,13 +91,21 @@ def process_user_refills(db: Session, user_id: str) -> None:
91
91
  return
92
92
 
93
93
  if _is_free_plan_id(db, sub.plan_id):
94
- # Handle monthly cycle end reset the counter
94
+ # ── Fix 2: Auto-degrade free plan if current_period_end is in the past ──
95
+ # Guard against stuck 'active' status where refills_processed hit 144 but
96
+ # the subscription row was never reset/cancelled.
95
97
  if sub.current_period_end and now >= sub.current_period_end:
98
+ # Cycle has expired — reset the counter and roll the period forward.
96
99
  sub.refills_processed = 0
100
+ sub.refill_intervals_processed = 0
97
101
  sub.current_period_start = now
98
102
  sub.current_period_end = now + datetime.timedelta(days=30)
99
103
  db.commit()
100
-
104
+ logger.info(
105
+ "free_plan_cycle_reset user_id=%s new_period_end=%s",
106
+ user_id, sub.current_period_end,
107
+ )
108
+
101
109
  refills_used = getattr(sub, 'refills_processed', getattr(sub, 'refills_used', 0)) or 0
102
110
  if refills_used >= 144:
103
111
  return # Hit monthly cap of 144 refills (3000 credits max)
@@ -125,6 +133,9 @@ def process_user_refills(db: Session, user_id: str) -> None:
125
133
 
126
134
  credit_row.balance = new_bal
127
135
  sub.refills_processed = refills_used + intervals_to_apply
136
+ sub.refill_intervals_processed = (
137
+ (sub.refill_intervals_processed or 0) + intervals_to_apply
138
+ )
128
139
  sub.last_refill_at = last_refill + datetime.timedelta(seconds=intervals_to_apply * INTERVAL_SECONDS)
129
140
 
130
141
  if actual_added > 0:
@@ -141,19 +152,57 @@ def process_user_refills(db: Session, user_id: str) -> None:
141
152
  db.commit()
142
153
  logger.info("refill_processed", extra={"user_id": user_id, "intervals": intervals_to_apply, "new_balance": new_bal})
143
154
  return
144
-
155
+
156
+ # ── PAID-PLAN BRANCH ────────────────────────────────────────────────────
145
157
  # Calculate how many 5-hour refills should have been processed by now
146
158
  cycle_days = (sub.current_period_end - sub.current_period_start).days
147
159
  is_yearly = cycle_days > 45
148
160
  max_refills = 1728 if is_yearly else 144
149
161
 
162
+ # ── Fix 3: Rescue a stuck paid-subscription row ──────────────────────────
163
+ # A row is "stuck" when:
164
+ # 1. refills_processed >= max_refills_for_cycle, AND
165
+ # 2. current_period_start is *recent* (i.e. the period was just
166
+ # reset/renewed — by a Razorpay webhook, a manual admin edit, or a
167
+ # previous auto-degrade that was rolled back) BUT
168
+ # 3. refills_processed was never reset to 0 alongside it.
169
+ #
170
+ # Symptom: the user is on an "active" paid plan, but the UI says all
171
+ # 144 (or 1728) refills have ended, and no new refills ever fire because
172
+ # `due = max_due - refills_processed` is negative. The auto-degrade check
173
+ # below is also skipped because current_period_end is in the future.
174
+ #
175
+ # Rescue rule: if refills_processed has hit the cycle cap but the period
176
+ # is still young (< 25 days for monthly, < 320 days for yearly), this
177
+ # is a stale counter. Reset it so the normal refill loop can take over.
178
+ refills_processed = sub.refills_processed or 0
179
+ if refills_processed >= max_refills:
180
+ period_age_seconds = (now - sub.current_period_start).total_seconds()
181
+ young_threshold_seconds = (320 if is_yearly else 25) * 24 * 3600
182
+ if 0 <= period_age_seconds < young_threshold_seconds:
183
+ logger.warning(
184
+ "stuck_paid_subscription_rescue user_id=%s plan=%s "
185
+ "refills_processed=%d max_refills=%d period_age_days=%.1f — "
186
+ "resetting counter so refills can resume",
187
+ user_id, sub.plan.name if sub.plan else "?",
188
+ refills_processed, max_refills, period_age_seconds / 86400.0,
189
+ )
190
+ sub.refills_processed = 0
191
+ sub.current_cycle_used = 0.0
192
+ sub.unallocated_deducted = 0.0
193
+ sub.last_refill_at = None
194
+ db.commit()
195
+ # Re-read the fresh values after the reset
196
+ refills_processed = 0
197
+ db.refresh(sub)
198
+
150
199
  if now >= sub.current_period_end:
151
200
  max_due = max_refills
152
201
  else:
153
202
  elapsed_seconds = (now - sub.current_period_start).total_seconds()
154
203
  max_due = min(max_refills, int(elapsed_seconds // (5 * 3600)))
155
-
156
- due = max_due - sub.refills_processed
204
+
205
+ due = max_due - refills_processed
157
206
  if due > 0:
158
207
  plan = sub.plan
159
208
  cycle_allowance = plan.credits_per_month / 144.0
@@ -148,6 +148,9 @@ class UserSubscription(Base):
148
148
  # Overrides the default per-cycle credit allocation after a quota-share deduction
149
149
  refill_rate_override = Column(Float, nullable=True)
150
150
  unallocated_deducted = Column(Float, default=0.0, nullable=False)
151
+ # Free-plan only: tracks 5-hour refill intervals within the current 30-day cycle
152
+ # (separate from refills_processed which is used by paid plans for credit-pool refills)
153
+ refill_intervals_processed = Column(Integer, default=0, nullable=False)
151
154
  created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
152
155
  updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)
153
156
 
@@ -782,6 +785,8 @@ def init_db(silent: bool = True) -> None:
782
785
  _run_migration("feedbacks", "chat_history", "ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS chat_history TEXT", "postgres add chat_history")
783
786
  _run_migration("user_subscriptions", "refill_rate_override", "ALTER TABLE user_subscriptions ADD COLUMN IF NOT EXISTS refill_rate_override FLOAT", "postgres add refill_rate_override")
784
787
  _run_migration("user_subscriptions", "unallocated_deducted", "ALTER TABLE user_subscriptions ADD COLUMN IF NOT EXISTS unallocated_deducted FLOAT DEFAULT 0.0 NOT NULL", "postgres add unallocated_deducted")
788
+ # Fix: new column for free-plan refill cycle tracking (avoids overloading refills_processed)
789
+ _run_migration("user_subscriptions", "refill_intervals_processed", "ALTER TABLE user_subscriptions ADD COLUMN IF NOT EXISTS refill_intervals_processed INTEGER DEFAULT 0 NOT NULL", "postgres add refill_intervals_processed")
785
790
  _run_migration("marketplace_listings", "payment_type", "ALTER TABLE marketplace_listings ADD COLUMN IF NOT EXISTS payment_type VARCHAR(32) DEFAULT 'one_time'", "postgres add payment_type")
786
791
  _run_migration("marketplace_listings", "subscription_interval", "ALTER TABLE marketplace_listings ADD COLUMN IF NOT EXISTS subscription_interval VARCHAR(16)", "postgres add subscription_interval")
787
792
  _run_migration("marketplace_listings", "razorpay_plan_id", "ALTER TABLE marketplace_listings ADD COLUMN IF NOT EXISTS razorpay_plan_id VARCHAR(128)", "postgres add razorpay_plan_id")
@@ -817,6 +822,8 @@ def init_db(silent: bool = True) -> None:
817
822
  _run_migration("user_subscriptions", "current_cycle_used", "ALTER TABLE user_subscriptions ADD COLUMN current_cycle_used FLOAT DEFAULT 0.0 NOT NULL", "sqlite add current_cycle_used")
818
823
  _run_migration("user_subscriptions", "refill_rate_override", "ALTER TABLE user_subscriptions ADD COLUMN refill_rate_override FLOAT", "sqlite add refill_rate_override")
819
824
  _run_migration("user_subscriptions", "unallocated_deducted", "ALTER TABLE user_subscriptions ADD COLUMN unallocated_deducted FLOAT DEFAULT 0.0 NOT NULL", "sqlite add unallocated_deducted")
825
+ # Fix: new column for free-plan refill cycle tracking
826
+ _run_migration("user_subscriptions", "refill_intervals_processed", "ALTER TABLE user_subscriptions ADD COLUMN refill_intervals_processed INTEGER DEFAULT 0 NOT NULL", "sqlite add refill_intervals_processed")
820
827
  _run_migration("credits", "bonus_balance", "ALTER TABLE credits ADD COLUMN bonus_balance FLOAT DEFAULT 0.0 NOT NULL", "sqlite add bonus_balance")
821
828
  _run_migration("credits", "bonus_limit", "ALTER TABLE credits ADD COLUMN bonus_limit FLOAT DEFAULT 0.0 NOT NULL", "sqlite add bonus_limit")
822
829
  _run_migration("credits", "free_monthly_used", "ALTER TABLE credits ADD COLUMN free_monthly_used FLOAT DEFAULT 0.0 NOT NULL", "sqlite add free_monthly_used")