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.
- novacode_cli/__init__.py +29 -0
- novacode_cli/__main__.py +6 -0
- novacode_cli/_lazy_heavy.py +99 -0
- novacode_cli/_version.py +3 -0
- novacode_cli/agent_stream.py +178 -0
- novacode_cli/agents/agent_file.py +250 -0
- novacode_cli/agents/agent_mailbox.py +120 -0
- novacode_cli/agents/approval_decisions.py +146 -0
- novacode_cli/agents/arg_repair.py +196 -0
- novacode_cli/agents/async_agents/__init__.py +6 -0
- novacode_cli/agents/async_agents/_config.py +213 -0
- novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
- novacode_cli/agents/async_agents/_specialist.py +84 -0
- novacode_cli/agents/async_agents/_test_commands.py +89 -0
- novacode_cli/agents/async_agents/_tools.py +432 -0
- novacode_cli/agents/async_agents/general_purpose.py +42 -0
- novacode_cli/agents/async_agents/langgraph.json +9 -0
- novacode_cli/agents/async_agents/research.py +22 -0
- novacode_cli/agents/async_context.py +303 -0
- novacode_cli/agents/async_on_demand.py +52 -0
- novacode_cli/agents/async_task_errors.py +75 -0
- novacode_cli/agents/async_workspace.py +27 -0
- novacode_cli/agents/attribution.py +165 -0
- novacode_cli/agents/browser_mode.py +119 -0
- novacode_cli/agents/btw_agent.py +71 -0
- novacode_cli/agents/caveman.py +352 -0
- novacode_cli/agents/caveman_support.py +370 -0
- novacode_cli/agents/code_mode.py +774 -0
- novacode_cli/agents/code_runtime.py +160 -0
- novacode_cli/agents/context_compaction.py +1339 -0
- novacode_cli/agents/core_agent.py +2710 -0
- novacode_cli/agents/default_subagents/__init__.py +24 -0
- novacode_cli/agents/default_subagents/async_subagents.py +296 -0
- novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
- novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
- novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
- novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
- novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
- novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
- novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
- novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
- novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
- novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
- novacode_cli/agents/default_subagents/prompt.py +88 -0
- novacode_cli/agents/default_subagents/subagents.py +138 -0
- novacode_cli/agents/dream_agent.py +93 -0
- novacode_cli/agents/edit_diagnostics.py +362 -0
- novacode_cli/agents/edit_fallback.py +62 -0
- novacode_cli/agents/handoff.py +166 -0
- novacode_cli/agents/invocation.py +152 -0
- novacode_cli/agents/model_budget.py +42 -0
- novacode_cli/agents/model_retry.py +113 -0
- novacode_cli/agents/model_router.py +420 -0
- novacode_cli/agents/openai_decisions.py +186 -0
- novacode_cli/agents/plan_agent/__init__.py +12 -0
- novacode_cli/agents/plan_agent/plan_agent.py +357 -0
- novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
- novacode_cli/agents/plan_execution.py +237 -0
- novacode_cli/agents/ponytail.py +66 -0
- novacode_cli/agents/prime.py +274 -0
- novacode_cli/agents/provider_caching.py +99 -0
- novacode_cli/agents/server_launcher.py +904 -0
- novacode_cli/agents/strict_tools.py +384 -0
- novacode_cli/agents/task_discipline.py +294 -0
- novacode_cli/agents/tool_call_repair.py +290 -0
- novacode_cli/agents/tool_offload.py +291 -0
- novacode_cli/agents/tool_search.py +806 -0
- novacode_cli/agents/tool_search_variants.py +106 -0
- novacode_cli/agents/tool_verdicts.py +1194 -0
- novacode_cli/agents/user_async_agents.py +312 -0
- novacode_cli/artifacts/__init__.py +10 -0
- novacode_cli/artifacts/registry.py +278 -0
- novacode_cli/artifacts/server.py +426 -0
- novacode_cli/assets/Nova.png +0 -0
- novacode_cli/assets/nova-avatar.png +0 -0
- novacode_cli/audio/__init__.py +301 -0
- novacode_cli/audio/capture.py +137 -0
- novacode_cli/audio/downloads.py +273 -0
- novacode_cli/audio/pipeline.py +391 -0
- novacode_cli/audio/playback.py +87 -0
- novacode_cli/audio/providers.py +208 -0
- novacode_cli/audio/speakable.py +78 -0
- novacode_cli/audio/stt.py +132 -0
- novacode_cli/audio/stt_deepgram.py +69 -0
- novacode_cli/audio/stt_elevenlabs.py +89 -0
- novacode_cli/audio/stt_parakeet.py +94 -0
- novacode_cli/audio/stt_whistle.py +61 -0
- novacode_cli/audio/summarize.py +92 -0
- novacode_cli/audio/tts.py +85 -0
- novacode_cli/audio/tts_kokoro.py +73 -0
- novacode_cli/audio/tts_orpheus.py +180 -0
- novacode_cli/audio/tts_pocket.py +131 -0
- novacode_cli/audio/tts_sano.py +69 -0
- novacode_cli/audio/vad.py +145 -0
- novacode_cli/backends/__init__.py +17 -0
- novacode_cli/backends/filesystem.py +894 -0
- novacode_cli/backends/native_search.py +92 -0
- novacode_cli/bootstrap/__init__.py +9 -0
- novacode_cli/bootstrap/env_snapshot.py +240 -0
- novacode_cli/bootstrap/steering.py +464 -0
- novacode_cli/bootstrap/vision_router.py +604 -0
- novacode_cli/brand.py +362 -0
- novacode_cli/cli_args.py +718 -0
- novacode_cli/commands/__init__.py +237 -0
- novacode_cli/commands/agents_commands.py +231 -0
- novacode_cli/commands/auth_handler.py +40 -0
- novacode_cli/commands/browser_use_handler.py +75 -0
- novacode_cli/commands/cache_handler.py +32 -0
- novacode_cli/commands/chat_handler.py +1357 -0
- novacode_cli/commands/ci_fix.py +171 -0
- novacode_cli/commands/claude_plan.py +331 -0
- novacode_cli/commands/codex_plan.py +195 -0
- novacode_cli/commands/commands.py +1113 -0
- novacode_cli/commands/create_handler.py +124 -0
- novacode_cli/commands/create_server.py +1887 -0
- novacode_cli/commands/cron_handler.py +119 -0
- novacode_cli/commands/dream_handler.py +203 -0
- novacode_cli/commands/effort_handler.py +65 -0
- novacode_cli/commands/evolution_handler.py +214 -0
- novacode_cli/commands/file_commands.py +381 -0
- novacode_cli/commands/handoff_handler.py +68 -0
- novacode_cli/commands/hooks_handler.py +269 -0
- novacode_cli/commands/init_handler.py +146 -0
- novacode_cli/commands/learning_handler.py +160 -0
- novacode_cli/commands/lite_mode_handler.py +76 -0
- novacode_cli/commands/log_commands.py +363 -0
- novacode_cli/commands/mcp_handler.py +29 -0
- novacode_cli/commands/model_handler.py +29 -0
- novacode_cli/commands/notifications_handler.py +158 -0
- novacode_cli/commands/onboard_handler.py +121 -0
- novacode_cli/commands/plan_handler.py +440 -0
- novacode_cli/commands/planner_usage.py +250 -0
- novacode_cli/commands/plugin_install_handler.py +38 -0
- novacode_cli/commands/plugins_handler.py +35 -0
- novacode_cli/commands/prime_handler.py +455 -0
- novacode_cli/commands/prompt_handler.py +149 -0
- novacode_cli/commands/provider_usage.py +160 -0
- novacode_cli/commands/ralph_events.py +106 -0
- novacode_cli/commands/ralph_handler.py +1293 -0
- novacode_cli/commands/refine_handler.py +213 -0
- novacode_cli/commands/research_handler.py +202 -0
- novacode_cli/commands/rootapp_remote.py +83 -0
- novacode_cli/commands/router_handler.py +36 -0
- novacode_cli/commands/server_commands.py +249 -0
- novacode_cli/commands/session_commands.py +203 -0
- novacode_cli/commands/share_handler.py +450 -0
- novacode_cli/commands/side_commands.py +366 -0
- novacode_cli/commands/skill_invoke.py +420 -0
- novacode_cli/commands/skills_commands.py +48 -0
- novacode_cli/commands/trace_handler.py +322 -0
- novacode_cli/commands/trello_handler.py +226 -0
- novacode_cli/commands/trello_remote.py +177 -0
- novacode_cli/commands/trello_server.py +1214 -0
- novacode_cli/commands/vision_handler.py +127 -0
- novacode_cli/commands/voice_handler.py +447 -0
- novacode_cli/commands/webhook_handler.py +134 -0
- novacode_cli/commands/wiki_commands.py +227 -0
- novacode_cli/compaction.py +753 -0
- novacode_cli/computation_cache.py +159 -0
- novacode_cli/computer/__init__.py +1 -0
- novacode_cli/computer/persistent.py +234 -0
- novacode_cli/computer/preview.py +253 -0
- novacode_cli/computer/runtime.py +474 -0
- novacode_cli/computer/windows_console.py +152 -0
- novacode_cli/computer/windows_qemu.py +211 -0
- novacode_cli/config/browser_mode.py +159 -0
- novacode_cli/config/catalog_models.py +95 -0
- novacode_cli/config/config.py +1388 -0
- novacode_cli/config/credentials.py +540 -0
- novacode_cli/config/google_oauth_auth.py +281 -0
- novacode_cli/config/model_capabilities.py +343 -0
- novacode_cli/config/model_catalog.py +275 -0
- novacode_cli/config/model_create.py +1325 -0
- novacode_cli/config/model_manager.py +695 -0
- novacode_cli/config/nova_config.py +1283 -0
- novacode_cli/config/openai_chatgpt_auth.py +384 -0
- novacode_cli/config/opencode_gateway.py +25 -0
- novacode_cli/config/plan_mode.py +188 -0
- novacode_cli/config/provider_auth.py +438 -0
- novacode_cli/config/provider_catalog.json +5127 -0
- novacode_cli/config/provider_registry.py +38 -0
- novacode_cli/config/provider_transports.py +258 -0
- novacode_cli/config/role_models.py +175 -0
- novacode_cli/context/__init__.py +50 -0
- novacode_cli/context/_analysis.py +799 -0
- novacode_cli/context/_budget.py +116 -0
- novacode_cli/context/_conversation.py +73 -0
- novacode_cli/context/_dynamic.py +264 -0
- novacode_cli/context/_model_config.py +380 -0
- novacode_cli/context/_models_dev.py +99 -0
- novacode_cli/context/archives.py +248 -0
- novacode_cli/context/estimates.py +65 -0
- novacode_cli/context/history.py +17 -0
- novacode_cli/context/knowledge_cache.py +292 -0
- novacode_cli/context/manager.py +101 -0
- novacode_cli/context/observations.py +65 -0
- novacode_cli/context/pressure.py +168 -0
- novacode_cli/context/provider_accounting.py +109 -0
- novacode_cli/context/snapshot.py +493 -0
- novacode_cli/context/strategies.py +279 -0
- novacode_cli/context/task_progress.py +122 -0
- novacode_cli/core/__init__.py +0 -0
- novacode_cli/core/agent_loop.py +1821 -0
- novacode_cli/core/autonomous_loop.py +277 -0
- novacode_cli/core/code_approvals.py +57 -0
- novacode_cli/core/input_preparation.py +276 -0
- novacode_cli/core/reasoning.py +67 -0
- novacode_cli/core/streaming.py +326 -0
- novacode_cli/core/subagent_tasks.py +696 -0
- novacode_cli/core/subagent_tracking.py +235 -0
- novacode_cli/core/tool_results.py +116 -0
- novacode_cli/core/verification_loop.py +272 -0
- novacode_cli/council.py +595 -0
- novacode_cli/council_consensus.py +115 -0
- novacode_cli/council_decision.py +157 -0
- novacode_cli/council_planner.py +1421 -0
- novacode_cli/council_settings.py +208 -0
- novacode_cli/council_web_settings.py +41 -0
- novacode_cli/council_workspace.py +56 -0
- novacode_cli/cowork/__init__.py +10 -0
- novacode_cli/cowork/broker_middleware.py +162 -0
- novacode_cli/cowork/launcher.py +102 -0
- novacode_cli/cowork/policy.py +284 -0
- novacode_cli/cowork/ui.py +826 -0
- novacode_cli/daemons/__init__.py +1 -0
- novacode_cli/daemons/registry.py +342 -0
- novacode_cli/desktop_notifications.py +302 -0
- novacode_cli/doctor.py +251 -0
- novacode_cli/embeddings.py +109 -0
- novacode_cli/entrypoint.py +56 -0
- novacode_cli/errors/__init__.py +19 -0
- novacode_cli/errors/handlers.py +648 -0
- novacode_cli/errors/provider_errors.py +249 -0
- novacode_cli/errors/taxonomy.py +80 -0
- novacode_cli/events.py +179 -0
- novacode_cli/file_ops.py +714 -0
- novacode_cli/git_safety.py +281 -0
- novacode_cli/handoff.py +383 -0
- novacode_cli/headless/__init__.py +18 -0
- novacode_cli/headless/input.py +58 -0
- novacode_cli/headless/output.py +224 -0
- novacode_cli/headless/pipe.py +532 -0
- novacode_cli/headless/runner.py +461 -0
- novacode_cli/hermes/__init__.py +39 -0
- novacode_cli/hermes/activation.py +297 -0
- novacode_cli/hermes/config.py +129 -0
- novacode_cli/hermes/curator.py +199 -0
- novacode_cli/hermes/evolution.py +374 -0
- novacode_cli/hermes/ledger.py +364 -0
- novacode_cli/hermes/memory_tiers.py +969 -0
- novacode_cli/hermes/middleware.py +678 -0
- novacode_cli/hermes/overview.py +174 -0
- novacode_cli/hermes/prompt_evolution.py +503 -0
- novacode_cli/hermes/refine_loop.py +643 -0
- novacode_cli/hermes/refinement_log.py +190 -0
- novacode_cli/hermes/review.py +674 -0
- novacode_cli/hermes/skill_debate.py +135 -0
- novacode_cli/hermes/skill_discovery.py +450 -0
- novacode_cli/hermes/skill_manager.py +335 -0
- novacode_cli/hermes/skill_proposals.py +43 -0
- novacode_cli/hermes/test_gaming.py +119 -0
- novacode_cli/hermes/tracker.py +435 -0
- novacode_cli/hermes/tuner.py +206 -0
- novacode_cli/hermes/verification_memory.py +134 -0
- novacode_cli/hermes/verifier.py +277 -0
- novacode_cli/hitl/__init__.py +22 -0
- novacode_cli/hitl/interrupts.py +493 -0
- novacode_cli/hooks.py +359 -0
- novacode_cli/image_utils.py +517 -0
- novacode_cli/init/__init__.py +7 -0
- novacode_cli/init/events.py +43 -0
- novacode_cli/input_utils.py +526 -0
- novacode_cli/install_commands.py +87 -0
- novacode_cli/integrations/__init__.py +82 -0
- novacode_cli/integrations/daytona.py +122 -0
- novacode_cli/integrations/docker.py +309 -0
- novacode_cli/integrations/host_path.py +111 -0
- novacode_cli/integrations/langsmith.py +151 -0
- novacode_cli/integrations/modal.py +134 -0
- novacode_cli/integrations/runloop.py +130 -0
- novacode_cli/integrations/sandbox_factory.py +1388 -0
- novacode_cli/integrations/sandbox_registry.py +312 -0
- novacode_cli/integrations/workdir_backend.py +589 -0
- novacode_cli/main.py +2078 -0
- novacode_cli/mcp/__init__.py +73 -0
- novacode_cli/mcp/auth.py +189 -0
- novacode_cli/mcp/client.py +571 -0
- novacode_cli/mcp/code_mode.py +5 -0
- novacode_cli/mcp/code_runtime.py +5 -0
- novacode_cli/mcp/commands.py +902 -0
- novacode_cli/mcp/config.py +406 -0
- novacode_cli/mcp/generic.py +128 -0
- novacode_cli/mcp/middleware.py +993 -0
- novacode_cli/mcp/presets.py +367 -0
- novacode_cli/memory/__init__.py +1 -0
- novacode_cli/memory/agent_memory.py +1312 -0
- novacode_cli/memory/builtin_lessons.py +60 -0
- novacode_cli/memory/dream_gates.py +192 -0
- novacode_cli/memory/limits.py +59 -0
- novacode_cli/memory/parsed_cache.py +106 -0
- novacode_cli/memory/recall_stats.py +97 -0
- novacode_cli/memory/semantic.py +116 -0
- novacode_cli/memory/store.py +335 -0
- novacode_cli/memory/wiki.py +274 -0
- novacode_cli/migrate.py +185 -0
- novacode_cli/onboarding.py +649 -0
- novacode_cli/onboarding_check.py +119 -0
- novacode_cli/path_approval.py +297 -0
- novacode_cli/plan_archive.py +148 -0
- novacode_cli/plugins/__init__.py +121 -0
- novacode_cli/plugins/claude_plugins.py +538 -0
- novacode_cli/plugins/cli.py +168 -0
- novacode_cli/plugins/components.py +413 -0
- novacode_cli/plugins/hooks.py +356 -0
- novacode_cli/plugins/loader.py +317 -0
- novacode_cli/plugins/marketplaces.py +199 -0
- novacode_cli/plugins/storage.py +70 -0
- novacode_cli/prime.py +759 -0
- novacode_cli/prime_factory/__init__.py +15 -0
- novacode_cli/prime_factory/container.py +198 -0
- novacode_cli/prime_factory/controller.py +565 -0
- novacode_cli/prime_factory/knowledge.py +261 -0
- novacode_cli/prime_factory/publication.py +218 -0
- novacode_cli/prime_factory/recovery.py +50 -0
- novacode_cli/prime_factory/research.py +370 -0
- novacode_cli/prime_factory/runner.py +608 -0
- novacode_cli/prime_factory/runtime.py +261 -0
- novacode_cli/prime_factory/state.py +201 -0
- novacode_cli/prime_factory/store.py +128 -0
- novacode_cli/prime_factory/worker.py +129 -0
- novacode_cli/prime_factory/workflow.py +899 -0
- novacode_cli/prime_factory/workspace.py +317 -0
- novacode_cli/prime_memory.py +290 -0
- novacode_cli/process_manager.py +585 -0
- novacode_cli/project_onboard.py +537 -0
- novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
- novacode_cli/prompts/__init__.py +168 -0
- novacode_cli/prompts/agent_definitions.jinja +11 -0
- novacode_cli/prompts/agent_generation.jinja +125 -0
- novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
- novacode_cli/prompts/claude_plan_mode.jinja +12 -0
- novacode_cli/prompts/coding_workflow.jinja +18 -0
- novacode_cli/prompts/computer_use.jinja +17 -0
- novacode_cli/prompts/core_agent_system.jinja +81 -0
- novacode_cli/prompts/delegation.jinja +15 -0
- novacode_cli/prompts/desktop_power.jinja +4 -0
- novacode_cli/prompts/file_paths.jinja +15 -0
- novacode_cli/prompts/file_tracker.jinja +23 -0
- novacode_cli/prompts/init_agents_md.jinja +56 -0
- novacode_cli/prompts/init_exploration.jinja +174 -0
- novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
- novacode_cli/prompts/instruction_scope.jinja +5 -0
- novacode_cli/prompts/learnings_extraction.jinja +45 -0
- novacode_cli/prompts/longterm_memory.jinja +54 -0
- novacode_cli/prompts/mcp.jinja +84 -0
- novacode_cli/prompts/memory_consolidation.jinja +40 -0
- novacode_cli/prompts/nova_evolution.jinja +73 -0
- novacode_cli/prompts/nova_review.jinja +158 -0
- novacode_cli/prompts/nova_skill_debate.jinja +36 -0
- novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
- novacode_cli/prompts/nova_verify.jinja +100 -0
- novacode_cli/prompts/onboard_dashboard.jinja +378 -0
- novacode_cli/prompts/plan_agent.jinja +87 -0
- novacode_cli/prompts/plan_scouts.jinja +5 -0
- novacode_cli/prompts/plan_storage.jinja +8 -0
- novacode_cli/prompts/planning.jinja +82 -0
- novacode_cli/prompts/ponytail/LICENSE +21 -0
- novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
- novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
- novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
- novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
- novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
- novacode_cli/prompts/prompt_evolution.jinja +27 -0
- novacode_cli/prompts/ralph_iteration.jinja +75 -0
- novacode_cli/prompts/refine_gate.jinja +31 -0
- novacode_cli/prompts/refine_plan.jinja +64 -0
- novacode_cli/prompts/refine_review.jinja +34 -0
- novacode_cli/prompts/research_delivery.jinja +9 -0
- novacode_cli/prompts/research_swarm.jinja +130 -0
- novacode_cli/prompts/response_style.jinja +5 -0
- novacode_cli/prompts/session_summarization.jinja +86 -0
- novacode_cli/prompts/skill_creation.jinja +85 -0
- novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
- novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
- novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
- novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
- novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
- novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
- novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
- novacode_cli/prompts/summarization.jinja +107 -0
- novacode_cli/prompts/summarization_answers.jinja +23 -0
- novacode_cli/prompts/summarization_questions.jinja +32 -0
- novacode_cli/prompts/vision_caption.jinja +21 -0
- novacode_cli/prompts/voice_summary.jinja +65 -0
- novacode_cli/prompts/wiki_file.jinja +57 -0
- novacode_cli/prompts/wiki_ingest.jinja +87 -0
- novacode_cli/recovery.py +383 -0
- novacode_cli/remote/__init__.py +45 -0
- novacode_cli/remote/approvals.py +282 -0
- novacode_cli/remote/async_task_clients.py +97 -0
- novacode_cli/remote/async_task_watcher.py +240 -0
- novacode_cli/remote/bridge.py +905 -0
- novacode_cli/remote/command_menu.py +26 -0
- novacode_cli/remote/command_ui.py +243 -0
- novacode_cli/remote/config.py +199 -0
- novacode_cli/remote/discord_bridge.py +530 -0
- novacode_cli/remote/discord_format.py +31 -0
- novacode_cli/remote/discord_threads.py +225 -0
- novacode_cli/remote/images.py +97 -0
- novacode_cli/remote/processor.py +601 -0
- novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
- novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
- novacode_cli/remote/rootapp_bot/package.json +18 -0
- novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
- novacode_cli/remote/rootapp_bridge.py +370 -0
- novacode_cli/remote/rootapp_setup.py +89 -0
- novacode_cli/remote/routing.py +103 -0
- novacode_cli/remote/scheduler.py +302 -0
- novacode_cli/remote/session.py +164 -0
- novacode_cli/remote/status.py +411 -0
- novacode_cli/remote/streaming.py +188 -0
- novacode_cli/remote/telegram_bridge.py +1118 -0
- novacode_cli/remote/telegram_format.py +215 -0
- novacode_cli/remote/telegram_hub.py +256 -0
- novacode_cli/remote/voice_notes.py +120 -0
- novacode_cli/remote/webhook_adapters.py +167 -0
- novacode_cli/remote/webhook_server.py +154 -0
- novacode_cli/rewind.py +150 -0
- novacode_cli/rooms/__init__.py +1 -0
- novacode_cli/rooms/bridge.py +86 -0
- novacode_cli/rooms/cli.py +231 -0
- novacode_cli/rooms/nova.py +227 -0
- novacode_cli/rooms/runtime.py +299 -0
- novacode_cli/rooms/store.py +538 -0
- novacode_cli/rooms/workspace.py +94 -0
- novacode_cli/security/__init__.py +29 -0
- novacode_cli/security/approval_context.py +45 -0
- novacode_cli/security/delegated_approval.py +68 -0
- novacode_cli/security/local_http.py +85 -0
- novacode_cli/security/middleware.py +87 -0
- novacode_cli/security/paths.py +35 -0
- novacode_cli/security/policy.py +535 -0
- novacode_cli/security/policy_writer.py +90 -0
- novacode_cli/security/prompt_injection.py +203 -0
- novacode_cli/security/remember.py +81 -0
- novacode_cli/security/rule_synthesis.py +116 -0
- novacode_cli/security/secret_files.py +61 -0
- novacode_cli/security/session_allow.py +114 -0
- novacode_cli/security/unicode_security.py +516 -0
- novacode_cli/security/validator.py +173 -0
- novacode_cli/server/__init__.py +9 -0
- novacode_cli/server/app.py +632 -0
- novacode_cli/server/event_adapter.py +189 -0
- novacode_cli/server/session_manager.py +90 -0
- novacode_cli/server_main.py +64 -0
- novacode_cli/server_runner/dev_server.py +530 -0
- novacode_cli/server_runner/test_runner.py +470 -0
- novacode_cli/session/__init__.py +1 -0
- novacode_cli/session/adapters/__init__.py +240 -0
- novacode_cli/session/adapters/claude_code.py +102 -0
- novacode_cli/session/adapters/codex.py +77 -0
- novacode_cli/session/adapters/harness_native.py +75 -0
- novacode_cli/session/checkpoint_coordinator.py +448 -0
- novacode_cli/session/context_restore.py +25 -0
- novacode_cli/session/history_export.py +228 -0
- novacode_cli/session/history_tree.py +174 -0
- novacode_cli/session/imported_context.py +267 -0
- novacode_cli/session/runtime_identity.py +32 -0
- novacode_cli/session/session_persistence.py +1168 -0
- novacode_cli/session/session_prompt_builder.py +279 -0
- novacode_cli/session/session_restore.py +351 -0
- novacode_cli/session/session_summarization.py +20 -0
- novacode_cli/session/transcript_journal.py +153 -0
- novacode_cli/sessions/__init__.py +11 -0
- novacode_cli/sessions/gateway.py +84 -0
- novacode_cli/sessions/launch.py +51 -0
- novacode_cli/sessions/lease.py +213 -0
- novacode_cli/sessions/messaging.py +255 -0
- novacode_cli/sessions/protocol.py +216 -0
- novacode_cli/sessions/resume.py +31 -0
- novacode_cli/sessions/supervisor.py +498 -0
- novacode_cli/sessions/tasks.py +73 -0
- novacode_cli/sessions/tui_messaging.py +413 -0
- novacode_cli/sessions/worker.py +652 -0
- novacode_cli/sessions/worktree.py +262 -0
- novacode_cli/shell/__init__.py +27 -0
- novacode_cli/shell/jobs.py +475 -0
- novacode_cli/shell/middleware.py +1568 -0
- novacode_cli/shell/os_sandbox.py +205 -0
- novacode_cli/shell/output.py +181 -0
- novacode_cli/shell/patterns.py +275 -0
- novacode_cli/shell/processes.py +87 -0
- novacode_cli/shell/utils.py +236 -0
- novacode_cli/skills/__init__.py +26 -0
- novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
- novacode_cli/skills/curation_middleware.py +69 -0
- novacode_cli/skills/executable.py +161 -0
- novacode_cli/skills/libraries.py +102 -0
- novacode_cli/skills/load.py +248 -0
- novacode_cli/skills/prune.py +215 -0
- novacode_cli/skills/refreshing_middleware.py +896 -0
- novacode_cli/skills/retrieval.py +248 -0
- novacode_cli/skills/runtime.py +118 -0
- novacode_cli/skills/schema.py +254 -0
- novacode_cli/skills/skill_creation.py +1549 -0
- novacode_cli/skills/skill_lock.py +75 -0
- novacode_cli/skills/skill_system_prompt.py +16 -0
- novacode_cli/skills/skills_prefs.py +126 -0
- novacode_cli/skills/tool_resolver.py +31 -0
- novacode_cli/skills/upstream.py +50 -0
- novacode_cli/skills/versioning.py +291 -0
- novacode_cli/states/Session.py +847 -0
- novacode_cli/states/__init__.py +31 -0
- novacode_cli/states/slices/__init__.py +17 -0
- novacode_cli/states/slices/agent_runtime.py +249 -0
- novacode_cli/states/slices/background_tasks.py +21 -0
- novacode_cli/states/slices/notifications.py +74 -0
- novacode_cli/states/slices/remote_bridge.py +56 -0
- novacode_cli/states/slices/ui_settings.py +49 -0
- novacode_cli/states/slices/wiki.py +25 -0
- novacode_cli/teammates/__init__.py +1 -0
- novacode_cli/teammates/controller.py +557 -0
- novacode_cli/teammates/graph.py +104 -0
- novacode_cli/teammates/registry.py +175 -0
- novacode_cli/teammates/service.py +254 -0
- novacode_cli/teammates/worker.py +19 -0
- novacode_cli/teams/__init__.py +1 -0
- novacode_cli/teams/adapters/__init__.py +1 -0
- novacode_cli/teams/adapters/container.py +316 -0
- novacode_cli/teams/adapters/inprocess.py +206 -0
- novacode_cli/teams/adapters/process.py +364 -0
- novacode_cli/teams/bootstrap.py +246 -0
- novacode_cli/teams/container_git.py +67 -0
- novacode_cli/teams/context.py +34 -0
- novacode_cli/teams/coordinator.py +815 -0
- novacode_cli/teams/gateway.py +200 -0
- novacode_cli/teams/host.py +64 -0
- novacode_cli/teams/legacy.py +52 -0
- novacode_cli/teams/middleware.py +176 -0
- novacode_cli/teams/models.py +104 -0
- novacode_cli/teams/output.py +47 -0
- novacode_cli/teams/protocol.py +50 -0
- novacode_cli/teams/recovery.py +80 -0
- novacode_cli/teams/scheduler.py +44 -0
- novacode_cli/teams/store.py +631 -0
- novacode_cli/teams/worker.py +251 -0
- novacode_cli/teams/workspace.py +100 -0
- novacode_cli/token_utils.py +256 -0
- novacode_cli/tools/__init__.py +104 -0
- novacode_cli/tools/_shared.py +97 -0
- novacode_cli/tools/_types.py +24 -0
- novacode_cli/tools/artifact_tools.py +137 -0
- novacode_cli/tools/code_search_tools.py +330 -0
- novacode_cli/tools/computer_tools.py +77 -0
- novacode_cli/tools/cron_tools.py +65 -0
- novacode_cli/tools/daemon_tool.py +290 -0
- novacode_cli/tools/desktop_tools.py +161 -0
- novacode_cli/tools/fetch_tools.py +530 -0
- novacode_cli/tools/http_tools.py +279 -0
- novacode_cli/tools/inspection_tools.py +107 -0
- novacode_cli/tools/job_tools.py +143 -0
- novacode_cli/tools/knowledge_tools.py +427 -0
- novacode_cli/tools/memory_tools.py +461 -0
- novacode_cli/tools/memory_wiki_tools.py +40 -0
- novacode_cli/tools/notebook_tools.py +238 -0
- novacode_cli/tools/oracle_tool.py +83 -0
- novacode_cli/tools/plan_archive_tools.py +80 -0
- novacode_cli/tools/plan_mode_tools.py +315 -0
- novacode_cli/tools/python_kernel_loop.py +165 -0
- novacode_cli/tools/python_kernel_tool.py +222 -0
- novacode_cli/tools/reflection_tools.py +34 -0
- novacode_cli/tools/scraper_tools.py +377 -0
- novacode_cli/tools/skill_tools.py +275 -0
- novacode_cli/tools/speak_tool.py +51 -0
- novacode_cli/tools/store_memory_tools.py +227 -0
- novacode_cli/tools/ui_tools.py +62 -0
- novacode_cli/tools/waiter_tools.py +408 -0
- novacode_cli/tools/web_tools.py +349 -0
- novacode_cli/tools/wiki_tools.py +152 -0
- novacode_cli/tools/worktree_tools.py +219 -0
- novacode_cli/tracking/__init__.py +10 -0
- novacode_cli/tracking/file_tracker.py +826 -0
- novacode_cli/tracking/local_metrics.py +170 -0
- novacode_cli/tracking/loop_guard.py +507 -0
- novacode_cli/tracking/request_metrics.py +60 -0
- novacode_cli/tracking/tracing.py +692 -0
- novacode_cli/tracking/usage_tree.py +677 -0
- novacode_cli/tracking/workspace_anchoring.py +231 -0
- novacode_cli/tui/__init__.py +11 -0
- novacode_cli/tui/animation_rate.py +9 -0
- novacode_cli/tui/animations.py +332 -0
- novacode_cli/tui/app.py +19315 -0
- novacode_cli/tui/auth_display.py +87 -0
- novacode_cli/tui/auth_screens.py +826 -0
- novacode_cli/tui/avatar.py +294 -0
- novacode_cli/tui/avatar_frames.py +21 -0
- novacode_cli/tui/background_tasks.py +35 -0
- novacode_cli/tui/browser_settings_screen.py +70 -0
- novacode_cli/tui/caveman_screen.py +185 -0
- novacode_cli/tui/council_settings_screen.py +182 -0
- novacode_cli/tui/council_view.py +535 -0
- novacode_cli/tui/decision_settings.py +268 -0
- novacode_cli/tui/diff_preview.py +199 -0
- novacode_cli/tui/gc_tuning.py +63 -0
- novacode_cli/tui/handoff_screen.py +206 -0
- novacode_cli/tui/harness.py +244 -0
- novacode_cli/tui/history_tree_screen.py +179 -0
- novacode_cli/tui/home.py +328 -0
- novacode_cli/tui/home_scene.py +94 -0
- novacode_cli/tui/icons.py +81 -0
- novacode_cli/tui/interface_styles.tcss +327 -0
- novacode_cli/tui/matrix3.py +108 -0
- novacode_cli/tui/mcp_tools_screen.py +131 -0
- novacode_cli/tui/motion.py +178 -0
- novacode_cli/tui/output_body.py +136 -0
- novacode_cli/tui/output_buffer.py +59 -0
- novacode_cli/tui/palette.py +334 -0
- novacode_cli/tui/pickers.py +237 -0
- novacode_cli/tui/prime_panel.py +506 -0
- novacode_cli/tui/provider_signin.py +213 -0
- novacode_cli/tui/pty_shell.py +159 -0
- novacode_cli/tui/reference_screens.py +444 -0
- novacode_cli/tui/resume_picker.py +346 -0
- novacode_cli/tui/rooms.py +597 -0
- novacode_cli/tui/screens.py +7088 -0
- novacode_cli/tui/session_import.py +333 -0
- novacode_cli/tui/session_pane.py +242 -0
- novacode_cli/tui/session_tab.py +42 -0
- novacode_cli/tui/session_transcript.py +118 -0
- novacode_cli/tui/shell_output.py +7 -0
- novacode_cli/tui/stall_watch.py +174 -0
- novacode_cli/tui/statusline.py +40 -0
- novacode_cli/tui/statusline_screen.py +91 -0
- novacode_cli/tui/teammate_updates.py +162 -0
- novacode_cli/tui/teammates.py +548 -0
- novacode_cli/tui/terminal.py +289 -0
- novacode_cli/tui/terminal_view.py +235 -0
- novacode_cli/tui/themes.py +118 -0
- novacode_cli/tui/update_notice.py +84 -0
- novacode_cli/tui/voice_downloads.py +113 -0
- novacode_cli/tui/widgets.py +2038 -0
- novacode_cli/tui/windows_input.py +38 -0
- novacode_cli/tui/workspace_approval.py +165 -0
- novacode_cli/ui/council_web.py +113 -0
- novacode_cli/ui/council_workspace.py +72 -0
- novacode_cli/ui/cowork_web.py +41 -0
- novacode_cli/ui/diff_highlight.py +160 -0
- novacode_cli/ui/execution.py +1396 -0
- novacode_cli/ui/exit_summary.py +65 -0
- novacode_cli/ui/hitl_approval.py +196 -0
- novacode_cli/ui/input_preparation.py +2 -0
- novacode_cli/ui/interrupt_handlers.py +215 -0
- novacode_cli/ui/question_prompt.py +151 -0
- novacode_cli/ui/status_phrases.py +157 -0
- novacode_cli/ui/stream_preview.py +66 -0
- novacode_cli/ui/streaming.py +2 -0
- novacode_cli/ui/subagent_tracking.py +2 -0
- novacode_cli/ui/ui_elements.py +1585 -0
- novacode_cli/ui/web_workspace.py +244 -0
- novacode_cli/ui_events.py +299 -0
- novacode_cli/update_ui.py +243 -0
- novacode_cli/updates.py +489 -0
- novacode_cli/utils/backend_patches.py +399 -0
- novacode_cli/utils/backend_paths.py +467 -0
- novacode_cli/utils/lazy_providers.py +51 -0
- novacode_cli/utils/model_info.py +223 -0
- novacode_cli/utils/pdf_extraction.py +183 -0
- novacode_cli/utils/startup.py +29 -0
- novacode_cli/utils/tasks.py +42 -0
- novacode_cli/utils/windows.py +9 -0
- novacode_cli/vixie/hook_adapter.py +127 -0
- novacode_cli/vixie/server.py +375 -0
- novacode_cli/wiki/__init__.py +15 -0
- novacode_cli/wiki/ask.py +56 -0
- novacode_cli/wiki/file.py +71 -0
- novacode_cli/wiki/ingest.py +184 -0
- novacode_cli/wiki/manager.py +261 -0
- novacode_cli-1.2.0.dist-info/METADATA +271 -0
- novacode_cli-1.2.0.dist-info/RECORD +691 -0
- novacode_cli-1.2.0.dist-info/WHEEL +5 -0
- novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
- novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- 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)
|