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.
- {utim_cli-2.3.12 → utim_cli-2.3.13}/CHANGELOG.md +5 -0
- {utim_cli-2.3.12/utim_cli.egg-info → utim_cli-2.3.13}/PKG-INFO +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/pyproject.toml +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim/__init__.py +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/__init__.py +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_version.py +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/agent.py +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/bootstrap.py +6 -5
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/client_utils.py +66 -7
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/config.py +102 -4
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/doctor.py +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator.py +35 -2
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_helpers.py +4 -4
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_llm_transport.py +4 -15
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_tool_exec.py +2 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/auth.py +54 -5
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/db.py +7 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/quota_routes.py +36 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tools.py +266 -121
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_dialog.py +8 -10
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/miniagents_dialog.py +72 -48
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/skills_dialog.py +11 -9
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/subagents_dialog.py +70 -47
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utim.py +90 -7
- {utim_cli-2.3.12 → utim_cli-2.3.13/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.3.12 → utim_cli-2.3.13}/LICENSE +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/MANIFEST.in +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/README.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/setup.cfg +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/setup.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/auth.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/constants.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/context_pruner.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/orchestrator_turn_history.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/hooks.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/report.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/admin_auth.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/batch_processor.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/concurrency.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/micro_batcher.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/models.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/router.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/admin_db_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/completion_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/share.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/state.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/model_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.12 → utim_cli-2.3.13}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: utim-cli
|
|
3
|
-
Version: 2.3.
|
|
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
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
583
|
+
array — never null.
|
|
584
584
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
-
#
|
|
1684
|
-
|
|
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
|
-
#
|
|
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 -
|
|
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")
|