utim-cli 2.3.19b0__tar.gz → 2.3.21__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.19b0 → utim_cli-2.3.21}/CHANGELOG.md +18 -6
- {utim_cli-2.3.19b0/utim_cli.egg-info → utim_cli-2.3.21}/PKG-INFO +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/pyproject.toml +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim/__init__.py +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/__init__.py +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_version.py +1 -5
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/client_utils.py +6 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/constants.py +2 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator.py +46 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_llm_transport.py +84 -129
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_turn_history.py +5 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/hooks.py +11 -3
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/admin_auth.py +194 -163
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/auth.py +174 -24
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/batch_processor.py +65 -6
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/concurrency.py +167 -154
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/db.py +272 -12
- utim_cli-2.3.21/utim_cli/server/docs_md/pricing.md +19 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/key_rotator.py +815 -224
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/micro_batcher.py +60 -34
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/models.py +134 -84
- utim_cli-2.3.21/utim_cli/server/plan_integrity.py +1065 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/pricing_updater.py +9 -6
- utim_cli-2.3.21/utim_cli/server/rate_limit.py +38 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/router.py +123 -27
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/admin_bonus_routes.py +8 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/admin_db_routes.py +422 -4
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/auth_routes.py +47 -13
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/completion_routes.py +777 -97
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/credit_routes.py +399 -357
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/quota_routes.py +370 -61
- utim_cli-2.3.21/utim_cli/server/routes/webhook_routes.py +99 -0
- utim_cli-2.3.21/utim_cli/server/security_alerts.py +82 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/share_tui.py +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tools.py +98 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/model_dialog.py +23 -7
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_share_dialog.py +458 -446
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utim.py +171 -71
- {utim_cli-2.3.19b0 → utim_cli-2.3.21/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/SOURCES.txt +3 -0
- utim_cli-2.3.19b0/utim_cli/server/docs_md/pricing.md +0 -22
- utim_cli-2.3.19b0/utim_cli/server/rate_limit.py +0 -27
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/LICENSE +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/MANIFEST.in +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/README.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/setup.cfg +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/setup.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/agent.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/auth.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/config.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/context_pruner.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/doctor.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_helpers.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_tool_exec.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/report.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/share.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/state.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [2.3.
|
|
3
|
+
## [2.3.21] - 2026-09-06
|
|
4
4
|
|
|
5
|
-
###
|
|
6
|
-
- **
|
|
7
|
-
- **
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
### 💰 Pricing Modifications
|
|
6
|
+
- **OpenRouter Tier Markup**: Standardized 30% markup across OpenRouter models in the server `model_registry` table.
|
|
7
|
+
- **Free Model Fallback Credit Rate Preservation**: Preserved fixed consumption rates ($0.30 input / $0.50 output per 1M tokens) when transitioning from exhausted APINEX keys to fallback paid slugs, ensuring consistent credit accounting.
|
|
8
|
+
|
|
9
|
+
### 📦 Models Payload Fixes
|
|
10
|
+
- **Upstream Provider Normalization**: Normalized vendor provider resolution for multi-provider models (MiniMax, Cohere, Dots-Studio, InclusionAI) to route cleanly via OpenRouter upstream.
|
|
11
|
+
- **Image Generation Model Filtering**: Excluded image-generation models from the interactive chat model catalog to avoid completion payload incompatibilities.
|
|
12
|
+
- **APINEX Model Mapping Support**: Added model registry definitions and key rotation support for `qwen/3.8-max` and `free/qwen-3.8-max`.
|
|
13
|
+
|
|
14
|
+
### 🛡️ Rate Limit & Vision Tooling Fixes
|
|
15
|
+
- **MiniMax Rate-Limit Fallback to Laguna**: Redirected exhausted MiniMax free models (`minimax/minimax-m2.7:free`, `minimax/minimax-m3:free`) to fall back to `poolside/laguna-s-2.1` paid slug rather than expensive MiniMax paid slugs.
|
|
16
|
+
- **APINEX Transparent Image Analysis**: Updated `read_file` on image files under APINEX models to automatically execute `analyze_image` in the background and return only the text summary, bypassing vision parameter constraints.
|
|
17
|
+
|
|
18
|
+
## [2.3.20] - 2026-09-04
|
|
19
|
+
|
|
20
|
+
### 🛠️ Minor Fixes and Enhancement
|
|
21
|
+
- Minor fixes and enhancement
|
|
10
22
|
|
|
11
23
|
## [2.3.19] - 2026-09-02
|
|
12
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: utim-cli
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.21
|
|
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.
|
|
7
|
+
version = "2.3.21"
|
|
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.
|
|
17
|
+
__version__ = "2.3.21"
|
|
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
|
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
# from here so every surface stays in sync automatically.
|
|
12
12
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
# (npm semver prerelease tag + PEP 440 pre-release segment; a space would
|
|
16
|
-
# break npm publish and pip). parse_version() strips the suffix so update
|
|
17
|
-
# checks still compare on the numeric core 2.3.19.
|
|
18
|
-
VERSION = "2.3.19-beta"
|
|
14
|
+
VERSION = "2.3.21"
|
|
19
15
|
|
|
20
16
|
|
|
@@ -248,12 +248,14 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
|
|
|
248
248
|
for m in models_list:
|
|
249
249
|
caps = [c.lower() for c in (m.get("capabilities") or [])]
|
|
250
250
|
tags = [t.lower() for t in (m.get("tags") or [])]
|
|
251
|
+
mid = str(m.get("model_id") or "")
|
|
251
252
|
is_img = bool(
|
|
252
253
|
m.get("is_image_gen")
|
|
253
254
|
or "image_generation" in caps
|
|
254
255
|
or "imagegen" in caps
|
|
255
256
|
or "image_output" in caps
|
|
256
|
-
or "
|
|
257
|
+
or mid.lower().startswith("krea/")
|
|
258
|
+
or ("image" in caps and not any(c in caps for c in ("chat", "vision", "text")))
|
|
257
259
|
)
|
|
258
260
|
is_vis = bool(m.get("is_vision") or "vision" in caps or "vision" in tags)
|
|
259
261
|
if is_img:
|
|
@@ -334,6 +336,9 @@ def _build_free_models_fallback_chain(now: float) -> list[str]:
|
|
|
334
336
|
(ModelDB.is_free == True) | (ModelDB.cost_input_per_1m == 0.0) | (ModelDB.model_id.like("%:free"))
|
|
335
337
|
).order_by(ModelDB.model_id.asc()).all()
|
|
336
338
|
for r in free_rows:
|
|
339
|
+
caps = [str(c).lower() for c in (r.capabilities or [])]
|
|
340
|
+
if r.is_image_gen or "image_generation" in caps or "imagegen" in caps:
|
|
341
|
+
continue
|
|
337
342
|
mid = r.model_id
|
|
338
343
|
if mid and mid not in seen and mid not in INVALID_DUMMY_IDS:
|
|
339
344
|
chain.append(mid)
|
|
@@ -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]"})
|
|
@@ -132,7 +132,36 @@ from .orchestrator_turn_history import TurnHistoryMixin # noqa: E402
|
|
|
132
132
|
Contains: _get_configured_model_source, _is_custom_model_selection,
|
|
133
133
|
_resolve_model_endpoint, _call_llm. Behavior-identical; Orchestrator inherits.
|
|
134
134
|
"""
|
|
135
|
+
|
|
136
|
+
# ── Effort → stall-timeout mapping (thinking-level based, NOT model-name based) ──
|
|
137
|
+
# Reasoning models think longer at higher effort levels. The stream stall detector
|
|
138
|
+
# (TTFT + inter-token) uses these per-effort durations instead of a single hard
|
|
139
|
+
# 900s ceiling, so a stuck "none" call fails fast while "max" gets full headroom.
|
|
140
|
+
_EFFORT_STALL_TIMEOUT_SECONDS = {
|
|
141
|
+
"none": 15,
|
|
142
|
+
"minimal": 45,
|
|
143
|
+
"low": 60,
|
|
144
|
+
"medium": 80,
|
|
145
|
+
"high": 100,
|
|
146
|
+
"xhigh": 180,
|
|
147
|
+
"max": 300,
|
|
148
|
+
}
|
|
149
|
+
_DEFAULT_STALL_TIMEOUT = 80 # matches "medium" (the default reasoning_effort)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _effort_stall_timeout(settings: dict) -> int:
|
|
153
|
+
"""Return the stall-detection timeout (seconds) for the given model settings,
|
|
154
|
+
keyed by reasoning effort (thinking level), not by model name."""
|
|
155
|
+
effort = (settings or {}).get("reasoning_effort", "medium")
|
|
156
|
+
return _EFFORT_STALL_TIMEOUT_SECONDS.get(effort, _DEFAULT_STALL_TIMEOUT)
|
|
157
|
+
|
|
158
|
+
|
|
135
159
|
class LLMTransportMixin:
|
|
160
|
+
_PRE_THINK_PATTERNS = re.compile(
|
|
161
|
+
r"^\s*(\*\s*Thinking\.\.\.?|\.\.\.(\s*thinking)?|thinking\.\.\.?)\s*$",
|
|
162
|
+
re.IGNORECASE | re.MULTILINE,
|
|
163
|
+
)
|
|
164
|
+
|
|
136
165
|
def _get_configured_model_source(self, model_id: str, *, is_primary: bool = False) -> Optional[str]:
|
|
137
166
|
"""Return the selected source for a model id when the picker recorded one."""
|
|
138
167
|
if is_primary:
|
|
@@ -736,7 +765,9 @@ class LLMTransportMixin:
|
|
|
736
765
|
# 1. Check if quota is exhausted (only for non-custom models)
|
|
737
766
|
is_5h_exhausted = bool(quota.get("five_hour_quota_exhausted", False))
|
|
738
767
|
is_monthly_exhausted = bool(quota.get("is_exhausted", False))
|
|
739
|
-
|
|
768
|
+
bonus_bal = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
|
|
769
|
+
# If the user has available bonus credits, inference is funded by bonus quota
|
|
770
|
+
is_exhausted = (is_5h_exhausted or is_monthly_exhausted) and bonus_bal <= 0.0
|
|
740
771
|
|
|
741
772
|
if not is_custom and is_exhausted:
|
|
742
773
|
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 +802,7 @@ class LLMTransportMixin:
|
|
|
771
802
|
|
|
772
803
|
# 2. Check if chosen model is allowed — skip entirely for BYOK/custom models
|
|
773
804
|
models_allowed = quota.get("models_allowed", [])
|
|
774
|
-
free_bonus_balance = quota.get("free_bonus_balance", 0.0) or 0.0
|
|
805
|
+
free_bonus_balance = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
|
|
775
806
|
chosen_model = override_model if override_model else self.model_id
|
|
776
807
|
is_custom = self._is_custom_model_selection(chosen_model, is_primary=not override_model)
|
|
777
808
|
is_free_user = (quota.get("plan") == "free" or str(quota.get("display_name", "")).lower() == "free")
|
|
@@ -1025,6 +1056,8 @@ class LLMTransportMixin:
|
|
|
1025
1056
|
# Intentionally NOT reset by keep-alive pings (empty lines) so stall
|
|
1026
1057
|
# detection isn't fooled by the server sending blank heartbeats.
|
|
1027
1058
|
last_content_time = start_time
|
|
1059
|
+
# Per-effort stall timeout (thinking level, not model name).
|
|
1060
|
+
stall_timeout = _effort_stall_timeout(settings)
|
|
1028
1061
|
_api_key = config.get("api_key")
|
|
1029
1062
|
_openrouter_key = self._local_api_key
|
|
1030
1063
|
_browser_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
@@ -1288,15 +1321,21 @@ class LLMTransportMixin:
|
|
|
1288
1321
|
# Stall detection runs on EVERY iteration (including empty
|
|
1289
1322
|
# keep-alive lines) so a true stream stall is always caught.
|
|
1290
1323
|
if not final_content and not final_tool_calls:
|
|
1291
|
-
#
|
|
1292
|
-
#
|
|
1293
|
-
|
|
1294
|
-
|
|
1324
|
+
# Effort-scaled Time-To-First-Token timeout. Models can
|
|
1325
|
+
# need 60-300s to process large tool outputs, so this is
|
|
1326
|
+
# keyed to reasoning effort (thinking level), not model name.
|
|
1327
|
+
if now - start_time > stall_timeout:
|
|
1328
|
+
raise requests.exceptions.Timeout(
|
|
1329
|
+
f"Hard TTFT timeout exceeded {stall_timeout}s (effort)"
|
|
1330
|
+
)
|
|
1295
1331
|
else:
|
|
1296
1332
|
# Inter-content stall detection: abort if no real content
|
|
1297
|
-
# has arrived for
|
|
1298
|
-
|
|
1299
|
-
|
|
1333
|
+
# has arrived for `stall_timeout` seconds, even during
|
|
1334
|
+
# keep-alive pings.
|
|
1335
|
+
if now - last_content_time > stall_timeout:
|
|
1336
|
+
raise requests.exceptions.Timeout(
|
|
1337
|
+
f"Inter-token stall timeout exceeded {stall_timeout}s (effort)"
|
|
1338
|
+
)
|
|
1300
1339
|
|
|
1301
1340
|
if not raw_line:
|
|
1302
1341
|
continue
|
|
@@ -1348,10 +1387,15 @@ class LLMTransportMixin:
|
|
|
1348
1387
|
if t == "queue_status":
|
|
1349
1388
|
pos = chunk.get("position", 1)
|
|
1350
1389
|
status = chunk.get("status", "queued")
|
|
1390
|
+
tier = chunk.get("tier", "")
|
|
1391
|
+
prio = chunk.get("priority", 0)
|
|
1351
1392
|
if status == "queued":
|
|
1352
1393
|
try:
|
|
1353
1394
|
from utim_cli.state import STATE
|
|
1354
|
-
|
|
1395
|
+
if prio > 0 and tier:
|
|
1396
|
+
STATE["thinking_topic"] = f"Request Queued Position {pos} ({str(tier).title()} Priority)"
|
|
1397
|
+
else:
|
|
1398
|
+
STATE["thinking_topic"] = f"Request Queued Position {pos}"
|
|
1355
1399
|
except Exception:
|
|
1356
1400
|
pass
|
|
1357
1401
|
elif status == "processing":
|
|
@@ -1514,12 +1558,23 @@ class LLMTransportMixin:
|
|
|
1514
1558
|
"tool_calls": None,
|
|
1515
1559
|
}, False
|
|
1516
1560
|
elif _is_rate_limit:
|
|
1561
|
+
rate_limit_display = (
|
|
1562
|
+
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
1563
|
+
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
1564
|
+
f"- **Retry:** You can resend your prompt anytime."
|
|
1565
|
+
)
|
|
1517
1566
|
if not silent:
|
|
1518
1567
|
self.console.print(
|
|
1519
|
-
f"\n[
|
|
1520
|
-
|
|
1568
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
|
|
1569
|
+
)
|
|
1570
|
+
self.console.print(
|
|
1571
|
+
" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
|
|
1521
1572
|
)
|
|
1522
|
-
|
|
1573
|
+
return {
|
|
1574
|
+
"role": "assistant",
|
|
1575
|
+
"content": rate_limit_display,
|
|
1576
|
+
"tool_calls": None,
|
|
1577
|
+
}, False
|
|
1523
1578
|
elif _is_provider_upstream:
|
|
1524
1579
|
if not silent:
|
|
1525
1580
|
self.console.print(
|
|
@@ -1940,7 +1995,8 @@ class LLMTransportMixin:
|
|
|
1940
1995
|
timeout=5,
|
|
1941
1996
|
)
|
|
1942
1997
|
if _qr.status_code == 200:
|
|
1943
|
-
|
|
1998
|
+
_data = _qr.json()
|
|
1999
|
+
_has_bonus = (float(_data.get("bonus_balance", _data.get("free_bonus_balance", 0.0)) or 0.0) > 0.0)
|
|
1944
2000
|
except Exception:
|
|
1945
2001
|
pass
|
|
1946
2002
|
if _has_bonus:
|
|
@@ -2059,124 +2115,23 @@ class LLMTransportMixin:
|
|
|
2059
2115
|
"tool_calls": None,
|
|
2060
2116
|
}, False
|
|
2061
2117
|
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
# Are there more models in the fallback chain to try?
|
|
2070
|
-
_has_more_models = model_idx < len(models_to_try) - 1
|
|
2071
|
-
|
|
2072
|
-
if _has_more_models:
|
|
2073
|
-
# Soft, dim notice — let the next model in the chain take over.
|
|
2074
|
-
# The user doesn't need to (and shouldn't) see provider jargon.
|
|
2075
|
-
try:
|
|
2076
|
-
self.console.print(
|
|
2077
|
-
f"\n[dim yellow]↪ {current_model} is busy right now — "
|
|
2078
|
-
f"switching to the next available model…[/dim yellow]"
|
|
2079
|
-
)
|
|
2080
|
-
except Exception:
|
|
2081
|
-
pass
|
|
2082
|
-
# Break the inner retry loop → outer loop will pick the next model
|
|
2083
|
-
last_exc = exc
|
|
2084
|
-
break
|
|
2085
|
-
else:
|
|
2086
|
-
# Last model in chain also rate-limited — show a CLEAN, friendly
|
|
2087
|
-
# banner. Mask raw OpenRouter jargon. If the model name looks
|
|
2088
|
-
# like a free/OpenRouter endpoint, frame it as "all free
|
|
2089
|
-
# models are momentarily busy" instead of a paid-upgrade CTA.
|
|
2090
|
-
_is_free_tier_model = (
|
|
2091
|
-
":free" in current_model
|
|
2092
|
-
or "/free" in current_model
|
|
2093
|
-
or "openrouter" in current_model.lower()
|
|
2094
|
-
)
|
|
2095
|
-
_display_msg = _rate_limit_detail
|
|
2096
|
-
# Strip provider-specific keywords that leak through to users
|
|
2097
|
-
import re as _re
|
|
2098
|
-
for _jargon in (
|
|
2099
|
-
"tokens per minute", "requests per minute",
|
|
2100
|
-
"free model", "free tier", "provider",
|
|
2101
|
-
):
|
|
2102
|
-
_display_msg = _re.sub(
|
|
2103
|
-
_re.escape(_jargon),
|
|
2104
|
-
"",
|
|
2105
|
-
_display_msg,
|
|
2106
|
-
flags=_re.IGNORECASE,
|
|
2107
|
-
)
|
|
2108
|
-
# Word-boundary stripping for short tokens (tpm, rpm)
|
|
2109
|
-
for _jargon in ("tpm", "rpm"):
|
|
2110
|
-
_display_msg = _re.sub(
|
|
2111
|
-
rf"\b{_jargon}\b",
|
|
2112
|
-
"",
|
|
2113
|
-
_display_msg,
|
|
2114
|
-
flags=_re.IGNORECASE,
|
|
2115
|
-
)
|
|
2116
|
-
# Collapse leftover whitespace + dangling punctuation
|
|
2117
|
-
_display_msg = _re.sub(r"\s+", " ", _display_msg).strip(" .,;:—-")
|
|
2118
|
-
# Drop the generic "returned error:" prefix — it's provider
|
|
2119
|
-
# boilerplate that adds no value to a human reader.
|
|
2120
|
-
_display_msg = _re.sub(
|
|
2121
|
-
r"^(?:returned error|error)\s*[:\-—]?\s*",
|
|
2122
|
-
"",
|
|
2123
|
-
_display_msg,
|
|
2124
|
-
flags=_re.IGNORECASE,
|
|
2125
|
-
).strip(" .,;:—-")
|
|
2126
|
-
# If the sanitized message is too short or doesn't really say
|
|
2127
|
-
# anything useful, swap in a polished default.
|
|
2128
|
-
_low = _display_msg.lower()
|
|
2129
|
-
# Count substantive words (>= 3 chars, alpha) to detect
|
|
2130
|
-
# sentences that were gutted by jargon stripping.
|
|
2131
|
-
_substantive = sum(
|
|
2132
|
-
1 for _w in _re.findall(r"[A-Za-z]{3,}", _display_msg)
|
|
2133
|
-
)
|
|
2134
|
-
# Detect a "stranded period" artifact: a period that's now
|
|
2135
|
-
# surrounded by whitespace because its surrounding words
|
|
2136
|
-
# were stripped. This is a strong signal that the message
|
|
2137
|
-
# was gutted by jargon removal and is no longer readable.
|
|
2138
|
-
_has_stranded_period = bool(_re.search(r"\s\.\s", _display_msg))
|
|
2139
|
-
_is_uninformative = (
|
|
2140
|
-
len(_display_msg) < 12
|
|
2141
|
-
or _substantive < 3
|
|
2142
|
-
or _has_stranded_period
|
|
2143
|
-
or _low in ("returned error", "error", "rate limit", "limit")
|
|
2144
|
-
or _low in ("", ".", "..", "...")
|
|
2145
|
-
)
|
|
2146
|
-
if _is_uninformative:
|
|
2147
|
-
if _is_free_tier_model:
|
|
2148
|
-
_display_msg = (
|
|
2149
|
-
"All free models are momentarily busy. "
|
|
2150
|
-
"This clears automatically within a minute or two."
|
|
2151
|
-
)
|
|
2152
|
-
else:
|
|
2153
|
-
_display_msg = "Rate limit reached for this model."
|
|
2154
|
-
|
|
2118
|
+
rate_limit_display = (
|
|
2119
|
+
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
2120
|
+
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
2121
|
+
f"- **Retry:** You can resend your prompt anytime."
|
|
2122
|
+
)
|
|
2123
|
+
if not silent:
|
|
2155
2124
|
self.console.print(
|
|
2156
|
-
f"\n[bold yellow]⏳ {
|
|
2125
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
|
|
2157
2126
|
)
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
self.console.print(
|
|
2161
|
-
" [dim]Free models rotate through a shared capacity pool — "
|
|
2162
|
-
"this usually clears in under a minute.[/dim]"
|
|
2163
|
-
)
|
|
2164
|
-
self.console.print(
|
|
2165
|
-
" [dim]You can also pick a different model with "
|
|
2166
|
-
"[bold]/model[/bold] or press Ctrl+M.[/dim]\n"
|
|
2167
|
-
)
|
|
2168
|
-
else:
|
|
2169
|
-
if not upgrade_url:
|
|
2170
|
-
upgrade_url = "https://utim.dev/pricing"
|
|
2171
|
-
self.console.print(
|
|
2172
|
-
f" [dim]Top up at [bold]{upgrade_url}[/bold] or run "
|
|
2173
|
-
f"[bold]/balance[/bold] to check your remaining quota.[/dim]\n"
|
|
2174
|
-
)
|
|
2175
|
-
|
|
2176
|
-
raise _FatalClientError(
|
|
2177
|
-
f"All models in the fallback chain are rate-limited: "
|
|
2178
|
-
f"{_rate_limit_detail}"
|
|
2127
|
+
self.console.print(
|
|
2128
|
+
" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
|
|
2179
2129
|
)
|
|
2130
|
+
return {
|
|
2131
|
+
"role": "assistant",
|
|
2132
|
+
"content": rate_limit_display,
|
|
2133
|
+
"tool_calls": None,
|
|
2134
|
+
}, False
|
|
2180
2135
|
if attempt < model_retries:
|
|
2181
2136
|
delay = 3 * (attempt + 1)
|
|
2182
2137
|
# 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:
|