utim-cli 2.3.20__tar.gz → 2.3.22__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.22}/CHANGELOG.md +22 -0
- {utim_cli-2.3.20/utim_cli.egg-info → utim_cli-2.3.22}/PKG-INFO +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.22}/pyproject.toml +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim/__init__.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/__init__.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_version.py +1 -1
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/client_utils.py +6 -1
- utim_cli-2.3.22/utim_cli/constants.py +99 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_llm_transport.py +68 -8
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/auth.py +68 -19
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/batch_processor.py +65 -6
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/concurrency.py +167 -154
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/db.py +201 -9
- utim_cli-2.3.22/utim_cli/server/docs_md/pricing.md +19 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/key_rotator.py +606 -226
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/micro_batcher.py +60 -34
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/models.py +146 -84
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/plan_integrity.py +3 -2
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/pricing_updater.py +9 -6
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/router.py +57 -18
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/completion_routes.py +636 -89
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/quota_routes.py +216 -17
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tools.py +98 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/model_dialog.py +23 -7
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utim.py +143 -68
- {utim_cli-2.3.20 → utim_cli-2.3.22/utim_cli.egg-info}/PKG-INFO +1 -1
- utim_cli-2.3.20/utim_cli/constants.py +0 -41
- utim_cli-2.3.20/utim_cli/server/docs_md/pricing.md +0 -22
- {utim_cli-2.3.20 → utim_cli-2.3.22}/LICENSE +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/MANIFEST.in +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/README.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/setup.cfg +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/setup.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/context_pruner.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/doctor.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_helpers.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_tool_exec.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_turn_history.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/hooks.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/report.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/admin_auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/admin_db_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/webhook_routes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/security_alerts.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/share.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/state.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.22] - 2026-09-06
|
|
4
|
+
|
|
5
|
+
### 🛡️ Seamless Rate-Limit Failover to Paid Slugs
|
|
6
|
+
- **Client-Side Auto-Shift**: In `orchestrator_llm_transport.py`, when a free model (e.g. `poolside/laguna-s-2.1:free`) encounters an HTTP 429 or streaming rate-limit event, the client immediately switches to its paid slug (`poolside/laguna-s-2.1`) without terminating the conversation turn or showing an interrupting rate-limit error.
|
|
7
|
+
- **Single Source of Truth**: Centralized `get_paid_slug()` in `utim_cli/constants.py` for uniform mapping across client transport and server completion routing.
|
|
8
|
+
- **Laguna Paid Slug Registration**: Added `poolside/laguna-s-2.1` to the server model registry with complete tool, chat, and reasoning capabilities.
|
|
9
|
+
- **Cost-Protected MiniMax Failover**: Ensured rate-limited MiniMax free models shift to the Laguna S 2.1 paid slug rather than expensive MiniMax paid tiers.
|
|
10
|
+
- **Credit Rate Preservation**: Retained fixed free model pricing ($0.30 input / $0.50 output per 1M tokens) on server fallback turns.
|
|
11
|
+
|
|
12
|
+
## [2.3.21] - 2026-09-06
|
|
13
|
+
|
|
14
|
+
### 💰 Pricing Modifications
|
|
15
|
+
- OpenRouter tier markup standardization and free model fallback credit preservation
|
|
16
|
+
|
|
17
|
+
### 📦 Models Payload Fixes
|
|
18
|
+
- Upstream provider normalization and APINEX model mapping support
|
|
19
|
+
|
|
20
|
+
### 🛡️ Rate Limit & Vision Tooling Fixes
|
|
21
|
+
- MiniMax rate-limit fallback to Laguna and APINEX transparent image analysis
|
|
22
|
+
|
|
23
|
+
*See [docs](docs.md) for detailed APINEX migration information and implementation notes.*
|
|
24
|
+
|
|
3
25
|
## [2.3.20] - 2026-09-04
|
|
4
26
|
|
|
5
27
|
### 🛠️ 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.22
|
|
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.22"
|
|
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)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DEFAULT_MODEL = "poolside/laguna-s-2.1:free"
|
|
4
|
+
SERVER_URL = os.getenv("UTIM_SERVER_URL", "https://api.utim.dev")
|
|
5
|
+
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
6
|
+
|
|
7
|
+
SUBAGENT_DEFAULTS = {
|
|
8
|
+
"web_search": DEFAULT_MODEL,
|
|
9
|
+
"plan_project": DEFAULT_MODEL,
|
|
10
|
+
"generate_image": "qwen/qwen-image-3",
|
|
11
|
+
"analyze_image": "google/gemma-4-31b-it:free",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
APINEX_MODEL_IDS = {
|
|
15
|
+
"claude/opus-5",
|
|
16
|
+
"claude/sonnet-5",
|
|
17
|
+
"deepseek/v4-flash-0731",
|
|
18
|
+
"deepseek/v4-pro-0813",
|
|
19
|
+
"free/deepseek-v4-flash-0731",
|
|
20
|
+
"free/deepseek-v4-pro-0813",
|
|
21
|
+
"free/glm-5.3-flash",
|
|
22
|
+
"free/gemini-3.1-pro",
|
|
23
|
+
"free/gemini-3.7-flash",
|
|
24
|
+
"gemini/3.1-pro",
|
|
25
|
+
"gemini/3.7-flash",
|
|
26
|
+
"kimi/k3",
|
|
27
|
+
"gpt/5.6-luna",
|
|
28
|
+
"gpt/5.6-sol",
|
|
29
|
+
"gpt/5.6-terra",
|
|
30
|
+
"glm/5.3",
|
|
31
|
+
"glm/5.3-flash",
|
|
32
|
+
"grok/4.6",
|
|
33
|
+
"qwen/3.8-max",
|
|
34
|
+
"free/qwen-3.8-max",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
APINEX_TO_OPENROUTER_PAID: dict[str, str] = {
|
|
38
|
+
"gemini/3.7-flash": "google/gemini-2.5-flash",
|
|
39
|
+
"free/gemini-3.7-flash": "google/gemini-2.5-flash",
|
|
40
|
+
"gemini/3.1-pro": "google/gemini-2.5-pro",
|
|
41
|
+
"free/gemini-3.1-pro": "google/gemini-2.5-pro",
|
|
42
|
+
"deepseek/v4-flash-0731": "deepseek/deepseek-chat",
|
|
43
|
+
"free/deepseek-v4-flash-0731": "deepseek/deepseek-chat",
|
|
44
|
+
"deepseek/v4-pro-0813": "deepseek/deepseek-r1",
|
|
45
|
+
"free/deepseek-v4-pro-0813": "deepseek/deepseek-r1",
|
|
46
|
+
"claude/sonnet-5": "anthropic/claude-3.5-sonnet",
|
|
47
|
+
"claude/opus-5": "anthropic/claude-3-opus",
|
|
48
|
+
"gpt/5.6-luna": "openai/gpt-4o-mini",
|
|
49
|
+
"gpt/5.6-sol": "openai/gpt-4o",
|
|
50
|
+
"gpt/5.6-terra": "openai/gpt-4o",
|
|
51
|
+
"glm/5.3": "z-ai/glm-4",
|
|
52
|
+
"glm/5.3-flash": "z-ai/glm-4",
|
|
53
|
+
"free/glm-5.3-flash": "z-ai/glm-4",
|
|
54
|
+
"kimi/k3": "moonshotai/kimi-k2",
|
|
55
|
+
"grok/4.6": "x-ai/grok-2-1212",
|
|
56
|
+
"qwen/3.8-max": "qwen/qwen-2.5-72b-instruct",
|
|
57
|
+
"free/qwen-3.8-max": "qwen/qwen-2.5-72b-instruct",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_paid_slug(model_id: str) -> str:
|
|
62
|
+
"""Return the paid equivalent slug for a free or gateway model.
|
|
63
|
+
- MiniMax models (e.g. minimax/minimax-m2.7:free, minimax/minimax-m3:free) are pricy,
|
|
64
|
+
so when hitting rate limits, the server falls back to Laguna S 2.1 paid slug
|
|
65
|
+
('poolside/laguna-s-2.1') instead of expensive MiniMax paid slugs.
|
|
66
|
+
- If model ends with ':free', strip ':free' (provider/model:free -> provider/model).
|
|
67
|
+
- If APINEX model, map to corresponding OpenRouter paid model.
|
|
68
|
+
- If model starts with 'free/', check APINEX mapping or strip 'free/'.
|
|
69
|
+
- If 'openrouter/free', return standard high-capacity default ('google/gemini-2.5-flash').
|
|
70
|
+
"""
|
|
71
|
+
clean_id = (model_id or "").strip()
|
|
72
|
+
clean_lower = clean_id.lower()
|
|
73
|
+
|
|
74
|
+
# MiniMax free models are pricy on their paid tier; route fallback to Laguna S 2.1 paid slug
|
|
75
|
+
if "minimax" in clean_lower:
|
|
76
|
+
return "poolside/laguna-s-2.1"
|
|
77
|
+
|
|
78
|
+
if clean_id.endswith(":free"):
|
|
79
|
+
return clean_id[:-5]
|
|
80
|
+
if clean_id in APINEX_TO_OPENROUTER_PAID:
|
|
81
|
+
return APINEX_TO_OPENROUTER_PAID[clean_id]
|
|
82
|
+
if clean_id.startswith("free/"):
|
|
83
|
+
unprefixed = clean_id[5:]
|
|
84
|
+
if unprefixed in APINEX_TO_OPENROUTER_PAID:
|
|
85
|
+
return APINEX_TO_OPENROUTER_PAID[unprefixed]
|
|
86
|
+
if unprefixed.endswith(":free"):
|
|
87
|
+
return unprefixed[:-5]
|
|
88
|
+
return unprefixed
|
|
89
|
+
if clean_id == "openrouter/free":
|
|
90
|
+
return "google/gemini-2.5-flash"
|
|
91
|
+
return clean_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
import sys
|
|
95
|
+
_IS_LEGACY_WIN = (sys.platform == "win32" and "WT_SESSION" not in os.environ)
|
|
96
|
+
PROMPT_SYMBOL = ">" if _IS_LEGACY_WIN else "❯"
|
|
97
|
+
ARROW_SYMBOL = "->" if _IS_LEGACY_WIN else "➔"
|
|
98
|
+
|
|
99
|
+
|
|
@@ -30,7 +30,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
30
30
|
from typing import Any, Dict, List, Optional, Tuple
|
|
31
31
|
|
|
32
32
|
import requests
|
|
33
|
-
from utim_cli.constants import DEFAULT_MODEL
|
|
33
|
+
from utim_cli.constants import DEFAULT_MODEL, get_paid_slug
|
|
34
34
|
|
|
35
35
|
# openai SDK removed — we call OpenRouter directly via requests (no Rust/jiter needed)
|
|
36
36
|
from rich.console import Console
|
|
@@ -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
|
|
@@ -1526,6 +1558,20 @@ class LLMTransportMixin:
|
|
|
1526
1558
|
"tool_calls": None,
|
|
1527
1559
|
}, False
|
|
1528
1560
|
elif _is_rate_limit:
|
|
1561
|
+
_paid_slug = get_paid_slug(current_model)
|
|
1562
|
+
if _paid_slug != current_model:
|
|
1563
|
+
if not silent:
|
|
1564
|
+
self.console.print(
|
|
1565
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited. Shifting seamlessly to paid slug [bold]{_paid_slug}[/bold]...[/bold yellow]\n"
|
|
1566
|
+
)
|
|
1567
|
+
if _paid_slug not in models_to_try:
|
|
1568
|
+
models_to_try.insert(model_idx + 1, _paid_slug)
|
|
1569
|
+
if not override_model and model_idx == 0:
|
|
1570
|
+
self.model_id = _paid_slug
|
|
1571
|
+
self.model_source = "utim"
|
|
1572
|
+
config.set("main_model_source", "utim")
|
|
1573
|
+
raise RuntimeError(f"__RATE_LIMIT_SHIFT__:{current_model}:{_paid_slug}")
|
|
1574
|
+
|
|
1529
1575
|
rate_limit_display = (
|
|
1530
1576
|
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
1531
1577
|
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
@@ -2083,6 +2129,20 @@ class LLMTransportMixin:
|
|
|
2083
2129
|
"tool_calls": None,
|
|
2084
2130
|
}, False
|
|
2085
2131
|
|
|
2132
|
+
_paid_slug = get_paid_slug(current_model)
|
|
2133
|
+
if _paid_slug != current_model:
|
|
2134
|
+
if not silent:
|
|
2135
|
+
self.console.print(
|
|
2136
|
+
f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited. Shifting seamlessly to paid slug [bold]{_paid_slug}[/bold]...[/bold yellow]\n"
|
|
2137
|
+
)
|
|
2138
|
+
if _paid_slug not in models_to_try:
|
|
2139
|
+
models_to_try.insert(model_idx + 1, _paid_slug)
|
|
2140
|
+
if not override_model and model_idx == 0:
|
|
2141
|
+
self.model_id = _paid_slug
|
|
2142
|
+
self.model_source = "utim"
|
|
2143
|
+
config.set("main_model_source", "utim")
|
|
2144
|
+
break # attempt loop → outer loop immediately tries _paid_slug next!
|
|
2145
|
+
|
|
2086
2146
|
rate_limit_display = (
|
|
2087
2147
|
f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
|
|
2088
2148
|
f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
|
|
@@ -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)
|