utim-cli 2.3.19__tar.gz → 2.3.20__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.19 → utim_cli-2.3.20}/CHANGELOG.md +5 -0
- {utim_cli-2.3.19/utim_cli.egg-info → utim_cli-2.3.20}/PKG-INFO +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/pyproject.toml +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim/__init__.py +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/__init__.py +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_version.py +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/auth.py +5 -3
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/client_utils.py +48 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator.py +46 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_helpers.py +12 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_llm_transport.py +51 -136
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_turn_history.py +5 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/hooks.py +11 -3
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/manager.py +27 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/tool_loader.py +8 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/admin_auth.py +194 -163
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/auth.py +144 -32
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/db.py +71 -3
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/key_rotator.py +264 -53
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/models.py +7 -10
- utim_cli-2.3.20/utim_cli/server/plan_integrity.py +1064 -0
- utim_cli-2.3.20/utim_cli/server/rate_limit.py +38 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/router.py +66 -9
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/admin_bonus_routes.py +8 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/admin_db_routes.py +422 -4
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/auth_routes.py +51 -13
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/completion_routes.py +123 -38
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/credit_routes.py +399 -357
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/quota_routes.py +200 -56
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/rewards_routes.py +14 -5
- utim_cli-2.3.20/utim_cli/server/routes/webhook_routes.py +99 -0
- utim_cli-2.3.20/utim_cli/server/security_alerts.py +82 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/share_tui.py +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tools.py +45 -41
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/model_dialog.py +12 -17
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/plugins_dialog.py +24 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_share_dialog.py +458 -446
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utim.py +60 -24
- {utim_cli-2.3.19 → utim_cli-2.3.20/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/SOURCES.txt +3 -0
- utim_cli-2.3.19/utim_cli/server/rate_limit.py +0 -27
- {utim_cli-2.3.19 → utim_cli-2.3.20}/LICENSE +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/MANIFEST.in +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/README.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/setup.cfg +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/setup.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/agent.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/config.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/constants.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/context_pruner.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/doctor.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_tool_exec.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/report.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/batch_processor.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/concurrency.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/micro_batcher.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/share.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/state.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.19 → utim_cli-2.3.20}/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.20
|
|
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.20"
|
|
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
|
|
@@ -497,7 +497,7 @@ def login(restart: bool = False) -> None:
|
|
|
497
497
|
config.set("uid", body.get("user_id", ""))
|
|
498
498
|
config.set("name", body.get("display_name") or (body.get("email", "") or "").split("@")[0])
|
|
499
499
|
plan_name = body.get("plan")
|
|
500
|
-
if not plan_name and body.get("api_key"):
|
|
500
|
+
if (not plan_name or plan_name == "free") and body.get("api_key"):
|
|
501
501
|
try:
|
|
502
502
|
_vr = requests.get(
|
|
503
503
|
f"{SERVER_URL}/api/user-plan",
|
|
@@ -505,10 +505,12 @@ def login(restart: bool = False) -> None:
|
|
|
505
505
|
timeout=3.0,
|
|
506
506
|
)
|
|
507
507
|
if _vr.status_code == 200:
|
|
508
|
-
|
|
508
|
+
_pdata = _vr.json() or {}
|
|
509
|
+
plan_name = _pdata.get("plan") or _pdata.get("plan_name")
|
|
509
510
|
except Exception:
|
|
510
511
|
pass
|
|
511
|
-
|
|
512
|
+
clean_plan = str(plan_name or "free").lower().removesuffix(" plan").strip()
|
|
513
|
+
config.set("user_plan", clean_plan)
|
|
512
514
|
if body.get("api_key"):
|
|
513
515
|
config.set("api_key", body["api_key"])
|
|
514
516
|
raw_print("")
|
|
@@ -64,6 +64,44 @@ def get_server_url() -> str:
|
|
|
64
64
|
return "https://api.utim.dev"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def is_paid_plan(plan_name: Optional[str]) -> bool:
|
|
68
|
+
"""Return True if the given plan name represents a paid subscription tier."""
|
|
69
|
+
if not plan_name:
|
|
70
|
+
return False
|
|
71
|
+
p = str(plan_name).lower().removesuffix(" plan").strip()
|
|
72
|
+
return p not in ("free", "free tier", "", "none", "null", "guest", "undefined")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_user_paid(force_refresh: bool = False) -> bool:
|
|
76
|
+
"""Helper to check if the current user has an active paid subscription tier."""
|
|
77
|
+
try:
|
|
78
|
+
from utim_cli.config import config
|
|
79
|
+
api_key = config.get("api_key")
|
|
80
|
+
cached_plan = config.get("user_plan")
|
|
81
|
+
|
|
82
|
+
if not force_refresh and cached_plan and is_paid_plan(cached_plan):
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
if not api_key:
|
|
86
|
+
return is_paid_plan(cached_plan)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
from utim_cli.orchestrator_helpers import get_cached_quota
|
|
90
|
+
quota = get_cached_quota(api_key, force_refresh=force_refresh)
|
|
91
|
+
if quota:
|
|
92
|
+
plan = quota.get("plan") or quota.get("plan_name")
|
|
93
|
+
if plan:
|
|
94
|
+
clean_plan = str(plan).lower().removesuffix(" plan").strip()
|
|
95
|
+
config.set("user_plan", clean_plan)
|
|
96
|
+
return is_paid_plan(clean_plan)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
return is_paid_plan(cached_plan)
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
67
105
|
def _sanitize_json(obj):
|
|
68
106
|
"""Recursively scrub a payload for values that break strict JSON parsers.
|
|
69
107
|
|
|
@@ -1026,10 +1064,19 @@ def repair_and_normalize_tool_call(tc: dict) -> dict:
|
|
|
1026
1064
|
raw_name = str(fn.get("name", "")).strip()
|
|
1027
1065
|
raw_args = fn.get("arguments", "{}")
|
|
1028
1066
|
|
|
1029
|
-
|
|
1067
|
+
raw_name_orig = raw_name
|
|
1030
1068
|
for known in KNOWN_TOOLS:
|
|
1031
1069
|
if raw_name.startswith(known):
|
|
1070
|
+
remainder = raw_name[len(known):].strip()
|
|
1032
1071
|
raw_name = known
|
|
1072
|
+
if remainder and (not str(raw_args).strip() or str(raw_args).strip() == "{}"):
|
|
1073
|
+
attrs = re.findall(r'(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'|(\S+))', remainder)
|
|
1074
|
+
if attrs:
|
|
1075
|
+
parsed_attrs = {}
|
|
1076
|
+
for _k, _v1, _v2, _v3 in attrs:
|
|
1077
|
+
_v = _v1 or _v2 or _v3 or ""
|
|
1078
|
+
parsed_attrs[_k] = _v
|
|
1079
|
+
raw_args = parsed_attrs
|
|
1033
1080
|
break
|
|
1034
1081
|
if raw_name == known + known:
|
|
1035
1082
|
raw_name = known
|
|
@@ -594,6 +594,30 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
|
|
|
594
594
|
except Exception:
|
|
595
595
|
pass
|
|
596
596
|
|
|
597
|
+
# ── Sanitize: strip base64-encoded binary blobs ─────────────────
|
|
598
|
+
# On Termux, copying an image file can place raw base64 data into the
|
|
599
|
+
# clipboard. If any paste-chip expansion path leaks this binary noise
|
|
600
|
+
# into the user message, we detect and truncate it here before it
|
|
601
|
+
# reaches the model context.
|
|
602
|
+
_B64_RUN_RE = re.compile(r'[A-Za-z0-9+/=]{400,}')
|
|
603
|
+
if _B64_RUN_RE.search(user_message):
|
|
604
|
+
# Count total base64-characters vs meaningful content
|
|
605
|
+
b64_chars = sum(1 for c in user_message if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=')
|
|
606
|
+
total_chars = max(len(user_message), 1)
|
|
607
|
+
if total_chars > 600 and (b64_chars / total_chars) > 0.85:
|
|
608
|
+
# The message is dominated by binary noise — keep only
|
|
609
|
+
# non-base64 lines and any embedded file paths.
|
|
610
|
+
clean_lines = []
|
|
611
|
+
for line in user_message.split('\n'):
|
|
612
|
+
line_stripped = line.strip()
|
|
613
|
+
b64_ratio = sum(1 for c in line_stripped if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=') / max(len(line_stripped), 1)
|
|
614
|
+
# Keep lines that are paths or have low base64 ratio
|
|
615
|
+
if b64_ratio < 0.70 or re.search(r'[A-Za-z]:[\\/]|/[^\s]+\.\w{2,4}', line_stripped):
|
|
616
|
+
clean_lines.append(line)
|
|
617
|
+
if clean_lines:
|
|
618
|
+
user_message = '\n'.join(clean_lines)
|
|
619
|
+
user_message += f"\n\n[Pasted binary clipboard data stripped — {total_chars} chars of base64 noise removed]"
|
|
620
|
+
|
|
597
621
|
# Auto-detect image paths in user_message and send direct multimodal vision payload
|
|
598
622
|
try:
|
|
599
623
|
img_matches = re.findall(r'(?:[A-Za-z]:[\\/][^\s"\']+\.(?:png|jpg|jpeg|webp|gif)|/[^\s"\']+\.(?:png|jpg|jpeg|webp|gif))', user_message, re.IGNORECASE)
|
|
@@ -829,6 +853,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
|
|
|
829
853
|
|
|
830
854
|
break # success
|
|
831
855
|
except _ServerUnavailableError as exc:
|
|
856
|
+
_err_text = str(exc).lower()
|
|
857
|
+
if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
|
|
858
|
+
self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
|
|
859
|
+
self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
|
|
860
|
+
if len(self.messages) <= turn_msg_start + 1:
|
|
861
|
+
self.messages.append({
|
|
862
|
+
"role": "assistant",
|
|
863
|
+
"content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
|
|
864
|
+
})
|
|
865
|
+
self._persist_messages()
|
|
866
|
+
return
|
|
832
867
|
if self.cancel_event.is_set():
|
|
833
868
|
del self.messages[turn_msg_start + 1:]
|
|
834
869
|
self._persist_messages()
|
|
@@ -875,6 +910,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
|
|
|
875
910
|
self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
|
|
876
911
|
break
|
|
877
912
|
except Exception as exc:
|
|
913
|
+
_err_text = str(exc).lower()
|
|
914
|
+
if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
|
|
915
|
+
self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
|
|
916
|
+
self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
|
|
917
|
+
if len(self.messages) <= turn_msg_start + 1:
|
|
918
|
+
self.messages.append({
|
|
919
|
+
"role": "assistant",
|
|
920
|
+
"content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
|
|
921
|
+
})
|
|
922
|
+
self._persist_messages()
|
|
923
|
+
return
|
|
878
924
|
if self.cancel_event.is_set():
|
|
879
925
|
if len(self.messages) <= turn_msg_start + 1:
|
|
880
926
|
self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
|
|
@@ -244,7 +244,13 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
|
|
|
244
244
|
session = get_http_session()
|
|
245
245
|
resp = session.get(f"{SERVER_URL}/quota", headers={"X-API-Key": api_key}, timeout=2.0)
|
|
246
246
|
if resp.status_code == 200:
|
|
247
|
-
|
|
247
|
+
data = resp.json()
|
|
248
|
+
_QUOTA_CACHE.update({"data": data, "timestamp": time.time(), "api_key": api_key})
|
|
249
|
+
plan = data.get("plan") or data.get("plan_name")
|
|
250
|
+
if plan:
|
|
251
|
+
from utim_cli.config import config
|
|
252
|
+
clean_plan = str(plan).lower().removesuffix(" plan").strip()
|
|
253
|
+
config.set("user_plan", clean_plan)
|
|
248
254
|
except Exception:
|
|
249
255
|
pass
|
|
250
256
|
threading.Thread(target=_bg_quota_refresh, daemon=True).start()
|
|
@@ -263,6 +269,11 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
|
|
|
263
269
|
data = resp.json()
|
|
264
270
|
_QUOTA_CACHE.clear()
|
|
265
271
|
_QUOTA_CACHE.update({"data": data, "timestamp": now, "api_key": api_key})
|
|
272
|
+
plan = data.get("plan") or data.get("plan_name")
|
|
273
|
+
if plan:
|
|
274
|
+
from utim_cli.config import config
|
|
275
|
+
clean_plan = str(plan).lower().removesuffix(" plan").strip()
|
|
276
|
+
config.set("user_plan", clean_plan)
|
|
266
277
|
return data
|
|
267
278
|
except Exception:
|
|
268
279
|
pass
|
|
@@ -133,6 +133,11 @@ Contains: _get_configured_model_source, _is_custom_model_selection,
|
|
|
133
133
|
_resolve_model_endpoint, _call_llm. Behavior-identical; Orchestrator inherits.
|
|
134
134
|
"""
|
|
135
135
|
class LLMTransportMixin:
|
|
136
|
+
_PRE_THINK_PATTERNS = re.compile(
|
|
137
|
+
r"^\s*(\*\s*Thinking\.\.\.?|\.\.\.(\s*thinking)?|thinking\.\.\.?)\s*$",
|
|
138
|
+
re.IGNORECASE | re.MULTILINE,
|
|
139
|
+
)
|
|
140
|
+
|
|
136
141
|
def _get_configured_model_source(self, model_id: str, *, is_primary: bool = False) -> Optional[str]:
|
|
137
142
|
"""Return the selected source for a model id when the picker recorded one."""
|
|
138
143
|
if is_primary:
|
|
@@ -736,7 +741,9 @@ class LLMTransportMixin:
|
|
|
736
741
|
# 1. Check if quota is exhausted (only for non-custom models)
|
|
737
742
|
is_5h_exhausted = bool(quota.get("five_hour_quota_exhausted", False))
|
|
738
743
|
is_monthly_exhausted = bool(quota.get("is_exhausted", False))
|
|
739
|
-
|
|
744
|
+
bonus_bal = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
|
|
745
|
+
# If the user has available bonus credits, inference is funded by bonus quota
|
|
746
|
+
is_exhausted = (is_5h_exhausted or is_monthly_exhausted) and bonus_bal <= 0.0
|
|
740
747
|
|
|
741
748
|
if not is_custom and is_exhausted:
|
|
742
749
|
reset_raw = quota.get("five_hour_reset_at") if is_5h_exhausted else (quota.get("reset_at") or quota.get("five_hour_reset_at", ""))
|
|
@@ -771,7 +778,7 @@ class LLMTransportMixin:
|
|
|
771
778
|
|
|
772
779
|
# 2. Check if chosen model is allowed — skip entirely for BYOK/custom models
|
|
773
780
|
models_allowed = quota.get("models_allowed", [])
|
|
774
|
-
free_bonus_balance = quota.get("free_bonus_balance", 0.0) or 0.0
|
|
781
|
+
free_bonus_balance = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
|
|
775
782
|
chosen_model = override_model if override_model else self.model_id
|
|
776
783
|
is_custom = self._is_custom_model_selection(chosen_model, is_primary=not override_model)
|
|
777
784
|
is_free_user = (quota.get("plan") == "free" or str(quota.get("display_name", "")).lower() == "free")
|
|
@@ -802,18 +809,8 @@ class LLMTransportMixin:
|
|
|
802
809
|
self.model_source = "utim"
|
|
803
810
|
config.set("main_model_source", "utim")
|
|
804
811
|
else:
|
|
805
|
-
# Paid plan
|
|
806
|
-
if is_apinex:
|
|
807
|
-
fallback_model = "anthropic/claude-3-5-sonnet-20241022"
|
|
808
|
-
self.console.print(f"\n[bold yellow]APINEX model '{chosen_model}' is exclusively for the Free plan.[/bold yellow]")
|
|
809
|
-
self.console.print(f" Switching to your plan's model: '{fallback_model}'.\n")
|
|
810
|
-
if override_model:
|
|
811
|
-
override_model = fallback_model
|
|
812
|
-
else:
|
|
813
|
-
self.model_id = fallback_model
|
|
814
|
-
self.model_source = "utim"
|
|
815
|
-
config.set("main_model_source", "utim")
|
|
816
|
-
elif models_allowed != ["all"] and chosen_model not in models_allowed and free_bonus_balance <= 0.0:
|
|
812
|
+
# Paid plan: APINEX models are free gateway models — allowed on every plan
|
|
813
|
+
if models_allowed != ["all"] and chosen_model not in models_allowed and not is_apinex and free_bonus_balance <= 0.0:
|
|
817
814
|
fallback_model = DEFAULT_MODEL
|
|
818
815
|
self.console.print(f"\n[bold yellow]Model '{chosen_model}' is gated under your current '{quota.get('display_name', 'Free')}' plan.[/bold yellow]")
|
|
819
816
|
self.console.print(f" Downgrading to default allowed model: '{fallback_model}' for this request.")
|
|
@@ -1314,8 +1311,10 @@ class LLMTransportMixin:
|
|
|
1314
1311
|
# NOTE: last_content_time is updated further below, only when
|
|
1315
1312
|
# actual content or tool-call data is parsed from the chunk.
|
|
1316
1313
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1314
|
+
if isinstance(raw_line, bytes):
|
|
1315
|
+
line_str = raw_line.decode("utf-8", errors="ignore").strip()
|
|
1316
|
+
else:
|
|
1317
|
+
line_str = str(raw_line).strip()
|
|
1319
1318
|
if not line_str:
|
|
1320
1319
|
continue
|
|
1321
1320
|
|
|
@@ -1356,10 +1355,15 @@ class LLMTransportMixin:
|
|
|
1356
1355
|
if t == "queue_status":
|
|
1357
1356
|
pos = chunk.get("position", 1)
|
|
1358
1357
|
status = chunk.get("status", "queued")
|
|
1358
|
+
tier = chunk.get("tier", "")
|
|
1359
|
+
prio = chunk.get("priority", 0)
|
|
1359
1360
|
if status == "queued":
|
|
1360
1361
|
try:
|
|
1361
1362
|
from utim_cli.state import STATE
|
|
1362
|
-
|
|
1363
|
+
if prio > 0 and tier:
|
|
1364
|
+
STATE["thinking_topic"] = f"Request Queued Position {pos} ({str(tier).title()} Priority)"
|
|
1365
|
+
else:
|
|
1366
|
+
STATE["thinking_topic"] = f"Request Queued Position {pos}"
|
|
1363
1367
|
except Exception:
|
|
1364
1368
|
pass
|
|
1365
1369
|
elif status == "processing":
|
|
@@ -1522,12 +1526,23 @@ class LLMTransportMixin:
|
|
|
1522
1526
|
"tool_calls": None,
|
|
1523
1527
|
}, False
|
|
1524
1528
|
elif _is_rate_limit:
|
|
1529
|
+
rate_limit_display = (
|
|
1530
|
+
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
1531
|
+
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
1532
|
+
f"- **Retry:** You can resend your prompt anytime."
|
|
1533
|
+
)
|
|
1525
1534
|
if not silent:
|
|
1526
1535
|
self.console.print(
|
|
1527
|
-
f"\n[
|
|
1528
|
-
f"switching to the next available model…[/dim yellow]"
|
|
1536
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
|
|
1529
1537
|
)
|
|
1530
|
-
|
|
1538
|
+
self.console.print(
|
|
1539
|
+
" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
|
|
1540
|
+
)
|
|
1541
|
+
return {
|
|
1542
|
+
"role": "assistant",
|
|
1543
|
+
"content": rate_limit_display,
|
|
1544
|
+
"tool_calls": None,
|
|
1545
|
+
}, False
|
|
1531
1546
|
elif _is_provider_upstream:
|
|
1532
1547
|
if not silent:
|
|
1533
1548
|
self.console.print(
|
|
@@ -1948,7 +1963,8 @@ class LLMTransportMixin:
|
|
|
1948
1963
|
timeout=5,
|
|
1949
1964
|
)
|
|
1950
1965
|
if _qr.status_code == 200:
|
|
1951
|
-
|
|
1966
|
+
_data = _qr.json()
|
|
1967
|
+
_has_bonus = (float(_data.get("bonus_balance", _data.get("free_bonus_balance", 0.0)) or 0.0) > 0.0)
|
|
1952
1968
|
except Exception:
|
|
1953
1969
|
pass
|
|
1954
1970
|
if _has_bonus:
|
|
@@ -2067,124 +2083,23 @@ class LLMTransportMixin:
|
|
|
2067
2083
|
"tool_calls": None,
|
|
2068
2084
|
}, False
|
|
2069
2085
|
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
# Are there more models in the fallback chain to try?
|
|
2078
|
-
_has_more_models = model_idx < len(models_to_try) - 1
|
|
2079
|
-
|
|
2080
|
-
if _has_more_models:
|
|
2081
|
-
# Soft, dim notice — let the next model in the chain take over.
|
|
2082
|
-
# The user doesn't need to (and shouldn't) see provider jargon.
|
|
2083
|
-
try:
|
|
2084
|
-
self.console.print(
|
|
2085
|
-
f"\n[dim yellow]↪ {current_model} is busy right now — "
|
|
2086
|
-
f"switching to the next available model…[/dim yellow]"
|
|
2087
|
-
)
|
|
2088
|
-
except Exception:
|
|
2089
|
-
pass
|
|
2090
|
-
# Break the inner retry loop → outer loop will pick the next model
|
|
2091
|
-
last_exc = exc
|
|
2092
|
-
break
|
|
2093
|
-
else:
|
|
2094
|
-
# Last model in chain also rate-limited — show a CLEAN, friendly
|
|
2095
|
-
# banner. Mask raw OpenRouter jargon. If the model name looks
|
|
2096
|
-
# like a free/OpenRouter endpoint, frame it as "all free
|
|
2097
|
-
# models are momentarily busy" instead of a paid-upgrade CTA.
|
|
2098
|
-
_is_free_tier_model = (
|
|
2099
|
-
":free" in current_model
|
|
2100
|
-
or "/free" in current_model
|
|
2101
|
-
or "openrouter" in current_model.lower()
|
|
2102
|
-
)
|
|
2103
|
-
_display_msg = _rate_limit_detail
|
|
2104
|
-
# Strip provider-specific keywords that leak through to users
|
|
2105
|
-
import re as _re
|
|
2106
|
-
for _jargon in (
|
|
2107
|
-
"tokens per minute", "requests per minute",
|
|
2108
|
-
"free model", "free tier", "provider",
|
|
2109
|
-
):
|
|
2110
|
-
_display_msg = _re.sub(
|
|
2111
|
-
_re.escape(_jargon),
|
|
2112
|
-
"",
|
|
2113
|
-
_display_msg,
|
|
2114
|
-
flags=_re.IGNORECASE,
|
|
2115
|
-
)
|
|
2116
|
-
# Word-boundary stripping for short tokens (tpm, rpm)
|
|
2117
|
-
for _jargon in ("tpm", "rpm"):
|
|
2118
|
-
_display_msg = _re.sub(
|
|
2119
|
-
rf"\b{_jargon}\b",
|
|
2120
|
-
"",
|
|
2121
|
-
_display_msg,
|
|
2122
|
-
flags=_re.IGNORECASE,
|
|
2123
|
-
)
|
|
2124
|
-
# Collapse leftover whitespace + dangling punctuation
|
|
2125
|
-
_display_msg = _re.sub(r"\s+", " ", _display_msg).strip(" .,;:—-")
|
|
2126
|
-
# Drop the generic "returned error:" prefix — it's provider
|
|
2127
|
-
# boilerplate that adds no value to a human reader.
|
|
2128
|
-
_display_msg = _re.sub(
|
|
2129
|
-
r"^(?:returned error|error)\s*[:\-—]?\s*",
|
|
2130
|
-
"",
|
|
2131
|
-
_display_msg,
|
|
2132
|
-
flags=_re.IGNORECASE,
|
|
2133
|
-
).strip(" .,;:—-")
|
|
2134
|
-
# If the sanitized message is too short or doesn't really say
|
|
2135
|
-
# anything useful, swap in a polished default.
|
|
2136
|
-
_low = _display_msg.lower()
|
|
2137
|
-
# Count substantive words (>= 3 chars, alpha) to detect
|
|
2138
|
-
# sentences that were gutted by jargon stripping.
|
|
2139
|
-
_substantive = sum(
|
|
2140
|
-
1 for _w in _re.findall(r"[A-Za-z]{3,}", _display_msg)
|
|
2141
|
-
)
|
|
2142
|
-
# Detect a "stranded period" artifact: a period that's now
|
|
2143
|
-
# surrounded by whitespace because its surrounding words
|
|
2144
|
-
# were stripped. This is a strong signal that the message
|
|
2145
|
-
# was gutted by jargon removal and is no longer readable.
|
|
2146
|
-
_has_stranded_period = bool(_re.search(r"\s\.\s", _display_msg))
|
|
2147
|
-
_is_uninformative = (
|
|
2148
|
-
len(_display_msg) < 12
|
|
2149
|
-
or _substantive < 3
|
|
2150
|
-
or _has_stranded_period
|
|
2151
|
-
or _low in ("returned error", "error", "rate limit", "limit")
|
|
2152
|
-
or _low in ("", ".", "..", "...")
|
|
2153
|
-
)
|
|
2154
|
-
if _is_uninformative:
|
|
2155
|
-
if _is_free_tier_model:
|
|
2156
|
-
_display_msg = (
|
|
2157
|
-
"All free models are momentarily busy. "
|
|
2158
|
-
"This clears automatically within a minute or two."
|
|
2159
|
-
)
|
|
2160
|
-
else:
|
|
2161
|
-
_display_msg = "Rate limit reached for this model."
|
|
2162
|
-
|
|
2086
|
+
rate_limit_display = (
|
|
2087
|
+
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
2088
|
+
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
2089
|
+
f"- **Retry:** You can resend your prompt anytime."
|
|
2090
|
+
)
|
|
2091
|
+
if not silent:
|
|
2163
2092
|
self.console.print(
|
|
2164
|
-
f"\n[bold yellow]⏳ {
|
|
2093
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
|
|
2165
2094
|
)
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
self.console.print(
|
|
2169
|
-
" [dim]Free models rotate through a shared capacity pool — "
|
|
2170
|
-
"this usually clears in under a minute.[/dim]"
|
|
2171
|
-
)
|
|
2172
|
-
self.console.print(
|
|
2173
|
-
" [dim]You can also pick a different model with "
|
|
2174
|
-
"[bold]/model[/bold] or press Ctrl+M.[/dim]\n"
|
|
2175
|
-
)
|
|
2176
|
-
else:
|
|
2177
|
-
if not upgrade_url:
|
|
2178
|
-
upgrade_url = "https://utim.dev/pricing"
|
|
2179
|
-
self.console.print(
|
|
2180
|
-
f" [dim]Top up at [bold]{upgrade_url}[/bold] or run "
|
|
2181
|
-
f"[bold]/balance[/bold] to check your remaining quota.[/dim]\n"
|
|
2182
|
-
)
|
|
2183
|
-
|
|
2184
|
-
raise _FatalClientError(
|
|
2185
|
-
f"All models in the fallback chain are rate-limited: "
|
|
2186
|
-
f"{_rate_limit_detail}"
|
|
2095
|
+
self.console.print(
|
|
2096
|
+
" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
|
|
2187
2097
|
)
|
|
2098
|
+
return {
|
|
2099
|
+
"role": "assistant",
|
|
2100
|
+
"content": rate_limit_display,
|
|
2101
|
+
"tool_calls": None,
|
|
2102
|
+
}, False
|
|
2188
2103
|
if attempt < model_retries:
|
|
2189
2104
|
delay = 3 * (attempt + 1)
|
|
2190
2105
|
# Interruptible wait — so Ctrl+C can abort immediately
|
|
@@ -1381,7 +1381,11 @@ class TurnHistoryMixin:
|
|
|
1381
1381
|
with open("package.json", "r", encoding="utf-8") as f:
|
|
1382
1382
|
pkg = json.load(f)
|
|
1383
1383
|
if "scripts" in pkg and "test" in pkg["scripts"]:
|
|
1384
|
-
|
|
1384
|
+
from shutil import which as _which_npm
|
|
1385
|
+
_npm = _which_npm("npm")
|
|
1386
|
+
if _npm is None:
|
|
1387
|
+
return "npm not found on PATH"
|
|
1388
|
+
res = subprocess.run([_npm, "test"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
|
|
1385
1389
|
if res.returncode != 0:
|
|
1386
1390
|
return _compact_test_err("npm test", res.stdout, res.stderr)
|
|
1387
1391
|
return None
|
|
@@ -107,11 +107,19 @@ def run_single_hook(
|
|
|
107
107
|
|
|
108
108
|
argv = _resolve_interpreter(command, Path.cwd())
|
|
109
109
|
stdin_data = json.dumps(payload)
|
|
110
|
+
# Clamp per-hook timeout: a misconfigured plugin manifest must not be
|
|
111
|
+
# able to hold the dispatch thread pool hostage (DoS guard).
|
|
112
|
+
h_timeout = min(max(float(h_timeout), 0.1), 60.0)
|
|
110
113
|
if argv is None:
|
|
111
|
-
# Shell mode
|
|
112
|
-
|
|
114
|
+
# Shell mode — run through an explicit shell argv instead of
|
|
115
|
+
# shell=True, which is injection-prone and resolves slower. On
|
|
116
|
+
# Windows, /d also skips AutoRun registry scripts (hijack guard).
|
|
117
|
+
if os.name == "nt":
|
|
118
|
+
shell_argv = ["cmd.exe", "/d", "/s", "/c", command]
|
|
119
|
+
else:
|
|
120
|
+
shell_argv = ["/bin/sh", "-c", command]
|
|
113
121
|
result = subprocess.run(
|
|
114
|
-
|
|
122
|
+
shell_argv, shell=False, input=stdin_data, capture_output=True,
|
|
115
123
|
text=True, timeout=h_timeout, env=_build_env(plugin_name, event, payload),
|
|
116
124
|
)
|
|
117
125
|
else:
|
|
@@ -21,6 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
from typing import Any, Dict, List, Optional, Set
|
|
22
22
|
|
|
23
23
|
from utim_cli.config import get_utim_dir, config
|
|
24
|
+
from utim_cli.client_utils import is_user_paid
|
|
24
25
|
from utim_cli.plugins.schema import Plugin, PluginManifest
|
|
25
26
|
from utim_cli.plugins.registry import get_registry_plugins, find_registry_plugin
|
|
26
27
|
from utim_cli.plugins import loader as staged_loader
|
|
@@ -149,6 +150,8 @@ class PluginManager:
|
|
|
149
150
|
# ── Enable / Disable / Toggle ────────────────────────────────────────────
|
|
150
151
|
|
|
151
152
|
def enable_plugin(self, name: str) -> bool:
|
|
153
|
+
if not is_user_paid():
|
|
154
|
+
return False
|
|
152
155
|
plugin = self.get_plugin(name)
|
|
153
156
|
if not plugin:
|
|
154
157
|
return False
|
|
@@ -171,6 +174,8 @@ class PluginManager:
|
|
|
171
174
|
return True
|
|
172
175
|
|
|
173
176
|
def toggle_plugin(self, name: str) -> bool:
|
|
177
|
+
if not is_user_paid():
|
|
178
|
+
return False
|
|
174
179
|
return self.disable_plugin(name) if self.is_enabled(name) else self.enable_plugin(name)
|
|
175
180
|
|
|
176
181
|
# ── Installation ─────────────────────────────────────────────────────────
|
|
@@ -191,6 +196,9 @@ class PluginManager:
|
|
|
191
196
|
def install_plugin(self, identifier: str, force: bool = False, plugin_type: str = "auto") -> Dict[str, Any]:
|
|
192
197
|
"""Install a plugin from a registry name, git URL, or local path."""
|
|
193
198
|
result: Dict[str, Any] = {"ok": False, "plugin": identifier, "message": ""}
|
|
199
|
+
if not is_user_paid():
|
|
200
|
+
result["message"] = "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade"
|
|
201
|
+
return result
|
|
194
202
|
meta = find_registry_plugin(identifier)
|
|
195
203
|
git_url = self._parse_git_url(identifier)
|
|
196
204
|
|
|
@@ -280,6 +288,12 @@ class PluginManager:
|
|
|
280
288
|
• Any other page -> knowledge plugin (scraped markdown becomes
|
|
281
289
|
a SKILL.md the model gets injected with)
|
|
282
290
|
"""
|
|
291
|
+
if not is_user_paid():
|
|
292
|
+
return {
|
|
293
|
+
"ok": False,
|
|
294
|
+
"message": "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade",
|
|
295
|
+
}
|
|
296
|
+
|
|
283
297
|
def _say(msg: str) -> None:
|
|
284
298
|
if progress_cb:
|
|
285
299
|
progress_cb(msg)
|
|
@@ -392,6 +406,9 @@ class PluginManager:
|
|
|
392
406
|
|
|
393
407
|
Returns (ok: bool, message: str, path: Optional[Path]).
|
|
394
408
|
"""
|
|
409
|
+
if not is_user_paid():
|
|
410
|
+
return False, "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade", None
|
|
411
|
+
|
|
395
412
|
clean = re.sub(r"[^a-zA-Z0-9_\-]", "-", name.strip().lower()).strip("-")
|
|
396
413
|
if not clean:
|
|
397
414
|
return False, "Invalid plugin name", None
|
|
@@ -469,6 +486,8 @@ class PluginManager:
|
|
|
469
486
|
# ── Aggregation (effectors) ──────────────────────────────────────────────
|
|
470
487
|
|
|
471
488
|
def get_active_skills(self) -> Dict[str, Dict[str, Any]]:
|
|
489
|
+
if not is_user_paid():
|
|
490
|
+
return {}
|
|
472
491
|
skills: Dict[str, Dict[str, Any]] = {}
|
|
473
492
|
for p in self.list_plugins():
|
|
474
493
|
if p.enabled and p.is_valid:
|
|
@@ -480,6 +499,8 @@ class PluginManager:
|
|
|
480
499
|
return skills
|
|
481
500
|
|
|
482
501
|
def get_active_rules(self) -> List[Dict[str, Any]]:
|
|
502
|
+
if not is_user_paid():
|
|
503
|
+
return []
|
|
483
504
|
rules: List[Dict[str, Any]] = []
|
|
484
505
|
for p in self.list_plugins():
|
|
485
506
|
if p.enabled and p.is_valid:
|
|
@@ -487,6 +508,8 @@ class PluginManager:
|
|
|
487
508
|
return rules
|
|
488
509
|
|
|
489
510
|
def get_active_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
|
|
511
|
+
if not is_user_paid():
|
|
512
|
+
return {}
|
|
490
513
|
servers: Dict[str, Dict[str, Any]] = {}
|
|
491
514
|
for p in self.list_plugins():
|
|
492
515
|
if p.enabled and p.is_valid:
|
|
@@ -494,6 +517,8 @@ class PluginManager:
|
|
|
494
517
|
return servers
|
|
495
518
|
|
|
496
519
|
def get_active_tools(self) -> List[Dict[str, Any]]:
|
|
520
|
+
if not is_user_paid():
|
|
521
|
+
return []
|
|
497
522
|
tools: List[Dict[str, Any]] = []
|
|
498
523
|
for p in self.list_plugins():
|
|
499
524
|
if p.enabled and p.is_valid:
|
|
@@ -511,6 +536,8 @@ class PluginManager:
|
|
|
511
536
|
(string/dict commands from manifests) go through hooks.py in
|
|
512
537
|
parallel with per-hook timeouts.
|
|
513
538
|
"""
|
|
539
|
+
if not is_user_paid():
|
|
540
|
+
return []
|
|
514
541
|
from utim_cli.plugins import hooks as hook_engine
|
|
515
542
|
payload = payload or {}
|
|
516
543
|
output: Dict[str, Any] = {}
|