novacode-cli 1.2.0__py3-none-any.whl

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 (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1325 @@
1
+ import os
2
+ import re
3
+
4
+ from langchain_core.language_models import BaseChatModel
5
+
6
+ from novacode_cli.config.config import console, settings
7
+ from novacode_cli.config.provider_registry import CATALOG_PROVIDERS
8
+
9
+ # Env var that must be set before a provider is usable. Ollama needs no key.
10
+ PROVIDER_KEY_ENV: dict[str, str] = {
11
+ "openai": "OPENAI_API_KEY",
12
+ "anthropic": "ANTHROPIC_API_KEY",
13
+ "google": "GOOGLE_API_KEY",
14
+ "openrouter": "OPENROUTER_API_KEY",
15
+ "opencode": "OPENCODE_API_KEY",
16
+ "opencode_zen": "OPENCODE_ZEN_API_KEY",
17
+ "commandcode": "COMMANDCODE_API_KEY",
18
+ "nvidia": "NVIDIA_API_KEY",
19
+ "azure": "AZURE_OPENAI_API_KEY",
20
+ "mistral": "MISTRAL_API_KEY",
21
+ "groq": "GROQ_API_KEY",
22
+ "cerebras": "CEREBRAS_API_KEY",
23
+ "xai": "XAI_API_KEY",
24
+ "huggingface": "HF_TOKEN",
25
+ "kimi_coding": "KIMI_API_KEY",
26
+ "minimax": "MINIMAX_API_KEY",
27
+ # Bedrock is absent on purpose: an AWS profile or role authenticates it with
28
+ # no key in the environment, so a key gate would wrongly turn it away.
29
+ }
30
+
31
+ PROVIDER_KEY_ENV.update({
32
+ provider: preset["api_key_var"]
33
+ for provider, preset in CATALOG_PROVIDERS.items()
34
+ if preset.get("api_key_var")
35
+ })
36
+
37
+
38
+ def azure_base_url() -> str:
39
+ """The Azure v1 OpenAI-compatible URL for the configured resource.
40
+
41
+ From ``AZURE_OPENAI_ENDPOINT``, the endpoint saved with the key in /auth, or
42
+ ``AZURE_RESOURCE_NAME``. Accepts the endpoint with or without ``/openai/v1``.
43
+ """
44
+ from novacode_cli.config.credentials import credential_meta
45
+
46
+ meta = credential_meta("AZURE_OPENAI_API_KEY")
47
+ endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") or (meta.base_url if meta else None)
48
+ if not endpoint and (resource := os.environ.get("AZURE_RESOURCE_NAME")):
49
+ endpoint = f"https://{resource}.openai.azure.com"
50
+ if not endpoint:
51
+ raise ValueError(
52
+ "Azure needs your resource endpoint: add it with the key in /auth, or set "
53
+ "AZURE_OPENAI_ENDPOINT (https://<resource>.openai.azure.com) or AZURE_RESOURCE_NAME."
54
+ )
55
+ endpoint = endpoint.strip().rstrip("/").removesuffix("/v1").removesuffix("/openai")
56
+ return f"{endpoint}/openai/v1"
57
+
58
+
59
+ def _has_openai_chatgpt_auth() -> bool:
60
+ """Whether OpenAI is available through a stored ChatGPT-plan sign-in."""
61
+ try:
62
+ from novacode_cli.config.openai_chatgpt_auth import has_credentials
63
+
64
+ return has_credentials()
65
+ except Exception: # noqa: BLE001 — provider discovery must not abort startup
66
+ return False
67
+
68
+
69
+ def _openai_auth_available() -> bool:
70
+ """Honor the selected OpenAI auth mode when gating model construction."""
71
+ from novacode_cli.config.openai_chatgpt_auth import has_credentials, selected_auth_mode
72
+
73
+ mode = selected_auth_mode()
74
+ has_key = bool(settings.openai_api_key or os.environ.get("OPENAI_API_KEY"))
75
+ if mode == "api_key":
76
+ return has_key
77
+ if mode == "chatgpt":
78
+ return has_credentials()
79
+ return has_key or has_credentials()
80
+
81
+
82
+ def _opencode_chat_class() -> type:
83
+ """A ChatOpenAI that drops message fields OpenCode Go's upstreams reject.
84
+
85
+ OpenCode Go is a gateway: it routes each model to a different upstream, and
86
+ some of those refuse a ``name`` on a message with
87
+
88
+ 400 invalid_request_error — messages[2]: "name" is not supported by
89
+ this endpoint
90
+
91
+ which kills the turn outright. Nova never sets ``name`` itself; LangChain
92
+ puts it there from the agent/subagent name, where it is traceability
93
+ metadata rather than anything the model needs. Dropping it costs nothing
94
+ and removes a whole class of hard failures.
95
+
96
+ Scoped to this client, via the one hook that sees the finished payload, so
97
+ no other provider's requests change — the strictness is OpenCode's, and
98
+ ``name`` is perfectly legal for OpenAI itself.
99
+ """
100
+ from langchain_openai import ChatOpenAI
101
+
102
+ class _OpenCodeChat(ChatOpenAI): # type: ignore[misc]
103
+ def _get_request_payload(self, input_, *, stop=None, **kwargs): # noqa: ANN001, ANN202
104
+ payload = super()._get_request_payload(input_, stop=stop, **kwargs)
105
+ for message in payload.get("messages", []):
106
+ if isinstance(message, dict):
107
+ message.pop("name", None)
108
+ return payload
109
+
110
+ return _OpenCodeChat
111
+
112
+
113
+ def _chatgpt_plan_chat_class() -> type:
114
+ """Adapt Nova system prompts to the ChatGPT-plan Responses contract."""
115
+ from langchain_core.messages import ChatMessage, SystemMessage
116
+ from langchain_openai import ChatOpenAI
117
+
118
+ class _ChatGPTPlanChat(ChatOpenAI): # type: ignore[misc]
119
+ def _get_request_payload(self, input_, *, stop=None, **kwargs): # noqa: ANN001, ANN202
120
+ messages = self._convert_input(input_).to_messages()
121
+ # The SIWC preview rejects input items with role="system". OpenAI
122
+ # documents developer messages as the supported equivalent.
123
+ normalized = [
124
+ ChatMessage(
125
+ role="developer",
126
+ content=message.content,
127
+ additional_kwargs=message.additional_kwargs,
128
+ response_metadata=message.response_metadata,
129
+ name=message.name,
130
+ id=message.id,
131
+ )
132
+ if isinstance(message, SystemMessage)
133
+ else message
134
+ for message in messages
135
+ ]
136
+ return super()._get_request_payload(normalized, stop=stop, **kwargs)
137
+
138
+ return _ChatGPTPlanChat
139
+
140
+
141
+ #: Stable for the life of the process — see :func:`opencode_session_id`.
142
+ _OPENCODE_SESSION_ID: str | None = None
143
+
144
+
145
+ def opencode_session_id() -> str:
146
+ """The ``x-opencode-session`` value for the OpenCode Go gateway.
147
+
148
+ The gateway rejects requests that omit this header (400 ``MissingSessionID``,
149
+ "cannot be routed efficiently"), and uses it to route and to key prompt
150
+ caching. It must therefore stay *stable across a conversation* rather than
151
+ change per request — a fresh id per call would defeat the cache it exists to
152
+ enable, which is presumably why the gateway refuses to serve without one.
153
+
154
+ One id per Nova process. That is the honest granularity available here: the
155
+ chat client is constructed once with fixed headers, well before any session
156
+ is chosen, and it outlives session switches. A Nova run is close enough to
157
+ one conversation for routing and caching to work.
158
+
159
+ Set ``OPENCODE_SESSION_ID`` to pin it — across restarts (so a resumed
160
+ session keeps its cache) or per pane if you run parallel sessions and want
161
+ them cached separately.
162
+ """
163
+ global _OPENCODE_SESSION_ID
164
+ pinned = os.environ.get("OPENCODE_SESSION_ID", "").strip()
165
+ if pinned:
166
+ return pinned
167
+ if _OPENCODE_SESSION_ID is None:
168
+ import uuid
169
+
170
+ _OPENCODE_SESSION_ID = f"nova-{uuid.uuid4().hex}"
171
+ return _OPENCODE_SESSION_ID
172
+
173
+
174
+ #: Claude model IDs: family, major, minor (``claude-sonnet-5-5``, ``claude-opus-4-8``).
175
+ #: A dated suffix (``claude-sonnet-4-20250514``) is not a minor version.
176
+ _CLAUDE_VERSION = re.compile(r"claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?=-|@|$)")
177
+
178
+
179
+ def _claude_takes_adaptive_thinking(model_name: str) -> bool:
180
+ """Claude 4.6+ and Fable/Mythos think adaptively; ``budget_tokens`` is a 400
181
+ on Sonnet 5.5, Sonnet 5, Opus 4.7+, Fable and Haiku 5.5. Older IDs
182
+ (Haiku 4.5, Sonnet 4.5, ``claude-3-*``) still use a token budget."""
183
+ match = _CLAUDE_VERSION.search(model_name.lower())
184
+ if match is None:
185
+ return False
186
+ family, major, minor = match.group(1), int(match.group(2)), int(match.group(3) or 0)
187
+ return family in ("fable", "mythos") or (major, minor) >= (4, 6)
188
+
189
+
190
+ def anthropic_thinking_kwargs(model_name: str, effort: str | None, budget: int = 0) -> dict:
191
+ """ChatAnthropic thinking settings for Nova's ``/effort`` (off/low/medium/high).
192
+
193
+ Adaptive models get ``effort`` (``output_config.effort``) with adaptive
194
+ thinking shown as a summary, so Nova's reasoning stream has text. "off" is
195
+ the lowest effort rather than disabled thinking, which Opus 5.5, Sonnet 5.5
196
+ and Fable reject. Older models keep ``budget_tokens``, from the configured
197
+ ``thinking_budget`` or a per-effort default.
198
+ """
199
+ level = effort if effort in ("low", "medium", "high") else None
200
+ if _claude_takes_adaptive_thinking(model_name):
201
+ if effort == "off":
202
+ return {"effort": "low"}
203
+ if level is None: # "default": the model's own defaults
204
+ return {}
205
+ # Opus/Sonnet 4.6 summarize by default; `display` arrived with 4.7.
206
+ display = {} if re.search(r"-4-6(?!\d)", model_name) else {"display": "summarized"}
207
+ return {"effort": level, "thinking": {"type": "adaptive", **display}}
208
+ if not budget and effort and effort != "off":
209
+ budget = {"low": 2048, "medium": 4096, "high": 16384}.get(effort, 4096)
210
+ if budget > 0:
211
+ return {"thinking": {"type": "enabled", "budget_tokens": budget}}
212
+ return {}
213
+
214
+
215
+ def openai_reasoning_kwargs(model_name: str, effort: str | None, *, summary: bool = False) -> dict:
216
+ """``reasoning_effort`` for OpenAI reasoning models (o-series, GPT-5 and later).
217
+
218
+ langchain-openai sends it as ``reasoning.effort`` on the Responses API.
219
+ Models without reasoning (gpt-4o, gpt-4.1, ``*-chat``) and o1-mini/o1-preview
220
+ reject it. "none" and "minimal" are not accepted everywhere (GPT-6 Astra,
221
+ GPT-6.1 Sol), so "off" is "low", which every reasoning model takes.
222
+ Summary-capable Responses routes opt in with ``summary=True``.
223
+ """
224
+ name = model_name.lower().rsplit("/", 1)[-1]
225
+ reasoning = re.match(r"(o\d|gpt-([5-9]|\d{2}))", name) and "chat" not in name
226
+ if not reasoning or name.startswith(("o1-mini", "o1-preview")):
227
+ return {}
228
+ if effort == "off":
229
+ return {"reasoning_effort": "low"}
230
+ if summary and effort in {"low", "medium", "high"} and not name.startswith("o1"):
231
+ return {
232
+ "use_responses_api": True,
233
+ "store": False,
234
+ "include": ["reasoning.encrypted_content"],
235
+ "reasoning": {"effort": effort, "summary": "auto"},
236
+ }
237
+ return {"reasoning_effort": effort} if effort in ("low", "medium", "high") else {}
238
+
239
+
240
+ def google_thinking_kwargs(model_name: str, effort: str | None) -> dict:
241
+ """Gemini thinking: ``thinking_level`` on 3.x and later, ``thinking_budget`` on 2.5.
242
+
243
+ Sending the other one is an error, and 2.0 has no thinking. Gemini 3 Pro
244
+ takes only low/high; "off" is "low" on 3.x (3 Pro and 3.1 Pro cannot turn
245
+ thinking off), a budget of 0 on 2.5 Flash and the 128 minimum on 2.5 Pro.
246
+ 2.5 budgets top out at 24,576 (Flash) and 32,768 (Pro).
247
+ """
248
+ name = model_name.lower().rsplit("/", 1)[-1]
249
+ match = re.search(r"gemini-(\d+)(?:\.(\d+))?", name)
250
+ if match is None or effort not in ("off", "low", "medium", "high"):
251
+ return {}
252
+ version = (int(match.group(1)), int(match.group(2) or 0))
253
+ if version >= (3, 0):
254
+ level = "low" if effort == "off" else effort
255
+ if level == "medium" and "gemini-3-pro" in name:
256
+ level = "high"
257
+ return {"thinking_level": level, "include_thoughts": True}
258
+ if version == (2, 5):
259
+ pro = "pro" in name
260
+ if effort == "off":
261
+ return {"thinking_budget": 128 if pro else 0}
262
+ budget = {"low": 1024, "medium": 8192, "high": 32768 if pro else 24576}[effort]
263
+ return {"thinking_budget": budget, "include_thoughts": True}
264
+ return {}
265
+
266
+
267
+ def gateway_reasoning_kwargs(provider: str, model_name: str, effort: str | None) -> dict:
268
+ """Map verified gateway controls without assuming all models accept effort.
269
+
270
+ OpenRouter translates its unified reasoning field across upstream families.
271
+ Command Code and OpenCode use OpenAI-shaped effort for GPT and DeepSeek V4.
272
+ On those gateways ``off`` requests minimum effort, not disabled thinking.
273
+ Unknown families keep provider defaults rather than receiving guessed fields.
274
+ """
275
+ if effort not in {"off", "low", "medium", "high"}:
276
+ return {}
277
+ if provider == "openrouter":
278
+ return {"extra_body": {"reasoning": {"effort": "low" if effort == "off" else effort}}}
279
+ kwargs = openai_reasoning_kwargs(model_name, effort)
280
+ name = model_name.lower().rsplit("/", 1)[-1]
281
+ if not kwargs and name.startswith("deepseek-v4"):
282
+ return {"reasoning_effort": "low" if effort in {"off", "low"} else "high"}
283
+ return kwargs
284
+
285
+
286
+ def reasoning_setting_note( # noqa: PLR0911 — each provider has a different control
287
+ provider: str | None, model_name: str, effort: str | None
288
+ ) -> str:
289
+ """Describe the configured control honestly, including model limitations."""
290
+ if not effort or effort == "default":
291
+ return "Using the provider's default reasoning settings."
292
+ name = model_name.lower().rsplit("/", 1)[-1]
293
+ if provider == "nvidia":
294
+ return "NVIDIA uses a model-specific thinking toggle; low/medium/high all enable it."
295
+ if provider == "ollama":
296
+ return (
297
+ "Ollama receives reasoning_effort (off → none); support depends on the installed model."
298
+ )
299
+ if provider == "google" and google_thinking_kwargs(model_name, effort):
300
+ return (
301
+ "Gemini receives its version-specific thinking level or budget; "
302
+ "some models cannot disable thinking."
303
+ )
304
+ if name.startswith("claude-") and provider in {
305
+ "anthropic",
306
+ "commandcode",
307
+ "opencode",
308
+ "opencode_zen",
309
+ }:
310
+ return (
311
+ "Claude receives adaptive effort or a thinking budget; "
312
+ "off uses minimum effort on adaptive models."
313
+ )
314
+ if provider == "openrouter":
315
+ return (
316
+ "OpenRouter receives unified reasoning effort; off requests low. "
317
+ "Upstream model support varies."
318
+ )
319
+ if openai_reasoning_kwargs(model_name, effort):
320
+ return (
321
+ "The model receives reasoning effort; off requests low "
322
+ "because some models cannot disable thinking."
323
+ )
324
+ if provider in {"commandcode", "opencode", "opencode_zen"} and name.startswith("deepseek-v4"):
325
+ return "DeepSeek V4 receives low/high effort (medium → high, off → low)."
326
+ return (
327
+ "No verified configurable effort control for this model/route; "
328
+ "provider defaults remain in effect."
329
+ )
330
+
331
+
332
+ def build_chat_model(
333
+ provider: str, model_name: str, *, reasoning_effort: str | None = None,
334
+ max_retries: int | None = None,
335
+ ) -> BaseChatModel:
336
+ """Build a provider model, with an optional per-route effort override.
337
+
338
+ The override uses the same capability mappings as the session setting and
339
+ never saves a new global preference.
340
+ """
341
+ if max_retries is not None:
342
+ # Factory calls must reserve capacity separately for every retry.
343
+ model = _build_chat_model(
344
+ provider, model_name, reasoning_effort=reasoning_effort, max_retries=max_retries
345
+ )
346
+ else:
347
+ model = (
348
+ _build_chat_model(provider, model_name, reasoning_effort=reasoning_effort)
349
+ if reasoning_effort is not None else _build_chat_model(provider, model_name)
350
+ )
351
+ # Lossless model identity for new worker processes; never infer a gateway
352
+ # provider from its OpenAI-compatible model class or change its defaults.
353
+ model.metadata = {**(model.metadata or {}), "nova_provider": provider, "nova_model": model_name}
354
+ from novacode_cli.tracking.usage_tree import attach_usage_callback
355
+
356
+ attach_usage_callback(model)
357
+ if os.environ.get("NOVA_LOCAL_METRICS") == "1":
358
+ from novacode_cli.tracking.local_metrics import callbacks
359
+
360
+ model.callbacks = [*(getattr(model, "callbacks", None) or []), *callbacks()]
361
+ return model
362
+
363
+
364
+ def ollama_openai_base_url() -> str:
365
+ """Ollama's OpenAI-compatible endpoint, from ``OLLAMA_HOST`` (default local).
366
+
367
+ ``OLLAMA_HOST`` is the variable the ``ollama`` CLI itself reads, and it is
368
+ often written without a scheme (``127.0.0.1:11434``) or as the bind address
369
+ ``0.0.0.0``, which is not somewhere a client can connect to.
370
+ """
371
+ host = (os.environ.get("OLLAMA_HOST") or "").strip().rstrip("/") or "http://localhost:11434"
372
+ if "://" not in host:
373
+ host = f"http://{host}"
374
+ host = host.replace("://0.0.0.0", "://localhost")
375
+ if host.endswith("/v1"):
376
+ return host
377
+ return f"{host}/v1"
378
+
379
+
380
+ def _build_chat_model(
381
+ provider: str, model_name: str, *, reasoning_effort: str | None = None,
382
+ max_retries: int = 5,
383
+ ) -> BaseChatModel:
384
+ """THE model constructor — every ChatX(...) in Nova is built here.
385
+
386
+ One deep module for provider construction: reasoning-effort / thinking
387
+ budgets, retries, Ollama num_ctx + content-block patch, OpenRouter base
388
+ URL. Key-presence *policy* stays with the callers (return None / warn and
389
+ fall back / raise) — this function only constructs.
390
+
391
+ Args:
392
+ provider: "ollama" | "openai" | "anthropic" | "google" | "openrouter" | "opencode".
393
+ model_name: The model identifier for that provider.
394
+
395
+ Raises:
396
+ ValueError: Unknown provider.
397
+ """
398
+ from novacode_cli.config.nova_config import NovaConfig
399
+
400
+ nova_config = NovaConfig()
401
+ effort = reasoning_effort if reasoning_effort is not None else nova_config.get("reasoning_effort")
402
+
403
+ if provider in CATALOG_PROVIDERS:
404
+ from novacode_cli.config.catalog_models import build_catalog_model
405
+
406
+ return build_catalog_model(provider, model_name, max_retries=max_retries)
407
+
408
+ if provider == "ollama":
409
+ # Ollama is driven through its OpenAI-compatible endpoint (`/v1`) with
410
+ # the OpenAI client — the same path as every other OpenAI-compatible
411
+ # provider here, and how OpenCode talks to Ollama.
412
+ #
413
+ # What that changes, all checked against a live server (2026-10-11):
414
+ # - Thinking is switched off with `reasoning_effort="none"`; the native
415
+ # `think: false` is ignored on this endpoint.
416
+ # - A reply is capped with `max_tokens` (a cut-off reply reports
417
+ # finish_reason "length"). Uncapped, a thinking model can run away:
418
+ # ~394k tokens in one reply, then an empty turn.
419
+ # - `num_ctx` cannot be sent. Ollama's cloud ignored it anyway. For a
420
+ # LOCAL model the server's own setting now applies, so that has to be
421
+ # large enough: start the server with OLLAMA_CONTEXT_LENGTH set, or
422
+ # bake `num_ctx` into the model with a Modelfile. Nova still reads the
423
+ # context Ollama actually allocated (`ollama ps`) to size its window.
424
+ # - `keep_alive` cannot be sent either; the server's default applies.
425
+ from langchain_openai import ChatOpenAI
426
+
427
+ from novacode_cli.utils.backend_patches import (
428
+ apply_openai_reasoning_content_patch,
429
+ )
430
+
431
+ apply_openai_reasoning_content_patch()
432
+
433
+ ollama_kwargs: dict = {}
434
+ if effort:
435
+ ollama_kwargs["reasoning_effort"] = "none" if effort == "off" else effort
436
+
437
+ return ChatOpenAI(
438
+ model=model_name,
439
+ base_url=ollama_openai_base_url(),
440
+ # The local server needs no key; the client refuses to start without
441
+ # one. A real key is only used when pointing straight at a hosted API.
442
+ api_key=os.environ.get("OLLAMA_API_KEY") or "ollama",
443
+ temperature=0,
444
+ # Sent as a raw `max_tokens`, NOT through the client's own field: the
445
+ # client renames that to `max_completion_tokens`, which Ollama
446
+ # accepts and ignores — a limit of 25 produced an 800-token reply.
447
+ extra_body={"max_tokens": nova_config.get("max_tokens", 16384)},
448
+ max_retries=max_retries,
449
+ # Token usage is only streamed back when asked for, and the client
450
+ # does not ask by default for a custom base URL.
451
+ stream_usage=True,
452
+ **ollama_kwargs,
453
+ )
454
+
455
+ if provider == "commandcode":
456
+ # Command Code's Provider API: Claude models are served only on the
457
+ # Anthropic Messages route (400 elsewhere), every other model on Chat
458
+ # Completions. The public /models list confirms the split by prefix.
459
+ from novacode_cli.config.model_manager import COMMANDCODE_BASE_URL
460
+
461
+ from novacode_cli.config.nova_config import _config_bool
462
+
463
+ api_key = settings.commandcode_api_key or os.environ.get("COMMANDCODE_API_KEY")
464
+ # Zero data retention, switched in /auth: only ZDR-capable upstreams
465
+ # serve the request, and a model with none fails with 422 instead.
466
+ zdr = {"x-cmd-zdr": "1"} if _config_bool(nova_config.get("commandcode_zdr", False)) else {}
467
+ if model_name.lower().startswith("claude-"):
468
+ from langchain_anthropic import ChatAnthropic
469
+
470
+ budget = nova_config.get("thinking_budget", 0) or 0
471
+ return ChatAnthropic(
472
+ model_name=model_name,
473
+ api_key=api_key,
474
+ # The SDK appends /v1/messages itself.
475
+ base_url=COMMANDCODE_BASE_URL.removesuffix("/v1"),
476
+ default_headers=zdr,
477
+ max_tokens=20_000, # type: ignore[arg-type]
478
+ max_retries=max_retries,
479
+ **anthropic_thinking_kwargs(model_name, effort, int(budget)),
480
+ )
481
+ from novacode_cli.utils.backend_patches import apply_openai_reasoning_content_patch
482
+
483
+ apply_openai_reasoning_content_patch()
484
+ # A gateway to many upstreams, like OpenCode: drop `name` on messages.
485
+ return _opencode_chat_class()(
486
+ model=model_name,
487
+ api_key=api_key,
488
+ base_url=COMMANDCODE_BASE_URL,
489
+ default_headers=zdr,
490
+ max_retries=max_retries,
491
+ stream_usage=True,
492
+ **gateway_reasoning_kwargs(provider, model_name, effort),
493
+ )
494
+
495
+ from novacode_cli.config.model_manager import OPENAI_COMPATIBLE_PROVIDERS
496
+
497
+ if provider in OPENAI_COMPATIBLE_PROVIDERS or provider == "azure":
498
+ # Mistral, Groq, Cerebras, xAI, Hugging Face, Kimi For Coding and Azure's
499
+ # v1 API all take OpenAI Chat Completions at their own URL. `name` on a
500
+ # message is dropped, as for the gateways: Groq and Hugging Face's
501
+ # upstreams refuse it, and it is metadata the model never needs.
502
+ from novacode_cli.config.model_manager import provider_api_key, provider_base_url
503
+ from novacode_cli.utils.backend_patches import apply_openai_reasoning_content_patch
504
+
505
+ apply_openai_reasoning_content_patch()
506
+ if provider == "azure":
507
+ base_url = azure_base_url()
508
+ reasoning = openai_reasoning_kwargs(model_name, effort, summary=True)
509
+ else:
510
+ base_url = provider_base_url(provider)
511
+ reasoning = {}
512
+ return _opencode_chat_class()(
513
+ model=model_name,
514
+ api_key=provider_api_key(provider),
515
+ base_url=base_url,
516
+ max_retries=max_retries,
517
+ stream_usage=True,
518
+ **reasoning,
519
+ )
520
+
521
+ if provider == "minimax":
522
+ # MiniMax serves its models on an Anthropic-compatible Messages API.
523
+ from langchain_anthropic import ChatAnthropic
524
+
525
+ from novacode_cli.config.model_manager import provider_api_key, provider_base_url
526
+
527
+ return ChatAnthropic(
528
+ model_name=model_name,
529
+ api_key=provider_api_key(provider),
530
+ base_url=provider_base_url(provider),
531
+ max_tokens=20_000, # type: ignore[arg-type]
532
+ max_retries=max_retries,
533
+ )
534
+
535
+ if provider == "bedrock":
536
+ # Credentials come from boto3's usual chain: AWS_BEARER_TOKEN_BEDROCK (a
537
+ # Bedrock API key, which /auth stores), AWS_ACCESS_KEY_ID, a profile or a role.
538
+ try:
539
+ from langchain_aws import ChatBedrockConverse
540
+ except ImportError as exc: # pragma: no cover - install-time path
541
+ from novacode_cli.install_commands import install_command
542
+
543
+ command = install_command(["langchain-aws"], "bedrock")
544
+ raise RuntimeError(
545
+ "The Bedrock provider needs the langchain-aws package, which is not "
546
+ f"installed in this environment. Install it with:\n\n {command}\n\n"
547
+ "then restart Nova and run /model again."
548
+ ) from exc
549
+
550
+ region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1"
551
+ bedrock_options = {}
552
+ if max_retries != 5:
553
+ from botocore.config import Config
554
+
555
+ bedrock_options["config"] = Config(retries={"total_max_attempts": max_retries + 1})
556
+ return ChatBedrockConverse(
557
+ model=model_name,
558
+ region_name=region,
559
+ max_tokens=nova_config.get("max_tokens", 16384),
560
+ **bedrock_options,
561
+ )
562
+
563
+ if provider in ("openai", "openrouter", "opencode", "opencode_zen"):
564
+ from langchain_openai import ChatOpenAI
565
+
566
+ from novacode_cli.utils.backend_patches import (
567
+ apply_openai_reasoning_content_patch,
568
+ )
569
+
570
+ # Thinking models (DeepSeek, GLM) require their reasoning_content to be
571
+ # sent back on the next turn; LangChain drops it. Applied for every
572
+ # OpenAI-compatible provider because the patch only acts on messages
573
+ # that actually carry the field.
574
+ apply_openai_reasoning_content_patch()
575
+
576
+ openai_kwargs: dict = (
577
+ openai_reasoning_kwargs(model_name, effort) if provider == "openai"
578
+ else gateway_reasoning_kwargs(provider, model_name, effort)
579
+ )
580
+ if provider == "openrouter":
581
+ # OpenRouter is OpenAI-compatible: same client, custom base URL + key.
582
+ from novacode_cli.config.model_manager import OPENROUTER_BASE_URL
583
+
584
+ openai_kwargs["base_url"] = OPENROUTER_BASE_URL
585
+ # Resolve via settings (keyring-or-env) then env — a key saved to the
586
+ # system keychain is NOT in os.environ on a fresh session, so reading
587
+ # os.environ alone left the model with api_key=None on every restart.
588
+ openai_kwargs["api_key"] = settings.openrouter_api_key or os.environ.get(
589
+ "OPENROUTER_API_KEY"
590
+ )
591
+ elif provider in {"opencode", "opencode_zen"}:
592
+ # OpenCode Go is OpenAI-compatible: same client, custom base URL + key.
593
+ from novacode_cli.config.model_manager import MODEL_PRESETS
594
+ from novacode_cli.config.opencode_gateway import headers, protocol
595
+
596
+ openai_kwargs["base_url"] = MODEL_PRESETS[provider]["base_url"]
597
+ # Keyring-aware (see openrouter note): os.environ alone was empty on a
598
+ # restart even with the key saved, so OpenCode silently 401'd.
599
+ openai_kwargs["api_key"] = getattr(settings, f"{provider}_api_key", None) or os.environ.get(
600
+ PROVIDER_KEY_ENV[provider]
601
+ )
602
+ # The gateway REJECTS requests without this header — every call
603
+ # fails with 400 MissingSessionID — so it is not optional.
604
+ openai_kwargs["default_headers"] = headers(opencode_session_id())
605
+ api = protocol(provider, model_name)
606
+ if api == "unsupported":
607
+ raise ValueError("This OpenCode model uses an API not supported by the chat provider.")
608
+ if api == "messages":
609
+ from langchain_anthropic import ChatAnthropic
610
+ thinking_kwargs = (
611
+ anthropic_thinking_kwargs(
612
+ model_name, effort, int(nova_config.get("thinking_budget", 0) or 0)
613
+ ) if model_name.lower().startswith("claude-") else {}
614
+ )
615
+ return ChatAnthropic(
616
+ model=model_name, api_key=openai_kwargs["api_key"],
617
+ base_url=openai_kwargs["base_url"],
618
+ default_headers=openai_kwargs["default_headers"],
619
+ max_tokens=20_000, max_retries=max_retries,
620
+ **thinking_kwargs,
621
+ )
622
+ if api == "responses":
623
+ openai_kwargs.update(use_responses_api=True, store=False)
624
+ else:
625
+ # Plain OpenAI: same keyring-or-env resolution as the gateways above.
626
+ # Without this, a key held only in the system keychain passed the
627
+ # caller's has-key gate but never reached the client, so ChatOpenAI
628
+ # raised "api_key must be set" instead of working.
629
+ openai_key = settings.openai_api_key or os.environ.get("OPENAI_API_KEY")
630
+ from novacode_cli.config.openai_chatgpt_auth import (
631
+ has_credentials,
632
+ selected_auth_mode,
633
+ )
634
+
635
+ auth_mode = selected_auth_mode()
636
+ if auth_mode == "chatgpt":
637
+ if not has_credentials():
638
+ raise ValueError("ChatGPT sign-in is selected for OpenAI. Connect it in /auth.")
639
+ openai_key = None
640
+ if openai_key:
641
+ openai_kwargs["api_key"] = openai_key
642
+ # Custom OpenAI-compatible endpoint (Azure, LM Studio, vLLM, a
643
+ # LiteLLM proxy…). Saved by /model, overridable per-shell via
644
+ # OPENAI_BASE_URL. Without this, ChatOpenAI always went to
645
+ # api.openai.com and a self-hosted endpoint was unreachable.
646
+ base_url = None
647
+ try:
648
+ base_url = nova_config.get_model_base_url()
649
+ except Exception: # noqa: BLE001 — config is best-effort here
650
+ base_url = None
651
+ base_url = base_url or os.environ.get("OPENAI_BASE_URL")
652
+ if auth_mode == "chatgpt":
653
+ # ChatGPT-plan tokens are valid only for OpenAI's public
654
+ # Responses endpoint, regardless of a saved custom API URL.
655
+ base_url = None
656
+ if auth_mode == "api_key" and not openai_key and not base_url:
657
+ raise ValueError("OpenAI API-key auth is selected, but no API key is configured.")
658
+ if base_url:
659
+ openai_kwargs["base_url"] = base_url
660
+ # A local endpoint usually ignores the key but the client still
661
+ # demands one; send a placeholder so it doesn't refuse to start.
662
+ openai_kwargs.setdefault("api_key", "not-needed")
663
+ elif not openai_key and auth_mode in {"auto", "chatgpt"}:
664
+ # ChatGPT plan credentials are separate from API keys and are
665
+ # accepted only on the public Responses API route.
666
+ from novacode_cli.config.openai_chatgpt_auth import (
667
+ has_credentials,
668
+ http_clients,
669
+ )
670
+
671
+ if has_credentials():
672
+ sync_client, async_client = http_clients()
673
+
674
+ openai_kwargs.update(
675
+ api_key="chatgpt-plan-token-managed-per-request",
676
+ use_responses_api=True,
677
+ streaming=True,
678
+ store=False,
679
+ http_client=sync_client,
680
+ http_async_client=async_client,
681
+ )
682
+
683
+ # Public OpenAI exposes reasoning summaries only through Responses.
684
+ # Preserve Chat Completions on custom endpoints and gateways unless
685
+ # their model route already explicitly selected Responses.
686
+ summary_kwargs = openai_reasoning_kwargs(model_name, effort, summary=True)
687
+ public_openai = provider == "openai" and openai_kwargs.get("base_url") in {
688
+ None,
689
+ "https://api.openai.com/v1",
690
+ "https://api.openai.com/v1/",
691
+ }
692
+ if (
693
+ "reasoning" in summary_kwargs
694
+ and "reasoning_effort" in openai_kwargs
695
+ and (public_openai or openai_kwargs.get("use_responses_api"))
696
+ ):
697
+ openai_kwargs.pop("reasoning_effort", None)
698
+ openai_kwargs.update(summary_kwargs)
699
+
700
+ if provider in {"opencode", "opencode_zen"}:
701
+ return _opencode_chat_class()(
702
+ model=model_name, max_retries=max_retries, **openai_kwargs
703
+ )
704
+ if openai_kwargs.get("use_responses_api"):
705
+ return _chatgpt_plan_chat_class()(model=model_name, max_retries=max_retries, **openai_kwargs)
706
+ return ChatOpenAI(model=model_name, max_retries=max_retries, **openai_kwargs)
707
+
708
+ if provider == "nvidia":
709
+ # NVIDIA NIM (build.nvidia.com). Its own client rather than ChatOpenAI:
710
+ # ChatNVIDIA handles NIM's model-listing and payload quirks, and takes
711
+ # NIM-specific options (chat_template_kwargs) that ChatOpenAI drops.
712
+ try:
713
+ from langchain_nvidia_ai_endpoints import ChatNVIDIA
714
+ except ImportError as exc: # pragma: no cover - install-time path
715
+ # A bare ModuleNotFoundError here reads as a Nova bug rather
716
+ # than a missing package, and leaves the user with no next
717
+ # step. Nova installs pull this in; a pre-existing
718
+ # environment upgraded in place will not have it.
719
+ from novacode_cli.install_commands import install_command
720
+
721
+ command = install_command(["langchain-nvidia-ai-endpoints"])
722
+ raise RuntimeError(
723
+ "The NVIDIA provider needs the langchain-nvidia-ai-endpoints "
724
+ "package, which is not installed in this environment. "
725
+ f"Install it with:\n\n {command}\n\n"
726
+ "then restart Nova and run /model again."
727
+ ) from exc
728
+
729
+ # Keyring-or-env, never a literal: same resolution as every other
730
+ # provider here, so a key stored in the system keychain still reaches
731
+ # the client on a fresh session (os.environ alone is empty there).
732
+ nvidia_key = settings.nvidia_api_key or os.environ.get("NVIDIA_API_KEY")
733
+
734
+ nvidia_kwargs: dict = {}
735
+ if nvidia_key:
736
+ nvidia_kwargs["api_key"] = nvidia_key
737
+ # A self-hosted NIM container speaks the same API on a different host.
738
+ nvidia_base = os.environ.get("NVIDIA_BASE_URL")
739
+ if nvidia_base:
740
+ nvidia_kwargs["base_url"] = nvidia_base
741
+
742
+ # Reasoning models on NIM gate their chain-of-thought behind a
743
+ # per-request template flag. Nova surfaces reasoning as its own event
744
+ # stream, so follow the session's /effort setting: off -> no thinking.
745
+ #
746
+ # Passed inside model_kwargs, not as a top-level argument. ChatNVIDIA
747
+ # does not declare chat_template_kwargs as a field, so a top-level one
748
+ # is relocated here anyway — with a warning on every model build telling
749
+ # the user to "confirm that chat_template_kwargs is what you intended".
750
+ # Both routes produce a byte-identical request payload; this one is
751
+ # simply the declared way to say it.
752
+ if effort:
753
+ nvidia_kwargs["model_kwargs"] = {
754
+ "chat_template_kwargs": {"thinking": effort != "off"}
755
+ }
756
+
757
+ return ChatNVIDIA(
758
+ model=model_name,
759
+ temperature=nova_config.get("temperature", 1.0),
760
+ top_p=nova_config.get("top_p", 0.95),
761
+ max_tokens=nova_config.get("max_tokens", 16384),
762
+ **nvidia_kwargs,
763
+ )
764
+
765
+ if provider == "anthropic":
766
+ from langchain_anthropic import ChatAnthropic
767
+
768
+ thinking_budget = nova_config.get("thinking_budget", 0) or int(
769
+ os.environ.get("Nova_THINKING_BUDGET", "0")
770
+ )
771
+ thinking_kwargs = anthropic_thinking_kwargs(model_name, effort, int(thinking_budget or 0))
772
+
773
+ return ChatAnthropic(
774
+ model_name=model_name,
775
+ max_tokens=20_000, # type: ignore[arg-type]
776
+ max_retries=max_retries,
777
+ **thinking_kwargs,
778
+ )
779
+
780
+ if provider == "google":
781
+ from langchain_google_genai import ChatGoogleGenerativeAI
782
+
783
+ from novacode_cli.config.google_oauth_auth import (
784
+ GoogleOAuthAuth,
785
+ has_credentials,
786
+ use_oauth,
787
+ )
788
+
789
+ google_kwargs: dict = {}
790
+ google_key = (os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
791
+ or settings.google_api_key)
792
+ oauth_selected = use_oauth(has_key=bool(google_key))
793
+ if oauth_selected:
794
+ if not has_credentials():
795
+ raise ValueError("Google sign-in is selected but missing. Reconnect through /auth.")
796
+ # The SDK currently requires an API-key constructor argument for
797
+ # Developer API. HTTPX replaces this non-secret placeholder with
798
+ # real OAuth bearer auth on every sync/async/streaming request.
799
+ google_kwargs.update(
800
+ google_api_key="nova-oauth-managed",
801
+ vertexai=False,
802
+ base_url="https://generativelanguage.googleapis.com/",
803
+ )
804
+ google_kwargs.update(google_thinking_kwargs(model_name, effort))
805
+
806
+ model = ChatGoogleGenerativeAI(
807
+ model=model_name,
808
+ temperature=0,
809
+ max_tokens=None,
810
+ max_retries=max_retries,
811
+ **google_kwargs,
812
+ )
813
+ if oauth_selected:
814
+ import httpx
815
+ from google import genai
816
+ from google.genai.types import HttpOptions
817
+ # Explicit HTTPX clients prevent the SDK's optional aiohttp path
818
+ # bypassing OAuth. Both streaming and non-streaming use these.
819
+ model.client.close()
820
+ model.client = genai.Client(
821
+ vertexai=False, api_key="nova-oauth-managed",
822
+ http_options=HttpOptions(
823
+ base_url="https://generativelanguage.googleapis.com/",
824
+ api_version=model.api_version,
825
+ httpx_client=httpx.Client(auth=GoogleOAuthAuth(), timeout=300),
826
+ httpx_async_client=httpx.AsyncClient(auth=GoogleOAuthAuth(), timeout=300),
827
+ ),
828
+ )
829
+ return model
830
+
831
+ raise ValueError(f"Unknown provider: {provider}")
832
+
833
+
834
+ def create_model_from_config(provider: str, model_name: str) -> BaseChatModel | None:
835
+ """Create a model instance from a provider and model name (no fallback).
836
+
837
+ Unlike :func:`create_model`, this function only tries the exact provider/model
838
+ requested and returns ``None`` if the required API key is missing — it never
839
+ falls back to another provider. This is used for the on-demand vision model.
840
+
841
+ Args:
842
+ provider: An Ollama, OpenAI, Anthropic, Google, OpenRouter or OpenCode provider.
843
+ model_name: The model name/identifier.
844
+
845
+ Returns:
846
+ A ``BaseChatModel`` instance, or ``None`` if the provider cannot be used.
847
+ """
848
+ key_var = PROVIDER_KEY_ENV.get(provider)
849
+ if provider in CATALOG_PROVIDERS:
850
+ from novacode_cli.config.provider_auth import get_provider_auth_status
851
+
852
+ if not get_provider_auth_status(provider).is_usable:
853
+ return None
854
+ try:
855
+ return build_chat_model(provider, model_name)
856
+ except ValueError:
857
+ return None
858
+ if key_var:
859
+ # Keyring-aware: a key saved to the system keychain is absent from
860
+ # os.environ on a fresh session, so an env-only check wrongly returned
861
+ # None on restart (settings.<provider>_api_key resolves keychain-or-env).
862
+ has_key = os.environ.get(key_var) or getattr(settings, f"{provider}_api_key", None)
863
+ if provider == "openai":
864
+ has_key = _openai_auth_available()
865
+ elif provider == "google":
866
+ from novacode_cli.config.provider_auth import get_provider_auth_status
867
+ has_key = get_provider_auth_status("google").is_usable
868
+ if not has_key:
869
+ return None
870
+ try:
871
+ return build_chat_model(provider, model_name)
872
+ except ValueError:
873
+ return None # unknown provider
874
+
875
+
876
+ def create_model_for_session(
877
+ provider: str | None,
878
+ model_name: str | None,
879
+ ) -> tuple[BaseChatModel | None, str | None]:
880
+ """Rebuild the model a resumed session was using.
881
+
882
+ A session records the provider and model it ran on, so resuming should
883
+ restore that model rather than silently adopting whatever the global config
884
+ says now. This tries the exact recorded pair and reports why it could not be
885
+ rebuilt, so the caller can warn and fall back instead of failing to resume.
886
+
887
+ Args:
888
+ provider: Provider recorded on the session, or None for sessions saved
889
+ before the provider was recorded.
890
+ model_name: Model recorded on the session.
891
+
892
+ Returns:
893
+ ``(model, warning)``:
894
+ - ``(None, None)`` — nothing to restore (a legacy session, or no model
895
+ recorded). The caller should use :func:`create_model` silently.
896
+ - ``(model, None)`` — the session's model was rebuilt.
897
+ - ``(None, warning)`` — the session's model could not be rebuilt; the
898
+ caller should surface ``warning`` and fall back to :func:`create_model`.
899
+ """
900
+ if not provider or not model_name:
901
+ # Legacy session: the provider was not recorded, so the exact model
902
+ # cannot be rebuilt. Not an error — fall back without warning.
903
+ return None, None
904
+
905
+ model = create_model_from_config(provider, model_name)
906
+ if model is not None:
907
+ return model, None
908
+
909
+ key_var = PROVIDER_KEY_ENV.get(provider)
910
+ if provider in CATALOG_PROVIDERS:
911
+ from novacode_cli.config.provider_auth import get_provider_auth_status
912
+
913
+ status = get_provider_auth_status(provider)
914
+ reason = (
915
+ status.detail if not status.is_usable
916
+ else f"provider '{provider}' or model is unavailable"
917
+ )
918
+ elif key_var and not (
919
+ os.environ.get(key_var) or getattr(settings, f"{provider}_api_key", None)
920
+ ):
921
+ has_chatgpt_login = _openai_auth_available() if provider == "openai" else False
922
+ reason = (
923
+ "OpenAI ChatGPT sign-in is missing"
924
+ if provider == "openai" and not has_chatgpt_login
925
+ else f"{key_var} is not set"
926
+ )
927
+ else:
928
+ reason = f"provider '{provider}' is unavailable"
929
+ return None, (
930
+ f"This session used {provider}:{model_name}, but it could not be restored "
931
+ f"({reason}). Falling back to the configured default model."
932
+ )
933
+
934
+
935
+ def create_model() -> BaseChatModel:
936
+ """Create the appropriate model based on available API keys.
937
+
938
+ Priority order:
939
+ 1. Saved configuration from Nova.config.json (highest priority)
940
+ 2. Environment variables from .env file
941
+ 3. Default to Ollama (fallback)
942
+
943
+ Returns:
944
+ ChatModel instance (OpenAI, Anthropic, Google, OpenRouter, or Ollama)
945
+ """
946
+ # Load saved configuration - this takes precedence over .env
947
+ from novacode_cli.config.nova_config import NovaConfig
948
+ from novacode_cli.config.provider_auth import get_provider_auth_status
949
+
950
+ nova_config = NovaConfig()
951
+ saved_model_config = nova_config.get_model_config()
952
+
953
+ # If we have a saved config, use it directly (bypasses .env settings).
954
+ # Missing key for the saved provider → warn and fall through to the
955
+ # env-priority chain below (same as the pre-consolidation behavior).
956
+ if saved_model_config:
957
+ provider = saved_model_config["provider"]
958
+ model_name = saved_model_config["model"]
959
+ key_var = PROVIDER_KEY_ENV.get(provider)
960
+ available = (
961
+ _openai_auth_available()
962
+ if provider == "openai"
963
+ else bool(key_var and os.environ.get(key_var))
964
+ )
965
+ if provider == "google":
966
+ from novacode_cli.config.provider_auth import get_provider_auth_status
967
+ available = get_provider_auth_status("google").is_usable
968
+ elif provider in CATALOG_PROVIDERS:
969
+ available = get_provider_auth_status(provider).is_usable
970
+ if (key_var or provider in CATALOG_PROVIDERS) and not available:
971
+ if provider in CATALOG_PROVIDERS:
972
+ detail = get_provider_auth_status(provider).detail or "provider unavailable"
973
+ console.print(
974
+ f"[yellow]Warning: {provider}: {detail}; falling back to Ollama[/yellow]"
975
+ )
976
+ else:
977
+ console.print(
978
+ f"[yellow]Warning: {key_var} not set, falling back to Ollama[/yellow]"
979
+ )
980
+ else:
981
+ return build_chat_model(provider, model_name)
982
+
983
+ # No usable saved config — pick the first provider with a key configured.
984
+ _ENV_PRIORITY = [
985
+ (
986
+ settings.has_openai or _openai_auth_available(),
987
+ "openai",
988
+ "OPENAI_MODEL",
989
+ "gpt-5-mini",
990
+ "OpenAI",
991
+ ),
992
+ (
993
+ settings.has_anthropic,
994
+ "anthropic",
995
+ "ANTHROPIC_MODEL",
996
+ "claude-sonnet-4-5-20250929",
997
+ "Anthropic",
998
+ ),
999
+ (get_provider_auth_status("google").is_usable,
1000
+ "google", "GOOGLE_MODEL", "gemini-3-pro-preview", "Google Gemini"),
1001
+ (
1002
+ settings.has_openrouter,
1003
+ "openrouter",
1004
+ "OPENROUTER_MODEL",
1005
+ "anthropic/claude-3.5-sonnet",
1006
+ "OpenRouter",
1007
+ ),
1008
+ (
1009
+ settings.has_opencode,
1010
+ "opencode",
1011
+ "OPENCODE_MODEL",
1012
+ "glm-5.3",
1013
+ "OpenCode Go",
1014
+ ),
1015
+ (settings.has_opencode_zen, "opencode_zen", "OPENCODE_ZEN_MODEL", "big-pickle", "OpenCode Zen"),
1016
+ (
1017
+ settings.has_commandcode,
1018
+ "commandcode",
1019
+ "COMMANDCODE_MODEL",
1020
+ "deepseek/deepseek-v4-flash",
1021
+ "Command Code",
1022
+ ),
1023
+ (
1024
+ settings.has_nvidia,
1025
+ "nvidia",
1026
+ "NVIDIA_MODEL",
1027
+ "deepseek-ai/deepseek-v4-pro-0813",
1028
+ "NVIDIA NIM",
1029
+ ),
1030
+ ]
1031
+ for available, provider, model_env, default_model, label in _ENV_PRIORITY:
1032
+ if available:
1033
+ model_name = os.environ.get(model_env, default_model)
1034
+ console.print(f"[dim]Using {label} model: {model_name}[/dim]")
1035
+ return build_chat_model(provider, model_name)
1036
+
1037
+ # Then the providers that only need a key. Azure also needs an endpoint and
1038
+ # Bedrock may have no key at all, so both wait for an explicit /model pick.
1039
+ from novacode_cli.config.model_manager import MODEL_PRESETS, OPENAI_COMPATIBLE_PROVIDERS
1040
+
1041
+ for provider in sorted(OPENAI_COMPATIBLE_PROVIDERS | {"minimax"}):
1042
+ # New gateways and regional variants need an explicit /model choice;
1043
+ # sharing a credential name never authorizes selecting a new origin.
1044
+ if provider in CATALOG_PROVIDERS:
1045
+ continue
1046
+ preset = MODEL_PRESETS[provider]
1047
+ if os.environ.get(preset["api_key_var"]):
1048
+ model_name = os.environ.get(preset["env_var"], preset["default_model"])
1049
+ console.print(f"[dim]Using {preset['name']} model: {model_name}[/dim]")
1050
+ return build_chat_model(provider, model_name)
1051
+
1052
+ # Default to Ollama if no API keys are configured
1053
+ from novacode_cli.config.model_manager import DEFAULT_OLLAMA_MODEL
1054
+
1055
+ model_name = os.environ.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
1056
+ console.print(f"[dim]No API keys configured. Defaulting to Ollama model: {model_name}[/dim]")
1057
+ return build_chat_model("ollama", model_name)
1058
+
1059
+
1060
+ # =============================================================================
1061
+ # Vision Model Registry
1062
+ # =============================================================================
1063
+
1064
+ # Models known to support vision/multimodal capabilities
1065
+ VISION_CAPABLE_MODELS: dict[str, bool] = {
1066
+ # Anthropic - Claude 3+ models support vision
1067
+ "claude-sonnet-4-5-20250929": True,
1068
+ "claude-opus-4-5-20251001": True,
1069
+ "claude-3-5-sonnet-20241022": True,
1070
+ "claude-3-5-sonnet-20240620": True,
1071
+ "claude-3-5-haiku-20241022": True,
1072
+ "claude-3-opus-20240229": True,
1073
+ "claude-3-sonnet-20240229": True,
1074
+ "claude-3-haiku-20240307": True,
1075
+ # OpenAI - GPT-4 Vision models
1076
+ "gpt-4o": True,
1077
+ "gpt-4o-mini": True,
1078
+ "gpt-4-turbo": True,
1079
+ "gpt-4-vision-preview": True,
1080
+ "gpt-4-turbo-2024-04-09": True,
1081
+ # Google - Gemini 1.5+ models
1082
+ "gemini-1.5-pro": True,
1083
+ "gemini-1.5-flash": True,
1084
+ "gemini-2.0-flash-exp": True,
1085
+ "gemini-3-pro-preview": True,
1086
+ # Ollama vision models (common ones)
1087
+ "llava": True,
1088
+ "llava:7b": True,
1089
+ "llava:13b": True,
1090
+ "llava:34b": True,
1091
+ "bakllava": True,
1092
+ "moondream": True,
1093
+ "moondream2": True,
1094
+ "llava-llama3": True,
1095
+ "llava-phi3": True,
1096
+ "minicpm-v": True,
1097
+ # User's preferred model
1098
+ "qwen3-vl:235b-cloud": True,
1099
+ "qwen2-vl": True,
1100
+ "qwen2-vl:7b": True,
1101
+ "qwen2-vl:72b": True,
1102
+ }
1103
+
1104
+ # Keywords that indicate vision capability in model names
1105
+ VISION_KEYWORDS = [
1106
+ "vision",
1107
+ "multimodal",
1108
+ "mm",
1109
+ "llava",
1110
+ "bakllava",
1111
+ "moondream",
1112
+ "-vl",
1113
+ "-v",
1114
+ "minicpm-v",
1115
+ "qwen-vl",
1116
+ "qwen2-vl",
1117
+ "qwen3-vl",
1118
+ ]
1119
+
1120
+
1121
+ def model_supports_vision(model_name: str) -> bool:
1122
+ """Check if a model supports vision/multimodal capabilities.
1123
+
1124
+ Args:
1125
+ model_name: Name of the model
1126
+
1127
+ Returns:
1128
+ True if model supports vision, False otherwise
1129
+ """
1130
+ # Normalize model name for comparison
1131
+ model_lower = model_name.lower()
1132
+
1133
+ # Check registry first (exact match)
1134
+ if model_name in VISION_CAPABLE_MODELS:
1135
+ return VISION_CAPABLE_MODELS[model_name]
1136
+
1137
+ # Check registry with lowercase
1138
+ if model_lower in VISION_CAPABLE_MODELS:
1139
+ return VISION_CAPABLE_MODELS[model_lower]
1140
+
1141
+ # For unknown models, check if name contains vision keywords
1142
+ return any(keyword in model_lower for keyword in VISION_KEYWORDS)
1143
+
1144
+
1145
+ def get_vision_model_suggestion(current_model: str) -> str | None:
1146
+ """Suggest a vision-capable model if current model doesn't support vision.
1147
+
1148
+ Args:
1149
+ current_model: Current model name
1150
+
1151
+ Returns:
1152
+ Suggested model name or None if current model supports vision
1153
+ """
1154
+ if model_supports_vision(current_model):
1155
+ return None # Current model already supports vision
1156
+
1157
+ # Suggest best available model based on configured providers
1158
+ if settings.has_anthropic:
1159
+ return "claude-sonnet-4-5-20250929"
1160
+ if settings.has_openai:
1161
+ return "gpt-4o"
1162
+ if settings.has_google:
1163
+ return "gemini-1.5-pro"
1164
+ # Default to Ollama vision model
1165
+ return "qwen3-vl:235b-cloud"
1166
+
1167
+
1168
+ def get_current_model_name() -> str:
1169
+ """Get the name of the currently configured model.
1170
+
1171
+ Returns:
1172
+ Model name string
1173
+ """
1174
+ from novacode_cli.config.nova_config import NovaConfig
1175
+
1176
+ nova_config = NovaConfig()
1177
+ saved_config = nova_config.get_model_config()
1178
+
1179
+ if saved_config:
1180
+ return saved_config["model"]
1181
+
1182
+ # Check environment variables
1183
+ if settings.has_openai:
1184
+ return os.environ.get("OPENAI_MODEL", "gpt-5-mini")
1185
+ if settings.has_anthropic:
1186
+ return os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
1187
+ if settings.has_google:
1188
+ return os.environ.get("GOOGLE_MODEL", "gemini-3-pro-preview")
1189
+ from novacode_cli.config.model_manager import DEFAULT_OLLAMA_MODEL
1190
+
1191
+ return os.environ.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
1192
+
1193
+
1194
+ # ── async-agent graphs ────────────────────────────────────────────────────────
1195
+ #
1196
+ # The six remote graphs (agents/async_agents/*.py, hosted by the LangGraph server) used to
1197
+ # build ``ChatOllama`` themselves, in six near-identical copies, so the model behind
1198
+ # background work was an Ollama decision. They now delegate here, which is also how
1199
+ # they inherit every provider quirk this module already handles.
1200
+
1201
+ #: Which provider the async-agent graphs build their model with.
1202
+ ASYNC_AGENT_PROVIDER_ENV = "ASYNC_AGENT_PROVIDER"
1203
+
1204
+ #: Model id for those graphs, falling back to the older DOC_AGENT_MODEL.
1205
+ ASYNC_AGENT_MODEL_ENV = "ASYNC_AGENT_MODEL"
1206
+
1207
+ #: Providers an async graph may run on: Ollama plus everything with a key variable.
1208
+ VALID_ASYNC_PROVIDERS: tuple[str, ...] = tuple(
1209
+ dict.fromkeys(("ollama", *PROVIDER_KEY_ENV, *CATALOG_PROVIDERS))
1210
+ )
1211
+
1212
+ #: Model used when nothing overrides it. Only providers whose default Nova can stand
1213
+ #: behind are listed; for the rest, asking for ASYNC_AGENT_MODEL beats guessing an id
1214
+ #: that may not exist at the provider.
1215
+ PROVIDER_DEFAULT_MODEL: dict[str, str] = {
1216
+ "ollama": "gemma4:31b-cloud", # the historical default, deliberately unchanged
1217
+ "openai": "gpt-4o-mini",
1218
+ "anthropic": "claude-sonnet-4-5-20250929",
1219
+ "google": "gemini-3-pro-preview",
1220
+ "openrouter": "z-ai/glm-5.2",
1221
+ }
1222
+ PROVIDER_DEFAULT_MODEL.update({
1223
+ provider: preset["default_model"] for provider, preset in CATALOG_PROVIDERS.items()
1224
+ if preset.get("default_model")
1225
+ })
1226
+
1227
+
1228
+ def build_role_model(role: str) -> BaseChatModel | None:
1229
+ """The model a role runs on, or None when the role inherits the main agent's.
1230
+
1231
+ Returns a model **object**, never the ``provider:model`` spec string. deepagents
1232
+ turns a spec string into a client with langchain's ``init_chat_model``, which
1233
+ only knows langchain's own provider names -- so a spec naming one of Nova's
1234
+ (``opencode``, ``nvidia``) dies at agent-construction time with
1235
+
1236
+ ValueError: Unable to infer model provider for model='opencode:...'
1237
+
1238
+ even though Nova builds that provider perfectly well right here. ``resolve_model``
1239
+ returns a ``BaseChatModel`` unchanged, so an object sidesteps the lookup entirely
1240
+ and every provider Nova supports works for a role, not just langchain's.
1241
+
1242
+ A role that cannot be built -- a missing key, an unknown provider -- warns and
1243
+ inherits the main agent's model instead of raising. That matches what the main
1244
+ model does, and it matters more than enforcing the choice: raising here would
1245
+ leave the CLI unusable over one setting, which is the very failure this function
1246
+ exists to remove.
1247
+ """
1248
+ from novacode_cli.config.nova_config import NovaConfig
1249
+
1250
+ entry = NovaConfig().get_role_model(role)
1251
+ if not entry:
1252
+ return None
1253
+ provider = str(entry.get("provider") or "").strip()
1254
+ model_name = str(entry.get("model") or "").strip()
1255
+ if not provider or not model_name:
1256
+ return None
1257
+ try:
1258
+ return build_chat_model(provider, model_name)
1259
+ except Exception as exc: # noqa: BLE001 - a bad role must never brick the CLI
1260
+ console.print(
1261
+ f"[yellow]Role '{role}' could not use {provider}:{model_name} "
1262
+ f"({type(exc).__name__}: {exc}); inheriting the main agent's model.[/yellow]"
1263
+ )
1264
+ return None
1265
+
1266
+
1267
+ def build_dynamic_role_model() -> BaseChatModel | None:
1268
+ """The model for discovered agents that name none of their own.
1269
+
1270
+ ``dynamic`` falls back to ``subagent`` rather than to None, because a discovered
1271
+ agent *is* a subagent; see :func:`novacode_cli.config.role_models.dynamic_spec`.
1272
+ """
1273
+ return build_role_model("dynamic") or build_role_model("subagent")
1274
+
1275
+
1276
+ def build_async_agent_model(*, per_agent_model_var: str | None = None) -> BaseChatModel:
1277
+ """The chat model one async-agent graph runs on.
1278
+
1279
+ Resolution, highest priority first:
1280
+
1281
+ 1. ``per_agent_model_var`` if given and set (e.g. ``PLAN_SCOUT_MODEL``)
1282
+ 2. ``ASYNC_AGENT_MODEL``
1283
+ 3. ``DOC_AGENT_MODEL``, what these graphs read before the move
1284
+ 4. the provider's entry in :data:`PROVIDER_DEFAULT_MODEL`
1285
+
1286
+ The provider comes from ``ASYNC_AGENT_PROVIDER`` and defaults to ``ollama``, so an
1287
+ existing deployment is unaffected. Failures are raised rather than worked around:
1288
+ a background agent that quietly ran on the wrong provider would be worse than one
1289
+ that refuses to start, because its results would still look plausible.
1290
+ """
1291
+ provider = (os.environ.get(ASYNC_AGENT_PROVIDER_ENV) or "").strip().lower() or "ollama"
1292
+ if provider not in VALID_ASYNC_PROVIDERS:
1293
+ message = (
1294
+ f"Unknown {ASYNC_AGENT_PROVIDER_ENV}={provider!r}. Valid providers: "
1295
+ + ", ".join(VALID_ASYNC_PROVIDERS)
1296
+ )
1297
+ raise ValueError(message)
1298
+
1299
+ model_name = ""
1300
+ for var in (per_agent_model_var, ASYNC_AGENT_MODEL_ENV, "DOC_AGENT_MODEL"):
1301
+ if var:
1302
+ model_name = (os.environ.get(var) or "").strip()
1303
+ if model_name:
1304
+ break
1305
+ if not model_name:
1306
+ model_name = PROVIDER_DEFAULT_MODEL.get(provider, "")
1307
+ if not model_name:
1308
+ message = (
1309
+ f"No model set for provider {provider!r}: set {ASYNC_AGENT_MODEL_ENV} "
1310
+ "(there is no default model for this provider)."
1311
+ )
1312
+ raise ValueError(message)
1313
+
1314
+ key_var = PROVIDER_KEY_ENV.get(provider)
1315
+ if (
1316
+ key_var and provider not in CATALOG_PROVIDERS
1317
+ and not (os.environ.get(key_var) or "").strip()
1318
+ ):
1319
+ message = (
1320
+ f"Provider {provider!r} needs an API key: set {key_var} (in .env for the "
1321
+ "container, or in the environment for a server Nova launches itself)."
1322
+ )
1323
+ raise ValueError(message)
1324
+
1325
+ return build_chat_model(provider, model_name)