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,677 @@
|
|
|
1
|
+
"""Per-turn LLM usage attribution tree.
|
|
2
|
+
|
|
3
|
+
Every model in Nova is built through :func:`novacode_cli.config.model_create.build_chat_model`;
|
|
4
|
+
a LangChain callback handler attached there records each call's token usage into
|
|
5
|
+
the *current usage scope* (a contextvar stack). Loops push scopes around their
|
|
6
|
+
work, so a turn's usage forms a tree:
|
|
7
|
+
|
|
8
|
+
turn
|
|
9
|
+
├── main (base agent stream)
|
|
10
|
+
├── verification (inline verifier grading + re-drive attempts)
|
|
11
|
+
│ └── main
|
|
12
|
+
├── goal (autonomous re-drives)
|
|
13
|
+
│ └── verification
|
|
14
|
+
│ └── main
|
|
15
|
+
├── compaction (/compact + auto-compact summarization)
|
|
16
|
+
└── hermes (out-of-band self-review)
|
|
17
|
+
|
|
18
|
+
The tree is created by the shared event loop or session worker, populated by the callback, and
|
|
19
|
+
persisted to the durable store at turn end. ``/usage`` renders it.
|
|
20
|
+
|
|
21
|
+
Design notes:
|
|
22
|
+
|
|
23
|
+
- ``usage_scope`` is a *sync* context manager — it only manipulates a
|
|
24
|
+
contextvar, so it works across ``await`` points inside async generators.
|
|
25
|
+
- Both ``usage_scope`` and the callback are no-ops when no tree is active, so
|
|
26
|
+
tests and non-turn model calls (doctor, model switching) are unaffected.
|
|
27
|
+
- Callback attribution is captured at call start, including worker threads
|
|
28
|
+
with propagated context, so concurrent completions keep their owning scope.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
# Callback signatures follow LangChain's heterogeneous framework interface.
|
|
34
|
+
# ruff: noqa: ANN401, ARG002
|
|
35
|
+
import contextvars
|
|
36
|
+
import logging
|
|
37
|
+
import re
|
|
38
|
+
import time
|
|
39
|
+
import uuid
|
|
40
|
+
from contextlib import contextmanager
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from threading import RLock
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import AsyncIterator, Iterator
|
|
49
|
+
|
|
50
|
+
from langchain_core.language_models import BaseChatModel
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger("nova.tracking.usage_tree")
|
|
53
|
+
|
|
54
|
+
#: Durable-store namespace for per-turn usage trees.
|
|
55
|
+
_USAGE_NS = ("nova", "usage_trees")
|
|
56
|
+
_MAX_REQUEST_RECORDS = 256
|
|
57
|
+
|
|
58
|
+
#: Per-1M-token USD prices (input, output) for known models — most-specific
|
|
59
|
+
#: catalog match wins; unlisted variants have unavailable prices (tokens still shown).
|
|
60
|
+
_PRICE_PER_M: dict[str, tuple[float, float]] = {
|
|
61
|
+
"claude-opus-4": (15.0, 75.0),
|
|
62
|
+
"claude-sonnet-4-5": (3.0, 15.0),
|
|
63
|
+
"claude-haiku-4-5": (1.0, 5.0),
|
|
64
|
+
"gpt-5": (1.25, 10.0),
|
|
65
|
+
"gpt-5-mini": (0.25, 2.0),
|
|
66
|
+
"gemini-3-pro": (2.0, 12.0),
|
|
67
|
+
"gemini-2.5": (1.25, 10.0),
|
|
68
|
+
"deepseek": (0.27, 1.10),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
73
|
+
# Tree
|
|
74
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class UsageNode:
|
|
79
|
+
"""One scope's accumulated usage; children form the attribution tree."""
|
|
80
|
+
|
|
81
|
+
scope: str
|
|
82
|
+
model: str = ""
|
|
83
|
+
input_tokens: int = 0
|
|
84
|
+
output_tokens: int = 0
|
|
85
|
+
cache_read_tokens: int = 0
|
|
86
|
+
cache_creation_tokens: int = 0
|
|
87
|
+
calls: int = 0
|
|
88
|
+
unavailable_calls: int = 0
|
|
89
|
+
cache_observed_input_tokens: int = 0
|
|
90
|
+
cache_observed_read_tokens: int = 0
|
|
91
|
+
model_usage: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
92
|
+
children: list[UsageNode] = field(default_factory=list)
|
|
93
|
+
parent: UsageNode | None = field(default=None, repr=False, compare=False)
|
|
94
|
+
|
|
95
|
+
def add(self, model: str, usage: dict[str, int]) -> None:
|
|
96
|
+
"""Accumulate one model call's usage into this node."""
|
|
97
|
+
if self.calls and not self.model_usage:
|
|
98
|
+
self.model_usage[self.model] = {
|
|
99
|
+
"input_tokens": self.input_tokens,
|
|
100
|
+
"output_tokens": self.output_tokens,
|
|
101
|
+
}
|
|
102
|
+
if model:
|
|
103
|
+
self.model = model
|
|
104
|
+
self.input_tokens += int(usage.get("input_tokens", 0))
|
|
105
|
+
self.output_tokens += int(usage.get("output_tokens", 0))
|
|
106
|
+
self.cache_read_tokens += int(usage.get("cache_read_tokens", 0))
|
|
107
|
+
self.cache_creation_tokens += int(usage.get("cache_creation_tokens", 0))
|
|
108
|
+
if "cache_read_tokens" in usage and "input_tokens" in usage:
|
|
109
|
+
self.cache_observed_input_tokens += int(usage.get("input_tokens", 0))
|
|
110
|
+
self.cache_observed_read_tokens += int(usage["cache_read_tokens"])
|
|
111
|
+
self.calls += 1
|
|
112
|
+
if not usage:
|
|
113
|
+
self.unavailable_calls += 1
|
|
114
|
+
totals = self.model_usage.setdefault(model, {"input_tokens": 0, "output_tokens": 0})
|
|
115
|
+
totals["input_tokens"] += int(usage.get("input_tokens", 0))
|
|
116
|
+
totals["output_tokens"] += int(usage.get("output_tokens", 0))
|
|
117
|
+
|
|
118
|
+
def cost(self, *, recursive: bool = False) -> float | None:
|
|
119
|
+
"""Estimate using each observed model; missing usage or prices stay unknown."""
|
|
120
|
+
if self.unavailable_calls:
|
|
121
|
+
return None
|
|
122
|
+
cost = 0.0
|
|
123
|
+
models = self.model_usage or (
|
|
124
|
+
{
|
|
125
|
+
self.model: {
|
|
126
|
+
"input_tokens": self.input_tokens,
|
|
127
|
+
"output_tokens": self.output_tokens,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if self.calls or self.input_tokens or self.output_tokens
|
|
131
|
+
else {}
|
|
132
|
+
)
|
|
133
|
+
for model, usage in models.items():
|
|
134
|
+
value = estimate_cost(usage["input_tokens"], usage["output_tokens"], model)
|
|
135
|
+
if value is None:
|
|
136
|
+
return None
|
|
137
|
+
cost += value
|
|
138
|
+
if recursive:
|
|
139
|
+
for child in self.children:
|
|
140
|
+
value = child.cost(recursive=True)
|
|
141
|
+
if value is None:
|
|
142
|
+
return None
|
|
143
|
+
cost += value
|
|
144
|
+
return cost
|
|
145
|
+
|
|
146
|
+
def totals(self) -> dict[str, int]:
|
|
147
|
+
"""Recursive totals (own + all descendants)."""
|
|
148
|
+
out = {
|
|
149
|
+
"input_tokens": self.input_tokens,
|
|
150
|
+
"output_tokens": self.output_tokens,
|
|
151
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
152
|
+
"cache_creation_tokens": self.cache_creation_tokens,
|
|
153
|
+
"calls": self.calls,
|
|
154
|
+
"cache_observed_input_tokens": self.cache_observed_input_tokens,
|
|
155
|
+
"cache_observed_read_tokens": self.cache_observed_read_tokens,
|
|
156
|
+
}
|
|
157
|
+
for child in self.children:
|
|
158
|
+
sub = child.totals()
|
|
159
|
+
for key in out:
|
|
160
|
+
out[key] += sub[key]
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
def to_dict(self) -> dict[str, Any]:
|
|
164
|
+
"""Serializable form (no parent backrefs)."""
|
|
165
|
+
return {
|
|
166
|
+
"scope": self.scope,
|
|
167
|
+
"model": self.model,
|
|
168
|
+
"input_tokens": self.input_tokens,
|
|
169
|
+
"output_tokens": self.output_tokens,
|
|
170
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
171
|
+
"cache_creation_tokens": self.cache_creation_tokens,
|
|
172
|
+
"calls": self.calls,
|
|
173
|
+
"children": [c.to_dict() for c in self.children],
|
|
174
|
+
"unavailable_calls": self.unavailable_calls,
|
|
175
|
+
"cache_observed_input_tokens": self.cache_observed_input_tokens,
|
|
176
|
+
"cache_observed_read_tokens": self.cache_observed_read_tokens,
|
|
177
|
+
"model_usage": {model: dict(usage) for model, usage in self.model_usage.items()},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_dict(cls, data: dict[str, Any]) -> UsageNode:
|
|
182
|
+
"""Rehydrate a node (and its children) from :meth:`to_dict` output."""
|
|
183
|
+
node = cls(
|
|
184
|
+
scope=data["scope"],
|
|
185
|
+
model=data.get("model", ""),
|
|
186
|
+
input_tokens=data.get("input_tokens", 0),
|
|
187
|
+
output_tokens=data.get("output_tokens", 0),
|
|
188
|
+
cache_read_tokens=data.get("cache_read_tokens", 0),
|
|
189
|
+
cache_creation_tokens=data.get("cache_creation_tokens", 0),
|
|
190
|
+
calls=data.get("calls", 0),
|
|
191
|
+
unavailable_calls=data.get("unavailable_calls", 0),
|
|
192
|
+
cache_observed_input_tokens=data.get("cache_observed_input_tokens", 0),
|
|
193
|
+
cache_observed_read_tokens=data.get("cache_observed_read_tokens", 0),
|
|
194
|
+
model_usage={key: dict(value) for key, value in data.get("model_usage", {}).items()},
|
|
195
|
+
)
|
|
196
|
+
for child in data.get("children", []):
|
|
197
|
+
node.children.append(cls.from_dict(child))
|
|
198
|
+
return node
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class UsageTree:
|
|
202
|
+
"""A per-turn usage tree rooted at a ``"turn"`` node."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, thread_id: str, started_at: float | None = None) -> None:
|
|
205
|
+
"""Create an empty tree for ``thread_id`` (root scope ``"turn"``)."""
|
|
206
|
+
self.thread_id = thread_id
|
|
207
|
+
self.started_at = started_at if started_at is not None else time.time()
|
|
208
|
+
self.record_id = uuid.uuid4().hex
|
|
209
|
+
self.root = UsageNode("turn")
|
|
210
|
+
self._nodes: dict[tuple[str, ...], UsageNode] = {("turn",): self.root}
|
|
211
|
+
self.requests: list[dict[str, Any]] = []
|
|
212
|
+
self.requests_aggregated = 0
|
|
213
|
+
|
|
214
|
+
def record_request(self, diagnostic: dict[str, Any]) -> None:
|
|
215
|
+
"""Retain at most 256 content-free call records; totals live in nodes."""
|
|
216
|
+
if len(self.requests) < _MAX_REQUEST_RECORDS:
|
|
217
|
+
self.requests.append(diagnostic)
|
|
218
|
+
else:
|
|
219
|
+
self.requests_aggregated += 1
|
|
220
|
+
|
|
221
|
+
def node_for(self, path: tuple[str, ...]) -> UsageNode:
|
|
222
|
+
"""Get-or-create the node at ``path`` (e.g. ``("verification", "main")``).
|
|
223
|
+
|
|
224
|
+
Links parents as needed.
|
|
225
|
+
"""
|
|
226
|
+
node = self._nodes.get(path)
|
|
227
|
+
if node is not None:
|
|
228
|
+
return node
|
|
229
|
+
parent = self.node_for(path[:-1]) if len(path) > 1 else self.root
|
|
230
|
+
node = UsageNode(path[-1], parent=parent)
|
|
231
|
+
parent.children.append(node)
|
|
232
|
+
self._nodes[path] = node
|
|
233
|
+
return node
|
|
234
|
+
|
|
235
|
+
def record(self, path: tuple[str, ...], model: str, usage: dict[str, int]) -> None:
|
|
236
|
+
"""Accumulate one call's usage into the node at ``path``."""
|
|
237
|
+
self.node_for(path).add(model, usage)
|
|
238
|
+
|
|
239
|
+
def to_dict(self) -> dict[str, Any]:
|
|
240
|
+
"""Serializable form (thread id, start time, root node)."""
|
|
241
|
+
return {
|
|
242
|
+
"thread_id": self.thread_id,
|
|
243
|
+
"started_at": self.started_at,
|
|
244
|
+
"record_id": self.record_id,
|
|
245
|
+
"root": self.root.to_dict(),
|
|
246
|
+
"requests": [dict(record) for record in self.requests],
|
|
247
|
+
"requests_aggregated": self.requests_aggregated,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_dict(cls, data: dict[str, Any]) -> UsageTree:
|
|
252
|
+
"""Rehydrate a tree from :meth:`to_dict` output (rebuilds the index)."""
|
|
253
|
+
tree = cls(data["thread_id"], started_at=data.get("started_at"))
|
|
254
|
+
tree.record_id = data.get("record_id", tree.record_id)
|
|
255
|
+
tree.root = UsageNode.from_dict(data["root"])
|
|
256
|
+
tree.requests = [dict(record) for record in data.get("requests", [])[:_MAX_REQUEST_RECORDS]]
|
|
257
|
+
tree.requests_aggregated = data.get("requests_aggregated", 0)
|
|
258
|
+
# Rebuild the path index from the rehydrated tree.
|
|
259
|
+
tree._nodes = {("turn",): tree.root}
|
|
260
|
+
|
|
261
|
+
def _index(node: UsageNode, path: tuple[str, ...]) -> None:
|
|
262
|
+
tree._nodes[path[1:]] = node # relative path (drop the "turn" root)
|
|
263
|
+
for child in node.children:
|
|
264
|
+
child.parent = node
|
|
265
|
+
_index(child, (*path, child.scope))
|
|
266
|
+
|
|
267
|
+
_index(tree.root, ("turn",))
|
|
268
|
+
return tree
|
|
269
|
+
|
|
270
|
+
def render(self) -> str:
|
|
271
|
+
"""Indented tree with tokens + estimated cost per node."""
|
|
272
|
+
lines: list[str] = []
|
|
273
|
+
total = self.root.totals()
|
|
274
|
+
lines.append(
|
|
275
|
+
f"Turn {time.strftime('%H:%M:%S', time.localtime(self.started_at))} "
|
|
276
|
+
f"(thread {self.thread_id})"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _walk(node: UsageNode, depth: int) -> None:
|
|
280
|
+
cost = node.cost()
|
|
281
|
+
cost_text = "unknown" if cost is None else f"${cost:,.2f}"
|
|
282
|
+
indent = " " * depth
|
|
283
|
+
lines.append(
|
|
284
|
+
f"{indent}{node.scope:<14} "
|
|
285
|
+
f"{node.input_tokens:>9,} in {node.output_tokens:>7,} out "
|
|
286
|
+
f"{node.calls:>3} calls {cost_text}"
|
|
287
|
+
)
|
|
288
|
+
for child in node.children:
|
|
289
|
+
_walk(child, depth + 1)
|
|
290
|
+
|
|
291
|
+
for child in self.root.children:
|
|
292
|
+
_walk(child, 1)
|
|
293
|
+
cost = self.root.cost(recursive=True)
|
|
294
|
+
cost_text = "unknown" if cost is None else f"${cost:,.2f}"
|
|
295
|
+
lines.append(
|
|
296
|
+
f"{'total':<14} "
|
|
297
|
+
f"{total['input_tokens']:>9,} in {total['output_tokens']:>7,} out "
|
|
298
|
+
f"{total['calls']:>3} calls {cost_text}"
|
|
299
|
+
)
|
|
300
|
+
if total["cache_observed_input_tokens"]:
|
|
301
|
+
reuse = total["cache_observed_read_tokens"] / total["cache_observed_input_tokens"] * 100
|
|
302
|
+
lines.insert(
|
|
303
|
+
-1,
|
|
304
|
+
f"Cache reuse: {reuse:.1f}% of observed input tokens "
|
|
305
|
+
f"({total['cache_observed_input_tokens']:,}/{total['input_tokens']:,} observed); "
|
|
306
|
+
f"{total['cache_read_tokens']:,} read, "
|
|
307
|
+
f"{total['cache_creation_tokens']:,} written. "
|
|
308
|
+
"Missing cache usage is unavailable. Costs are uncached estimates.",
|
|
309
|
+
)
|
|
310
|
+
elif total["input_tokens"]:
|
|
311
|
+
lines.insert(-1, "Cache reuse: unavailable. Costs are uncached estimates.")
|
|
312
|
+
attributed: dict[tuple[str, ...], dict[str, int]] = {}
|
|
313
|
+
for record in self.requests:
|
|
314
|
+
key = tuple(
|
|
315
|
+
str(record.get(k) or "unknown") for k in ("provider", "model", "purpose", "task_id")
|
|
316
|
+
)
|
|
317
|
+
subtotal = attributed.setdefault(
|
|
318
|
+
key, {"calls": 0, "input_tokens": 0, "cache_read_tokens": 0}
|
|
319
|
+
)
|
|
320
|
+
subtotal["calls"] += 1
|
|
321
|
+
for name in ("input_tokens", "cache_read_tokens"):
|
|
322
|
+
subtotal[name] += int(record.get("usage", {}).get(name, 0))
|
|
323
|
+
for (provider, model, purpose, task), subtotal in attributed.items():
|
|
324
|
+
lines.insert(
|
|
325
|
+
-1,
|
|
326
|
+
f" {provider}/{model} · {purpose} · task {task}: "
|
|
327
|
+
f"{subtotal['calls']} calls, {subtotal['input_tokens']:,} in, "
|
|
328
|
+
f"{subtotal['cache_read_tokens']:,} cache read",
|
|
329
|
+
)
|
|
330
|
+
if self.requests_aggregated:
|
|
331
|
+
lines.insert(
|
|
332
|
+
-1, f"{self.requests_aggregated} additional requests included in scope totals."
|
|
333
|
+
)
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
338
|
+
# Scope (contextvar stack)
|
|
339
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
340
|
+
|
|
341
|
+
_current_tree: contextvars.ContextVar[UsageTree | None] = contextvars.ContextVar(
|
|
342
|
+
"nova_usage_tree", default=None
|
|
343
|
+
)
|
|
344
|
+
_scope_stack: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
|
|
345
|
+
"nova_usage_scope", default=()
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def set_current_tree(tree: UsageTree | None) -> None:
|
|
350
|
+
"""Bind the active tree for the current task (turn entry points)."""
|
|
351
|
+
_current_tree.set(tree)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_current_tree() -> UsageTree | None:
|
|
355
|
+
"""The active tree, or ``None`` outside a turn."""
|
|
356
|
+
return _current_tree.get()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@contextmanager
|
|
360
|
+
def usage_scope(name: str) -> Iterator[None]:
|
|
361
|
+
"""Push ``name`` onto the scope stack for the block's duration.
|
|
362
|
+
|
|
363
|
+
A no-op when no tree is active (tests, non-turn model calls). Nested scopes
|
|
364
|
+
form the tree's parent-child paths.
|
|
365
|
+
"""
|
|
366
|
+
if _current_tree.get() is None:
|
|
367
|
+
yield
|
|
368
|
+
return
|
|
369
|
+
token = _scope_stack.set((*_scope_stack.get(), name))
|
|
370
|
+
try:
|
|
371
|
+
yield
|
|
372
|
+
finally:
|
|
373
|
+
_scope_stack.reset(token)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def scoped_stream(source: AsyncIterator[Any], name: str) -> AsyncIterator[Any]:
|
|
377
|
+
"""Attribute stream work without leaving a scope bound across UI yields."""
|
|
378
|
+
path = _scope_stack.get()
|
|
379
|
+
if _current_tree.get() is not None:
|
|
380
|
+
path = (*path, name)
|
|
381
|
+
try:
|
|
382
|
+
while True:
|
|
383
|
+
token = _scope_stack.set(path)
|
|
384
|
+
try:
|
|
385
|
+
item = await anext(source)
|
|
386
|
+
except StopAsyncIteration:
|
|
387
|
+
break
|
|
388
|
+
finally:
|
|
389
|
+
_scope_stack.reset(token)
|
|
390
|
+
yield item
|
|
391
|
+
finally:
|
|
392
|
+
# Closure can run in another task. A fresh token restores that task's
|
|
393
|
+
# scope while keeping model finalization attributed to this stream.
|
|
394
|
+
token = _scope_stack.set(path)
|
|
395
|
+
try:
|
|
396
|
+
close = getattr(source, "aclose", None)
|
|
397
|
+
if close is not None:
|
|
398
|
+
await close()
|
|
399
|
+
finally:
|
|
400
|
+
_scope_stack.reset(token)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
404
|
+
# Callback
|
|
405
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _extract_usage(response: Any) -> dict[str, int]:
|
|
409
|
+
"""Merge normalized and raw usage without conflating absent fields and zero."""
|
|
410
|
+
meta: dict = {}
|
|
411
|
+
raw: dict = {}
|
|
412
|
+
try:
|
|
413
|
+
message = response.generations[0][0].message
|
|
414
|
+
meta = getattr(message, "usage_metadata", None) or {}
|
|
415
|
+
if hasattr(meta, "model_dump"):
|
|
416
|
+
meta = meta.model_dump()
|
|
417
|
+
response_meta = getattr(message, "response_metadata", None) or {}
|
|
418
|
+
raw.update(response_meta.get("token_usage") or response_meta.get("usage") or {})
|
|
419
|
+
except (AttributeError, IndexError, TypeError):
|
|
420
|
+
pass
|
|
421
|
+
output = getattr(response, "llm_output", None) or {}
|
|
422
|
+
raw.update(output.get("token_usage") or output.get("usage") or {})
|
|
423
|
+
|
|
424
|
+
def first(*values: Any) -> int | None:
|
|
425
|
+
for value in values:
|
|
426
|
+
if value is not None:
|
|
427
|
+
try:
|
|
428
|
+
return max(0, int(value))
|
|
429
|
+
except (ValueError, TypeError, OverflowError):
|
|
430
|
+
continue
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
details = meta.get("input_token_details") or {}
|
|
434
|
+
raw_details = raw.get("prompt_tokens_details") or raw.get("input_tokens_details") or {}
|
|
435
|
+
read = first(
|
|
436
|
+
details.get("cache_read"),
|
|
437
|
+
details.get("priority_cache_read"),
|
|
438
|
+
details.get("flex_cache_read"),
|
|
439
|
+
meta.get("cache_read_input_tokens"),
|
|
440
|
+
raw_details.get("cached_tokens"),
|
|
441
|
+
raw.get("cache_read_input_tokens"),
|
|
442
|
+
raw.get("prompt_cache_hit_tokens"),
|
|
443
|
+
)
|
|
444
|
+
write = first(
|
|
445
|
+
details.get("cache_creation"),
|
|
446
|
+
meta.get("cache_creation_input_tokens"),
|
|
447
|
+
raw.get("cache_creation_input_tokens"),
|
|
448
|
+
)
|
|
449
|
+
input_tokens = first(meta.get("input_tokens"))
|
|
450
|
+
if input_tokens is None:
|
|
451
|
+
input_tokens = first(raw.get("prompt_tokens"), raw.get("input_tokens"))
|
|
452
|
+
# Anthropic raw input excludes cached tokens; normalized input and OpenAI
|
|
453
|
+
# prompt_tokens already include them.
|
|
454
|
+
if (
|
|
455
|
+
input_tokens is not None
|
|
456
|
+
and "prompt_tokens" not in raw
|
|
457
|
+
and ("cache_read_input_tokens" in raw or "cache_creation_input_tokens" in raw)
|
|
458
|
+
):
|
|
459
|
+
input_tokens += (first(raw.get("cache_read_input_tokens")) or 0) + (
|
|
460
|
+
first(raw.get("cache_creation_input_tokens")) or 0
|
|
461
|
+
)
|
|
462
|
+
if (
|
|
463
|
+
input_tokens is None
|
|
464
|
+
and "prompt_cache_hit_tokens" in raw
|
|
465
|
+
and ("prompt_cache_miss_tokens" in raw)
|
|
466
|
+
):
|
|
467
|
+
input_tokens = (first(raw.get("prompt_cache_hit_tokens")) or 0) + (
|
|
468
|
+
first(raw.get("prompt_cache_miss_tokens")) or 0
|
|
469
|
+
)
|
|
470
|
+
result = {
|
|
471
|
+
"input_tokens": input_tokens,
|
|
472
|
+
"output_tokens": first(
|
|
473
|
+
meta.get("output_tokens"), raw.get("output_tokens"), raw.get("completion_tokens")
|
|
474
|
+
),
|
|
475
|
+
"cache_read_tokens": read,
|
|
476
|
+
"cache_creation_tokens": write,
|
|
477
|
+
}
|
|
478
|
+
uncached = first(raw.get("prompt_cache_miss_tokens"))
|
|
479
|
+
if uncached is not None:
|
|
480
|
+
result["uncached_tokens"] = uncached
|
|
481
|
+
elif input_tokens is not None and read is not None and write is not None:
|
|
482
|
+
result["uncached_tokens"] = max(0, input_tokens - read - write)
|
|
483
|
+
return {key: value for key, value in result.items() if value is not None}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _model_from_serialized(serialized: dict) -> str:
|
|
487
|
+
kwargs = serialized.get("kwargs", {}) or {}
|
|
488
|
+
return str(kwargs.get("model") or kwargs.get("model_name") or serialized.get("name") or "")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class UsageCallbackHandler(BaseCallbackHandler):
|
|
492
|
+
"""Attribute calls to their starting task, including concurrent callbacks."""
|
|
493
|
+
|
|
494
|
+
def __init__(self) -> None:
|
|
495
|
+
"""Keep active call attribution without retaining messages or credentials."""
|
|
496
|
+
super().__init__()
|
|
497
|
+
self._model_names: dict[Any, str] = {}
|
|
498
|
+
self._runs: dict[Any, dict[str, Any]] = {}
|
|
499
|
+
self._lock = RLock()
|
|
500
|
+
|
|
501
|
+
def on_chat_model_start(
|
|
502
|
+
self, serialized: dict, messages: Any, *, run_id: Any, **kwargs: Any
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Capture chat calls without retaining their messages."""
|
|
505
|
+
self.on_llm_start(serialized, [], run_id=run_id, **kwargs)
|
|
506
|
+
|
|
507
|
+
def on_llm_start(self, serialized: dict, prompts: Any, *, run_id: Any, **kwargs: Any) -> None:
|
|
508
|
+
"""Capture timing and attribution before context switches."""
|
|
509
|
+
from novacode_cli.tracking.request_metrics import (
|
|
510
|
+
request_diagnostic,
|
|
511
|
+
request_purpose,
|
|
512
|
+
task_correlation,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
metadata = kwargs.get("metadata") or {}
|
|
516
|
+
model = str(
|
|
517
|
+
metadata.get("nova_model")
|
|
518
|
+
or metadata.get("ls_model_name")
|
|
519
|
+
or _model_from_serialized(serialized)
|
|
520
|
+
)
|
|
521
|
+
purpose = request_purpose.get()
|
|
522
|
+
if purpose == "unknown":
|
|
523
|
+
purpose = (_scope_stack.get() or (str(metadata.get("langgraph_node") or "unknown"),))[
|
|
524
|
+
-1
|
|
525
|
+
]
|
|
526
|
+
with self._lock:
|
|
527
|
+
self._model_names[run_id] = model
|
|
528
|
+
self._runs[run_id] = {
|
|
529
|
+
"tree": _current_tree.get(),
|
|
530
|
+
"path": _scope_stack.get() or ("turn",),
|
|
531
|
+
"started": time.perf_counter(),
|
|
532
|
+
"model": model,
|
|
533
|
+
"provider": str(metadata.get("nova_provider") or metadata.get("ls_provider") or ""),
|
|
534
|
+
"purpose": str(metadata.get("nova_request_purpose") or purpose),
|
|
535
|
+
"task_id": str(metadata.get("nova_task_id") or task_correlation.get() or ""),
|
|
536
|
+
"first_token_ms": None,
|
|
537
|
+
**(request_diagnostic.get() or {}),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
def on_llm_new_token(
|
|
541
|
+
self, token: str | list[str | dict[str, Any]], *, run_id: Any, **kwargs: Any
|
|
542
|
+
) -> None:
|
|
543
|
+
"""Observe first-token time only when the adapter emits a token."""
|
|
544
|
+
with self._lock:
|
|
545
|
+
run = self._runs.get(run_id)
|
|
546
|
+
if token and run is not None and run["first_token_ms"] is None:
|
|
547
|
+
run["first_token_ms"] = (time.perf_counter() - run["started"]) * 1000
|
|
548
|
+
|
|
549
|
+
def _finish(self, run_id: Any, usage: dict[str, int], outcome: str) -> None:
|
|
550
|
+
with self._lock:
|
|
551
|
+
self._model_names.pop(run_id, None)
|
|
552
|
+
run = self._runs.pop(run_id, None)
|
|
553
|
+
if run is None:
|
|
554
|
+
return
|
|
555
|
+
tree = run.pop("tree")
|
|
556
|
+
path = run.pop("path")
|
|
557
|
+
run["scope"] = list(path)
|
|
558
|
+
run["duration_ms"] = (time.perf_counter() - run.pop("started")) * 1000
|
|
559
|
+
run["outcome"] = outcome
|
|
560
|
+
run["usage"] = dict(usage)
|
|
561
|
+
if tree is not None:
|
|
562
|
+
tree.record(path, run["model"], usage)
|
|
563
|
+
if usage and ("input_tokens" not in usage or "output_tokens" not in usage):
|
|
564
|
+
tree.node_for(path).unavailable_calls += 1
|
|
565
|
+
tree.record_request(run)
|
|
566
|
+
|
|
567
|
+
def on_llm_end(self, response: Any, *, run_id: Any, **kwargs: Any) -> None:
|
|
568
|
+
"""Record merged usage and release all in-flight attribution."""
|
|
569
|
+
with self._lock:
|
|
570
|
+
run = self._runs.get(run_id)
|
|
571
|
+
if run is not None and not run["model"]:
|
|
572
|
+
run["model"] = str(
|
|
573
|
+
(getattr(response, "llm_output", None) or {}).get("model_name") or ""
|
|
574
|
+
)
|
|
575
|
+
self._finish(run_id, _extract_usage(response), "success")
|
|
576
|
+
|
|
577
|
+
def on_llm_error(self, error: Any, *, run_id: Any, **kwargs: Any) -> None:
|
|
578
|
+
"""Release failed/cancelled calls while retaining their attribution."""
|
|
579
|
+
self._finish(run_id, {}, "error")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
_HANDLER = UsageCallbackHandler()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def attach_usage_callback(model: BaseChatModel) -> None:
|
|
586
|
+
"""Append the shared usage handler to a model's default callbacks."""
|
|
587
|
+
existing = list(getattr(model, "callbacks", None) or [])
|
|
588
|
+
if _HANDLER not in existing:
|
|
589
|
+
model.callbacks = [*existing, _HANDLER]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
593
|
+
# Cost
|
|
594
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def estimate_cost(input_tokens: int, output_tokens: int, model: str = "") -> float | None:
|
|
598
|
+
"""Estimated USD cost using the bundled table; unknown prices return None.
|
|
599
|
+
|
|
600
|
+
These are uncached list-price estimates, not a billing ledger. Provider
|
|
601
|
+
discounts, cache prices and unobserved SDK retry usage are unavailable.
|
|
602
|
+
"""
|
|
603
|
+
if not model:
|
|
604
|
+
return None
|
|
605
|
+
name = model.casefold().rsplit("/", 1)[-1]
|
|
606
|
+
best_key = ""
|
|
607
|
+
best_price: tuple[float, float] | None = None
|
|
608
|
+
for key, price in _PRICE_PER_M.items():
|
|
609
|
+
# A family substring is not a price for every variant (e.g. Flash vs
|
|
610
|
+
# Pro). Accept only catalog names and their dated snapshot aliases.
|
|
611
|
+
if re.fullmatch(re.escape(key) + r"(?:-\d{4}-?\d{2}-?\d{2})?", name) and len(key) > len(
|
|
612
|
+
best_key
|
|
613
|
+
):
|
|
614
|
+
best_key = key
|
|
615
|
+
best_price = price
|
|
616
|
+
if best_price is None:
|
|
617
|
+
return None
|
|
618
|
+
return (input_tokens / 1_000_000) * best_price[0] + (output_tokens / 1_000_000) * best_price[1]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
622
|
+
# Persistence + reporting
|
|
623
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def persist_usage_tree(tree: UsageTree, store: Any = None) -> None:
|
|
627
|
+
"""Persist a turn's tree to the durable store (best-effort, never raises)."""
|
|
628
|
+
try:
|
|
629
|
+
if store is None:
|
|
630
|
+
from novacode_cli.memory.store import get_durable_store
|
|
631
|
+
|
|
632
|
+
store = get_durable_store()
|
|
633
|
+
key = f"{tree.thread_id}:{tree.record_id}"
|
|
634
|
+
await store.aput(_USAGE_NS, key, tree.to_dict())
|
|
635
|
+
except Exception: # usage tracking must never break a turn
|
|
636
|
+
logger.exception("Failed to persist usage tree")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
async def load_usage_trees(thread_id: str, store: Any = None, limit: int = 10) -> list[dict]:
|
|
640
|
+
"""Recent usage trees for a thread, newest first (best-effort)."""
|
|
641
|
+
if limit <= 0:
|
|
642
|
+
return []
|
|
643
|
+
try:
|
|
644
|
+
if store is None:
|
|
645
|
+
from novacode_cli.memory.store import get_durable_store
|
|
646
|
+
|
|
647
|
+
store = get_durable_store()
|
|
648
|
+
trees: list[dict] = []
|
|
649
|
+
offset = 0
|
|
650
|
+
page_size = 100
|
|
651
|
+
# Stores do not guarantee ordering by the record's started_at. Search
|
|
652
|
+
# this thread in pages, retaining only the newest requested records.
|
|
653
|
+
while True:
|
|
654
|
+
items = await store.asearch(
|
|
655
|
+
_USAGE_NS,
|
|
656
|
+
filter={"thread_id": thread_id},
|
|
657
|
+
limit=page_size,
|
|
658
|
+
offset=offset,
|
|
659
|
+
)
|
|
660
|
+
trees.extend(
|
|
661
|
+
dict(item.value) for item in items if str(item.key).startswith(f"{thread_id}:")
|
|
662
|
+
)
|
|
663
|
+
trees.sort(key=lambda t: t.get("started_at", 0), reverse=True)
|
|
664
|
+
del trees[limit:]
|
|
665
|
+
if len(items) < page_size:
|
|
666
|
+
return trees
|
|
667
|
+
offset += len(items)
|
|
668
|
+
except Exception: # usage tracking must never break a turn
|
|
669
|
+
logger.exception("Failed to load usage trees")
|
|
670
|
+
return []
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def format_usage_report(trees: list[dict]) -> str:
|
|
674
|
+
"""Render persisted trees into a human-readable report."""
|
|
675
|
+
if not trees:
|
|
676
|
+
return "No usage data recorded yet for this session."
|
|
677
|
+
return "\n\n".join(UsageTree.from_dict(t).render() for t in trees)
|