utim-cli 2.3.20__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.20 → utim_cli-2.3.21}/CHANGELOG.md +15 -0
- {utim_cli-2.3.20/utim_cli.egg-info → utim_cli-2.3.21}/PKG-INFO +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/pyproject.toml +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim/__init__.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/__init__.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/_version.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/client_utils.py +6 -1
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/constants.py +2 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/orchestrator_llm_transport.py +39 -7
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/auth.py +68 -19
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/batch_processor.py +65 -6
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/concurrency.py +167 -154
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/db.py +201 -9
- utim_cli-2.3.21/utim_cli/server/docs_md/pricing.md +19 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/key_rotator.py +606 -226
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/micro_batcher.py +60 -34
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/models.py +134 -84
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/plan_integrity.py +3 -2
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/pricing_updater.py +9 -6
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/router.py +57 -18
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/completion_routes.py +684 -89
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/quota_routes.py +216 -17
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tools.py +98 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/model_dialog.py +23 -7
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/utim.py +143 -68
- {utim_cli-2.3.20 → utim_cli-2.3.21/utim_cli.egg-info}/PKG-INFO +1 -1
- utim_cli-2.3.20/utim_cli/server/docs_md/pricing.md +0 -22
- {utim_cli-2.3.20 → utim_cli-2.3.21}/LICENSE +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/MANIFEST.in +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/README.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/setup.cfg +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/setup.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/context_pruner.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/doctor.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/orchestrator.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/orchestrator_helpers.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/orchestrator_tool_exec.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/orchestrator_turn_history.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/hooks.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/report.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/admin_auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/admin_db_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/routes/webhook_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/security_alerts.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/share.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/state.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.21}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.21] - 2026-09-06
|
|
4
|
+
|
|
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
|
+
|
|
3
18
|
## [2.3.20] - 2026-09-04
|
|
4
19
|
|
|
5
20
|
### 🛠️ Minor Fixes and Enhancement
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -132,6 +132,30 @@ 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:
|
|
136
160
|
_PRE_THINK_PATTERNS = re.compile(
|
|
137
161
|
r"^\s*(\*\s*Thinking\.\.\.?|\.\.\.(\s*thinking)?|thinking\.\.\.?)\s*$",
|
|
@@ -1032,6 +1056,8 @@ class LLMTransportMixin:
|
|
|
1032
1056
|
# Intentionally NOT reset by keep-alive pings (empty lines) so stall
|
|
1033
1057
|
# detection isn't fooled by the server sending blank heartbeats.
|
|
1034
1058
|
last_content_time = start_time
|
|
1059
|
+
# Per-effort stall timeout (thinking level, not model name).
|
|
1060
|
+
stall_timeout = _effort_stall_timeout(settings)
|
|
1035
1061
|
_api_key = config.get("api_key")
|
|
1036
1062
|
_openrouter_key = self._local_api_key
|
|
1037
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"
|
|
@@ -1295,15 +1321,21 @@ class LLMTransportMixin:
|
|
|
1295
1321
|
# Stall detection runs on EVERY iteration (including empty
|
|
1296
1322
|
# keep-alive lines) so a true stream stall is always caught.
|
|
1297
1323
|
if not final_content and not final_tool_calls:
|
|
1298
|
-
#
|
|
1299
|
-
#
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
+
)
|
|
1302
1331
|
else:
|
|
1303
1332
|
# Inter-content stall detection: abort if no real content
|
|
1304
|
-
# has arrived for
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
+
)
|
|
1307
1339
|
|
|
1308
1340
|
if not raw_line:
|
|
1309
1341
|
continue
|
|
@@ -108,12 +108,11 @@ def extract_client_ip(request: Optional[Request]) -> str:
|
|
|
108
108
|
|
|
109
109
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
110
110
|
|
|
111
|
-
def process_user_refills(db: Session, user_id: str) -> None:
|
|
111
|
+
def process_user_refills(db: Session, user_id: str, now: Optional[datetime.datetime] = None) -> None:
|
|
112
112
|
"""
|
|
113
|
-
Catch-up and process any pending 5-hour quota refills for the user's active subscription.
|
|
113
|
+
Catch-up and process any pending 5-hour quota refills and weekly rollovers for the user's active subscription.
|
|
114
114
|
Max refills per 30-day billing cycle = 144.
|
|
115
115
|
"""
|
|
116
|
-
import datetime
|
|
117
116
|
import uuid
|
|
118
117
|
from .db import UserSubscription, Credit, Transaction
|
|
119
118
|
|
|
@@ -122,7 +121,8 @@ def process_user_refills(db: Session, user_id: str) -> None:
|
|
|
122
121
|
UserSubscription.status == "active"
|
|
123
122
|
).first()
|
|
124
123
|
|
|
125
|
-
now
|
|
124
|
+
if now is None:
|
|
125
|
+
now = datetime.datetime.utcnow()
|
|
126
126
|
|
|
127
127
|
if not sub or not sub.plan:
|
|
128
128
|
return
|
|
@@ -155,29 +155,27 @@ def process_user_refills(db: Session, user_id: str) -> None:
|
|
|
155
155
|
)
|
|
156
156
|
|
|
157
157
|
refills_used = getattr(sub, 'refills_processed', getattr(sub, 'refills_used', 0)) or 0
|
|
158
|
-
if refills_used >=
|
|
159
|
-
return # Hit monthly cap of
|
|
158
|
+
if refills_used >= 4:
|
|
159
|
+
return # Hit monthly cap of 4 weekly refills (1000 credits monthly cap)
|
|
160
160
|
|
|
161
|
-
last_refill = sub.last_refill_at or sub.created_at
|
|
161
|
+
last_refill = sub.last_refill_at or sub.current_period_start or sub.created_at or now
|
|
162
162
|
elapsed_seconds = (now - last_refill).total_seconds()
|
|
163
163
|
|
|
164
|
-
# Calculate how many
|
|
165
|
-
INTERVAL_SECONDS =
|
|
164
|
+
# Calculate how many 7-day (weekly) intervals have passed since last refill
|
|
165
|
+
INTERVAL_SECONDS = 7 * 86400 # 7 days (weekly refill)
|
|
166
166
|
intervals_passed = int(elapsed_seconds // INTERVAL_SECONDS)
|
|
167
167
|
|
|
168
168
|
if intervals_passed > 0:
|
|
169
|
-
# Cap intervals to not exceed the
|
|
170
|
-
intervals_to_apply = min(intervals_passed,
|
|
169
|
+
# Cap intervals to not exceed the 4 weekly refills monthly limit
|
|
170
|
+
intervals_to_apply = min(intervals_passed, 4 - refills_used)
|
|
171
171
|
|
|
172
172
|
if intervals_to_apply > 0:
|
|
173
173
|
credit_row = db.query(Credit).filter(Credit.user_id == user_id).first()
|
|
174
174
|
if credit_row:
|
|
175
175
|
old_bal = credit_row.balance or 0.0
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
new_bal = min(100.0, old_bal + amount_to_add)
|
|
180
|
-
actual_added = new_bal - old_bal
|
|
176
|
+
# Free plan: weekly quota of 250 credits. No stacking: resets to 250 credits max balance.
|
|
177
|
+
new_bal = 250.0
|
|
178
|
+
actual_added = max(0.0, new_bal - old_bal)
|
|
181
179
|
|
|
182
180
|
credit_row.balance = new_bal
|
|
183
181
|
sub.refills_processed = refills_used + intervals_to_apply
|
|
@@ -193,12 +191,12 @@ def process_user_refills(db: Session, user_id: str) -> None:
|
|
|
193
191
|
kind="refill",
|
|
194
192
|
amount=actual_added,
|
|
195
193
|
balance_after=new_bal,
|
|
196
|
-
description=f"
|
|
194
|
+
description=f"Weekly quota refill: +{actual_added:.1f} credits ({intervals_to_apply} week{'s' if intervals_to_apply > 1 else ''})"
|
|
197
195
|
)
|
|
198
196
|
db.add(tx)
|
|
199
197
|
|
|
200
198
|
db.commit()
|
|
201
|
-
logger.info("
|
|
199
|
+
logger.info("weekly_refill_processed", extra={"user_id": user_id, "intervals": intervals_to_apply, "new_balance": new_bal})
|
|
202
200
|
return
|
|
203
201
|
|
|
204
202
|
# ── PAID-PLAN BRANCH ────────────────────────────────────────────────────
|
|
@@ -294,6 +292,57 @@ def process_user_refills(db: Session, user_id: str) -> None:
|
|
|
294
292
|
sub.last_refill_at = now
|
|
295
293
|
db.commit()
|
|
296
294
|
|
|
295
|
+
# ── Weekly Quota Rollover into Quota Bank (Every 7 days) ─────────────────
|
|
296
|
+
# Any unused weekly allowance from completed 7-day periods rolls over into Quota Bank.
|
|
297
|
+
plan = sub.plan
|
|
298
|
+
if plan and plan.credits_per_month:
|
|
299
|
+
weekly_allowance = plan.credits_per_month / 4.0
|
|
300
|
+
period_start = sub.current_period_start or sub.created_at or now
|
|
301
|
+
if getattr(sub, "last_weekly_reset_at", None) is None:
|
|
302
|
+
sub.last_weekly_reset_at = period_start
|
|
303
|
+
|
|
304
|
+
elapsed_total_seconds = max(0.0, (now - period_start).total_seconds())
|
|
305
|
+
weeks_elapsed = int(elapsed_total_seconds // (7 * 86400))
|
|
306
|
+
|
|
307
|
+
last_weekly_reset = sub.last_weekly_reset_at or period_start
|
|
308
|
+
last_week_elapsed = max(0.0, (last_weekly_reset - period_start).total_seconds())
|
|
309
|
+
weeks_processed = int(last_week_elapsed // (7 * 86400))
|
|
310
|
+
weeks_due = weeks_elapsed - weeks_processed
|
|
311
|
+
|
|
312
|
+
if weeks_due > 0:
|
|
313
|
+
weekly_used = getattr(sub, "weekly_credits_used", 0.0) or 0.0
|
|
314
|
+
unused_weekly = max(0.0, weekly_allowance - weekly_used)
|
|
315
|
+
if weeks_due > 1:
|
|
316
|
+
unused_weekly += weekly_allowance * (weeks_due - 1)
|
|
317
|
+
|
|
318
|
+
credit = db.query(Credit).filter(Credit.user_id == user_id).first()
|
|
319
|
+
if not credit:
|
|
320
|
+
credit = Credit(user_id=user_id, balance=0.0, total_spent=0.0, total_topped_up=0.0)
|
|
321
|
+
db.add(credit)
|
|
322
|
+
db.flush()
|
|
323
|
+
|
|
324
|
+
max_limit = plan.credits_per_month * 2.0
|
|
325
|
+
old_balance = credit.balance or 0.0
|
|
326
|
+
new_balance = min(max_limit, old_balance + unused_weekly)
|
|
327
|
+
|
|
328
|
+
if new_balance > old_balance:
|
|
329
|
+
added_amount = new_balance - old_balance
|
|
330
|
+
credit.balance = new_balance
|
|
331
|
+
tx = Transaction(
|
|
332
|
+
id=str(uuid.uuid4()),
|
|
333
|
+
user_id=user_id,
|
|
334
|
+
kind="weekly_rollover",
|
|
335
|
+
amount=added_amount,
|
|
336
|
+
balance_after=new_balance,
|
|
337
|
+
description=f"Weekly quota rollover: +{added_amount:.1f} unused credits added to Quota Bank ({plan.name.title()} Plan)"
|
|
338
|
+
)
|
|
339
|
+
db.add(tx)
|
|
340
|
+
|
|
341
|
+
sub.weekly_credits_used = 0.0
|
|
342
|
+
sub.weekly_resets_used = 0
|
|
343
|
+
sub.last_weekly_reset_at = period_start + datetime.timedelta(seconds=weeks_elapsed * 7 * 86400)
|
|
344
|
+
db.commit()
|
|
345
|
+
|
|
297
346
|
# If the subscription period has expired (current_period_end passed), auto-degrade to Free plan!
|
|
298
347
|
if sub.current_period_end and now >= sub.current_period_end:
|
|
299
348
|
# Auto-degrade: the paid period has ended and the user did not renew.
|
|
@@ -630,7 +679,7 @@ def create_user(
|
|
|
630
679
|
db.add(user)
|
|
631
680
|
|
|
632
681
|
# Create credit row for backward compatibility
|
|
633
|
-
credit = Credit(user_id=user.id, balance=
|
|
682
|
+
credit = Credit(user_id=user.id, balance=250.0, total_topped_up=0.0)
|
|
634
683
|
db.add(credit)
|
|
635
684
|
|
|
636
685
|
# Provision Free plan subscription
|
|
@@ -28,16 +28,21 @@ class BatchAPIProcessor:
|
|
|
28
28
|
"Content-Type": "application/json"
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
def create_batch_job(self, requests_list: List[Dict[str, Any]], model_id: str) -> Optional[Dict[str, Any]]:
|
|
32
|
-
"""Package requests into a .jsonl batch payload and submit to POST /v1/batches.
|
|
31
|
+
def create_batch_job(self, requests_list: List[Dict[str, Any]], model_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
32
|
+
"""Package requests into a .jsonl batch payload and submit to POST /v1/batches.
|
|
33
|
+
Supports multi-model requests where each item in requests_list can specify its own model.
|
|
34
|
+
"""
|
|
33
35
|
jsonl_lines = []
|
|
36
|
+
models_in_batch = set()
|
|
34
37
|
for idx, req_payload in enumerate(requests_list):
|
|
38
|
+
req_model = req_payload.get("model") or model_id or "openai/gpt-4o"
|
|
39
|
+
models_in_batch.add(req_model)
|
|
35
40
|
item = {
|
|
36
|
-
"custom_id": f"request-{idx}-{int(time.time())}",
|
|
41
|
+
"custom_id": req_payload.get("custom_id", f"request-{idx}-{int(time.time())}"),
|
|
37
42
|
"method": "POST",
|
|
38
43
|
"url": "/v1/chat/completions",
|
|
39
44
|
"body": {
|
|
40
|
-
"model":
|
|
45
|
+
"model": req_model,
|
|
41
46
|
"messages": req_payload.get("messages", []),
|
|
42
47
|
"max_tokens": req_payload.get("max_tokens", 1500),
|
|
43
48
|
"temperature": req_payload.get("temperature", 0.2)
|
|
@@ -46,7 +51,8 @@ class BatchAPIProcessor:
|
|
|
46
51
|
jsonl_lines.append(json.dumps(item))
|
|
47
52
|
|
|
48
53
|
jsonl_content = "\n".join(jsonl_lines)
|
|
49
|
-
|
|
54
|
+
model_desc = model_id if len(models_in_batch) == 1 and model_id else f"multi-model ({len(models_in_batch)} models: {list(models_in_batch)[:3]})"
|
|
55
|
+
print(f"[BATCH FILE API] Packaging {len(requests_list)} requests into .jsonl payload for {model_desc}...")
|
|
50
56
|
|
|
51
57
|
# Step 1: Upload file to /v1/files
|
|
52
58
|
try:
|
|
@@ -85,7 +91,7 @@ class BatchAPIProcessor:
|
|
|
85
91
|
print(f"[BATCH PROCESSOR EXCEPTION]: {exc}")
|
|
86
92
|
return None
|
|
87
93
|
|
|
88
|
-
def poll_and_retrieve_results(self, batch_id: str, poll_interval: float = 0.
|
|
94
|
+
def poll_and_retrieve_results(self, batch_id: str, poll_interval: float = 0.5, timeout: int = 300) -> Optional[List[Dict[str, Any]]]:
|
|
89
95
|
"""Poll batch job until status is completed and download output file."""
|
|
90
96
|
start_time = time.time()
|
|
91
97
|
batch_url = f"{self.base_url}/batches/{batch_id}"
|
|
@@ -117,3 +123,56 @@ class BatchAPIProcessor:
|
|
|
117
123
|
|
|
118
124
|
print(f"[BATCH TIMEOUT] Reached timeout of {timeout}s waiting for job '{batch_id}'")
|
|
119
125
|
return None
|
|
126
|
+
|
|
127
|
+
async def dispatch_concurrent_batch(
|
|
128
|
+
self,
|
|
129
|
+
requests_list: List[Dict[str, Any]],
|
|
130
|
+
default_model: Optional[str] = None,
|
|
131
|
+
timeout: float = 60.0,
|
|
132
|
+
) -> List[Dict[str, Any]]:
|
|
133
|
+
"""
|
|
134
|
+
Concurrently dispatches a batch of multi-model requests to OpenRouter / upstream API at once.
|
|
135
|
+
Does NOT wait for one request to complete before sending the next.
|
|
136
|
+
"""
|
|
137
|
+
import httpx
|
|
138
|
+
import asyncio
|
|
139
|
+
|
|
140
|
+
limits = httpx.Limits(max_keepalive_connections=20, max_connections=100)
|
|
141
|
+
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
|
|
142
|
+
async def _send_one(req_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
143
|
+
model = req_payload.get("model") or default_model or "openai/gpt-4o"
|
|
144
|
+
url = f"{self.base_url}/chat/completions"
|
|
145
|
+
headers = {
|
|
146
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
"HTTP-Referer": "https://utim.ai",
|
|
149
|
+
"X-Title": "UTIM CLI",
|
|
150
|
+
}
|
|
151
|
+
body: Dict[str, Any] = {
|
|
152
|
+
"model": model,
|
|
153
|
+
"messages": req_payload.get("messages", []),
|
|
154
|
+
"max_tokens": req_payload.get("max_tokens", 1500),
|
|
155
|
+
"temperature": req_payload.get("temperature", 0.7),
|
|
156
|
+
}
|
|
157
|
+
for k, v in req_payload.items():
|
|
158
|
+
if k not in body and k not in ("custom_id",):
|
|
159
|
+
body[k] = v
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
resp = await client.post(url, json=body, headers=headers)
|
|
163
|
+
return {
|
|
164
|
+
"custom_id": req_payload.get("custom_id"),
|
|
165
|
+
"model": model,
|
|
166
|
+
"status_code": resp.status_code,
|
|
167
|
+
"data": resp.json() if resp.status_code == 200 else resp.text,
|
|
168
|
+
}
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return {
|
|
171
|
+
"custom_id": req_payload.get("custom_id"),
|
|
172
|
+
"model": model,
|
|
173
|
+
"status_code": 500,
|
|
174
|
+
"error": str(e),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
tasks = [_send_one(req) for req in requests_list]
|
|
178
|
+
return await asyncio.gather(*tasks)
|