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.
Files changed (172) hide show
  1. {utim_cli-2.3.20 → utim_cli-2.3.22}/CHANGELOG.md +22 -0
  2. {utim_cli-2.3.20/utim_cli.egg-info → utim_cli-2.3.22}/PKG-INFO +1 -1
  3. {utim_cli-2.3.20 → utim_cli-2.3.22}/pyproject.toml +1 -1
  4. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_version.py +1 -1
  7. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/client_utils.py +6 -1
  8. utim_cli-2.3.22/utim_cli/constants.py +99 -0
  9. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_llm_transport.py +68 -8
  10. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/auth.py +68 -19
  11. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/batch_processor.py +65 -6
  12. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/concurrency.py +167 -154
  13. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/db.py +201 -9
  14. utim_cli-2.3.22/utim_cli/server/docs_md/pricing.md +19 -0
  15. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/key_rotator.py +606 -226
  16. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/micro_batcher.py +60 -34
  17. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/models.py +146 -84
  18. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/plan_integrity.py +3 -2
  19. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/pricing_updater.py +9 -6
  20. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/router.py +57 -18
  21. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/completion_routes.py +636 -89
  22. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/quota_routes.py +216 -17
  23. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tools.py +98 -0
  24. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/model_dialog.py +23 -7
  25. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utim.py +143 -68
  26. {utim_cli-2.3.20 → utim_cli-2.3.22/utim_cli.egg-info}/PKG-INFO +1 -1
  27. utim_cli-2.3.20/utim_cli/constants.py +0 -41
  28. utim_cli-2.3.20/utim_cli/server/docs_md/pricing.md +0 -22
  29. {utim_cli-2.3.20 → utim_cli-2.3.22}/LICENSE +0 -0
  30. {utim_cli-2.3.20 → utim_cli-2.3.22}/MANIFEST.in +0 -0
  31. {utim_cli-2.3.20 → utim_cli-2.3.22}/README.md +0 -0
  32. {utim_cli-2.3.20 → utim_cli-2.3.22}/setup.cfg +0 -0
  33. {utim_cli-2.3.20 → utim_cli-2.3.22}/setup.py +0 -0
  34. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_verify_repro.py +0 -0
  35. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_verify_syntax.py +0 -0
  36. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/_wizard_smoke_test.py +0 -0
  37. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/agent.py +0 -0
  38. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/ask_helper.py +0 -0
  39. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/auth.py +0 -0
  40. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/backup.py +0 -0
  41. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/billing.py +0 -0
  42. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/blender_agent.py +0 -0
  43. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/bootstrap.py +0 -0
  44. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/brain.py +0 -0
  45. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  46. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/config.py +0 -0
  47. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/context_pruner.py +0 -0
  48. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/__init__.py +0 -0
  49. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/engine.py +0 -0
  50. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/crawler/markdown_distiller.py +0 -0
  51. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/dependency_manager.py +0 -0
  52. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/doctor.py +0 -0
  53. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/evidence_registry.py +0 -0
  54. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/harbor.py +0 -0
  55. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/knowledge_graph.py +0 -0
  56. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/local_db.py +0 -0
  57. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/logger.py +0 -0
  58. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_clean_wrapper.py +0 -0
  59. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_client.py +0 -0
  60. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/mcp_registry.json +0 -0
  61. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/models.txt +0 -0
  62. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator.py +0 -0
  63. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_helpers.py +0 -0
  64. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_tool_exec.py +0 -0
  65. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/orchestrator_turn_history.py +0 -0
  66. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/personalities.py +0 -0
  67. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/platform_installer.py +0 -0
  68. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/__init__.py +0 -0
  69. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/hooks.py +0 -0
  70. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/loader.py +0 -0
  71. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/manager.py +0 -0
  72. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/registry.py +0 -0
  73. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/runtime.py +0 -0
  74. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/schema.py +0 -0
  75. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/plugins/tool_loader.py +0 -0
  76. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/reflection.py +0 -0
  77. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/report.py +0 -0
  78. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/scrapy_search.py +0 -0
  79. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/__init__.py +0 -0
  80. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/config.py +0 -0
  81. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/context.py +0 -0
  82. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/events.py +0 -0
  83. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/handlers.py +0 -0
  84. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/session.py +0 -0
  85. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/sdk/sidecar.py +0 -0
  86. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/__init__.py +0 -0
  87. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/admin_auth.py +0 -0
  88. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/attribution.py +0 -0
  89. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/audit_log.py +0 -0
  90. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/captcha.py +0 -0
  91. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/cli_auth.py +0 -0
  92. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  93. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/about.md +0 -0
  94. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/changelog.md +0 -0
  95. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/docs.md +0 -0
  96. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/features.md +0 -0
  97. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/license.md +0 -0
  98. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/privacy.md +0 -0
  99. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/refund.md +0 -0
  100. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/support.md +0 -0
  101. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/docs_md/terms.md +0 -0
  102. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/email_utils.py +0 -0
  103. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/exchange_rate.py +0 -0
  104. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/firebase.py +0 -0
  105. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/fix_duplicate_users.py +0 -0
  106. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/history.py +0 -0
  107. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/local_llm.py +0 -0
  108. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/logging_config.py +0 -0
  109. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/model_agent.py +0 -0
  110. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/provision_build.py +0 -0
  111. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/rate_limit.py +0 -0
  112. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/rewards_engine.py +0 -0
  113. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/__init__.py +0 -0
  114. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
  115. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/admin_db_routes.py +0 -0
  116. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/auth_routes.py +0 -0
  117. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/billing_routes.py +0 -0
  118. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/credit_routes.py +0 -0
  119. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/feedback_routes.py +0 -0
  120. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/marketplace_routes.py +0 -0
  121. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/quota_share_routes.py +0 -0
  122. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/referral_routes.py +0 -0
  123. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/rewards_routes.py +0 -0
  124. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/security_routes.py +0 -0
  125. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/session_routes.py +0 -0
  126. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/share_routes.py +0 -0
  127. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/routes/webhook_routes.py +0 -0
  128. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/security_alerts.py +0 -0
  129. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/server.py +0 -0
  130. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/storage_nodes.py +0 -0
  131. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/server/utils.py +0 -0
  132. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/share.py +0 -0
  133. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/share_tui.py +0 -0
  134. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/situational_scoring.py +0 -0
  135. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/state.py +0 -0
  136. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/subagent_manager.py +0 -0
  137. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/task_dispatcher.py +0 -0
  138. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/task_logger.py +0 -0
  139. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tools_grep.py +0 -0
  140. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/__init__.py +0 -0
  141. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/feedback_dialog.py +0 -0
  142. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/history_dialog.py +0 -0
  143. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_app_state.py +0 -0
  144. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_dialog.py +0 -0
  145. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  146. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/mcp_dialog.py +0 -0
  147. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/miniagents_dialog.py +0 -0
  148. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/personality_dialog.py +0 -0
  149. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/plugins_dialog.py +0 -0
  150. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/publish_dialog.py +0 -0
  151. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_dialog.py +0 -0
  152. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  153. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/quota_share_dialog.py +0 -0
  154. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/resume_dialog.py +0 -0
  155. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/rewards_tui.py +0 -0
  156. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/skills_dialog.py +0 -0
  157. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/subagents_dialog.py +0 -0
  158. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/tasks_dialog.py +0 -0
  159. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/thinking_display.py +0 -0
  160. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/tools_dialog.py +0 -0
  161. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/tui/update_dialog.py +0 -0
  162. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/update_checker.py +0 -0
  163. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utilities.py +0 -0
  164. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/utimmodel.txt +0 -0
  165. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/vector_memory.py +0 -0
  166. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/wheel.py +0 -0
  167. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli/workspace.py +0 -0
  168. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/SOURCES.txt +0 -0
  169. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/dependency_links.txt +0 -0
  170. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/entry_points.txt +0 -0
  171. {utim_cli-2.3.20 → utim_cli-2.3.22}/utim_cli.egg-info/requires.txt +0 -0
  172. {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.20
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.3.20"
7
+ version = "2.3.22"
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.20"
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
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.3.20"
8
+ __version__ = "2.3.22"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.3.20"
14
+ VERSION = "2.3.22"
15
15
 
16
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 "image" in tags
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
- # Hard 900-second timeout for Time-To-First-Token
1299
- # Models often need 60-300s to process large tool outputs before streaming
1300
- if now - start_time > 900:
1301
- raise requests.exceptions.Timeout("Hard TTFT timeout exceeded 900s")
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 900 seconds (15 mins), even during keep-alive pings.
1305
- if now - last_content_time > 900:
1306
- raise requests.exceptions.Timeout("Inter-token stall timeout exceeded 900s")
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 = datetime.datetime.utcnow()
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 >= 144:
159
- return # Hit monthly cap of 144 refills (3000 credits max)
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 5-hour intervals have passed since last refill
165
- INTERVAL_SECONDS = 5 * 3600 # 5 hours
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 144 monthly limit
170
- intervals_to_apply = min(intervals_passed, 144 - refills_used)
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
- # Add $0.50 (20 credits) per interval, capped at $2.50 (100 credits) max balance
177
- # If balance is already >= $2.50, balance stays same but refill interval updates
178
- amount_to_add = intervals_to_apply * 20.0
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"Auto-refill: +{actual_added:.1f} credits ({intervals_to_apply} interval{'s' if intervals_to_apply > 1 else ''})"
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("refill_processed", extra={"user_id": user_id, "intervals": intervals_to_apply, "new_balance": new_bal})
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=100.0, total_topped_up=0.0)
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": model_id,
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
- print(f"[BATCH FILE API] Packaging {len(requests_list)} requests into .jsonl payload for model '{model_id}'...")
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.3, timeout: int = 300) -> Optional[List[Dict[str, Any]]]:
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)