diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e741060..8d370fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] ### Added +- `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Stored in the on-disk config so it persists across restarts. + + - Strict IPC input validation guards for project/session/subagent/search limits. - `get-waterfall-data` IPC endpoint implementation. - Cross-platform path normalization in renderer path resolvers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b842ec16..4ce39e36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,10 +23,27 @@ pnpm build ``` ## Pull Request Guidelines -- Keep changes focused and small. +- Keep changes focused and small — one purpose per PR. - Add/adjust tests for behavior changes. - Update docs when changing public behavior or setup. - Use clear PR titles and include a short validation checklist. +- **Large changes (new features, new dependencies, large data additions) must have a discussion in an Issue first.** Do not open a large PR without prior agreement on the approach. +- Avoid committing large hardcoded data blobs. If data can be fetched at runtime or generated at build time, prefer that approach. + +## AI-Assisted Contributions + +AI coding tools are welcome, but **you are responsible for what you submit**: + +- **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output. +- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools (e.g. `docs/plans/`, `.speckit/`, etc.) do not belong in the repository. +- **Test it yourself.** AI-generated code must be manually verified — run the app, confirm the feature works, check edge cases. +- **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it. + +## What Does NOT Belong in the Repo +- Personal planning/workflow artifacts (AI session plans, task lists, etc.) +- Large static data that could be fetched at runtime +- Generated files that aren't part of the build output +- Experimental features without prior discussion ## Commit Style - Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`). diff --git a/package.json b/package.json index e97f0957..0b74d0d3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "dev": "electron-vite dev", "dev:kill": "node bin/kill-dev.js", + "prebuild": "tsx scripts/fetch-pricing-data.ts", "build": "electron-vite build", "dist": "electron-builder --mac --win --linux", "dist:mac": "electron-builder --mac --publish always", @@ -156,6 +157,12 @@ "asarUnpack": [ "out/renderer/**" ], + "extraResources": [ + { + "from": "resources/pricing.json", + "to": "pricing.json" + } + ], "npmRebuild": false, "extraMetadata": { "main": "dist-electron/main/index.cjs" diff --git a/resources/pricing.json b/resources/pricing.json new file mode 100644 index 00000000..9687baaa --- /dev/null +++ b/resources/pricing.json @@ -0,0 +1,4195 @@ +{ + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-haiku-4-5@20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_streaming": true + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.00003, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "cache_creation_input_token_cost_above_1hr": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000015, + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7 + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.00003, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "cache_creation_input_token_cost_above_1hr": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000015 + }, + "anthropic.claude-3-7-sonnet-20240620-v1:0": { + "cache_creation_input_token_cost": 0.0000045, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "au.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "apac.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024 + }, + "anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "apac.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "azure_ai/claude-haiku-4-5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-opus-4-6": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "azure_ai/claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "bedrock/ap-northeast-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 0.00000223, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.00000755, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 0.00000248, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.00000838, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024 + }, + "bedrock/eu-central-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Anthropic via Invoke route does not currently support pdf input." + }, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-east-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { + "input_cost_per_token": 0.0000033, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.0000045, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { + "input_cost_per_token": 0.0000033, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-west-2/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "claude-3-5-haiku-20241022": { + "cache_creation_input_token_cost": 0.000001, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 8e-8, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 8e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-haiku-latest": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 1e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-haiku-4-5-20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_computer_use": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-haiku-4-5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_computer_use": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-3-5-sonnet-20240620": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-latest": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-20250219": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2026-02-19", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-latest": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-haiku-20240307": { + "cache_creation_input_token_cost": 3e-7, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-opus-20240229": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 0.0000015, + "deprecation_date": "2026-05-01", + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-3-opus-latest": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 0.0000015, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-4-opus-20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-4-sonnet-20250514": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-5-20250929": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "inference_geo": "us" + }, + "claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1-20250805": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "deprecation_date": "2026-08-05", + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "deprecation_date": "2026-05-14", + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5-20251101": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/us/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-20250514": { + "deprecation_date": "2026-05-14", + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "databricks/databricks-claude-3-7-sonnet": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-haiku-4-5": { + "input_cost_per_token": 0.00000100002, + "input_dbu_cost_per_token": 0.000014286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00000500003, + "output_dbu_cost_per_token": 0.000071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4": { + "input_cost_per_token": 0.000015000020000000002, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00007500003000000001, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-1": { + "input_cost_per_token": 0.000015000020000000002, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00007500003000000001, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-5": { + "input_cost_per_token": 0.00000500003, + "input_dbu_cost_per_token": 0.000071429, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000025000010000000002, + "output_dbu_cost_per_token": 0.000357143, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-1": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-5": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-3-7-sonnet-latest": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-opus": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.0000825, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-sonnet": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "eu.anthropic.claude-3-5-haiku-20241022-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "deprecation_date": "2026-10-15", + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "gmi/anthropic/claude-opus-4.5": { + "input_cost_per_token": 0.000005, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-sonnet-4.5": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-sonnet-4": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-opus-4": { + "input_cost_per_token": 0.000015, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_vision": true + }, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "global.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "gradient_ai/anthropic-claude-3-opus": { + "input_cost_per_token": 0.000015, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-haiku": { + "input_cost_per_token": 8e-7, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.7-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "openrouter/anthropic/claude-3-haiku": { + "input_cost_per_image": 0.0004, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "openrouter", + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/anthropic/claude-3.5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-3.7-sonnet": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 0.000003, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4.1": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-sonnet-4": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4.5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-sonnet-4.5": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-haiku-4.5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "replicate/anthropic/claude-4.5-haiku": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-4-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.7-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.5-haiku": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.5-sonnet": { + "input_cost_per_token": 0.00000375, + "output_cost_per_token": 0.00001875, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-4.5-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "au.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vercel_ai_gateway/anthropic/claude-3-haiku": { + "cache_creation_input_token_cost": 3e-7, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3-opus": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.5-haiku": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.5-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.7-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-4-opus": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-4-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vercel_ai_gateway/anthropic/claude-3-5-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-3-7-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-haiku-4.5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-sonnet-4": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-sonnet-4.5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-haiku": { + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-3-5-haiku@20241022": { + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-haiku-4-5@20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_native_streaming": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2@20241022": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet@20240620": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-7-sonnet@20250219": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-3-haiku": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-haiku@20240307": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus": { + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus@20240229": { + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet@20240229": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "input_cost_per_token_batches": 0.0000075, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "output_cost_per_token_batches": 0.0000375, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4-1@20250805": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "input_cost_per_token_batches": 0.0000075, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "output_cost_per_token_batches": 0.0000375, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-opus-4-5@20251101": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159, + "supports_native_streaming": true + }, + "vertex_ai/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-opus-4-6@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token_batches": 0.0000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_batches": 0.0000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + } + }, + "vertex_ai/claude-sonnet-4-5@20250929": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token_batches": 0.0000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_batches": 0.0000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_streaming": true + }, + "vertex_ai/claude-opus-4@20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4@20250514": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4-6@default": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + } + } +} \ No newline at end of file diff --git a/scripts/fetch-pricing-data.ts b/scripts/fetch-pricing-data.ts new file mode 100644 index 00000000..703a0e21 --- /dev/null +++ b/scripts/fetch-pricing-data.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env tsx + +/** + * Fetch latest model pricing from LiteLLM and save to renderer assets. + * Filters to Claude models only to reduce bundle size. + * Runs automatically during prebuild. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const LITELLM_PRICING_URL = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'; +const OUTPUT_PATH = path.join(__dirname, '..', 'resources', 'pricing.json'); +const FETCH_TIMEOUT = 10000; // 10 seconds + +interface ModelPricing { + input_cost_per_token: number; + output_cost_per_token: number; + cache_creation_input_token_cost?: number; + cache_read_input_token_cost?: number; + input_cost_per_token_above_200k_tokens?: number; + output_cost_per_token_above_200k_tokens?: number; + cache_creation_input_token_cost_above_200k_tokens?: number; + cache_read_input_token_cost_above_200k_tokens?: number; + [key: string]: unknown; +} + +function isValidModelPricing(entry: unknown): entry is ModelPricing { + return ( + typeof entry === 'object' && + entry !== null && + 'input_cost_per_token' in entry && + 'output_cost_per_token' in entry && + typeof (entry as ModelPricing).input_cost_per_token === 'number' && + typeof (entry as ModelPricing).output_cost_per_token === 'number' + ); +} + +function isClaudeModel(modelName: string): boolean { + const lower = modelName.toLowerCase(); + return lower.includes('claude'); +} + +async function fetchPricingData(): Promise> { + console.log('Fetching pricing data from LiteLLM...'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + + try { + const response = await fetch(LITELLM_PRICING_URL, { signal: controller.signal }); + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as Record; + console.log(`Fetched pricing for ${Object.keys(data).length} models`); + + // Filter to Claude models only and validate entries + const claudeModels: Record = {}; + for (const [modelName, entry] of Object.entries(data)) { + if (isClaudeModel(modelName) && isValidModelPricing(entry)) { + claudeModels[modelName] = entry; + } + } + + console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`); + return claudeModels; + } catch (error) { + clearTimeout(timeout); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Fetch timeout after 10 seconds'); + } + throw error; + } +} + +async function main(): Promise { + try { + console.log('Fetching pricing data for models...'); + const pricing = await fetchPricingData(); + + // Ensure output directory exists + const outputDir = path.dirname(OUTPUT_PATH); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write formatted JSON + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(pricing, null, 2), 'utf-8'); + + // Calculate file size + const stats = fs.statSync(OUTPUT_PATH); + const sizeKB = (stats.size / 1024).toFixed(2); + + console.log(`✓ Wrote pricing data to ${OUTPUT_PATH}`); + console.log(` Bundle size: ${sizeKB} KB`); + } catch (error) { + console.error('Failed to fetch pricing data:', error); + console.error('Build will continue with existing pricing.json if available'); + // Don't fail the build - allow using existing pricing.json + process.exit(0); + } +} + +main(); diff --git a/src/main/index.ts b/src/main/index.ts index 11c57484..9a4dd3b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -454,8 +454,8 @@ function syncTrafficLightPosition(win: BrowserWindow): void { */ function createWindow(): void { const isMac = process.platform === 'darwin'; - const isLinux = process.platform === 'linux'; const iconPath = isMac ? undefined : getWindowIconPath(); + const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar; mainWindow = new BrowserWindow({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, @@ -466,7 +466,7 @@ function createWindow(): void { contextIsolation: true, }, backgroundColor: '#1a1a1a', - ...(isLinux ? {} : { titleBarStyle: 'hidden' as const }), + ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'Claude Agent Teams UI', }); @@ -527,7 +527,17 @@ function createWindow(): void { const ZOOM_OUT_KEYS = new Set(['-', '_']); mainWindow.webContents.on('before-input-event', (event, input) => { if (!mainWindow || mainWindow.isDestroyed()) return; - if (!input.meta || input.type !== 'keyDown') return; + if (input.type !== 'keyDown') return; + + // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer + // keyboard handler can use it as "Refresh Session" (fixes #58). + // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). + if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { + event.preventDefault(); + return; + } + + if (!input.meta) return; const currentLevel = mainWindow.webContents.getZoomLevel(); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 2e99c45c..a15cb2c1 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -204,6 +204,8 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'defaultTab', 'claudeRootPath', 'agentLanguage', + 'autoExpandAIGroups', + 'useNativeTitleBar', ]; const result: Partial = {}; @@ -274,6 +276,18 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V } result.agentLanguage = value.trim(); break; + case 'autoExpandAIGroups': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.autoExpandAIGroups = value; + break; + case 'useNativeTitleBar': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.useNativeTitleBar = value; + break; default: return { valid: false, error: `Unsupported general key: ${key}` }; } diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts index 42367607..fa6fcbf7 100644 --- a/src/main/ipc/window.ts +++ b/src/main/ipc/window.ts @@ -5,9 +5,9 @@ */ import { createLogger } from '@shared/utils/logger'; +import { app, BrowserWindow, type IpcMain } from 'electron'; const WINDOW_IS_FULLSCREEN = 'window:isFullScreen'; -import { BrowserWindow, type IpcMain } from 'electron'; const logger = createLogger('IPC:window'); @@ -47,6 +47,11 @@ export function registerWindowHandlers(ipcMain: IpcMain): void { return win != null && !win.isDestroyed() && win.isFullScreen(); }); + ipcMain.handle('app:relaunch', () => { + app.relaunch(); + app.exit(0); + }); + logger.info('Window handlers registered'); } @@ -56,5 +61,6 @@ export function removeWindowHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('window:close'); ipcMain.removeHandler('window:isMaximized'); ipcMain.removeHandler(WINDOW_IS_FULLSCREEN); + ipcMain.removeHandler('app:relaunch'); logger.info('Window handlers removed'); } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index f2502243..d1775f6f 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -182,6 +182,8 @@ export interface GeneralConfig { defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; agentLanguage: string; + autoExpandAIGroups: boolean; + useNativeTitleBar: boolean; } export interface DisplayConfig { @@ -250,6 +252,8 @@ const DEFAULT_CONFIG: AppConfig = { defaultTab: 'dashboard', claudeRootPath: null, agentLanguage: 'system', + autoExpandAIGroups: false, + useNativeTitleBar: false, }, display: { showTimestamps: true, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 6eba59a6..a92db953 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -373,8 +373,12 @@ export class NotificationManager extends EventEmitter { * Shows a native macOS notification for an error. */ private showNativeNotification(error: DetectedError): void { - // Check if Notification is supported - if (!Notification.isSupported()) { + // Guard against standalone/Docker mode where Electron's Notification API is unavailable + if ( + typeof Notification === 'undefined' || + typeof Notification.isSupported !== 'function' || + !Notification.isSupported() + ) { logger.warn('Native notifications not supported'); return; } diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index 4828c471..68e7145c 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -461,6 +461,7 @@ export const EMPTY_METRICS: SessionMetrics = { cacheReadTokens: 0, cacheCreationTokens: 0, messageCount: 0, + costUsd: 0, }; // ============================================================================= diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index eaf618f0..60297c7f 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -9,6 +9,7 @@ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; +import { calculateMessageCost } from '@shared/utils/pricing'; import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; @@ -228,7 +229,6 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - const costUsd = 0; // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); @@ -244,12 +244,30 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { } } + // Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals) + let costUsd = 0; + for (const msg of messages) { if (msg.usage) { - inputTokens += msg.usage.input_tokens ?? 0; - outputTokens += msg.usage.output_tokens ?? 0; - cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0; - cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0; + const msgInputTokens = msg.usage.input_tokens ?? 0; + const msgOutputTokens = msg.usage.output_tokens ?? 0; + const msgCacheReadTokens = msg.usage.cache_read_input_tokens ?? 0; + const msgCacheCreationTokens = msg.usage.cache_creation_input_tokens ?? 0; + + inputTokens += msgInputTokens; + outputTokens += msgOutputTokens; + cacheReadTokens += msgCacheReadTokens; + cacheCreationTokens += msgCacheCreationTokens; + + if (msg.model) { + costUsd += calculateMessageCost( + msg.model, + msgInputTokens, + msgOutputTokens, + msgCacheReadTokens, + msgCacheCreationTokens + ); + } } } @@ -261,7 +279,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { cacheReadTokens, cacheCreationTokens, messageCount: messages.length, - costUsd: costUsd > 0 ? costUsd : undefined, + costUsd, }; } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a40fd826..f42633d3 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -178,6 +178,9 @@ export const WINDOW_IS_FULLSCREEN = 'window:isFullScreen'; /** Event: (isFullScreen: boolean) when window enters or leaves fullscreen */ export const WINDOW_FULLSCREEN_CHANGED = 'window:fullscreen-changed'; +/** Relaunch the application */ +export const APP_RELAUNCH = 'app:relaunch'; + // ============================================================================= // Team API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index d73f0d24..caff4697 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer } from 'electron'; import { + APP_RELAUNCH, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -409,6 +410,7 @@ const electronAPI: ElectronAPI = { close: () => ipcRenderer.invoke(WINDOW_CLOSE), isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise, isFullScreen: () => ipcRenderer.invoke(WINDOW_IS_FULLSCREEN) as Promise, + relaunch: () => ipcRenderer.invoke(APP_RELAUNCH), }, onFullScreenChange: (callback: (isFullScreen: boolean) => void): (() => void) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index eb5b5885..e2f8ce47 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -532,6 +532,7 @@ export class HttpAPIClient implements ElectronAPI { close: async (): Promise => {}, isMaximized: async (): Promise => false, isFullScreen: async (): Promise => false, + relaunch: async (): Promise => {}, }; onFullScreenChange = diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 7bf24642..fb522b3b 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -245,6 +245,9 @@ const AIChatGroupInner = ({ return null; }, [aiGroup.responses]); + // Get the total cost + const costUSD = aiGroup.metrics.costUsd; + // Calculate thinking and text output tokens from assistant message content blocks // These are estimated from the actual content, providing breakdown of output token usage const { thinkingTokens, textOutputTokens } = useMemo(() => { @@ -470,6 +473,7 @@ const AIChatGroupInner = ({ contextStats={contextStats} phaseNumber={phaseNumber} totalPhases={totalPhases} + costUsd={costUSD} /> )} diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index d1a2fd0d..6991f4b2 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -1,15 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; +import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useStore } from '@renderer/store'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { ChevronRight, Users } from 'lucide-react'; +import { ChevronRight, ChevronsDown, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index'; + +/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */ +const SCROLL_THRESHOLD = 300; +/** Must match the `w-80` (320px) context panel width used in the layout below. */ +const CONTEXT_PANEL_WIDTH_PX = 320; + import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; @@ -60,6 +66,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { setTabVisibleAIGroup, teams, openTeamTab, + openSessionReport, } = useStore( useShallow((s) => ({ searchQuery: s.searchQuery, @@ -74,6 +81,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { setTabVisibleAIGroup: s.setTabVisibleAIGroup, teams: s.teams, openTeamTab: s.openTeamTab, + openSessionReport: s.openSessionReport, })) ); @@ -98,6 +106,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { sessionDetail, } = tabData; + // Compute combined subagent cost from process metrics + const subagentCostUsd = useMemo(() => { + const processes = sessionDetail?.processes; + if (!processes || processes.length === 0) return undefined; + const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); + return total > 0 ? total : undefined; + }, [sessionDetail?.processes]); + // State for Context button hover (local state OK - doesn't need per-tab isolation) const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); @@ -355,11 +371,21 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { rootRef: scrollContainerRef, }); + // Scroll-to-bottom button visibility + const [showScrollButton, setShowScrollButton] = useState(false); + + const checkScrollButton = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + const { scrollTop, scrollHeight, clientHeight } = container; + setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, SCROLL_THRESHOLD)); + }, []); + // Auto-follow when conversation updates, but only if the user was already near bottom. // This preserves manual reading position when the user scrolls up. // Disabled during navigation to prevent conflicts with deep-link/search scrolling. - useAutoScrollBottom([conversation], { - threshold: 150, + const { scrollToBottom } = useAutoScrollBottom([conversation], { + threshold: SCROLL_THRESHOLD, smoothDuration: 300, autoBehavior: 'auto', disabled: shouldDisableAutoScroll, @@ -367,6 +393,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { resetKey: effectiveTabId, }); + // Re-check button visibility whenever conversation updates + useEffect(() => { + checkScrollButton(); + }, [conversation, checkScrollButton]); + // Callback to register AI group refs (combines with visibility hook) const registerAIGroupRefCombined = useCallback( (groupId: string) => { @@ -730,12 +761,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { className="flex flex-1 flex-col overflow-hidden" style={{ backgroundColor: 'var(--color-surface)' }} > -
+
{/* Chat content */}
{/* Sticky Context button */} {allContextInjections.length > 0 && ( @@ -845,6 +877,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
+ {/* Scroll to bottom button */} + {showScrollButton && ( + + )} + {/* Context panel sidebar */} {isContextPanelVisible && allContextInjections.length > 0 && (
@@ -856,6 +912,9 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { onNavigateToTool={handleNavigateToTool} onNavigateToUserGroup={handleNavigateToUserGroup} totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={sessionDetail?.metrics} + subagentCostUsd={subagentCostUsd} + onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} phaseInfo={sessionPhaseInfo ?? undefined} selectedPhase={selectedContextPhase} onPhaseChange={setSelectedContextPhase} diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index ae9da2e1..0879452a 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,6 +12,7 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; +import { formatCostUsd } from '@shared/utils/costFormatting'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; @@ -20,12 +21,16 @@ import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; import type { ContextViewMode } from '../types'; import type { ContextPhaseInfo } from '@renderer/types/contextInjection'; +import type { SessionMetrics } from '@shared/types'; interface SessionContextHeaderProps { injectionCount: number; totalTokens: number; totalSessionTokens?: number; + sessionMetrics?: SessionMetrics; + subagentCostUsd?: number; onClose?: () => void; + onViewReport?: () => void; phaseInfo?: ContextPhaseInfo; selectedPhase: number | null; onPhaseChange: (phase: number | null) => void; @@ -37,7 +42,10 @@ export const SessionContextHeader = ({ injectionCount, totalTokens, totalSessionTokens, + sessionMetrics, + subagentCostUsd, onClose, + onViewReport, phaseInfo, selectedPhase, onPhaseChange, @@ -115,6 +123,46 @@ export const SessionContextHeader = ({ )}
+ {/* Session Metrics Breakdown */} + {sessionMetrics && ( +
+ {/* Cost */} + {sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && ( +
+ Session Cost: + + {formatCostUsd(sessionMetrics.costUsd + (subagentCostUsd ?? 0))} + + {subagentCostUsd !== undefined && subagentCostUsd > 0 && ( + + {' ('} + {formatCostUsd(sessionMetrics.costUsd)} + {' parent + '} + {formatCostUsd(subagentCostUsd)} + {' subagents'} + {onViewReport && ( + <> + {' · '} + + + )} + {')'} + + )} +
+ )} +
+ )} + {/* Phase selector - only shown when compactions exist */} {phaseInfo && phaseInfo.phases.length > 1 && (
void; /** Total session tokens (input + output + cache) for comparison */ totalSessionTokens?: number; + /** Full session metrics (input, output, cache tokens, cost) */ + sessionMetrics?: SessionMetrics; + /** Combined cost of all subagent processes */ + subagentCostUsd?: number; + /** Open the Session Report to see full cost breakdown */ + onViewReport?: () => void; /** Phase information for phase selector */ phaseInfo?: ContextPhaseInfo; /** Currently selected phase (null = current/latest) */ diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index d38690f7..70fb4887 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { formatCostUsd } from '@shared/utils/costFormatting'; import { getModelColorClass } from '@shared/utils/modelParser'; import { formatTokensCompact as formatTokens, @@ -49,6 +50,8 @@ interface TokenUsageDisplayProps { phaseNumber?: number; /** Total number of phases in the session */ totalPhases?: number; + /** Optional USD cost for this usage */ + costUsd?: number; } /** @@ -255,6 +258,7 @@ export const TokenUsageDisplay = ({ contextStats, phaseNumber, totalPhases, + costUsd, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; const formattedTotal = formatTokens(totalTokens); @@ -513,6 +517,19 @@ export const TokenUsageDisplay = ({
+ {/* Cost (USD) - if available */} + {costUsd !== undefined && costUsd > 0 && ( +
+ Cost (USD) + + {formatCostUsd(costUsd)} + +
+ )} + {/* Visible Context Breakdown - expandable section */} {contextStats && (contextStats.totalEstimatedTokens > 0 || diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 76052de7..a96f1610 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -18,6 +18,7 @@ import { normalizePath, type TaskStatusCounts, } from '@renderer/utils/pathNormalize'; +import { formatShortcut } from '@renderer/utils/stringUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; @@ -82,7 +83,11 @@ const CommandSearch = ({ value, onChange }: Readonly): React + ); + + const separator = ( +
+ ); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown menu */} + {isOpen && ( +
+ {topItems.map(renderItem)} + + {sessionItems.length > 0 && ( + <> + {separator} + {sessionItems.map(renderItem)} + + )} + + {separator} + {bottomItems.map(renderItem)} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index d2b086ab..7d12e284 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -7,6 +7,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; import { NotificationsView } from '../notifications/NotificationsView'; +import { SessionReportTab } from '../report/SessionReportTab'; import { SettingsView } from '../settings/SettingsView'; import { TeamDetailView } from '../team/TeamDetailView'; import { TeamListView } from '../team/TeamListView'; @@ -51,6 +52,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} + {tab.type === 'report' && }
); })} diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index c7f9f443..54a998a6 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -17,7 +17,7 @@ import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { truncateMiddle } from '@renderer/utils/stringUtils'; +import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils'; import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -329,7 +329,7 @@ export const SidebarHeader = (): React.JSX.Element => { backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent', } as React.CSSProperties } - title="Collapse sidebar (⌘B)" + title={`Collapse sidebar (${formatShortcut('B')})`} > diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index f6798c7e..c645bb28 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -8,7 +8,17 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useStore } from '@renderer/store'; -import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, Users, X } from 'lucide-react'; +import { + Activity, + Bell, + FileText, + LayoutDashboard, + Pin, + Search, + Settings, + Users, + X, +} from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { Tab } from '@renderer/types/tabs'; @@ -32,6 +42,7 @@ const TAB_ICONS = { session: FileText, teams: Users, team: Users, + report: Activity, } as const; export const SortableTab = ({ @@ -62,7 +73,8 @@ export const SortableTab = ({ }, }); - const style: React.CSSProperties = { + const style = { + WebkitAppRegion: 'no-drag', transform: CSS.Transform.toString(transform), transition: isDragging ? 'none' : transition, opacity: isDragging ? 0.3 : 1, diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index ae197c87..893f27d6 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -14,11 +14,11 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; -import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings, Users } from 'lucide-react'; +import { formatShortcut } from '@renderer/utils/stringUtils'; +import { Bell, PanelLeft, Plus, RefreshCw, Settings, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ExportDropdown } from '../common/ExportDropdown'; - +import { MoreMenu } from './MoreMenu'; import { SortableTab } from './SortableTab'; import { TabContextMenu } from './TabContextMenu'; @@ -41,7 +41,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { openDashboard, fetchSessionDetail, fetchSessions, - openCommandPalette, unreadCount, openNotificationsTab, openTeamsTab, @@ -69,7 +68,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { openDashboard: s.openDashboard, fetchSessionDetail: s.fetchSessionDetail, fetchSessions: s.fetchSessions, - openCommandPalette: s.openCommandPalette, unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openTeamsTab: s.openTeamsTab, @@ -104,7 +102,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const [expandHover, setExpandHover] = useState(false); const [refreshHover, setRefreshHover] = useState(false); const [newTabHover, setNewTabHover] = useState(false); - const [searchHover, setSearchHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); @@ -271,8 +268,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { sidebarCollapsed && isLeftmostPane ? 'var(--macos-traffic-light-padding-left, 72px)' : '8px', - WebkitAppRegion: - isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined, + WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined, backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)', opacity: isFocused || paneCount === 1 ? 1 : 0.7, @@ -299,15 +295,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )} - {/* Tab list with horizontal scroll, sortable DnD, and droppable area */} + {/* Tab list with horizontal scroll, sortable DnD, and droppable area. + Capped at 75% so the drag spacer always has room to the right. */}
{ scrollContainerRef.current = el; setDroppableRef(el); }} - className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" + className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto" style={ { + maxWidth: '75%', WebkitAppRegion: 'no-drag', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outlineOffset: '-1px', @@ -342,13 +340,25 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { onMouseEnter={() => setRefreshHover(true)} onMouseLeave={() => setRefreshHover(false)} onClick={handleRefresh} - title="Refresh Session (Cmd+R)" + title={`Refresh Session (${formatShortcut('R')})`} > )}
+ {/* Drag spacer — fills empty space between tab list and action buttons. + Gives users a reliable window-drag target regardless of how many tabs are open. + Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */} +
+ {/* Right side actions */}
{ - {/* Search button (icon only) */} - - - {/* Export dropdown - show only for session tabs with loaded data */} - {activeTab?.type === 'session' && activeTabSessionDetail && ( - - )} - {/* Notifications bell icon */} + + {/* More menu (Search, Export, Analyze, Settings) */} +
{/* Context menu */} diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx index cd3fedf8..a58e0de3 100644 --- a/src/renderer/components/layout/TabContextMenu.tsx +++ b/src/renderer/components/layout/TabContextMenu.tsx @@ -7,6 +7,8 @@ import { useEffect, useRef } from 'react'; +import { formatShortcut } from '@renderer/utils/stringUtils'; + interface TabContextMenuProps { x: number; y: number; @@ -100,13 +102,17 @@ export const TabContextMenu = ({ onClick={handleClick(onCloseSelectedTabs)} /> ) : ( - + )}
@@ -127,7 +133,11 @@ export const TabContextMenu = ({ /> )}
- +
); }; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index cb8e61d8..80265600 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -17,9 +17,9 @@ import { UpdateDialog } from '../common/UpdateDialog'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; import { CommandPalette } from '../search/CommandPalette'; +import { CustomTitleBar } from './CustomTitleBar'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; -import { WindowsTitleBar } from './WindowsTitleBar'; export const TabbedLayout = (): React.JSX.Element => { useKeyboardShortcuts(); @@ -38,7 +38,7 @@ export const TabbedLayout = (): React.JSX.Element => { { '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties } > - +
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/report/AssessmentBadge.tsx b/src/renderer/components/report/AssessmentBadge.tsx new file mode 100644 index 00000000..99ce0d10 --- /dev/null +++ b/src/renderer/components/report/AssessmentBadge.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { + assessmentColor, + assessmentExplanation, + assessmentLabel, +} from '@renderer/utils/reportAssessments'; + +import type { MetricKey } from '@renderer/utils/reportAssessments'; + +interface AssessmentBadgeProps { + assessment: string; + metricKey?: MetricKey; +} + +export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps) => { + const color = assessmentColor(assessment); + const explanation = metricKey ? assessmentExplanation(metricKey, assessment) : ''; + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }); + const badgeRef = useRef(null); + const enterTimer = useRef>(); + const leaveTimer = useRef>(); + + const handleMouseEnter = useCallback(() => { + if (!explanation) return; + clearTimeout(leaveTimer.current); + enterTimer.current = setTimeout(() => { + if (badgeRef.current) { + const rect = badgeRef.current.getBoundingClientRect(); + setTooltipPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }); + } + setShowTooltip(true); + }, 200); + }, [explanation]); + + const handleMouseLeave = useCallback(() => { + clearTimeout(enterTimer.current); + leaveTimer.current = setTimeout(() => setShowTooltip(false), 150); + }, []); + + useEffect(() => { + return () => { + clearTimeout(enterTimer.current); + clearTimeout(leaveTimer.current); + }; + }, []); + + return ( + <> + + {assessmentLabel(assessment)} + + {showTooltip && + explanation && + createPortal( +
+ {explanation} +
, + document.body + )} + + ); +}; diff --git a/src/renderer/components/report/ReportSection.tsx b/src/renderer/components/report/ReportSection.tsx new file mode 100644 index 00000000..d6ecc129 --- /dev/null +++ b/src/renderer/components/report/ReportSection.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const sectionId = (title: string) => + `report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + +interface ReportSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultCollapsed?: boolean; +} + +export const ReportSection = ({ + title, + icon: Icon, + children, + defaultCollapsed = false, +}: ReportSectionProps) => { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const handler = () => { + setCollapsed(false); + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + el.addEventListener('report-section-expand', handler); + return () => el.removeEventListener('report-section-expand', handler); + }, []); + + return ( +
+ + {!collapsed &&
{children}
} +
+ ); +}; + +export { sectionId }; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx new file mode 100644 index 00000000..f5cd8031 --- /dev/null +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { computeTakeaways } from '@renderer/utils/reportAssessments'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; + +import { CostSection } from './sections/CostSection'; +import { ErrorSection } from './sections/ErrorSection'; +import { FrictionSection } from './sections/FrictionSection'; +import { GitSection } from './sections/GitSection'; +import { InsightsSection } from './sections/InsightsSection'; +import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { QualitySection } from './sections/QualitySection'; +import { SubagentSection } from './sections/SubagentSection'; +import { TimelineSection } from './sections/TimelineSection'; +import { TokenSection } from './sections/TokenSection'; +import { ToolSection } from './sections/ToolSection'; + +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]); + + if (!report) { + return ( +
+ No session data available. Open the session tab first. +
+ ); + } + + return ( +
+

Session Analysis Report

+
+ {takeaways.length > 0 && } + + + + + {report.subagentMetrics.count > 0 && ( + + )} + {report.errors.errors.length > 0 && } + + + + + +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx new file mode 100644 index 00000000..c1fdd2e0 --- /dev/null +++ b/src/renderer/components/report/sections/CostSection.tsx @@ -0,0 +1,260 @@ +import { Fragment, useState } from 'react'; + +import { getPricing } from '@renderer/utils/sessionAnalyzer'; +import { DollarSign } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection, sectionId } from '../ReportSection'; + +import type { + ModelPricing, + ModelTokenStats, + ReportCostAnalysis, +} from '@renderer/types/sessionReport'; + +const fmt = (v: number) => `$${v.toFixed(4)}`; +const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)); +const fmtRate = (v: number) => `$${v}`; +const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000; + +interface CostSectionProps { + data: ReportCostAnalysis; + tokensByModel: Record; + commitCount: number; + linesChanged: number; + defaultCollapsed?: boolean; +} + +interface BreakdownLine { + label: string; + tokens: number; + ratePerM: number; +} + +const CostBreakdownCard = ({ + stats, + pricing, +}: { + stats: ModelTokenStats; + pricing: ModelPricing; +}) => { + const lines: BreakdownLine[] = [ + { label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input }, + { label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output }, + { label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read }, + { label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation }, + ]; + const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0); + + return ( +
+
+ Cost Breakdown (per 1M tokens) +
+
+ {lines.map((l) => { + const cost = lineCost(l.tokens, l.ratePerM); + return ( +
+ {l.label} + + {l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)} + +
+ ); + })} +
+ Total + {fmt(total)} +
+
+
+ ); +}; + +export const CostSection = ({ + data, + tokensByModel, + commitCount, + linesChanged, + defaultCollapsed, +}: CostSectionProps) => { + const [expandedModel, setExpandedModel] = useState(null); + const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); + const showStackedBar = data.subagentCostUsd > 0; + const parentPct = + showStackedBar && data.totalSessionCostUsd > 0 + ? (data.parentCostUsd / data.totalSessionCostUsd) * 100 + : 100; + + return ( + +
{fmt(data.totalSessionCostUsd)}
+ + {/* Parent/Subagent stacked bar */} + {showStackedBar && ( +
+
+
+
+
+
+
+ + Parent: {fmt(data.parentCostUsd)} +
+
+ + Subagent: {fmt(data.subagentCostUsd)} +
+
+
+ )} + +
+ {!showStackedBar && ( + <> +
+
Parent Cost
+
{fmt(data.parentCostUsd)}
+
+
+
Subagent Cost
+
{fmt(data.subagentCostUsd)}
+
+ + )} +
+
Per Commit
+
+ {commitCount > 0 ? ( + <> + total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''} + + ) : ( + 'no commits' + )} +
+
+ + {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} + + {data.costPerCommitAssessment && ( + + )} +
+
+
+
Per Line Changed
+
+ {linesChanged > 0 ? ( + <> + total cost {'\u00F7'} {linesChanged.toLocaleString()} line + {linesChanged !== 1 ? 's' : ''} + + ) : ( + 'no lines changed' + )} +
+
+ + {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} + + {data.costPerLineAssessment && ( + + )} +
+
+
+ + {modelEntries.length > 0 && ( + + + + + + + + + + + + + {modelEntries.map(([model, cost]) => { + const stats = tokensByModel[model]; + // Don't allow expansion for the synthetic aggregated row — getPricing + // would return wrong default rates for a non-model label. + const isAggregateRow = model === 'Subagents (combined)'; + const isExpanded = expandedModel === model && !!stats && !isAggregateRow; + const pricing = isAggregateRow ? null : getPricing(model); + return ( + + { + if (isAggregateRow) { + const el = document.getElementById(sectionId('Subagents')); + if (el) { + el.scrollIntoView({ behavior: 'smooth' }); + el.dispatchEvent(new CustomEvent('report-section-expand')); + } + } else if (stats) { + setExpandedModel(isExpanded ? null : model); + } + }} + > + + + + + + + + {isExpanded && stats && pricing && ( + + + + )} + + ); + })} + +
ModelInputOutputCache ReadCache WriteCost
+ {isAggregateRow ? ( + {'\u2192'} + ) : ( + + {stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''} + + )} + {model} + + {stats ? fmtK(stats.inputTokens) : '—'} + + {stats ? fmtK(stats.outputTokens) : '—'} + + {stats ? fmtK(stats.cacheRead) : '—'} + + {stats ? fmtK(stats.cacheCreation) : '—'} + {fmt(cost)}
+ +
+ )} + + ); +}; diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx new file mode 100644 index 00000000..88832434 --- /dev/null +++ b/src/renderer/components/report/sections/ErrorSection.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; + +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportErrors, ToolError } from '@renderer/types/sessionReport'; + +interface ErrorItemProps { + error: ToolError; +} + +const ErrorItem = ({ error }: ErrorItemProps) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {error.inputPreview && ( +
+
+ Input +
+
+ {error.inputPreview} +
+
+ )} +
+
+ Error +
+
+ {error.error} +
+
+
+ )} +
+ ); +}; + +interface ErrorSectionProps { + data: ReportErrors; + defaultCollapsed?: boolean; +} + +export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => { + return ( + +
+ + {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} + + {data.permissionDenials.count > 0 && ( + + {data.permissionDenials.count} permission denial + {data.permissionDenials.count !== 1 ? 's' : ''} + + )} +
+ +
+ {data.errors.map((error, idx) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx new file mode 100644 index 00000000..147b74ed --- /dev/null +++ b/src/renderer/components/report/sections/FrictionSection.tsx @@ -0,0 +1,97 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { MessageSquareWarning } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; + +interface FrictionSectionProps { + data: ReportFrictionSignals; + thrashing: ReportThrashingSignals; + defaultCollapsed?: boolean; +} + +export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => { + const frictionSeverity = + data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger'; + const frictionColor = severityColor(frictionSeverity); + + return ( + +
+ + Friction Rate: {(data.frictionRate * 100).toFixed(1)}% + + + {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''} + +
+ + {data.corrections.length > 0 && ( +
+
Corrections
+
+ {data.corrections.map((corr, idx) => ( +
+ + {corr.keyword} + + {corr.preview} +
+ ))} +
+
+ )} + + {(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && ( +
+
+ Thrashing Signals + +
+ + {thrashing.bashNearDuplicates.length > 0 && ( +
+
Repeated Bash Commands
+ {thrashing.bashNearDuplicates.map((dup, idx) => ( +
+ {dup.count}x + {dup.prefix} +
+ ))} +
+ )} + + {thrashing.editReworkFiles.length > 0 && ( +
+
Reworked Files (3+ edits)
+ {thrashing.editReworkFiles.map((file, idx) => ( +
+ {file.editIndices.length}x + {file.filePath} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx new file mode 100644 index 00000000..5481d835 --- /dev/null +++ b/src/renderer/components/report/sections/GitSection.tsx @@ -0,0 +1,72 @@ +import { GitBranch } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportGitActivity } from '@renderer/types/sessionReport'; + +interface GitSectionProps { + data: ReportGitActivity; + defaultCollapsed?: boolean; +} + +export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => { + return ( + +
+
+
Commits
+
{data.commitCount}
+
+
+
Pushes
+
{data.pushCount}
+
+
+
Lines Added
+
+ +{data.linesAdded.toLocaleString()} +
+
+
+
Lines Removed
+
+ -{data.linesRemoved.toLocaleString()} +
+
+
+ + {data.commits.length > 0 && ( +
+
Commits
+
+ {data.commits.map((commit, idx) => ( +
+ #{commit.messageIndex} + {commit.messagePreview} +
+ ))} +
+
+ )} + + {data.branchCreations.length > 0 && ( +
+
Branches Created
+
+ {data.branchCreations.map((branch, idx) => ( + + {branch} + + ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/InsightsSection.tsx b/src/renderer/components/report/sections/InsightsSection.tsx new file mode 100644 index 00000000..655ecb1a --- /dev/null +++ b/src/renderer/components/report/sections/InsightsSection.tsx @@ -0,0 +1,207 @@ +import { Lightbulb } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + OutOfScopeFindings, + ReportAgentTree, + ReportBashCommands, + SkillInvocation, + SubagentBasicEntry, + UserQuestion, +} from '@renderer/types/sessionReport'; + +interface InsightsSectionProps { + skills: SkillInvocation[]; + bash: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScope: OutOfScopeFindings[]; + agentTree: ReportAgentTree; + subagentsList: SubagentBasicEntry[]; + defaultCollapsed?: boolean; +} + +export const InsightsSection = ({ + skills, + bash, + lifecycleTasks, + userQuestions, + outOfScope, + agentTree, + subagentsList, + defaultCollapsed, +}: InsightsSectionProps) => { + return ( + + {/* Skills invoked */} + {skills.length > 0 && ( +
+
+ Skills Invoked ({skills.length}) +
+
+ {skills.map((s, idx) => ( +
+ {s.skill} + {s.argsPreview && {s.argsPreview}} +
+ ))} +
+
+ )} + + {/* Bash commands */} +
+
Bash Commands
+
+
+
Total
+
{bash.total}
+
+
+
Unique
+
{bash.unique}
+
+
+
Repeated
+
{Object.keys(bash.repeated).length}
+
+
+ {Object.keys(bash.repeated).length > 0 && ( +
+ {Object.entries(bash.repeated) + .slice(0, 10) + .map(([cmd, count], idx) => ( +
+ {count}x + {cmd} +
+ ))} +
+ )} +
+ + {/* Task tool subagent list */} + {subagentsList.length > 0 && ( +
+
+ Task Dispatches ({subagentsList.length}) +
+
+ {subagentsList.map((s, idx) => ( +
+ + {s.subagentType} + + {s.description} + {s.runInBackground && (background)} +
+ ))} +
+
+ )} + + {/* Lifecycle tasks */} + {lifecycleTasks.length > 0 && ( +
+
+ Tasks Created ({lifecycleTasks.length}) +
+
+ {lifecycleTasks.map((task, idx) => ( +
+ {task} +
+ ))} +
+
+ )} + + {/* User questions */} + {userQuestions.length > 0 && ( +
+
+ Questions Asked ({userQuestions.length}) +
+
+ {userQuestions.map((q, idx) => ( +
+
{q.question}
+ {q.options.length > 0 && ( +
+ {q.options.map((opt, optIdx) => ( + + {opt} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Agent tree */} + {agentTree.agentCount > 0 && ( +
+
+ Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''}) + {agentTree.hasTeamMode && ( + + Team Mode + + )} +
+ {agentTree.teamNames.length > 0 && ( +
+ Teams: {agentTree.teamNames.join(', ')} +
+ )} +
+ {agentTree.agents.map((agent, idx) => ( +
+ + {agent.agentType} + + + {agent.agentId.slice(0, 12)}... + +
+ ))} +
+
+ )} + + {/* Out-of-scope findings */} + {outOfScope.length > 0 && ( +
+
+ Out-of-Scope Findings ({outOfScope.length}) +
+
+ {outOfScope.map((f, idx) => ( +
+ + {f.keyword} + + {f.snippet} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx new file mode 100644 index 00000000..9eee50b0 --- /dev/null +++ b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx @@ -0,0 +1,55 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react'; + +import { sectionId } from '../ReportSection'; + +import type { Severity, Takeaway } from '@renderer/utils/reportAssessments'; + +const SEVERITY_ICONS: Record> = { + danger: XCircle, + warning: AlertTriangle, + good: CheckCircle, + neutral: Info, +}; + +const scrollToSection = (sectionTitle: string) => { + const el = document.getElementById(sectionId(sectionTitle)); + if (!el) return; + el.dispatchEvent(new CustomEvent('report-section-expand')); +}; + +interface KeyTakeawaysSectionProps { + takeaways: Takeaway[]; +} + +export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => { + return ( +
+
Key Takeaways
+
+ {takeaways.map((t, idx) => { + const Icon = SEVERITY_ICONS[t.severity]; + const color = severityColor(t.severity); + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx new file mode 100644 index 00000000..d871b02d --- /dev/null +++ b/src/renderer/components/report/sections/OverviewSection.tsx @@ -0,0 +1,64 @@ +import { assessmentColor } from '@renderer/utils/reportAssessments'; +import { Activity } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportOverview } from '@renderer/types/sessionReport'; + +interface OverviewSectionProps { + data: ReportOverview; +} + +export const OverviewSection = ({ data }: OverviewSectionProps) => { + return ( + +
{data.firstMessage}
+
+
+
Duration
+
{data.durationHuman}
+
+
+
Messages
+
{data.totalMessages.toLocaleString()}
+
+
+
Context Usage
+
+ {data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'} + {data.contextAssessment && ( + ({data.contextAssessment}) + )} +
+
+
+
Compactions
+
{data.compactionCount}
+
+
+
Branch
+
{data.gitBranch}
+
+
+
Subagents
+
{data.hasSubagents ? 'Yes' : 'No'}
+
+
+
Project
+
+ {data.projectPath} +
+
+
+
Session ID
+
+ {data.sessionId.slice(0, 12)}... +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx new file mode 100644 index 00000000..fa1794cb --- /dev/null +++ b/src/renderer/components/report/sections/QualitySection.tsx @@ -0,0 +1,153 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { BarChart3 } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { + ReportFileReadRedundancy, + ReportPromptQuality, + ReportStartupOverhead, + ReportTestProgression, +} from '@renderer/types/sessionReport'; + +interface QualitySectionProps { + prompt: ReportPromptQuality; + startup: ReportStartupOverhead; + testProgression: ReportTestProgression; + fileReadRedundancy: ReportFileReadRedundancy; + defaultCollapsed?: boolean; +} + +export const QualitySection = ({ + prompt, + startup, + testProgression, + fileReadRedundancy, + defaultCollapsed, +}: QualitySectionProps) => { + return ( + + {/* Prompt quality */} +
+
Prompt Quality
+
+ +
+
{prompt.note}
+
+
+
First Message
+
+ {prompt.firstMessageLengthChars.toLocaleString()} chars +
+
+
+
User Messages
+
{prompt.userMessageCount}
+
+
+
Corrections
+
{prompt.correctionCount}
+
+
+
Friction Rate
+
+ {(prompt.frictionRate * 100).toFixed(1)}% +
+
+
+
+ + {/* Startup overhead */} +
+
+ Startup Overhead + +
+
+
+
Messages Before Work
+
{startup.messagesBeforeFirstWork}
+
+
+
Tokens Before Work
+
+ {startup.tokensBeforeFirstWork.toLocaleString()} +
+
+
+
% of Total
+
{startup.pctOfTotal}%
+
+
+
+ + {/* File read redundancy */} +
+
+ File Read Redundancy + +
+
+
+
Total Reads
+
{fileReadRedundancy.totalReads}
+
+
+
Unique Files
+
{fileReadRedundancy.uniqueFiles}
+
+
+
Reads/Unique File
+
+ {fileReadRedundancy.readsPerUniqueFile}x +
+
+
+
+ + {/* Test progression */} +
+
Test Progression
+
+ + + {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} + +
+ {testProgression.firstSnapshot && testProgression.lastSnapshot && ( +
+
+
First Run
+
+ + {testProgression.firstSnapshot.passed} passed + + {' / '} + + {testProgression.firstSnapshot.failed} failed + +
+
+
+
Last Run
+
+ + {testProgression.lastSnapshot.passed} passed + + {' / '} + + {testProgression.lastSnapshot.failed} failed + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx new file mode 100644 index 00000000..7ce3dd60 --- /dev/null +++ b/src/renderer/components/report/sections/SubagentSection.tsx @@ -0,0 +1,88 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { Users } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportSubagentMetrics } from '@renderer/types/sessionReport'; + +const fmtCost = (v: number) => `$${v.toFixed(4)}`; +const fmtDuration = (ms: number) => { + const s = Math.round(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}m ${sec}s` : `${sec}s`; +}; + +interface SubagentSectionProps { + data: ReportSubagentMetrics; + defaultCollapsed?: boolean; +} + +export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => { + return ( + +
+
+
Count
+
{data.count}
+
+
+
Total Tokens
+
{data.totalTokens.toLocaleString()}
+
+
+
Total Duration
+
{fmtDuration(data.totalDurationMs)}
+
+
+
Total Cost
+
{fmtCost(data.totalCostUsd)}
+
+
+ + {data.byAgent.length > 0 && ( +
+ + + + + + + + + + + + {data.byAgent.map((agent, idx) => ( + + + + + + + + ))} + +
DescriptionTypeTokensDurationCost
+
+ {agent.description} +
+ {agent.modelMismatch && ( +
+ {agent.modelMismatch.recommendation} +
+ )} +
{agent.subagentType} + {agent.totalTokens.toLocaleString()} + + {fmtDuration(agent.totalDurationMs)} + {fmtCost(agent.costUsd)}
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx new file mode 100644 index 00000000..395974dd --- /dev/null +++ b/src/renderer/components/report/sections/TimelineSection.tsx @@ -0,0 +1,111 @@ +import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; +import { Clock } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { + KeyEvent, + ReportIdleAnalysis, + ReportModelSwitches, +} from '@renderer/types/sessionReport'; + +interface TimelineSectionProps { + idle: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + keyEvents: KeyEvent[]; + defaultCollapsed?: boolean; +} + +export const TimelineSection = ({ + idle, + modelSwitches, + keyEvents, + defaultCollapsed, +}: TimelineSectionProps) => { + const idleColor = assessmentColor(idle.idleAssessment); + + return ( + + {/* Idle stats */} +
+
+ Idle Analysis + +
+
+
+
Idle Gaps
+
{idle.idleGapCount}
+
+
+
Total Idle
+
{idle.totalIdleHuman}
+
+
+
Active Time
+
{idle.activeWorkingHuman}
+
+
+
Idle %
+
+ {idle.idlePct}% +
+
+
+
+ + {/* Model switches */} + {modelSwitches.count > 0 && ( +
+
+ + Model Switches ({modelSwitches.count}) + + {modelSwitches.switchPattern && ( + + {assessmentLabel(modelSwitches.switchPattern)} + + )} +
+
+ {modelSwitches.switches.map((sw, idx) => ( +
+ {sw.from} + + {sw.to} + msg #{sw.messageIndex} +
+ ))} +
+
+ )} + + {/* Key events */} + {keyEvents.length > 0 && ( +
+
Key Events
+
+ {keyEvents.map((event, idx) => ( +
+ + {event.timestamp.toLocaleTimeString()} + + {event.label} + {event.deltaHuman && ( + +{event.deltaHuman} + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx new file mode 100644 index 00000000..f3aeaa27 --- /dev/null +++ b/src/renderer/components/report/sections/TokenSection.tsx @@ -0,0 +1,116 @@ +import { Coins } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => v.toLocaleString(); +const fmtCost = (v: number) => `$${v.toFixed(4)}`; + +interface TokenSectionProps { + data: ReportTokenUsage; + cacheEconomics: ReportCacheEconomics; + defaultCollapsed?: boolean; +} + +export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => { + const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); + + return ( + + {/* By-model table */} +
+ + + + + + + + + + + + + + {modelEntries.map(([model, stats]) => ( + + + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + + +
ModelAPI CallsInputOutputCache ReadCache CreateCost
{model}{fmt(stats.apiCalls)}{fmt(stats.inputTokens)}{fmt(stats.outputTokens)}{fmt(stats.cacheRead)}{fmt(stats.cacheCreation)}{fmtCost(stats.costUsd)}
Total + {fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))} + {fmt(data.totals.inputTokens)}{fmt(data.totals.outputTokens)}{fmt(data.totals.cacheRead)}{fmt(data.totals.cacheCreation)} + {fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))} +
+
+ + {/* Cache economics */} +
+
+
Cache Efficiency
+
+ + {cacheEconomics.cacheEfficiencyPct}% + + {cacheEconomics.cacheEfficiencyAssessment && ( + + )} +
+
+
+
R/W Ratio
+
+ + {cacheEconomics.cacheReadToWriteRatio}x + + {cacheEconomics.cacheRatioAssessment && ( + + )} +
+
+
+
Cache Read %
+
{data.totals.cacheReadPct}%
+
+
+
Cold Start
+
+ {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx new file mode 100644 index 00000000..fd15c852 --- /dev/null +++ b/src/renderer/components/report/sections/ToolSection.tsx @@ -0,0 +1,77 @@ +import { assessmentColor } from '@renderer/utils/reportAssessments'; +import { Wrench } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection, sectionId } from '../ReportSection'; + +import type { ReportToolUsage } from '@renderer/types/sessionReport'; + +interface ToolSectionProps { + data: ReportToolUsage; + defaultCollapsed?: boolean; +} + +export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => { + const toolEntries = Object.entries(data.successRates).sort( + (a, b) => b[1].totalCalls - a[1].totalCalls + ); + + return ( + +
+ + {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools + + +
+
+ + + + + + + + + + + + {toolEntries.map(([tool, stats]) => { + const color = assessmentColor(stats.assessment); + return ( + + + + + + + + ); + })} + +
ToolCallsErrorsSuccess %Health
{tool} + {stats.totalCalls.toLocaleString()} + + {stats.errors > 0 ? ( + + ) : ( + stats.errors.toLocaleString() + )} + + {stats.successRatePct}% + + +
+
+
+ ); +}; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 6e0bd225..5c4d8386 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -31,6 +31,8 @@ export interface SafeConfig { defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; agentLanguage: string; + autoExpandAIGroups: boolean; + useNativeTitleBar: boolean; }; notifications: { enabled: boolean; @@ -156,6 +158,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn { defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, agentLanguage: displayConfig?.general?.agentLanguage ?? 'system', + autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false, + useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false, }, notifications: { enabled: displayConfig?.notifications?.enabled ?? true, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index ebe7a960..b1169648 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -296,6 +296,8 @@ export function useSettingsHandlers({ defaultTab: 'dashboard', claudeRootPath: null, agentLanguage: 'system', + autoExpandAIGroups: false, + useNativeTitleBar: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index ad872a1d..16471af6 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -17,6 +17,7 @@ import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } fro import type { SafeConfig } from '../hooks/useSettingsConfig'; import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; import type { HttpServerStatus } from '@shared/types/api'; +import type { AppConfig } from '@shared/types/notifications'; // Theme options const THEME_OPTIONS = [ @@ -28,7 +29,7 @@ const THEME_OPTIONS = [ interface GeneralSectionProps { readonly safeConfig: SafeConfig; readonly saving: boolean; - readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void; + readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void; readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void; readonly onLanguageChange: (value: string) => void; } @@ -341,6 +342,41 @@ export const GeneralSection = ({ disabled={saving} /> + + onGeneralToggle('autoExpandAIGroups', v)} + disabled={saving} + /> + + {isElectron && !window.navigator.userAgent.includes('Macintosh') && ( + + { + const shouldRelaunch = await confirm({ + title: 'Restart required', + message: 'The app needs to restart to apply the title bar change. Restart now?', + confirmLabel: 'Restart', + }); + if (shouldRelaunch) { + onGeneralToggle('useNativeTitleBar', v); + // Small delay to let config persist before relaunch + setTimeout(() => { + void window.electronAPI?.windowControls?.relaunch(); + }, 200); + } + }} + disabled={saving} + /> + + )} {isElectron && ( <> diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx index 4adbf6ee..3579eaad 100644 --- a/src/renderer/components/sidebar/SessionContextMenu.tsx +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { MAX_PANES } from '@renderer/types/panes'; +import { formatShortcut } from '@renderer/utils/stringUtils'; import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react'; interface SessionContextMenuProps { @@ -98,7 +99,11 @@ export const SessionContextMenu = ({ }} > - +
{ if (resetKey !== prevResetKeyRef.current) { isAtBottomRef.current = true; wasAtBottomBeforeUpdateRef.current = true; prevResetKeyRef.current = resetKey; + needsInitialScrollRef.current = true; } }, [resetKey]); /** - * After content updates (dependencies change), scroll to bottom if we were at bottom. + * After content updates (dependencies change), scroll to bottom if: + * - User was already near the bottom before the update, OR + * - This is the first load after a tab/session switch (needsInitialScrollRef) + * Uses double-RAF + cleanup so React StrictMode's double-invoke doesn't fire twice. */ useEffect(() => { // Skip if disabled (e.g., during navigation) or not enabled if (!enabled || disabled) return; - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - // Re-check disabled state inside RAF - it might have changed between effect and callback - // This prevents auto-scroll from firing if navigation started after the effect ran - if (disabledRef.current) return; + let id1 = 0; + let id2 = 0; - // Only auto-scroll if user was at bottom before the update - if (wasAtBottomBeforeUpdateRef.current) { - scrollToBottom(autoBehavior); - } + id1 = requestAnimationFrame(() => { + id2 = requestAnimationFrame(() => { + // Re-check disabled state — navigation may have started between effect and RAF + if (disabledRef.current) return; + + const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current; + if (shouldScroll) { + needsInitialScrollRef.current = false; + scrollToBottom(autoBehavior); + } + }); }); + + return () => { + cancelAnimationFrame(id1); + cancelAnimationFrame(id2); + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design }, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]); diff --git a/src/renderer/index.css b/src/renderer/index.css index bbc0d816..55692a24 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -181,6 +181,12 @@ --card-text-lighter: #e2e8f0; --card-separator: #2a2c38; + /* Assessment severity colors (badges, health indicators) */ + --assess-good: #4ade80; + --assess-warning: #fbbf24; + --assess-danger: #f87171; + --assess-neutral: #a1a1aa; + /* Sticky Context button — indigo glass */ --context-btn-bg: rgba(148, 163, 184, 0.08); --context-btn-bg-hover: rgba(148, 163, 184, 0.14); @@ -206,6 +212,12 @@ --color-text-secondary: #4d4b46; /* Warm secondary text */ --color-text-muted: #6d6b65; /* Warm muted text */ + /* Assessment severity colors - darker for light backgrounds */ + --assess-good: #16a34a; + --assess-warning: #d97706; + --assess-danger: #dc2626; + --assess-neutral: #57534e; + /* Scrollbar colors for light mode */ --scrollbar-thumb: rgba(0, 0, 0, 0.15); --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 9a158f49..7a1de354 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -416,6 +416,15 @@ export const createSessionDetailSlice: StateCreator item.type === 'ai') + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id) + ); + // Update only the data, preserve UI states set((state) => ({ sessionDetail: detail, @@ -572,6 +589,29 @@ export const createSessionDetailSlice: StateCreator + item.type === 'ai' && + !oldGroupIds.has((item as { type: 'ai'; group: { id: string } }).group.id) + ) + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id); + + if (newGroupIds.length > 0) { + for (const tab of latestAllTabs) { + if (tab.type === 'session' && tab.sessionId === sessionId) { + for (const groupId of newGroupIds) { + get().expandAIGroupForTab(tab.id, groupId); + } + } + } + } + } + // Also update per-tab session data for all tabs viewing this session const latestTabSessionData = { ...get().tabSessionData }; for (const tab of latestAllTabs) { diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index a6e4091a..506a2252 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -51,6 +51,7 @@ export interface TabSlice { closeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; openDashboard: () => void; + openSessionReport: (sourceTabId: string) => void; getActiveTab: () => Tab | null; isSessionOpen: (sessionId: string) => boolean; enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void; @@ -423,6 +424,28 @@ export const createTabSlice: StateCreator = (set, ge set(syncFromLayout(newLayout)); }, + // Open a session report tab based on a source session tab + openSessionReport: (sourceTabId: string) => { + const state = get(); + const allTabs = getAllTabs(state.paneLayout); + const sourceTab = allTabs.find((t) => t.id === sourceTabId); + if (sourceTab?.type !== 'session') return; + if (!sourceTab.sessionId || !sourceTab.projectId) return; + + const tabData = state.tabSessionData[sourceTabId]; + const firstMsg = tabData?.sessionDetail?.session.firstMessage; + const label = firstMsg + ? `Report: ${firstMsg.slice(0, 30)}${firstMsg.length > 30 ? '…' : ''}` + : 'Session Report'; + + state.openTab({ + type: 'report', + label, + projectId: sourceTab.projectId, + sessionId: sourceTab.sessionId, + }); + }, + // Get the currently active tab (from the focused pane) getActiveTab: () => { const state = get(); diff --git a/src/renderer/types/sessionReport.ts b/src/renderer/types/sessionReport.ts new file mode 100644 index 00000000..560ef5c5 --- /dev/null +++ b/src/renderer/types/sessionReport.ts @@ -0,0 +1,386 @@ +/** + * Session analysis report types. + * Output of analyzeSession() — one interface per report section. + */ + +import type { + CacheAssessment, + CostAssessment, + IdleAssessment, + ModelMismatch, + OverheadAssessment, + RedundancyAssessment, + SubagentCostShareAssessment, + SwitchPattern, + ThrashingAssessment, + ToolHealthAssessment, +} from '@renderer/utils/reportAssessments'; + +// ============================================================================= +// Pricing +// ============================================================================= + +export type { DisplayPricing as ModelPricing } from '@shared/utils/pricing'; + +// ============================================================================= +// Report Sections +// ============================================================================= + +export interface ReportOverview { + sessionId: string; + projectId: string; + projectPath: string; + firstMessage: string; + messageCount: number; + hasSubagents: boolean; + contextConsumption: number; + contextConsumptionPct: number | null; + contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null; + compactionCount: number; + gitBranch: string; + startTime: Date | null; + endTime: Date | null; + durationSeconds: number; + durationHuman: string; + totalMessages: number; +} + +export interface ModelTokenStats { + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + costUsd: number; +} + +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + grandTotal: number; + cacheReadPct: number; +} + +export interface ReportTokenUsage { + byModel: Record; + totals: TokenTotals; +} + +export interface ReportCostAnalysis { + parentCostUsd: number; + subagentCostUsd: number; + totalSessionCostUsd: number; + costByModel: Record; + costPerCommit: number | null; + costPerLineChanged: number | null; + costPerCommitAssessment: CostAssessment | null; + costPerLineAssessment: CostAssessment | null; + subagentCostSharePct: number | null; + subagentCostShareAssessment: SubagentCostShareAssessment | null; +} + +export interface ReportCacheEconomics { + cacheRead: number; + cacheEfficiencyPct: number; + coldStartDetected: boolean; + cacheReadToWriteRatio: number; + cacheEfficiencyAssessment: CacheAssessment | null; + cacheRatioAssessment: CacheAssessment | null; +} + +export interface ToolSuccessRate { + totalCalls: number; + errors: number; + successRatePct: number; + assessment: ToolHealthAssessment; +} + +export interface ReportToolUsage { + counts: Record; + totalCalls: number; + successRates: Record; + overallToolHealth: ToolHealthAssessment; +} + +export interface SubagentEntry { + description: string; + subagentType: string; + model: string; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + costUsd: number; + costNote?: string; + modelMismatch: ModelMismatch | null; +} + +export interface ReportSubagentMetrics { + count: number; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + totalCostUsd: number; + byAgent: SubagentEntry[]; +} + +export interface ToolError { + tool: string; + inputPreview: string; + error: string; + messageIndex: number; + isPermissionDenial: boolean; +} + +export interface ReportErrors { + errors: ToolError[]; + permissionDenials: { + count: number; + denials: ToolError[]; + affectedTools: string[]; + }; +} + +export interface GitCommit { + messagePreview: string; + messageIndex: number; +} + +export interface ReportGitActivity { + commitCount: number; + commits: GitCommit[]; + pushCount: number; + branchCreations: string[]; + linesAdded: number; + linesRemoved: number; + linesChanged: number; +} + +export interface FrictionCorrection { + messageIndex: number; + keyword: string; + preview: string; +} + +export interface ReportFrictionSignals { + correctionCount: number; + corrections: FrictionCorrection[]; + frictionRate: number; +} + +export interface ReportThrashingSignals { + bashNearDuplicates: { prefix: string; count: number }[]; + editReworkFiles: { filePath: string; editIndices: number[] }[]; + thrashingAssessment: ThrashingAssessment; +} + +export interface ReportConversationTree { + totalNodes: number; + maxDepth: number; + sidechainCount: number; + branchPoints: number; + branchDetails: { + parentUuid: string; + childCount: number; + parentMessageIndex: number | undefined; + }[]; +} + +export interface IdleGap { + gapSeconds: number; + gapHuman: string; + afterMessageIndex: number; +} + +export interface ReportIdleAnalysis { + idleThresholdSeconds: number; + idleGapCount: number; + totalIdleSeconds: number; + totalIdleHuman: string; + wallClockSeconds: number; + activeWorkingSeconds: number; + activeWorkingHuman: string; + idlePct: number; + longestGaps: IdleGap[]; + idleAssessment: IdleAssessment; +} + +export interface ModelSwitch { + from: string; + to: string; + messageIndex: number; + timestamp: Date | null; +} + +export interface ReportModelSwitches { + count: number; + switches: ModelSwitch[]; + modelsUsed: string[]; + switchPattern: SwitchPattern | null; +} + +export interface ReportWorkingDirectories { + uniqueDirectories: string[]; + directoryCount: number; + changes: { from: string; to: string; messageIndex: number }[]; + changeCount: number; + isMultiDirectory: boolean; +} + +export interface TestSnapshot { + messageIndex: number; + passed: number; + failed: number; + total: number; + raw: string; +} + +export interface ReportTestProgression { + snapshotCount: number; + snapshots: TestSnapshot[]; + trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data'; + firstSnapshot: TestSnapshot | null; + lastSnapshot: TestSnapshot | null; +} + +export interface ReportStartupOverhead { + messagesBeforeFirstWork: number; + tokensBeforeFirstWork: number; + pctOfTotal: number; + overheadAssessment: OverheadAssessment; +} + +export interface ReportTokenDensityTimeline { + quartiles: { q: number; avgTokens: number; messageCount: number }[]; +} + +export interface ReportPromptQuality { + firstMessageLengthChars: number; + userMessageCount: number; + correctionCount: number; + frictionRate: number; + assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; + note: string; +} + +export interface ThinkingBlockAnalysis { + messageIndex: number; + preview: string; + charLength: number; + signals: Record; +} + +export interface ReportThinkingBlocks { + count: number; + analyzedCount: number; + signalSummary: Record; + notableBlocks: ThinkingBlockAnalysis[]; +} + +export interface KeyEvent { + timestamp: Date; + label: string; + deltaSeconds?: number; + deltaHuman?: string; +} + +export interface ReportFileReadRedundancy { + totalReads: number; + uniqueFiles: number; + readsPerUniqueFile: number; + redundantFiles: Record; + redundancyAssessment: RedundancyAssessment; +} + +// ============================================================================= +// Missing Sections (ported from Python analyzer) +// ============================================================================= + +export interface SkillInvocation { + skill: string; + argsPreview: string; +} + +export interface ReportBashCommands { + total: number; + unique: number; + repeated: Record; +} + +export interface UserQuestion { + question: string; + options: string[]; +} + +export interface OutOfScopeFindings { + keyword: string; + messageIndex: number; + snippet: string; +} + +export interface AgentTreeNode { + agentId: string; + agentType: string; + teamName: string; + parentToolUseId: string; + messageIndex: number; +} + +export interface ReportAgentTree { + agentCount: number; + agents: AgentTreeNode[]; + hasTeamMode: boolean; + teamNames: string[]; +} + +export interface ReportCompaction { + count: number; + compactSummaryCount: number; + note: string; +} + +export interface SubagentBasicEntry { + description: string; + subagentType: string; + model: string; + runInBackground: boolean; +} + +// ============================================================================= +// Combined Report +// ============================================================================= + +export interface SessionReport { + overview: ReportOverview; + tokenUsage: ReportTokenUsage; + costAnalysis: ReportCostAnalysis; + cacheEconomics: ReportCacheEconomics; + toolUsage: ReportToolUsage; + subagentMetrics: ReportSubagentMetrics; + subagentsList: SubagentBasicEntry[]; + errors: ReportErrors; + gitActivity: ReportGitActivity; + frictionSignals: ReportFrictionSignals; + thrashingSignals: ReportThrashingSignals; + conversationTree: ReportConversationTree; + idleAnalysis: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + workingDirectories: ReportWorkingDirectories; + testProgression: ReportTestProgression; + startupOverhead: ReportStartupOverhead; + tokenDensityTimeline: ReportTokenDensityTimeline; + promptQuality: ReportPromptQuality; + thinkingBlocks: ReportThinkingBlocks; + keyEvents: KeyEvent[]; + messageTypes: Record; + fileReadRedundancy: ReportFileReadRedundancy; + compaction: ReportCompaction; + gitBranches: string[]; + skillsInvoked: SkillInvocation[]; + bashCommands: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScopeFindings: OutOfScopeFindings[]; + agentTree: ReportAgentTree; +} diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index de7ae0d4..2f2038db 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -76,7 +76,7 @@ export interface Tab { id: string; /** Type of content displayed in this tab */ - type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team'; + type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team' | 'report'; /** Session ID (required when type === 'session') */ sessionId?: string; diff --git a/src/renderer/utils/reportAssessments.ts b/src/renderer/utils/reportAssessments.ts new file mode 100644 index 00000000..abaa3a65 --- /dev/null +++ b/src/renderer/utils/reportAssessments.ts @@ -0,0 +1,555 @@ +/** + * Centralized assessment severity/color utilities for session reports. + * + * Maps raw assessment values to severity levels and colors, + * replacing duplicated assessmentColor() functions across report sections. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export type Severity = 'good' | 'warning' | 'danger' | 'neutral'; + +// ============================================================================= +// Colors +// ============================================================================= + +const SEVERITY_CSS_VAR: Record = { + good: '--assess-good', + warning: '--assess-warning', + danger: '--assess-danger', + neutral: '--assess-neutral', +}; + +const SEVERITY_FALLBACKS: Record = { + good: '#4ade80', + warning: '#fbbf24', + danger: '#f87171', + neutral: '#a1a1aa', +}; + +export function severityColor(severity: Severity): string { + if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity]; + const value = getComputedStyle(document.documentElement) + .getPropertyValue(SEVERITY_CSS_VAR[severity]) + .trim(); + return value || SEVERITY_FALLBACKS[severity]; +} + +// ============================================================================= +// Assessment → Severity Mapping +// ============================================================================= + +const ASSESSMENT_SEVERITY: Record = { + // Context + healthy: 'good', + moderate: 'warning', + high: 'danger', + critical: 'danger', + + // Cost / subagent share + efficient: 'good', + normal: 'good', + expensive: 'warning', + red_flag: 'danger', + very_high: 'danger', + + // Cache + good: 'good', + concerning: 'warning', + + // Tool health + degraded: 'warning', + unreliable: 'danger', + + // Idle ('moderate' already mapped above under Context) + high_idle: 'danger', + + // File read + wasteful: 'warning', + + // Startup + heavy: 'warning', + + // Thrashing + none: 'good', + mild: 'warning', + severe: 'danger', + + // Prompt quality + well_specified: 'good', + moderate_friction: 'warning', + underspecified: 'danger', + verbose_but_unclear: 'danger', + + // Test trajectory + improving: 'good', + stable: 'warning', + regressing: 'danger', + insufficient_data: 'neutral', + + // Model switch + opus_plan_mode: 'good', + manual_switch: 'neutral', +}; + +export function assessmentSeverity(assessment: string | null | undefined): Severity { + if (!assessment) return 'neutral'; + return ASSESSMENT_SEVERITY[assessment] ?? 'neutral'; +} + +export function assessmentColor(assessment: string | null | undefined): string { + return severityColor(assessmentSeverity(assessment)); +} + +// ============================================================================= +// Label Formatting +// ============================================================================= + +export function assessmentLabel(value: string): string { + return value + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +// ============================================================================= +// Threshold Constants +// ============================================================================= + +export const THRESHOLDS = { + costPerCommit: { + efficient: 0.5, + normal: 2, + expensive: 5, + }, + costPerLine: { + efficient: 0.01, + normal: 0.05, + expensive: 0.2, + }, + subagentCostShare: { + normal: 30, + high: 60, + veryHigh: 80, + }, + cacheEfficiency: { + good: 95, + }, + cacheRwRatio: { + good: 20, + }, + toolSuccess: { + healthy: 95, + degraded: 80, + }, + idle: { + efficient: 20, + moderate: 50, + }, + fileReadsPerUnique: { + normal: 2.0, + }, + startupOverhead: { + normal: 5, + }, +} as const; + +// ============================================================================= +// Metric Keys & Explanations +// ============================================================================= + +export type MetricKey = + | 'costPerCommit' + | 'costPerLine' + | 'subagentCostShare' + | 'cacheEfficiency' + | 'cacheRatio' + | 'toolHealth' + | 'idle' + | 'fileReads' + | 'startup' + | 'thrashing' + | 'promptQuality' + | 'testTrajectory'; + +const EXPLANATIONS: Record> = { + costPerCommit: { + efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`, + normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`, + expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`, + red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`, + }, + costPerLine: { + efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`, + normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`, + expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`, + red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`, + }, + subagentCostShare: { + normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`, + high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`, + very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`, + red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`, + }, + cacheEfficiency: { + good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`, + concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`, + }, + cacheRatio: { + good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`, + concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`, + }, + toolHealth: { + healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`, + degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`, + unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`, + }, + idle: { + efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`, + moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`, + high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`, + }, + fileReads: { + normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`, + wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`, + }, + startup: { + normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`, + heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`, + }, + thrashing: { + none: 'No repeated commands or reworked files', + mild: '1\u20132 thrashing signals detected', + severe: '3+ thrashing signals detected', + }, + promptQuality: { + well_specified: 'Clear first message with low friction rate', + moderate_friction: 'Some corrections needed mid-session', + underspecified: 'Short initial prompt led to many corrections', + verbose_but_unclear: 'Long initial prompt but still high friction', + }, + testTrajectory: { + improving: 'Test failures decreased over the session', + stable: 'Test results stayed roughly the same', + regressing: 'Test failures increased over the session', + insufficient_data: 'Not enough test runs to determine trend', + }, +}; + +export function assessmentExplanation(metricKey: MetricKey, assessment: string): string { + return EXPLANATIONS[metricKey]?.[assessment] ?? ''; +} + +// ============================================================================= +// Assessment Computers +// ============================================================================= + +export type CostAssessment = 'efficient' | 'normal' | 'expensive' | 'red_flag'; +export type CacheAssessment = 'good' | 'concerning'; +export type ToolHealthAssessment = 'healthy' | 'degraded' | 'unreliable'; +export type IdleAssessment = 'efficient' | 'moderate' | 'high_idle'; +export type RedundancyAssessment = 'normal' | 'wasteful'; +export type OverheadAssessment = 'normal' | 'heavy'; +export type ThrashingAssessment = 'none' | 'mild' | 'severe'; +export type SubagentCostShareAssessment = 'normal' | 'high' | 'very_high' | 'red_flag'; +export type SwitchPattern = 'opus_plan_mode' | 'manual_switch' | 'none'; + +export function computeCostPerCommitAssessment(costPerCommit: number): CostAssessment { + if (costPerCommit < THRESHOLDS.costPerCommit.efficient) return 'efficient'; + if (costPerCommit < THRESHOLDS.costPerCommit.normal) return 'normal'; + if (costPerCommit < THRESHOLDS.costPerCommit.expensive) return 'expensive'; + return 'red_flag'; +} + +export function computeCostPerLineAssessment(costPerLine: number): CostAssessment { + if (costPerLine < THRESHOLDS.costPerLine.efficient) return 'efficient'; + if (costPerLine < THRESHOLDS.costPerLine.normal) return 'normal'; + if (costPerLine < THRESHOLDS.costPerLine.expensive) return 'expensive'; + return 'red_flag'; +} + +export function computeSubagentCostShareAssessment(pct: number): SubagentCostShareAssessment { + if (pct < THRESHOLDS.subagentCostShare.normal) return 'normal'; + if (pct < THRESHOLDS.subagentCostShare.high) return 'high'; + if (pct < THRESHOLDS.subagentCostShare.veryHigh) return 'very_high'; + return 'red_flag'; +} + +export function computeCacheEfficiencyAssessment(pct: number): CacheAssessment { + return pct >= THRESHOLDS.cacheEfficiency.good ? 'good' : 'concerning'; +} + +export function computeCacheRatioAssessment(ratio: number): CacheAssessment { + return ratio >= THRESHOLDS.cacheRwRatio.good ? 'good' : 'concerning'; +} + +export function computeToolHealthAssessment(successPct: number): ToolHealthAssessment { + if (successPct > THRESHOLDS.toolSuccess.healthy) return 'healthy'; + if (successPct >= THRESHOLDS.toolSuccess.degraded) return 'degraded'; + return 'unreliable'; +} + +export function computeIdleAssessment(idlePct: number): IdleAssessment { + if (idlePct < THRESHOLDS.idle.efficient) return 'efficient'; + if (idlePct < THRESHOLDS.idle.moderate) return 'moderate'; + return 'high_idle'; +} + +export function computeRedundancyAssessment(readsPerUnique: number): RedundancyAssessment { + return readsPerUnique <= THRESHOLDS.fileReadsPerUnique.normal ? 'normal' : 'wasteful'; +} + +export function computeOverheadAssessment(pctOfTotal: number): OverheadAssessment { + return pctOfTotal <= THRESHOLDS.startupOverhead.normal ? 'normal' : 'heavy'; +} + +export function computeThrashingAssessment(signalCount: number): ThrashingAssessment { + if (signalCount === 0) return 'none'; + if (signalCount <= 2) return 'mild'; + return 'severe'; +} + +export interface ModelMismatch { + description: string; + expectedComplexity: 'mechanical' | 'read_only'; + recommendation: string; +} + +const MECHANICAL_PATTERNS = /\b(rename|move|lint|format|delete|remove|copy|replace)\b/i; +const READ_ONLY_PATTERNS = /\b(explore|search|find|verify|check|scan|discover|list|read)\b/i; + +export function detectModelMismatch(description: string, model: string): ModelMismatch | null { + const isOpus = model.toLowerCase().includes('opus'); + if (!isOpus) return null; + + if (MECHANICAL_PATTERNS.test(description)) { + return { + description, + expectedComplexity: 'mechanical', + recommendation: 'Consider using Haiku for mechanical tasks to reduce cost.', + }; + } + + if (READ_ONLY_PATTERNS.test(description)) { + return { + description, + expectedComplexity: 'read_only', + recommendation: 'Consider using Haiku or Sonnet for read-only exploration tasks.', + }; + } + + return null; +} + +export function detectSwitchPattern( + switches: { from: string; to: string }[] +): SwitchPattern | null { + if (switches.length === 0) return null; + if (switches.length < 2) return 'manual_switch'; + + // Look for Sonnet→Opus→Sonnet pattern (plan mode) + for (let i = 0; i < switches.length - 1; i++) { + const s1 = switches[i]; + const s2 = switches[i + 1]; + if ( + s1.from.toLowerCase().includes('sonnet') && + s1.to.toLowerCase().includes('opus') && + s2.from.toLowerCase().includes('opus') && + s2.to.toLowerCase().includes('sonnet') + ) { + return 'opus_plan_mode'; + } + } + + return 'manual_switch'; +} + +// ============================================================================= +// Key Takeaways +// ============================================================================= + +export interface Takeaway { + severity: Severity; + title: string; + detail: string; + sectionTitle: string; +} + +interface TakeawayReport { + costAnalysis: { + costPerCommitAssessment: string | null; + costPerLineAssessment: string | null; + totalSessionCostUsd: number; + }; + cacheEconomics: { + cacheEfficiencyAssessment: string | null; + cacheEfficiencyPct: number; + }; + toolUsage: { + overallToolHealth: string; + }; + thrashingSignals: { + thrashingAssessment: string; + bashNearDuplicates: unknown[]; + editReworkFiles: unknown[]; + }; + idleAnalysis: { + idleAssessment: string; + idlePct: number; + }; + promptQuality: { + assessment: string; + frictionRate: number; + }; + overview: { + contextAssessment: string | null; + compactionCount: number; + }; + fileReadRedundancy: { + redundancyAssessment: string; + readsPerUniqueFile: number; + }; + testProgression: { + trajectory: string; + }; +} + +export function computeTakeaways(report: TakeawayReport): Takeaway[] { + const items: Takeaway[] = []; + + // Cost red flags + const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment); + if (costSev === 'danger') { + items.push({ + severity: 'danger', + title: 'High cost per commit', + detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`, + sectionTitle: 'Cost Analysis', + }); + } else if (costSev === 'warning') { + items.push({ + severity: 'warning', + title: 'Elevated cost per commit', + detail: 'Cost per commit is above typical range', + sectionTitle: 'Cost Analysis', + }); + } + + // Cache efficiency + if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') { + items.push({ + severity: 'warning', + title: 'Low cache efficiency', + detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`, + sectionTitle: 'Token Usage', + }); + } + + // Tool health + const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth); + if (toolSev === 'danger') { + items.push({ + severity: 'danger', + title: 'Tool reliability issues', + detail: 'Multiple tool calls are failing \u2014 check error section for details', + sectionTitle: 'Tool Usage', + }); + } else if (toolSev === 'warning') { + items.push({ + severity: 'warning', + title: 'Degraded tool health', + detail: 'Some tools have elevated failure rates', + sectionTitle: 'Tool Usage', + }); + } + + // Thrashing + if (report.thrashingSignals.thrashingAssessment === 'severe') { + items.push({ + severity: 'danger', + title: 'Significant thrashing detected', + detail: 'Repeated commands and file rework suggest unclear direction', + sectionTitle: 'Friction Signals', + }); + } else if (report.thrashingSignals.thrashingAssessment === 'mild') { + items.push({ + severity: 'warning', + title: 'Mild thrashing detected', + detail: 'Some repeated commands or file rework occurred', + sectionTitle: 'Friction Signals', + }); + } + + // Idle time + if (report.idleAnalysis.idleAssessment === 'high_idle') { + items.push({ + severity: 'warning', + title: 'High idle time', + detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`, + sectionTitle: 'Timeline & Activity', + }); + } + + // Prompt quality + const promptSev = assessmentSeverity(report.promptQuality.assessment); + if (promptSev === 'danger') { + items.push({ + severity: 'danger', + title: 'Prompt quality issues', + detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`, + sectionTitle: 'Quality Signals', + }); + } + + // Context pressure + if ( + report.overview.contextAssessment === 'critical' || + report.overview.contextAssessment === 'high' + ) { + items.push({ + severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning', + title: 'Context window pressure', + detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`, + sectionTitle: 'Overview', + }); + } + + // File read redundancy + if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') { + items.push({ + severity: 'warning', + title: 'Redundant file reads', + detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`, + sectionTitle: 'Quality Signals', + }); + } + + // Test regression + if (report.testProgression.trajectory === 'regressing') { + items.push({ + severity: 'danger', + title: 'Tests regressing', + detail: 'Test failures increased over the session', + sectionTitle: 'Quality Signals', + }); + } + + // Sort by severity (danger first), then limit to 4 + const severityOrder: Record = { danger: 0, warning: 1, neutral: 2, good: 3 }; + items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + if (items.length === 0) { + return [ + { + severity: 'good', + title: 'Session looks healthy', + detail: 'No significant issues detected across all metrics', + sectionTitle: 'Overview', + }, + ]; + } + + return items.slice(0, 4); +} diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts new file mode 100644 index 00000000..0a4217bd --- /dev/null +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -0,0 +1,1346 @@ +/** + * Session analyzer — TypeScript port of scripts/analyze-session.py. + * + * Takes a SessionDetail (already parsed by the main process) and produces + * a SessionReport with structured metrics, cost analysis, friction signals, + * conversation tree analysis, idle gap detection, and more. + * + * Runs entirely in the renderer process — no IPC needed. + */ + +import { + computeCacheEfficiencyAssessment, + computeCacheRatioAssessment, + computeCostPerCommitAssessment, + computeCostPerLineAssessment, + computeIdleAssessment, + computeOverheadAssessment, + computeRedundancyAssessment, + computeSubagentCostShareAssessment, + computeThrashingAssessment, + computeToolHealthAssessment, + detectModelMismatch, + detectSwitchPattern, +} from '@renderer/utils/reportAssessments'; +import { calculateMessageCost } from '@shared/utils/pricing'; + +import type { + AgentTreeNode, + FrictionCorrection, + GitCommit, + IdleGap, + KeyEvent, + ModelSwitch, + ModelTokenStats, + OutOfScopeFindings, + SessionReport, + SkillInvocation, + SubagentBasicEntry, + SubagentEntry, + TestSnapshot, + ThinkingBlockAnalysis, + ToolError, + ToolSuccessRate, + UserQuestion, +} from '@renderer/types/sessionReport'; +import type { + ContentBlock, + ParsedMessage, + Process, + SessionDetail, + TextContent, + ThinkingContent, + ToolCall, +} from '@shared/types'; + +// Re-export getDisplayPricing as getPricing for backward compat with CostSection +export { getDisplayPricing as getPricing } from '@shared/utils/pricing'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function isTextBlock(block: ContentBlock): block is TextContent { + return block.type === 'text'; +} + +function isThinkingBlock(block: ContentBlock): block is ThinkingContent { + return block.type === 'thinking'; +} + +function extractTextContent(msg: ParsedMessage): string { + const { content } = msg; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(isTextBlock) + .map((block) => block.text) + .join(' '); + } + return ''; +} + +function formatDuration(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = Math.floor(totalSeconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +// Friction keyword patterns +const FRICTION_PATTERNS: [RegExp, string][] = [ + [/\bno,/i, 'no,'], + [/\bwrong\b/i, 'wrong'], + [/\bactually\b/i, 'actually'], + [/\bundo\b/i, 'undo'], + [/\brevert\b/i, 'revert'], + [/that's not\b/i, "that's not"], + [/\binstead,/i, 'instead,'], + [/\bwait,/i, 'wait,'], + [/\bnevermind\b/i, 'nevermind'], + [/I don't want\b/i, "I don't want"], +]; + +// Permission denial keywords (case-insensitive substring match) +const PERMISSION_KEYWORDS = [ + 'permission denied', + 'not allowed', + 'requires approval', + 'cannot execute', + 'access denied', + 'operation not permitted', + 'eacces', + 'eperm', + 'user rejected', + 'user denied', + 'needs_user_approval', +]; + +function isPermissionDenial(text: string): boolean { + const lower = text.toLowerCase(); + return PERMISSION_KEYWORDS.some((kw) => lower.includes(kw)); +} + +/** + * Extract a number immediately before a keyword in text. + * E.g., extractNumberBefore("42 passed", "passed") => 42 + */ +function extractNumberBefore(text: string, keyword: string): number | null { + const idx = text.toLowerCase().indexOf(keyword.toLowerCase()); + if (idx <= 0) return null; + const before = text.slice(Math.max(0, idx - 15), idx).trim(); + const parts = before.split(/\s+/); + const numStr = parts[parts.length - 1]; + if (numStr && /^\d+$/.test(numStr)) return parseInt(numStr, 10); + return null; +} + +/** + * Parse test summary from command output. + * Returns [passed, failed] or null if no match. + */ +function parseTestSummary(text: string): [number, number] | null { + // Try "passed"/"failed" keywords — treat missing count as 0 + // (runners often omit "0 failed" when all tests pass) + const passed = extractNumberBefore(text, ' passed'); + const failed = extractNumberBefore(text, ' failed'); + if (passed != null || failed != null) return [passed ?? 0, failed ?? 0]; + + // Try "passing"/"failing" keywords (mocha-style) + const passing = extractNumberBefore(text, ' passing'); + const failing = extractNumberBefore(text, ' failing'); + if (passing != null || failing != null) return [passing ?? 0, failing ?? 0]; + + return null; +} + +// Thinking block analysis signals +const THINKING_SIGNALS: Record = { + alternatives: /\balternative(?:ly|s)?\b|\binstead\b|\bother approach\b|\bcould also\b/i, + uncertainty: /\bnot sure\b|\buncertain\b|\bmight be\b|\bpossibly\b|\bI think\b.*\bbut\b/i, + errors_noticed: /\bbug\b|\berror\b|\bwrong\b|\bincorrect\b|\bfail\b|\bbroken\b/i, + planning: /\bfirst.*then\b|\bstep \d\b|\bplan\b|\bapproach\b|\bstrategy\b/i, + direction_change: /\bwait\b|\bactually\b|\bon second thought\b|\blet me reconsider\b|\bhmm\b/i, +}; + +// "Work" tools (non-Skill) for startup overhead detection +const NON_SKILL_TOOLS = new Set([ + 'Read', + 'Write', + 'Edit', + 'Bash', + 'Grep', + 'Glob', + 'Task', + 'WebFetch', + 'WebSearch', + 'NotebookEdit', +]); + +// ============================================================================= +// Main Analyzer +// ============================================================================= + +export function analyzeSession(detail: SessionDetail): SessionReport { + const { session, messages } = detail; + + // --- Session Overview --- + const timestamps = messages.filter((m) => m.timestamp).map((m) => m.timestamp); + const firstTs = timestamps.length > 0 ? timestamps[0] : null; + const lastTs = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null; + const durationMs = firstTs && lastTs ? lastTs.getTime() - firstTs.getTime() : 0; + const durationSeconds = durationMs / 1000; + + // Context consumption interpretation + const ctxConsumption = session.contextConsumption ?? 0; + let ctxConsumptionPct: number | null = null; + let ctxAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null = null; + if (ctxConsumption <= 1) { + ctxConsumptionPct = ctxConsumption ? Math.round(ctxConsumption * 1000) / 10 : 0; + if (ctxConsumption > 0.8) ctxAssessment = 'critical'; + else if (ctxConsumption > 0.6) ctxAssessment = 'high'; + else if (ctxConsumption > 0.4) ctxAssessment = 'moderate'; + else ctxAssessment = 'healthy'; + } + + // =================================================================== + // SINGLE-PASS ACCUMULATORS + // =================================================================== + + // Token usage by model + const modelStats = new Map(); + + const getModelStats = (model: string): ModelTokenStats => { + let stats = modelStats.get(model); + if (!stats) { + stats = { + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + costUsd: 0, + }; + modelStats.set(model, stats); + } + return stats; + }; + + // Cache economics + let totalCacheCreation = 0; + let totalCacheRead = 0; + let coldStartDetected = false; + let firstAssistantWithUsageSeen = false; + + // Message type counts + const typeCounts = new Map(); + + // Tool usage counts + const toolCounts = new Map(); + + // Tool call index: toolUseId -> [messageIndex, toolCall] + const toolCallIndex = new Map(); + + // Tool errors + const errors: ToolError[] = []; + const errorsByTool = new Map(); + + // Permission denials + const permissionDenials: ToolError[] = []; + + // Key events + const keyEvents: KeyEvent[] = []; + + // Thinking blocks + let thinkingCount = 0; + const thinkingAnalysis: ThinkingBlockAnalysis[] = []; + + // Git branches + const branches = new Set(); + + // Friction signals + const corrections: FrictionCorrection[] = []; + let userMessageCount = 0; + + // Thrashing detection + const bashPrefixGroups = new Map(); + const fileEditIndices = new Map(); + + // Startup overhead + let firstWorkToolSeen = false; + let startupMessages = 0; + let startupTokens = 0; + + // Token density timeline + const assistantMsgData: [Date, number][] = []; + + // Conversation tree + const uuidToIdx = new Map(); + const parentMap = new Map(); + let sidechainCount = 0; + const childrenMap = new Map(); + + // Idle gap detection + let lastAssistantTs: Date | null = null; + const idleGaps: IdleGap[] = []; + const IDLE_THRESHOLD_SEC = 60; + + // Model switch detection + let lastModel: string | null = null; + const modelSwitches: ModelSwitch[] = []; + + // Working directory tracking + const cwdSet = new Set(); + const cwdChanges: { from: string; to: string; messageIndex: number }[] = []; + let lastCwd: string | null = null; + + // Test progression + const testSnapshots: TestSnapshot[] = []; + + // Cost tracking + let parentCost = 0; + + // Git activity + const gitCommits: GitCommit[] = []; + let gitPushCount = 0; + const gitBranchCreations: string[] = []; + let linesAddedTotal = 0; + let linesRemovedTotal = 0; + + // File read redundancy + const fileReadCounts = new Map(); + + // First user message length + let firstUserMessageLength = 0; + let firstUserSeen = false; + + // Skills invoked + const skillsInvoked: SkillInvocation[] = []; + + // Bash commands + const bashCmds: string[] = []; + + // Subagents list (backward compat) + const subagentsList: SubagentBasicEntry[] = []; + + // Lifecycle tasks + const lifecycleTasks: string[] = []; + + // User questions + const userQuestions: UserQuestion[] = []; + + // Out-of-scope findings + const OUT_OF_SCOPE_KEYWORDS = [ + 'pre-existing', + 'out of scope', + 'tech debt', + 'anti-pattern', + 'existed before', + ]; + const outOfScopeFindings: OutOfScopeFindings[] = []; + + // Agent tree metadata + const agentTreeNodes: AgentTreeNode[] = []; + + // Compact summary count + + let compactSummaryCount = 0; + + // =================================================================== + // SINGLE PASS + // =================================================================== + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + const msgType = m.type ?? 'unknown'; + typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1); + const msgUuid = m.uuid ?? ''; + const msgParent = m.parentUuid ?? ''; + const msgTs = m.timestamp; + + // --- Conversation tree --- + if (msgUuid) { + uuidToIdx.set(msgUuid, i); + parentMap.set(msgUuid, msgParent || null); + if (msgParent) { + const children = childrenMap.get(msgParent); + if (children) children.push(msgUuid); + else childrenMap.set(msgParent, [msgUuid]); + } + } + + if (m.isSidechain) sidechainCount++; + + // --- Working directory tracking --- + const msgCwd = m.cwd ?? ''; + if (msgCwd) { + cwdSet.add(msgCwd); + if (lastCwd && msgCwd !== lastCwd) { + cwdChanges.push({ from: lastCwd, to: msgCwd, messageIndex: i }); + } + lastCwd = msgCwd; + } + + // --- Token usage, cache economics, and cost --- + // Skip sidechain messages to avoid double-counting (subagent costs are + // accounted for separately via processSubagentCost). + if (m.usage && m.model && !m.isSidechain && m.model !== '') { + const model = m.model; + const u = m.usage; + const inpTok = u.input_tokens ?? 0; + const outTok = u.output_tokens ?? 0; + const cc = u.cache_creation_input_tokens ?? 0; + const cr = u.cache_read_input_tokens ?? 0; + + const stats = getModelStats(model); + stats.apiCalls += 1; + stats.inputTokens += inpTok; + stats.outputTokens += outTok; + stats.cacheCreation += cc; + stats.cacheRead += cr; + + const callCost = calculateMessageCost(model, inpTok, outTok, cr, cc); + stats.costUsd += callCost; + parentCost += callCost; + + totalCacheCreation += cc; + totalCacheRead += cr; + + // Cold start detection + if (msgType === 'assistant' && !firstAssistantWithUsageSeen) { + firstAssistantWithUsageSeen = true; + if (cc > 0 && cr === 0) coldStartDetected = true; + } + } + + // --- Git branches --- + if (m.gitBranch) branches.add(m.gitBranch); + + // --- Compact summaries --- + if (m.isCompactSummary) compactSummaryCount++; + + // --- Agent tree metadata --- + if (m.agentId) { + // agentType/teamName/parentToolUseId may exist on raw data but not typed in ParsedMessage + const raw = m as unknown as Record; + agentTreeNodes.push({ + agentId: m.agentId, + agentType: (raw.agentType as string) ?? 'unknown', + teamName: (raw.teamName as string) ?? '', + parentToolUseId: (raw.parentToolUseId as string) ?? '', + messageIndex: i, + }); + } + + // --- Thinking blocks (with content analysis) --- + if (Array.isArray(m.content)) { + for (const block of m.content) { + if (isThinkingBlock(block)) { + thinkingCount++; + const thinkText = block.thinking ?? ''; + const signalsFound: Record = {}; + for (const [signalName, pattern] of Object.entries(THINKING_SIGNALS)) { + if (pattern.test(thinkText)) signalsFound[signalName] = true; + } + if (Object.keys(signalsFound).length > 0 || thinkingCount <= 5) { + thinkingAnalysis.push({ + messageIndex: i, + preview: thinkText.slice(0, 200).replace(/\n/g, ' ').trim(), + charLength: thinkText.length, + signals: signalsFound, + }); + } + } + } + } + + // --- Model switch detection --- + if (msgType === 'assistant' && m.model) { + const currentModel = m.model; + if (lastModel && currentModel !== lastModel) { + modelSwitches.push({ + from: lastModel, + to: currentModel, + messageIndex: i, + timestamp: msgTs ?? null, + }); + } + lastModel = currentModel; + } + + // --- Idle gap detection --- + if (msgType === 'assistant' && msgTs) { + lastAssistantTs = msgTs; + } + if (msgType === 'user' && msgTs && lastAssistantTs) { + const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000; + if (gap > IDLE_THRESHOLD_SEC) { + idleGaps.push({ + gapSeconds: Math.round(gap * 10) / 10, + gapHuman: formatDuration(Math.floor(gap)), + afterMessageIndex: i, + }); + } + } + + // --- First user message length (prompt quality) --- + if (msgType === 'user' && !firstUserSeen && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + firstUserMessageLength = contentText.length; + firstUserSeen = true; + } + } + + // --- Tool calls (assistant messages) --- + for (const tc of m.toolCalls) { + const toolName = tc.name; + toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1); + if (tc.id) toolCallIndex.set(tc.id, [i, tc]); + const inp = tc.input ?? {}; + + // Bash commands + if (toolName === 'Bash') { + const cmd = typeof inp.command === 'string' ? inp.command : ''; + const cmdTrunc = cmd.slice(0, 200); + bashCmds.push(cmdTrunc); + // Thrashing: bash prefix groups + const prefix = cmd.slice(0, 40); + bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1); + + // Git activity + if (cmd.includes('git commit')) { + const heredocMatch = /cat\s+<<['"]?EOF['"]?\n(.+?)(?:\n|$)/.exec(cmd); + let preview: string; + if (heredocMatch) { + preview = heredocMatch[1].trim().slice(0, 80); + } else { + const msgMatch = /-m\s+["'](.+?)["']/.exec(cmd); + preview = msgMatch ? msgMatch[1].slice(0, 80) : cmd.slice(0, 80); + } + gitCommits.push({ messagePreview: preview, messageIndex: i }); + } + if (cmd.includes('git push')) gitPushCount++; + if (cmd.includes('git checkout -b') || cmd.includes('git switch -c')) { + const branchMatch = /git (?:checkout -b|switch -c)\s+(\S+)/.exec(cmd); + if (branchMatch) gitBranchCreations.push(branchMatch[1]); + } + } + + // Skills + if (toolName === 'Skill') { + skillsInvoked.push({ + skill: (inp.skill as string) ?? 'unknown', + argsPreview: (typeof inp.args === 'string' + ? inp.args + : JSON.stringify(inp.args ?? '') + ).slice(0, 120), + }); + } + + // Task (subagent list) + if (toolName === 'Task') { + subagentsList.push({ + description: (inp.description as string) ?? 'unknown', + subagentType: (inp.subagent_type as string) ?? 'unknown', + model: (inp.model as string) ?? 'default (inherits parent)', + runInBackground: (inp.run_in_background as boolean) ?? false, + }); + } + + // TaskCreate + if (toolName === 'TaskCreate') { + lifecycleTasks.push((inp.subject as string) ?? 'unknown'); + } + + // AskUserQuestion + if (toolName === 'AskUserQuestion') { + const questions = inp.questions as { question?: string; options?: { label?: string }[] }[]; + if (Array.isArray(questions)) { + for (const q of questions) { + userQuestions.push({ + question: q.question ?? '', + options: Array.isArray(q.options) ? q.options.map((o) => o.label ?? '') : [], + }); + } + } + } + + // File reads + if (toolName === 'Read') { + const filePath = (inp.file_path as string) ?? ''; + if (filePath) { + fileReadCounts.set(filePath, (fileReadCounts.get(filePath) ?? 0) + 1); + } + } + + // Write/Edit for thrashing + if (toolName === 'Write' || toolName === 'Edit') { + const fp = (inp.file_path as string) ?? ''; + if (fp) { + const indices = fileEditIndices.get(fp); + if (indices) indices.push(i); + else fileEditIndices.set(fp, [i]); + } + } + + // Startup overhead: track first non-Skill tool call + if (!firstWorkToolSeen && NON_SKILL_TOOLS.has(toolName)) { + firstWorkToolSeen = true; + } + } + + // --- Startup overhead: count assistant messages before first work tool --- + if (msgType === 'assistant' && !firstWorkToolSeen) { + startupMessages++; + if (m.usage) { + startupTokens += m.usage.output_tokens ?? 0; + startupTokens += m.usage.input_tokens ?? 0; + startupTokens += m.usage.cache_creation_input_tokens ?? 0; + startupTokens += m.usage.cache_read_input_tokens ?? 0; + } + } + + // --- Token density timeline --- + if (msgType === 'assistant' && msgTs && m.usage) { + const totalMsgTokens = + (m.usage.input_tokens ?? 0) + + (m.usage.output_tokens ?? 0) + + (m.usage.cache_creation_input_tokens ?? 0) + + (m.usage.cache_read_input_tokens ?? 0); + assistantMsgData.push([msgTs, totalMsgTokens]); + } + + // --- Timing / key events --- + if (msgTs) { + let label: string | null = null; + if (msgType === 'user' && typeof m.content === 'string') { + const content = m.content; + if (content.includes('start feature')) { + label = `User: ${content.slice(0, 60)}`; + } else if (content.includes('being continued')) { + label = 'Context compaction/continuation'; + } + } + + for (const tc of m.toolCalls) { + if (tc.name === 'Skill') { + label = `Skill: ${(tc.input.skill as string) ?? ''}`; + } else if (tc.name === 'Task') { + const inpTc = tc.input ?? {}; + label = `Task: ${(inpTc.description as string) ?? ''} (${(inpTc.subagent_type as string) ?? ''})`; + } + } + + if (label) { + keyEvents.push({ timestamp: msgTs, label }); + } + } + + // --- Friction signals (user messages) --- + if (msgType === 'user' && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + userMessageCount++; + for (const [regex, keyword] of FRICTION_PATTERNS) { + if (regex.test(contentText)) { + corrections.push({ + messageIndex: i, + keyword, + preview: contentText.slice(0, 120).replace(/\n/g, ' '), + }); + break; + } + } + } + } + + // --- Out-of-scope findings (assistant messages) --- + if (msgType === 'assistant') { + const contentText = extractTextContent(m); + const contentLower = contentText.toLowerCase(); + for (const kw of OUT_OF_SCOPE_KEYWORDS) { + const kwIdx = contentLower.indexOf(kw.toLowerCase()); + if (kwIdx >= 0) { + const start = Math.max(0, kwIdx - 80); + const end = Math.min(contentText.length, kwIdx + 300); + outOfScopeFindings.push({ + keyword: kw, + messageIndex: i, + snippet: contentText.slice(start, end).replace(/\n/g, ' ').trim(), + }); + break; + } + } + } + + // --- Tool results --- + for (const tr of m.toolResults) { + const toolUseId = tr.toolUseId; + if (!toolUseId) continue; + const contentStr = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content); + + // Tool errors + if (tr.isError) { + let toolName = 'unknown'; + let toolInput = ''; + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + toolName = tc.name ?? 'unknown'; + toolInput = JSON.stringify(tc.input ?? {}).slice(0, 300); + } + + const errorEntry: ToolError = { + tool: toolName, + inputPreview: toolInput, + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + + if (isPermissionDenial(contentStr)) { + errorEntry.isPermissionDenial = true; + permissionDenials.push(errorEntry); + } + + errors.push(errorEntry); + errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1); + } + + // Bash exit code errors + if ( + !tr.isError && + (contentStr.includes('Exit code 1') || contentStr.includes('Exit code 127')) + ) { + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + if (tc.name === 'Bash') { + const bashError: ToolError = { + tool: 'Bash (non-zero exit)', + inputPreview: JSON.stringify(tc.input ?? {}).slice(0, 300), + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + if (isPermissionDenial(contentStr)) { + bashError.isPermissionDenial = true; + permissionDenials.push(bashError); + } + errors.push(bashError); + errorsByTool.set( + 'Bash (non-zero exit)', + (errorsByTool.get('Bash (non-zero exit)') ?? 0) + 1 + ); + } + } + } + + // --- Test progression: parse test output from bash results --- + const indexedForTest = toolCallIndex.get(toolUseId); + if (indexedForTest) { + const [, tcOrig] = indexedForTest; + if (tcOrig.name === 'Bash') { + const testResult = parseTestSummary(contentStr); + if (testResult) { + const [passed, failed] = testResult; + testSnapshots.push({ + messageIndex: i, + passed, + failed, + total: passed + failed, + raw: contentStr.slice(0, 200).replace(/\n/g, ' '), + }); + } + } + } + + // --- Lines changed: parse git diff --stat output --- + const indexedForDiff = toolCallIndex.get(toolUseId); + if (indexedForDiff) { + const [, tcOrig] = indexedForDiff; + if (tcOrig.name === 'Bash') { + const rawCmd = tcOrig.input?.command; + const cmdText = typeof rawCmd === 'string' ? rawCmd : ''; + if (cmdText.includes('git diff') || cmdText.includes('git show')) { + const insertionIdx = contentStr.indexOf(' insertion'); + const deletionIdx = contentStr.indexOf(' deletion'); + if (insertionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, insertionIdx - 10), insertionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesAddedTotal += parseInt(numStr, 10); + } + if (deletionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, deletionIdx - 10), deletionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesRemovedTotal += parseInt(numStr, 10); + } + } + } + } + } + } + + // =================================================================== + // POST-PASS AGGREGATION + // =================================================================== + + // --- Token usage --- + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; + + const byModel: Record = {}; + for (const [model, stats] of modelStats) { + stats.costUsd = Math.round(stats.costUsd * 10000) / 10000; + byModel[model] = stats; + totalInputTokens += stats.inputTokens; + totalOutputTokens += stats.outputTokens; + totalCacheCreationTokens += stats.cacheCreation; + totalCacheReadTokens += stats.cacheRead; + } + + let grandTotal = + totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; + + // --- Cost analysis --- + const commitCount = gitCommits.length; + const linesChanged = linesAddedTotal + linesRemovedTotal; + + // --- Subagent metrics from detail.processes --- + const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => { + const desc = proc.description ?? 'unknown'; + // Extract actual model from subagent messages (first assistant message with a model field) + const subagentModel = + proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ?? + 'default (inherits parent)'; + // Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream) + const computedCost = calculateMessageCost( + subagentModel, + proc.metrics.inputTokens, + proc.metrics.outputTokens, + proc.metrics.cacheReadTokens, + proc.metrics.cacheCreationTokens + ); + return { + description: desc, + subagentType: proc.subagentType ?? 'unknown', + model: subagentModel, + totalTokens: proc.metrics.totalTokens, + totalDurationMs: proc.durationMs, + totalToolUseCount: proc.messages.reduce( + (sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length, + 0 + ), + costUsd: computedCost, + modelMismatch: detectModelMismatch(desc, subagentModel), + }; + }); + + const saFromProcesses = { + count: subagentEntries.length, + totalTokens: subagentEntries.reduce((sum, a) => sum + a.totalTokens, 0), + totalDurationMs: subagentEntries.reduce((sum, a) => sum + a.totalDurationMs, 0), + totalToolUseCount: subagentEntries.reduce((sum, a) => sum + a.totalToolUseCount, 0), + totalCostUsd: + Math.round(subagentEntries.reduce((sum, a) => sum + a.costUsd, 0) * 10000) / 10000, + byAgent: subagentEntries, + }; + + // --- Tool usage with success rates --- + const toolSuccessRates: Record = {}; + const sortedToolCounts = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]); + const countsRecord: Record = {}; + for (const [tool, count] of sortedToolCounts) { + countsRecord[tool] = count; + const errCount = errorsByTool.get(tool) ?? 0; + const successPct = count ? Math.round(((count - errCount) / count) * 1000) / 10 : 0; + toolSuccessRates[tool] = { + totalCalls: count, + errors: errCount, + successRatePct: successPct, + assessment: computeToolHealthAssessment(successPct), + }; + } + + // Overall tool health: worst assessment among tools with >5 calls + const significantTools = Object.values(toolSuccessRates).filter((t) => t.totalCalls > 5); + type THAssessment = 'healthy' | 'degraded' | 'unreliable'; + const overallToolHealth: THAssessment = + significantTools.length > 0 + ? significantTools.reduce((worst, t) => { + const order = { healthy: 0, degraded: 1, unreliable: 2 } as const; + return order[t.assessment] > order[worst] ? t.assessment : worst; + }, 'healthy') + : computeToolHealthAssessment(100); + + // --- Key events timing --- + for (let j = 1; j < keyEvents.length; j++) { + const prevDt = keyEvents[j - 1].timestamp; + const currDt = keyEvents[j].timestamp; + const delta = (currDt.getTime() - prevDt.getTime()) / 1000; + keyEvents[j].deltaSeconds = Math.round(delta * 10) / 10; + keyEvents[j].deltaHuman = formatDuration(Math.floor(delta)); + } + + // --- Thinking blocks signal aggregation --- + const signalTotals: Record = {}; + for (const ta of thinkingAnalysis) { + for (const sig of Object.keys(ta.signals)) { + signalTotals[sig] = (signalTotals[sig] ?? 0) + 1; + } + } + + // --- Cache economics --- + const cacheTotalCreationAndRead = totalCacheCreation + totalCacheRead; + const cacheEfficiency = cacheTotalCreationAndRead + ? Math.round((totalCacheRead / cacheTotalCreationAndRead) * 10000) / 100 + : 0; + const cacheRwRatio = totalCacheCreation + ? Math.round((totalCacheRead / totalCacheCreation) * 10) / 10 + : 0; + + // --- File read redundancy --- + let totalReads = 0; + const redundantFiles: Record = {}; + for (const [path, count] of fileReadCounts) { + totalReads += count; + if (count > 2) redundantFiles[path] = count; + } + const uniqueFiles = fileReadCounts.size; + + // --- Token density timeline --- + const quartiles: { q: number; avgTokens: number; messageCount: number }[] = []; + if (assistantMsgData.length > 0) { + const n = assistantMsgData.length; + const qSize = Math.max(1, Math.floor(n / 4)); + for (let q = 0; q < 4; q++) { + const startIdx = q * qSize; + const endIdx = q === 3 ? n : (q + 1) * qSize; + const chunk = assistantMsgData.slice(startIdx, endIdx); + if (chunk.length > 0) { + const avgTokens = Math.round(chunk.reduce((sum, [, t]) => sum + t, 0) / chunk.length); + quartiles.push({ q: q + 1, avgTokens, messageCount: chunk.length }); + } else { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + } else { + for (let q = 0; q < 4; q++) { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + + // --- Conversation tree analysis --- + const depthMemo = new Map(); + function getDepth(uuid: string, visited = new Set()): number { + if (depthMemo.has(uuid)) return depthMemo.get(uuid)!; + if (visited.has(uuid)) { + depthMemo.set(uuid, 0); + return 0; + } + visited.add(uuid); + const parent = parentMap.get(uuid); + if (!parent) { + depthMemo.set(uuid, 0); + return 0; + } + const depth = 1 + getDepth(parent, visited); + depthMemo.set(uuid, depth); + return depth; + } + + let maxDepth = 0; + for (const uuid of parentMap.keys()) { + const d = getDepth(uuid); + if (d > maxDepth) maxDepth = d; + } + + // Branch points: parents with multiple children + const branchPoints = new Map(); + for (const [parent, children] of childrenMap) { + if (children.length > 1) branchPoints.set(parent, children); + } + + const branchDetails = [...branchPoints.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 10) + .map(([p, c]) => ({ + parentUuid: p.slice(0, 12) + '...', + childCount: c.length, + parentMessageIndex: uuidToIdx.get(p), + })); + + // --- Idle gap analysis --- + const totalIdle = idleGaps.reduce((sum, g) => sum + g.gapSeconds, 0); + const wallClock = durationSeconds; + const activeTime = wallClock > 0 ? wallClock - totalIdle : 0; + + // --- Thrashing signals --- + const bashNearDuplicates = [...bashPrefixGroups.entries()] + .filter(([, count]) => count > 2) + .sort((a, b) => b[1] - a[1]) + .map(([prefix, count]) => ({ prefix, count })); + + const editReworkFiles = [...fileEditIndices.entries()] + .filter(([, indices]) => indices.length >= 3) + .map(([filePath, editIndices]) => ({ filePath, editIndices })); + + // --- Model switches --- + const modelsUsed = + modelSwitches.length > 0 + ? [...new Set([...modelSwitches.map((s) => s.from), ...modelSwitches.map((s) => s.to)])] + : [...modelStats.keys()]; + + // --- Test progression trajectory --- + let trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data' = 'insufficient_data'; + if (testSnapshots.length >= 2) { + const first = testSnapshots[0]; + const last = testSnapshots[testSnapshots.length - 1]; + if (last.passed > first.passed) trajectory = 'improving'; + else if (last.passed < first.passed) trajectory = 'regressing'; + else trajectory = 'stable'; + } + + // --- Prompt quality assessment --- + const correctionCount = corrections.length; + const frictionRate = userMessageCount + ? Math.round((correctionCount / userMessageCount) * 10000) / 10000 + : 0; + + type PromptAssessment = + | 'underspecified' + | 'verbose_but_unclear' + | 'well_specified' + | 'moderate_friction'; + + let assessment: PromptAssessment; + let promptNote: string; + + if (firstUserMessageLength < 100 && correctionCount >= 2) { + assessment = 'underspecified'; + promptNote = + 'Short initial prompt with multiple corrections suggests the task needed more upfront specification.'; + } else if (firstUserMessageLength > 500 && correctionCount >= 3) { + assessment = 'verbose_but_unclear'; + promptNote = + 'Initial prompt was detailed but still required corrections — consider restructuring for clarity.'; + } else if (correctionCount <= 1) { + assessment = 'well_specified'; + promptNote = 'Low friction — initial prompt effectively communicated intent.'; + } else { + assessment = 'moderate_friction'; + promptNote = + 'Moderate friction detected — review correction patterns for improvement opportunities.'; + } + + // --- Message types --- + const messageTypes: Record = {}; + for (const [type, count] of typeCounts) { + messageTypes[type] = count; + } + + // --- Subagent cost from processes --- + const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0); + const totalCost = parentCost + processSubagentCost; + + // Add aggregated subagent row to costByModel and byModel for the cost table + if (subagentEntries.length > 0 && processSubagentCost > 0) { + const subagentTokenStats: ModelTokenStats = { + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + costUsd: 0, + }; + for (const proc of detail.processes) { + subagentTokenStats.inputTokens += proc.metrics.inputTokens; + subagentTokenStats.outputTokens += proc.metrics.outputTokens; + subagentTokenStats.cacheCreation += proc.metrics.cacheCreationTokens; + subagentTokenStats.cacheRead += proc.metrics.cacheReadTokens; + // Count assistant messages with usage as API calls + subagentTokenStats.apiCalls += proc.messages.filter( + (m: ParsedMessage) => m.type === 'assistant' && m.usage + ).length; + } + subagentTokenStats.costUsd = Math.round(processSubagentCost * 10000) / 10000; + const subagentLabel = 'Subagents (combined)'; + byModel[subagentLabel] = subagentTokenStats; + modelStats.set(subagentLabel, subagentTokenStats); + + // Update totals to include subagent tokens so the footer row stays consistent + totalInputTokens += subagentTokenStats.inputTokens; + totalOutputTokens += subagentTokenStats.outputTokens; + totalCacheCreationTokens += subagentTokenStats.cacheCreation; + totalCacheReadTokens += subagentTokenStats.cacheRead; + grandTotal = + totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; + } + + // --- Assessment computations --- + const costPerCommitVal = + commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null; + const costPerLineVal = + linesChanged > 0 ? Math.round((totalCost / linesChanged) * 1000000) / 1000000 : null; + const subagentCostSharePct = + totalCost > 0 ? Math.round((processSubagentCost / totalCost) * 10000) / 100 : null; + + const readsPerUniqueFile = uniqueFiles ? Math.round((totalReads / uniqueFiles) * 100) / 100 : 0; + const startupPctOfTotal = grandTotal ? Math.round((startupTokens / grandTotal) * 10000) / 100 : 0; + const idlePct = wallClock > 0 ? Math.round((totalIdle / wallClock) * 1000) / 10 : 0; + const thrashingSignalCount = bashNearDuplicates.length + editReworkFiles.length; + + // =================================================================== + // BUILD REPORT + // =================================================================== + + const report: SessionReport = { + overview: { + sessionId: session.id, + projectId: session.projectId ?? 'unknown', + projectPath: session.projectPath ?? 'unknown', + firstMessage: session.firstMessage ?? 'unknown', + messageCount: session.messageCount ?? 0, + hasSubagents: session.hasSubagents ?? false, + contextConsumption: ctxConsumption, + contextConsumptionPct: ctxConsumptionPct, + contextAssessment: ctxAssessment, + compactionCount: session.compactionCount ?? 0, + gitBranch: session.gitBranch ?? 'unknown', + startTime: firstTs, + endTime: lastTs, + durationSeconds, + durationHuman: durationSeconds > 0 ? formatDuration(Math.floor(durationSeconds)) : 'unknown', + totalMessages: messages.length, + }, + + tokenUsage: { + byModel, + totals: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheCreation: totalCacheCreationTokens, + cacheRead: totalCacheReadTokens, + grandTotal, + cacheReadPct: grandTotal + ? Math.round((totalCacheReadTokens / grandTotal) * 10000) / 100 + : 0, + }, + }, + + costAnalysis: { + parentCostUsd: Math.round(parentCost * 10000) / 10000, + subagentCostUsd: Math.round(processSubagentCost * 10000) / 10000, + totalSessionCostUsd: Math.round(totalCost * 10000) / 10000, + costByModel: Object.fromEntries( + [...modelStats.entries()].map(([model, stats]) => [ + model, + Math.round(stats.costUsd * 10000) / 10000, + ]) + ), + costPerCommit: costPerCommitVal, + costPerLineChanged: costPerLineVal, + costPerCommitAssessment: + costPerCommitVal != null ? computeCostPerCommitAssessment(costPerCommitVal) : null, + costPerLineAssessment: + costPerLineVal != null ? computeCostPerLineAssessment(costPerLineVal) : null, + subagentCostSharePct, + subagentCostShareAssessment: + subagentCostSharePct != null + ? computeSubagentCostShareAssessment(subagentCostSharePct) + : null, + }, + + cacheEconomics: { + cacheRead: totalCacheRead, + cacheEfficiencyPct: cacheEfficiency, + coldStartDetected, + cacheReadToWriteRatio: cacheRwRatio, + cacheEfficiencyAssessment: + cacheTotalCreationAndRead > 0 ? computeCacheEfficiencyAssessment(cacheEfficiency) : null, + cacheRatioAssessment: + totalCacheCreation > 0 ? computeCacheRatioAssessment(cacheRwRatio) : null, + }, + + toolUsage: { + counts: countsRecord, + totalCalls: [...toolCounts.values()].reduce((sum, c) => sum + c, 0), + successRates: toolSuccessRates, + overallToolHealth, + }, + + subagentMetrics: saFromProcesses, + + subagentsList, + + errors: { + errors, + permissionDenials: { + count: permissionDenials.length, + denials: permissionDenials, + affectedTools: [...new Set(permissionDenials.map((d) => d.tool))], + }, + }, + + gitActivity: { + commitCount: gitCommits.length, + commits: gitCommits, + pushCount: gitPushCount, + branchCreations: gitBranchCreations, + linesAdded: linesAddedTotal, + linesRemoved: linesRemovedTotal, + linesChanged, + }, + + frictionSignals: { + correctionCount, + corrections, + frictionRate, + }, + + thrashingSignals: { + bashNearDuplicates, + editReworkFiles, + thrashingAssessment: computeThrashingAssessment(thrashingSignalCount), + }, + + conversationTree: { + totalNodes: uuidToIdx.size, + maxDepth, + sidechainCount, + branchPoints: branchPoints.size, + branchDetails, + }, + + idleAnalysis: { + idleThresholdSeconds: IDLE_THRESHOLD_SEC, + idleGapCount: idleGaps.length, + totalIdleSeconds: Math.round(totalIdle * 10) / 10, + totalIdleHuman: formatDuration(Math.floor(totalIdle)), + wallClockSeconds: Math.round(wallClock * 10) / 10, + activeWorkingSeconds: Math.round(Math.max(activeTime, 0) * 10) / 10, + activeWorkingHuman: formatDuration(Math.floor(Math.max(activeTime, 0))), + idlePct, + longestGaps: [...idleGaps].sort((a, b) => b.gapSeconds - a.gapSeconds).slice(0, 5), + idleAssessment: computeIdleAssessment(idlePct), + }, + + modelSwitches: { + count: modelSwitches.length, + switches: modelSwitches, + modelsUsed, + switchPattern: detectSwitchPattern(modelSwitches), + }, + + workingDirectories: { + uniqueDirectories: [...cwdSet], + directoryCount: cwdSet.size, + changes: cwdChanges, + changeCount: cwdChanges.length, + isMultiDirectory: cwdSet.size > 1, + }, + + testProgression: { + snapshotCount: testSnapshots.length, + snapshots: testSnapshots, + trajectory, + firstSnapshot: testSnapshots.length > 0 ? testSnapshots[0] : null, + lastSnapshot: testSnapshots.length > 0 ? testSnapshots[testSnapshots.length - 1] : null, + }, + + startupOverhead: { + messagesBeforeFirstWork: startupMessages, + tokensBeforeFirstWork: startupTokens, + pctOfTotal: startupPctOfTotal, + overheadAssessment: computeOverheadAssessment(startupPctOfTotal), + }, + + tokenDensityTimeline: { quartiles }, + + promptQuality: { + firstMessageLengthChars: firstUserMessageLength, + userMessageCount, + correctionCount, + frictionRate, + assessment, + note: promptNote, + }, + + thinkingBlocks: { + count: thinkingCount, + analyzedCount: thinkingAnalysis.length, + signalSummary: signalTotals, + notableBlocks: thinkingAnalysis.slice(0, 20), + }, + + keyEvents, + + messageTypes, + + fileReadRedundancy: { + totalReads, + uniqueFiles, + readsPerUniqueFile, + redundantFiles, + redundancyAssessment: computeRedundancyAssessment(readsPerUniqueFile), + }, + + compaction: { + count: session.compactionCount ?? 0, + compactSummaryCount, + note: + (session.compactionCount ?? 0) > 0 + ? 'Session underwent compaction, which may have caused loss of earlier context. Check for repeated work after compaction events.' + : 'No compaction occurred — session stayed within context limits.', + }, + + gitBranches: [...branches], + + skillsInvoked, + + bashCommands: { + total: bashCmds.length, + unique: new Set(bashCmds).size, + repeated: Object.fromEntries( + [ + ...bashCmds + .reduce((acc, cmd) => acc.set(cmd, (acc.get(cmd) ?? 0) + 1), new Map()) + .entries(), + ] + .filter(([, count]) => count > 1) + .sort((a, b) => b[1] - a[1]) + ), + }, + + lifecycleTasks, + + userQuestions, + + outOfScopeFindings, + + agentTree: (() => { + const uniqueAgents = new Map(); + for (const node of agentTreeNodes) { + if (!uniqueAgents.has(node.agentId)) uniqueAgents.set(node.agentId, node); + } + return { + agentCount: uniqueAgents.size, + agents: [...uniqueAgents.values()], + hasTeamMode: agentTreeNodes.some((n) => n.teamName), + teamNames: [...new Set(agentTreeNodes.filter((n) => n.teamName).map((n) => n.teamName))], + }; + })(), + }; + + return report; +} diff --git a/src/renderer/utils/stringUtils.ts b/src/renderer/utils/stringUtils.ts index 6c84441b..71bae686 100644 --- a/src/renderer/utils/stringUtils.ts +++ b/src/renderer/utils/stringUtils.ts @@ -2,6 +2,27 @@ * String utilities for display formatting. */ +const isMacPlatform = + typeof window !== 'undefined' && window.navigator.userAgent.includes('Macintosh'); + +/** Returns '⌘' on macOS, 'Ctrl' on Windows/Linux. */ +export const modKey = isMacPlatform ? '⌘' : 'Ctrl+'; + +/** Returns '⇧' on macOS, 'Shift+' on Windows/Linux. */ +export const shiftKey = isMacPlatform ? '⇧' : 'Shift+'; + +/** + * Formats a keyboard shortcut for the current platform. + * @example formatShortcut('R') → '⌘R' on Mac, 'Ctrl+R' on Windows/Linux + * @example formatShortcut('W', { shift: true }) → '⇧⌘W' on Mac, 'Ctrl+Shift+W' on Windows/Linux + */ +export function formatShortcut(key: string, opts?: { shift?: boolean }): string { + if (opts?.shift) { + return isMacPlatform ? `${shiftKey}${modKey}${key}` : `${modKey}${shiftKey}${key}`; + } + return `${modKey}${key}`; +} + /** * Truncates a string in the middle to preserve both the beginning and end. * Useful for branch names where the unique identifier is often at the end. diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 7b4a334a..cc9a2757 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -56,6 +56,30 @@ import type { SubagentDetail, } from '@main/types'; +// ============================================================================= +// Cost Calculation Types +// ============================================================================= + +/** + * Detailed cost breakdown by token type for a session or chunk + */ +export interface CostBreakdown { + /** Cost for input tokens */ + inputCost: number; + /** Cost for output tokens */ + outputCost: number; + /** Cost for cache creation tokens */ + cacheCreationCost: number; + /** Cost for cache read tokens */ + cacheReadCost: number; + /** Total cost (sum of all components) */ + totalCost: number; + /** Model name used for calculation */ + model: string; + /** Source of the cost data */ + source: 'calculated' | 'precalculated' | 'unavailable'; +} + // ============================================================================= // Agent Config // ============================================================================= @@ -492,6 +516,7 @@ export interface ElectronAPI { close: () => Promise; isMaximized: () => Promise; isFullScreen: () => Promise; + relaunch: () => Promise; }; /** Subscribe to fullscreen changes (e.g. to remove macOS traffic light padding in fullscreen) */ diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index b7d757a2..a1125548 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -264,6 +264,10 @@ export interface AppConfig { claudeRootPath: string | null; /** Agent communication language ('system' = use OS locale) */ agentLanguage: string; + /** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */ + autoExpandAIGroups: boolean; + /** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */ + useNativeTitleBar: boolean; }; /** Display and UI settings */ display: { diff --git a/src/shared/utils/costFormatting.ts b/src/shared/utils/costFormatting.ts new file mode 100644 index 00000000..3cbedd44 --- /dev/null +++ b/src/shared/utils/costFormatting.ts @@ -0,0 +1,45 @@ +/** + * Cost formatting utilities + */ + +/** + * Format USD cost with appropriate precision + * - $0.001 or more: 2 decimal places ($1.23) + * - Less than $0.001: 3-4 decimal places for precision ($0.0012) + * - Zero: $0.00 + */ +export function formatCostUsd(cost: number): string { + if (cost === 0) { + return '$0.00'; + } + + if (cost >= 0.01) { + // Standard currency format for amounts >= 1 cent + return `$${cost.toFixed(2)}`; + } else if (cost >= 0.001) { + // 3 decimal places for sub-cent amounts + return `$${cost.toFixed(3)}`; + } else { + // 4 decimal places for very small amounts + return `$${cost.toFixed(4)}`; + } +} + +/** + * Format cost compactly for display in badges + * - Rounds to 2 decimal places + * - Omits $ prefix for brevity + */ +export function formatCostCompact(cost: number): string { + if (cost === 0) { + return '0.00'; + } + + if (cost >= 0.01) { + return cost.toFixed(2); + } else if (cost >= 0.001) { + return cost.toFixed(3); + } else { + return cost.toFixed(4); + } +} diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts new file mode 100644 index 00000000..7a175df1 --- /dev/null +++ b/src/shared/utils/pricing.ts @@ -0,0 +1,121 @@ +// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available +import pricingData from '../../../resources/pricing.json'; + +export interface LiteLLMPricing { + input_cost_per_token: number; + output_cost_per_token: number; + cache_creation_input_token_cost?: number; + cache_read_input_token_cost?: number; + input_cost_per_token_above_200k_tokens?: number; + output_cost_per_token_above_200k_tokens?: number; + cache_creation_input_token_cost_above_200k_tokens?: number; + cache_read_input_token_cost_above_200k_tokens?: number; + [key: string]: unknown; +} + +export interface DisplayPricing { + input: number; + output: number; + cache_read: number; + cache_creation: number; +} + +const TIER_THRESHOLD = 200_000; + +const PRICING_MAP = pricingData as Record; + +// Pre-compute lowercase key map for O(1) case-insensitive lookups +const LOWERCASE_KEY_MAP = new Map(); +for (const key of Object.keys(PRICING_MAP)) { + if (!LOWERCASE_KEY_MAP.has(key.toLowerCase())) { + LOWERCASE_KEY_MAP.set(key.toLowerCase(), key); + } +} + +function isLiteLLMPricing(entry: unknown): entry is LiteLLMPricing { + return ( + !!entry && + typeof entry === 'object' && + 'input_cost_per_token' in entry && + 'output_cost_per_token' in entry + ); +} + +function tryGetPricing(key: string): LiteLLMPricing | null { + const entry = PRICING_MAP[key]; + return isLiteLLMPricing(entry) ? entry : null; +} + +export function getPricing(modelName: string): LiteLLMPricing | null { + const exact = tryGetPricing(modelName); + if (exact) return exact; + + const lowerName = modelName.toLowerCase(); + const originalKey = LOWERCASE_KEY_MAP.get(lowerName); + if (originalKey) { + return tryGetPricing(originalKey); + } + + return null; +} + +export function calculateTieredCost(tokens: number, baseRate: number, tieredRate?: number): number { + if (tokens <= 0) return 0; + if (tieredRate == null || tokens <= TIER_THRESHOLD) { + return tokens * baseRate; + } + const costBelow = TIER_THRESHOLD * baseRate; + const costAbove = (tokens - TIER_THRESHOLD) * tieredRate; + return costBelow + costAbove; +} + +export function calculateMessageCost( + modelName: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + cacheCreationTokens: number +): number { + const pricing = getPricing(modelName); + if (!pricing) { + if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) { + console.warn(`[pricing] No pricing data for model "${modelName}", cost will be $0`); + } + return 0; + } + + const inputCost = calculateTieredCost( + inputTokens, + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_200k_tokens + ); + const outputCost = calculateTieredCost( + outputTokens, + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_200k_tokens + ); + const cacheCreationCost = calculateTieredCost( + cacheCreationTokens, + pricing.cache_creation_input_token_cost ?? 0, + pricing.cache_creation_input_token_cost_above_200k_tokens + ); + const cacheReadCost = calculateTieredCost( + cacheReadTokens, + pricing.cache_read_input_token_cost ?? 0, + pricing.cache_read_input_token_cost_above_200k_tokens + ); + + return inputCost + outputCost + cacheCreationCost + cacheReadCost; +} + +export function getDisplayPricing(modelName: string): DisplayPricing | null { + const pricing = getPricing(modelName); + if (!pricing) return null; + + return { + input: pricing.input_cost_per_token * 1_000_000, + output: pricing.output_cost_per_token * 1_000_000, + cache_read: (pricing.cache_read_input_token_cost ?? 0) * 1_000_000, + cache_creation: (pricing.cache_creation_input_token_cost ?? 0) * 1_000_000, + }; +} diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 0dbd7707..4dcb9714 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -20,6 +20,28 @@ describe('configValidation', () => { } }); + it('accepts general.autoExpandAIGroups boolean toggle', () => { + const resultOn = validateConfigUpdatePayload('general', { autoExpandAIGroups: true }); + expect(resultOn.valid).toBe(true); + if (resultOn.valid) { + expect(resultOn.data).toEqual({ autoExpandAIGroups: true }); + } + + const resultOff = validateConfigUpdatePayload('general', { autoExpandAIGroups: false }); + expect(resultOff.valid).toBe(true); + if (resultOff.valid) { + expect(resultOff.data).toEqual({ autoExpandAIGroups: false }); + } + }); + + it('rejects non-boolean general.autoExpandAIGroups', () => { + const result = validateConfigUpdatePayload('general', { autoExpandAIGroups: 'yes' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('boolean'); + } + }); + it('accepts absolute general.claudeRootPath updates', () => { const result = validateConfigUpdatePayload('general', { claudeRootPath: '/Users/test/.claude', diff --git a/test/main/utils/costCalculation.test.ts b/test/main/utils/costCalculation.test.ts new file mode 100644 index 00000000..2ea481a3 --- /dev/null +++ b/test/main/utils/costCalculation.test.ts @@ -0,0 +1,556 @@ +/** + * Tests for cost calculation in jsonl.ts + */ + +import { describe, it, expect, vi } from 'vitest'; +import { calculateMetrics } from '@main/utils/jsonl'; +import type { ParsedMessage } from '@main/types'; + +describe('Cost Calculation', () => { + describe('Basic Cost Calculation', () => { + it('should calculate cost for simple token usage', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Expected: (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105 + expect(metrics.costUsd).toBeCloseTo(0.0105, 6); + }); + + it('should calculate cost with cache tokens', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 1000 * 0.000003 = 0.003 + // Output: 500 * 0.000015 = 0.0075 + // Cache creation: 200 * 0.00000375 = 0.00075 + // Cache read: 300 * 0.0000003 = 0.00009 + // Total: 0.01134 + expect(metrics.costUsd).toBeCloseTo(0.01134, 6); + }); + + it('should return 0 cost when no model is specified', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should return 0 cost when model pricing not found', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'unknown-model', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + warnSpy.mockRestore(); + }); + }); + + describe('Tiered Pricing', () => { + it('should use base rates for tokens below 200k threshold', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 100_000, + output_tokens: 50_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 100000 * 0.000003 = 0.3 + // Output: 50000 * 0.000015 = 0.75 + // Total: 1.05 + expect(metrics.costUsd).toBeCloseTo(1.05, 6); + }); + + it('should use base rates for input tokens above 200k when model has no tiered pricing', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 250_000, + output_tokens: 1_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // claude-3-5-sonnet-20241022 has no tiered rates in pricing.json, so base rates apply + // Input: 250000 * 0.000003 = 0.75 + // Output: 1000 * 0.000015 = 0.015 + // Total: 0.765 + expect(metrics.costUsd).toBeCloseTo(0.765, 6); + }); + + it('should use base rates for output tokens above 200k when model has no tiered pricing', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1_000, + output_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // No tiered rates, so base rates for all tokens + // Input: 1000 * 0.000003 = 0.003 + // Output: 250000 * 0.000015 = 3.75 + // Total: 3.753 + expect(metrics.costUsd).toBeCloseTo(3.753, 6); + }); + + it('should use base rates for cache tokens above 200k when model has no tiered pricing', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1_000, + output_tokens: 1_000, + cache_creation_input_tokens: 250_000, + cache_read_input_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // No tiered rates for this model, so base rates apply + // Input: 1000 * 0.000003 = 0.003 + // Output: 1000 * 0.000015 = 0.015 + // Cache creation: 250000 * 0.00000375 = 0.9375 + // Cache read: 250000 * 0.0000003 = 0.075 + // Total: 1.0305 + expect(metrics.costUsd).toBeCloseTo(1.0305, 6); + }); + + it('should handle model without tiered pricing', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-opus-20240229', + usage: { + input_tokens: 250_000, + output_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // No tiered rates, so use base rates even above 200k + // Input: 250000 * 0.000015 = 3.75 + // Output: 250000 * 0.000075 = 18.75 + // Total: 22.5 + expect(metrics.costUsd).toBeCloseTo(22.5, 6); + }); + + it('should use tiered rates for a model that has them (claude-4-sonnet)', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-4-sonnet-20250514', + usage: { + input_tokens: 250_000, + output_tokens: 1_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // claude-4-sonnet has tiered rates: + // input base=0.000003, above_200k=0.000006 + // Input: (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9 + // Output: 1000 * 0.000015 = 0.015 + // Total: 0.915 + expect(metrics.costUsd).toBeCloseTo(0.915, 6); + }); + }); + + describe('Multiple Messages', () => { + it('should aggregate costs across multiple messages', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + { + type: 'assistant', + uuid: 'msg-2', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 2000, + output_tokens: 1000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Message 1: (1000 * 0.000003) + (500 * 0.000015) = 0.0105 + // Message 2: (2000 * 0.000003) + (1000 * 0.000015) = 0.021 + // Total: 0.0315 + expect(metrics.costUsd).toBeCloseTo(0.0315, 6); + }); + + it("should calculate cost per-message using each message's model", () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + { + type: 'assistant', + uuid: 'msg-2', + timestamp: new Date(), + content: [], + model: 'claude-3-opus-20240229', // Different model + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Each message uses its own model's pricing + // Message 1 (sonnet): (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105 + // Message 2 (opus): (1000 * 0.000015) + (500 * 0.000075) = 0.015 + 0.0375 = 0.0525 + // Total cost: 0.0105 + 0.0525 = 0.063 + expect(metrics.costUsd).toBeCloseTo(0.063, 6); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero tokens', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should handle messages without usage data', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should handle empty messages array', () => { + const messages: ParsedMessage[] = []; + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + }); + + describe('Model Name Lookup', () => { + it('should find model with exact match', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBeGreaterThan(0); + }); + + it('should find model with case-insensitive match', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'CLAUDE-3-5-SONNET-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBeGreaterThan(0); + }); + }); + + describe('Per-Message Tiering', () => { + it('should apply tiered pricing per-message, not to aggregated totals', () => { + // Scenario: Many messages each with cache_read tokens < 200k, + // but aggregated total > 200k + // Each message should use base rates, not tiered rates + const messages: ParsedMessage[] = []; + + // Create 10 messages, each with 50k cache_read tokens (500k total) + for (let i = 0; i < 10; i++) { + messages.push({ + type: 'assistant', + uuid: `msg-${i}`, + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 50000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }); + } + + const metrics = calculateMetrics(messages); + + // Per-message tiering: Each message uses base rate (< 200k threshold) + // Each message: 50,000 * 0.0000003 = $0.015 + // Total: 10 * $0.015 = $0.15 + const expectedCost = 10 * 50000 * 0.0000003; + expect(metrics.costUsd).toBeCloseTo(expectedCost, 6); + + // Verify this is NOT using tiered rate on aggregated total + // If incorrectly aggregated: (200k * 0.0000003) + (300k * 0.0000006) = $0.24 + const incorrectAggregatedCost = 0.24; + expect(metrics.costUsd).not.toBeCloseTo(incorrectAggregatedCost, 2); + }); + + it('should use base rates when individual messages exceed 200k and model has no tiered rates', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 300000, // Exceeds 200k threshold + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // No tiered rates for this model, so all 300k at base rate + // 300,000 * 0.0000003 = $0.09 + const expectedCost = 300000 * 0.0000003; + expect(metrics.costUsd).toBeCloseTo(expectedCost, 6); + }); + }); + + describe('Integration with Other Metrics', () => { + it('should include cost alongside other session metrics', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Check that all expected metrics are present + expect(metrics).toHaveProperty('totalTokens'); + expect(metrics).toHaveProperty('inputTokens'); + expect(metrics).toHaveProperty('outputTokens'); + expect(metrics).toHaveProperty('costUsd'); + expect(metrics.totalTokens).toBe(1500); + expect(metrics.inputTokens).toBe(1000); + expect(metrics.outputTokens).toBe(500); + expect(metrics.costUsd).toBeCloseTo(0.0105, 6); + }); + }); +}); diff --git a/test/renderer/utils/reportAssessments.test.ts b/test/renderer/utils/reportAssessments.test.ts new file mode 100644 index 00000000..b25e08b0 --- /dev/null +++ b/test/renderer/utils/reportAssessments.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect } from 'vitest'; + +import { + assessmentColor, + assessmentExplanation, + assessmentLabel, + assessmentSeverity, + computeCacheEfficiencyAssessment, + computeCacheRatioAssessment, + computeCostPerCommitAssessment, + computeCostPerLineAssessment, + computeIdleAssessment, + computeOverheadAssessment, + computeRedundancyAssessment, + computeSubagentCostShareAssessment, + computeTakeaways, + computeThrashingAssessment, + computeToolHealthAssessment, + detectModelMismatch, + detectSwitchPattern, + severityColor, + THRESHOLDS, +} from '@renderer/utils/reportAssessments'; + +import type { MetricKey } from '@renderer/utils/reportAssessments'; + +describe('reportAssessments', () => { + describe('severityColor', () => { + it('maps severity to hex color', () => { + expect(severityColor('good')).toBe('#4ade80'); + expect(severityColor('warning')).toBe('#fbbf24'); + expect(severityColor('danger')).toBe('#f87171'); + expect(severityColor('neutral')).toBe('#a1a1aa'); + }); + }); + + describe('assessmentSeverity', () => { + it('maps known assessments to severity', () => { + expect(assessmentSeverity('healthy')).toBe('good'); + expect(assessmentSeverity('efficient')).toBe('good'); + expect(assessmentSeverity('expensive')).toBe('warning'); + expect(assessmentSeverity('red_flag')).toBe('danger'); + expect(assessmentSeverity('very_high')).toBe('danger'); + expect(assessmentSeverity('degraded')).toBe('warning'); + expect(assessmentSeverity('unreliable')).toBe('danger'); + expect(assessmentSeverity('high_idle')).toBe('danger'); + expect(assessmentSeverity('moderate')).toBe('warning'); + }); + + it('returns neutral for null/undefined/unknown', () => { + expect(assessmentSeverity(null)).toBe('neutral'); + expect(assessmentSeverity(undefined)).toBe('neutral'); + expect(assessmentSeverity('unknown_value')).toBe('neutral'); + }); + }); + + describe('assessmentColor', () => { + it('returns correct color for assessment string', () => { + expect(assessmentColor('healthy')).toBe('#4ade80'); + expect(assessmentColor('red_flag')).toBe('#f87171'); + expect(assessmentColor(null)).toBe('#a1a1aa'); + }); + }); + + describe('assessmentLabel', () => { + it('converts snake_case to Title Case', () => { + expect(assessmentLabel('red_flag')).toBe('Red Flag'); + expect(assessmentLabel('well_specified')).toBe('Well Specified'); + expect(assessmentLabel('healthy')).toBe('Healthy'); + expect(assessmentLabel('high_idle')).toBe('High Idle'); + expect(assessmentLabel('opus_plan_mode')).toBe('Opus Plan Mode'); + }); + }); + + describe('computeCostPerCommitAssessment', () => { + it('returns efficient below threshold', () => { + expect(computeCostPerCommitAssessment(0.3)).toBe('efficient'); + }); + it('returns normal in range', () => { + expect(computeCostPerCommitAssessment(1.0)).toBe('normal'); + }); + it('returns expensive in range', () => { + expect(computeCostPerCommitAssessment(3.0)).toBe('expensive'); + }); + it('returns red_flag above threshold', () => { + expect(computeCostPerCommitAssessment(10.0)).toBe('red_flag'); + }); + it('respects threshold boundaries', () => { + expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient - 0.01)).toBe( + 'efficient' + ); + expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient)).toBe('normal'); + }); + }); + + describe('computeCostPerLineAssessment', () => { + it('returns efficient below threshold', () => { + expect(computeCostPerLineAssessment(0.005)).toBe('efficient'); + }); + it('returns red_flag above threshold', () => { + expect(computeCostPerLineAssessment(0.5)).toBe('red_flag'); + }); + }); + + describe('computeSubagentCostShareAssessment', () => { + it('returns normal below 30%', () => { + expect(computeSubagentCostShareAssessment(20)).toBe('normal'); + }); + it('returns high in range', () => { + expect(computeSubagentCostShareAssessment(45)).toBe('high'); + }); + it('returns very_high in range', () => { + expect(computeSubagentCostShareAssessment(70)).toBe('very_high'); + }); + it('returns red_flag above 80%', () => { + expect(computeSubagentCostShareAssessment(90)).toBe('red_flag'); + }); + }); + + describe('computeCacheEfficiencyAssessment', () => { + it('returns good above 95%', () => { + expect(computeCacheEfficiencyAssessment(96)).toBe('good'); + }); + it('returns concerning below 95%', () => { + expect(computeCacheEfficiencyAssessment(90)).toBe('concerning'); + }); + }); + + describe('computeCacheRatioAssessment', () => { + it('returns good above 20', () => { + expect(computeCacheRatioAssessment(25)).toBe('good'); + }); + it('returns concerning below 20', () => { + expect(computeCacheRatioAssessment(10)).toBe('concerning'); + }); + }); + + describe('computeToolHealthAssessment', () => { + it('returns healthy above 95%', () => { + expect(computeToolHealthAssessment(98)).toBe('healthy'); + }); + it('returns degraded between 80-95%', () => { + expect(computeToolHealthAssessment(85)).toBe('degraded'); + }); + it('returns unreliable below 80%', () => { + expect(computeToolHealthAssessment(70)).toBe('unreliable'); + }); + it('boundary: 95 is degraded, 95.1 is healthy', () => { + expect(computeToolHealthAssessment(95)).toBe('degraded'); + expect(computeToolHealthAssessment(95.1)).toBe('healthy'); + }); + }); + + describe('computeIdleAssessment', () => { + it('returns efficient below 20%', () => { + expect(computeIdleAssessment(10)).toBe('efficient'); + }); + it('returns moderate between 20-50%', () => { + expect(computeIdleAssessment(35)).toBe('moderate'); + }); + it('returns high_idle above 50%', () => { + expect(computeIdleAssessment(60)).toBe('high_idle'); + }); + }); + + describe('computeRedundancyAssessment', () => { + it('returns normal at or below 2.0', () => { + expect(computeRedundancyAssessment(1.5)).toBe('normal'); + expect(computeRedundancyAssessment(2.0)).toBe('normal'); + }); + it('returns wasteful above 2.0', () => { + expect(computeRedundancyAssessment(3.0)).toBe('wasteful'); + }); + }); + + describe('computeOverheadAssessment', () => { + it('returns normal at or below 5%', () => { + expect(computeOverheadAssessment(3)).toBe('normal'); + expect(computeOverheadAssessment(5)).toBe('normal'); + }); + it('returns heavy above 5%', () => { + expect(computeOverheadAssessment(10)).toBe('heavy'); + }); + }); + + describe('computeThrashingAssessment', () => { + it('returns none for 0 signals', () => { + expect(computeThrashingAssessment(0)).toBe('none'); + }); + it('returns mild for 1-2 signals', () => { + expect(computeThrashingAssessment(1)).toBe('mild'); + expect(computeThrashingAssessment(2)).toBe('mild'); + }); + it('returns severe for 3+ signals', () => { + expect(computeThrashingAssessment(3)).toBe('severe'); + expect(computeThrashingAssessment(5)).toBe('severe'); + }); + }); + + describe('detectModelMismatch', () => { + it('returns null for non-opus models', () => { + expect(detectModelMismatch('rename files', 'claude-sonnet-4')).toBeNull(); + }); + + it('detects mechanical tasks on opus', () => { + const result = detectModelMismatch('rename all variables', 'claude-opus-4'); + expect(result).not.toBeNull(); + expect(result!.expectedComplexity).toBe('mechanical'); + }); + + it('detects read-only tasks on opus', () => { + const result = detectModelMismatch('explore the codebase', 'claude-opus-4'); + expect(result).not.toBeNull(); + expect(result!.expectedComplexity).toBe('read_only'); + }); + + it('returns null for complex tasks on opus', () => { + expect(detectModelMismatch('implement authentication system', 'claude-opus-4')).toBeNull(); + }); + + it('detects various mechanical keywords', () => { + for (const kw of ['lint', 'format', 'delete', 'move', 'copy', 'replace']) { + expect(detectModelMismatch(`${kw} the code`, 'opus')).not.toBeNull(); + } + }); + + it('detects various read-only keywords', () => { + for (const kw of ['search', 'find', 'verify', 'check', 'scan', 'discover']) { + expect(detectModelMismatch(`${kw} for errors`, 'opus')).not.toBeNull(); + } + }); + }); + + describe('detectSwitchPattern', () => { + it('returns null for no switches', () => { + expect(detectSwitchPattern([])).toBeNull(); + }); + + it('returns manual_switch for single switch', () => { + expect(detectSwitchPattern([{ from: 'claude-sonnet-4', to: 'claude-haiku-4' }])).toBe( + 'manual_switch' + ); + }); + + it('detects opus_plan_mode pattern', () => { + expect( + detectSwitchPattern([ + { from: 'claude-sonnet-4', to: 'claude-opus-4' }, + { from: 'claude-opus-4', to: 'claude-sonnet-4' }, + ]) + ).toBe('opus_plan_mode'); + }); + + it('returns manual_switch for non-plan-mode switches', () => { + expect( + detectSwitchPattern([ + { from: 'claude-sonnet-4', to: 'claude-haiku-4' }, + { from: 'claude-haiku-4', to: 'claude-sonnet-4' }, + ]) + ).toBe('manual_switch'); + }); + }); + + describe('assessmentExplanation', () => { + const ALL_METRIC_ASSESSMENTS: Record = { + costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'], + costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'], + subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'], + cacheEfficiency: ['good', 'concerning'], + cacheRatio: ['good', 'concerning'], + toolHealth: ['healthy', 'degraded', 'unreliable'], + idle: ['efficient', 'moderate', 'high_idle'], + fileReads: ['normal', 'wasteful'], + startup: ['normal', 'heavy'], + thrashing: ['none', 'mild', 'severe'], + promptQuality: [ + 'well_specified', + 'moderate_friction', + 'underspecified', + 'verbose_but_unclear', + ], + testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'], + }; + + it('returns non-empty string for all valid metric/assessment combos', () => { + for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) { + for (const assessment of assessments) { + const result = assessmentExplanation(metricKey as MetricKey, assessment); + expect(result, `${metricKey}/${assessment}`).not.toBe(''); + } + } + }); + + it('returns empty string for unknown combinations', () => { + expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe(''); + expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe(''); + }); + + it('includes threshold values in explanations', () => { + expect(assessmentExplanation('costPerCommit', 'efficient')).toContain( + String(THRESHOLDS.costPerCommit.efficient) + ); + expect(assessmentExplanation('toolHealth', 'healthy')).toContain( + String(THRESHOLDS.toolSuccess.healthy) + ); + }); + }); + + describe('computeTakeaways', () => { + const healthyReport = { + costAnalysis: { + costPerCommitAssessment: 'efficient', + costPerLineAssessment: 'efficient', + totalSessionCostUsd: 0.5, + }, + cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 }, + toolUsage: { overallToolHealth: 'healthy' }, + thrashingSignals: { + thrashingAssessment: 'none', + bashNearDuplicates: [], + editReworkFiles: [], + }, + idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 }, + promptQuality: { assessment: 'well_specified', frictionRate: 0.05 }, + overview: { contextAssessment: 'healthy', compactionCount: 0 }, + fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 }, + testProgression: { trajectory: 'improving' }, + }; + + it('returns healthy message when all metrics are good', () => { + const result = computeTakeaways(healthyReport); + expect(result).toHaveLength(1); + expect(result[0].severity).toBe('good'); + expect(result[0].title).toContain('healthy'); + }); + + it('detects cost red flags', () => { + const report = { + ...healthyReport, + costAnalysis: { + ...healthyReport.costAnalysis, + costPerCommitAssessment: 'red_flag', + totalSessionCostUsd: 15, + }, + }; + const result = computeTakeaways(report); + expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true); + }); + + it('detects thrashing', () => { + const report = { + ...healthyReport, + thrashingSignals: { + thrashingAssessment: 'severe', + bashNearDuplicates: [{}], + editReworkFiles: [], + }, + }; + const result = computeTakeaways(report); + expect(result.some((t) => t.title.includes('thrashing'))).toBe(true); + }); + + it('limits to 4 takeaways', () => { + const report = { + ...healthyReport, + costAnalysis: { + ...healthyReport.costAnalysis, + costPerCommitAssessment: 'red_flag', + totalSessionCostUsd: 15, + }, + cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, + toolUsage: { overallToolHealth: 'unreliable' }, + thrashingSignals: { + thrashingAssessment: 'severe', + bashNearDuplicates: [{}], + editReworkFiles: [], + }, + promptQuality: { assessment: 'underspecified', frictionRate: 0.5 }, + overview: { contextAssessment: 'critical', compactionCount: 3 }, + fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 }, + testProgression: { trajectory: 'regressing' }, + }; + const result = computeTakeaways(report); + expect(result.length).toBeLessThanOrEqual(4); + }); + + it('sorts danger before warning', () => { + const report = { + ...healthyReport, + cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, + toolUsage: { overallToolHealth: 'unreliable' }, + }; + const result = computeTakeaways(report); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].severity).toBe('danger'); + }); + }); +}); diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts new file mode 100644 index 00000000..06064df4 --- /dev/null +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -0,0 +1,1509 @@ +import { describe, it, expect } from 'vitest'; + +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import type { ParsedMessage, Session, SessionDetail, SessionMetrics, Process } from '@shared/types'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +let msgCounter = 0; + +function createMockMessage(overrides: Partial = {}): ParsedMessage { + msgCounter++; + return { + uuid: `uuid-${msgCounter}`, + parentUuid: `uuid-${msgCounter - 1}`, + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'test-session', + projectId: 'test-project', + projectPath: '/test/path', + createdAt: Date.now(), + hasSubagents: false, + messageCount: 0, + ...overrides, + }; +} + +function createMockMetrics(overrides: Partial = {}): SessionMetrics { + return { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 0, + ...overrides, + }; +} + +function createMockDetail(overrides: Partial = {}): SessionDetail { + return { + session: createMockSession(), + messages: [], + chunks: [], + processes: [], + metrics: createMockMetrics(), + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('analyzeSession', () => { + beforeEach(() => { + msgCounter = 0; + }); + + // ------------------------------------------------------------------------- + // 1. Empty session + // ------------------------------------------------------------------------- + describe('empty session', () => { + it('returns a zeroed report with correct structure', () => { + const report = analyzeSession(createMockDetail()); + + expect(report.overview.sessionId).toBe('test-session'); + expect(report.overview.totalMessages).toBe(0); + expect(report.overview.durationSeconds).toBe(0); + + expect(report.tokenUsage.totals.grandTotal).toBe(0); + expect(report.tokenUsage.totals.inputTokens).toBe(0); + expect(report.tokenUsage.totals.outputTokens).toBe(0); + + expect(report.costAnalysis.totalSessionCostUsd).toBe(0); + + expect(report.toolUsage.totalCalls).toBe(0); + expect(report.toolUsage.counts).toEqual({}); + + expect(report.errors.errors).toHaveLength(0); + expect(report.errors.permissionDenials.count).toBe(0); + + expect(report.frictionSignals.correctionCount).toBe(0); + expect(report.frictionSignals.corrections).toHaveLength(0); + + expect(report.gitActivity.commitCount).toBe(0); + expect(report.gitActivity.pushCount).toBe(0); + + expect(report.idleAnalysis.idleGapCount).toBe(0); + + expect(report.modelSwitches.count).toBe(0); + expect(report.modelSwitches.switches).toHaveLength(0); + + expect(report.conversationTree.maxDepth).toBe(0); + expect(report.conversationTree.totalNodes).toBe(0); + + expect(report.tokenDensityTimeline.quartiles).toHaveLength(4); + expect(report.tokenDensityTimeline.quartiles.every((q) => q.avgTokens === 0)).toBe(true); + + expect(report.compaction.count).toBe(0); + expect(report.compaction.compactSummaryCount).toBe(0); + expect(report.gitBranches).toEqual([]); + + // New sections + expect(report.skillsInvoked).toEqual([]); + expect(report.bashCommands.total).toBe(0); + expect(report.lifecycleTasks).toEqual([]); + expect(report.userQuestions).toEqual([]); + expect(report.outOfScopeFindings).toEqual([]); + expect(report.agentTree.agentCount).toBe(0); + expect(report.subagentsList).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Basic session with usage data + // ------------------------------------------------------------------------- + describe('basic session', () => { + it('computes overview, token totals, and cost', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Hello world', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + content: [{ type: 'text' as const, text: 'Hi there!' }], + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Follow up', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:03:00Z'), + content: [{ type: 'text' as const, text: 'Sure thing.' }], + usage: { + input_tokens: 1500, + output_tokens: 300, + cache_read_input_tokens: 400, + cache_creation_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession( + createMockDetail({ + messages, + session: createMockSession({ messageCount: 4 }), + }) + ); + + // Overview + expect(report.overview.totalMessages).toBe(4); + expect(report.overview.durationSeconds).toBe(180); // 3 minutes + expect(report.overview.durationHuman).toBe('3:00'); + + // Token totals + expect(report.tokenUsage.totals.inputTokens).toBe(2500); + expect(report.tokenUsage.totals.outputTokens).toBe(800); + expect(report.tokenUsage.totals.cacheRead).toBe(600); + expect(report.tokenUsage.totals.cacheCreation).toBe(100); + expect(report.tokenUsage.totals.grandTotal).toBe(4000); + + // Cost should be positive (sonnet-4 pricing) + expect(report.costAnalysis.parentCostUsd).toBeGreaterThan(0); + expect(report.costAnalysis.totalSessionCostUsd).toBeGreaterThan(0); + + // Message types + expect(report.messageTypes.user).toBe(2); + expect(report.messageTypes.assistant).toBe(2); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Tool usage + // ------------------------------------------------------------------------- + describe('tool usage', () => { + it('counts tool calls and computes totalCalls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-2', name: 'Bash', input: { command: 'ls' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [ + { + type: 'tool_result' as const, + tool_use_id: 'tc-1', + content: 'file contents', + is_error: false, + }, + { + type: 'tool_result' as const, + tool_use_id: 'tc-2', + content: 'output', + is_error: false, + }, + ], + toolResults: [ + { toolUseId: 'tc-1', content: 'file contents', isError: false }, + { toolUseId: 'tc-2', content: 'output', isError: false }, + ], + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + toolCalls: [{ id: 'tc-3', name: 'Read', input: { file_path: '/bar.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.toolUsage.totalCalls).toBe(3); + expect(report.toolUsage.counts.Read).toBe(2); + expect(report.toolUsage.counts.Bash).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // 4. Error detection + // ------------------------------------------------------------------------- + describe('error detection', () => { + it('collects tool errors from isError results', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/missing.ts' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'ENOENT: no such file or directory', isError: true }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.errors).toHaveLength(1); + expect(report.errors.errors[0].tool).toBe('Read'); + expect(report.errors.errors[0].error).toContain('ENOENT'); + expect(report.errors.errors[0].isPermissionDenial).toBe(false); + }); + + it('detects Bash non-zero exit codes as errors', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Bash', input: { command: 'false' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'Exit code 1\nCommand failed', isError: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.errors).toHaveLength(1); + expect(report.errors.errors[0].tool).toBe('Bash (non-zero exit)'); + }); + }); + + // ------------------------------------------------------------------------- + // 5. Permission denial + // ------------------------------------------------------------------------- + describe('permission denial', () => { + it('flags errors containing permission keywords', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'rm /root/file' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-1', content: 'Error: permission denied', isError: true }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.permissionDenials.count).toBe(1); + expect(report.errors.permissionDenials.denials[0].isPermissionDenial).toBe(true); + expect(report.errors.permissionDenials.affectedTools).toContain('Bash'); + }); + + it('detects permission denial in Bash non-zero exit', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'cat /etc/shadow' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { + toolUseId: 'tc-1', + content: 'Exit code 1\ncat: /etc/shadow: Operation not permitted', + isError: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.permissionDenials.count).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Friction detection + // ------------------------------------------------------------------------- + describe('friction detection', () => { + it('detects friction keywords in user messages', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build the login page', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'No, that is wrong. Use React.', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Updated.' }], + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Actually, use Next.js instead', + timestamp: new Date('2024-01-01T10:04:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.frictionSignals.correctionCount).toBe(2); + expect(report.frictionSignals.corrections).toHaveLength(2); + + const keywords = report.frictionSignals.corrections.map((c) => c.keyword); + // "No," matches 'no,' and "actually" matches 'actually' + expect(keywords).toContain('no,'); + expect(keywords).toContain('actually'); + + // Friction rate = 2 corrections / 3 user messages + expect(report.frictionSignals.frictionRate).toBeCloseTo(2 / 3, 2); + }); + + it('does not count isMeta user messages as friction', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: true, + content: 'No, wrong, actually this is meta', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.frictionSignals.correctionCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // 7. Git activity + // ------------------------------------------------------------------------- + describe('git activity', () => { + it('detects git commits from Bash tool calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: "git commit -m 'initial commit'" }, + isTask: false, + }, + ], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-2', + name: 'Bash', + input: { command: "git commit -m 'add feature'" }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.gitActivity.commitCount).toBe(2); + expect(report.gitActivity.commits).toHaveLength(2); + expect(report.gitActivity.commits[0].messagePreview).toContain('initial commit'); + expect(report.gitActivity.commits[1].messagePreview).toContain('add feature'); + }); + + it('detects git push and branch creation', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: 'git checkout -b feat/new-branch' }, + isTask: false, + }, + ], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-2', + name: 'Bash', + input: { command: 'git push -u origin feat/new-branch' }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.gitActivity.pushCount).toBe(1); + expect(report.gitActivity.branchCreations).toContain('feat/new-branch'); + }); + }); + + // ------------------------------------------------------------------------- + // 8. Idle gaps + // ------------------------------------------------------------------------- + describe('idle gaps', () => { + it('detects idle gaps >60s between assistant and next user message', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + // 2 minutes later + createMockMessage({ + type: 'user', + isMeta: false, + content: 'back now', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:02:30Z'), + }), + // 30 seconds - no idle gap + createMockMessage({ + type: 'user', + isMeta: false, + content: 'quick reply', + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:03:30Z'), + }), + // 5 minutes later + createMockMessage({ + type: 'user', + isMeta: false, + content: 'took a break', + timestamp: new Date('2024-01-01T10:08:30Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.idleAnalysis.idleGapCount).toBe(2); + expect(report.idleAnalysis.totalIdleSeconds).toBeGreaterThan(0); + expect(report.idleAnalysis.idlePct).toBeGreaterThan(0); + + // First gap: 120s, second gap: 300s + const gapSeconds = report.idleAnalysis.longestGaps.map((g) => g.gapSeconds); + expect(gapSeconds).toContain(120); + expect(gapSeconds).toContain(300); + }); + + it('reports zero idle for no gaps', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'quick', + timestamp: new Date('2024-01-01T10:00:30Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleGapCount).toBe(0); + expect(report.idleAnalysis.totalIdleSeconds).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // 9. Model switches + // ------------------------------------------------------------------------- + describe('model switches', () => { + it('detects switches between different model names', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-opus-4-20250514', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-haiku-4-20250514', + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.modelSwitches.count).toBe(2); + expect(report.modelSwitches.switches[0].from).toBe('claude-sonnet-4-20250514'); + expect(report.modelSwitches.switches[0].to).toBe('claude-opus-4-20250514'); + expect(report.modelSwitches.switches[1].from).toBe('claude-opus-4-20250514'); + expect(report.modelSwitches.switches[1].to).toBe('claude-haiku-4-20250514'); + + expect(report.modelSwitches.modelsUsed).toContain('claude-sonnet-4-20250514'); + expect(report.modelSwitches.modelsUsed).toContain('claude-opus-4-20250514'); + expect(report.modelSwitches.modelsUsed).toContain('claude-haiku-4-20250514'); + }); + + it('reports zero switches for single model', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.count).toBe(0); + // modelsUsed falls back to modelStats keys when no switches + expect(report.modelSwitches.modelsUsed).toHaveLength(1); + expect(report.modelSwitches.modelsUsed[0]).toBe('claude-sonnet-4-20250514'); + }); + }); + + // ------------------------------------------------------------------------- + // 10. Conversation tree + // ------------------------------------------------------------------------- + describe('conversation tree', () => { + it('computes maxDepth from uuid/parentUuid chains', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null }), + createMockMessage({ uuid: 'child-1', parentUuid: 'root' }), + createMockMessage({ uuid: 'child-2', parentUuid: 'child-1' }), + createMockMessage({ uuid: 'child-3', parentUuid: 'child-2' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.conversationTree.totalNodes).toBe(4); + expect(report.conversationTree.maxDepth).toBe(3); // root(0)->child1(1)->child2(2)->child3(3) + }); + + it('detects branch points (multiple children)', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null }), + createMockMessage({ uuid: 'branch-a', parentUuid: 'root' }), + createMockMessage({ uuid: 'branch-b', parentUuid: 'root' }), + createMockMessage({ uuid: 'branch-c', parentUuid: 'root' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.conversationTree.branchPoints).toBe(1); + expect(report.conversationTree.branchDetails).toHaveLength(1); + expect(report.conversationTree.branchDetails[0].childCount).toBe(3); + }); + + it('counts sidechains', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null, isSidechain: false }), + createMockMessage({ uuid: 'side-1', parentUuid: 'root', isSidechain: true }), + createMockMessage({ uuid: 'side-2', parentUuid: 'root', isSidechain: true }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.conversationTree.sidechainCount).toBe(2); + }); + }); + + // ------------------------------------------------------------------------- + // Additional coverage + // ------------------------------------------------------------------------- + describe('context consumption assessment', () => { + it('assesses healthy context consumption', () => { + const report = analyzeSession( + createMockDetail({ + session: createMockSession({ contextConsumption: 0.3 }), + }) + ); + + expect(report.overview.contextAssessment).toBe('healthy'); + expect(report.overview.contextConsumptionPct).toBe(30); + }); + + it('assesses critical context consumption', () => { + const report = analyzeSession( + createMockDetail({ + session: createMockSession({ contextConsumption: 0.85 }), + }) + ); + + expect(report.overview.contextAssessment).toBe('critical'); + }); + }); + + describe('cache economics', () => { + it('detects cold start when first assistant has cache creation but no reads', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.coldStartDetected).toBe(true); + }); + + it('computes cache efficiency percentage', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 800, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + // efficiency = 800 / (200 + 800) * 100 = 80% + expect(report.cacheEconomics.cacheEfficiencyPct).toBe(80); + expect(report.cacheEconomics.cacheReadToWriteRatio).toBe(4); + }); + }); + + describe('file read redundancy', () => { + it('tracks redundant file reads', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-3', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-4', name: 'Read', input: { file_path: '/bar.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.fileReadRedundancy.totalReads).toBe(4); + expect(report.fileReadRedundancy.uniqueFiles).toBe(2); + expect(report.fileReadRedundancy.redundantFiles['/foo.ts']).toBe(3); + expect(report.fileReadRedundancy.redundantFiles['/bar.ts']).toBeUndefined(); // only 1 read, threshold is >2 + }); + }); + + describe('prompt quality', () => { + it('assesses well_specified when few corrections', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build me a React login component with form validation and error states', + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.assessment).toBe('well_specified'); + }); + + it('assesses underspecified when short prompt and many corrections', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Fix the bug', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'No, wrong file', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Updated.' }], + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Actually the other module', + timestamp: new Date('2024-01-01T10:04:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.assessment).toBe('underspecified'); + expect(report.promptQuality.firstMessageLengthChars).toBe('Fix the bug'.length); + }); + }); + + describe('subagent metrics from processes', () => { + it('computes subagent summary from detail.processes', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [ + createMockMessage({ + toolCalls: [ + { id: 'tc-1', name: 'Read', input: {}, isTask: false }, + { id: 'tc-2', name: 'Edit', input: {}, isTask: false }, + ], + }), + ], + startTime: new Date('2024-01-01T10:00:00Z'), + endTime: new Date('2024-01-01T10:01:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 5000, costUsd: 0.05 }), + description: 'Refactor module', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession(createMockDetail({ processes })); + + expect(report.subagentMetrics.count).toBe(1); + expect(report.subagentMetrics.totalTokens).toBe(5000); + expect(report.subagentMetrics.totalToolUseCount).toBe(2); + expect(report.subagentMetrics.byAgent[0].description).toBe('Refactor module'); + }); + }); + + describe('thinking blocks', () => { + it('counts thinking blocks and analyzes signals', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + content: [ + { + type: 'thinking' as const, + thinking: + 'Let me think about an alternative approach. Actually, I should reconsider.', + signature: 'sig-1', + }, + { type: 'text' as const, text: 'Here is my response.' }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thinkingBlocks.count).toBe(1); + expect(report.thinkingBlocks.analyzedCount).toBe(1); + expect(report.thinkingBlocks.signalSummary.alternatives).toBe(1); + expect(report.thinkingBlocks.signalSummary.direction_change).toBe(1); + }); + }); + + describe('working directories', () => { + it('tracks working directory changes', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ cwd: '/project/src' }), + createMockMessage({ cwd: '/project/src' }), + createMockMessage({ cwd: '/project/test' }), + createMockMessage({ cwd: '/project/src' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.workingDirectories.directoryCount).toBe(2); + expect(report.workingDirectories.isMultiDirectory).toBe(true); + expect(report.workingDirectories.changeCount).toBe(2); // src->test, test->src + }); + }); + + describe('git branches', () => { + it('collects unique git branches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ gitBranch: 'main' }), + createMockMessage({ gitBranch: 'main' }), + createMockMessage({ gitBranch: 'feat/new' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.gitBranches).toContain('main'); + expect(report.gitBranches).toContain('feat/new'); + expect(report.gitBranches).toHaveLength(2); + }); + }); + + describe('test progression', () => { + it('detects improving test trajectory', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-1', content: '3 passed 2 failed', isError: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-2', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-2', content: '5 passed 0 failed', isError: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.testProgression.snapshotCount).toBe(2); + expect(report.testProgression.trajectory).toBe('improving'); + expect(report.testProgression.firstSnapshot?.passed).toBe(3); + expect(report.testProgression.lastSnapshot?.passed).toBe(5); + }); + }); + + describe('startup overhead', () => { + it('counts messages and tokens before first work tool', () => { + const messages: ParsedMessage[] = [ + // Startup: assistant response with no work tools + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 500, output_tokens: 200 }, + toolCalls: [], + }), + // First work tool + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 1000, output_tokens: 300 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.startupOverhead.messagesBeforeFirstWork).toBe(1); + expect(report.startupOverhead.tokensBeforeFirstWork).toBe(700); // 500 + 200 + }); + }); + + describe('thrashing signals', () => { + it('detects bash near-duplicates', () => { + const makeMsg = (cmd: string, id: string) => + createMockMessage({ + type: 'assistant', + toolCalls: [{ id, name: 'Bash', input: { command: cmd }, isTask: false }], + }); + + const messages: ParsedMessage[] = [ + makeMsg('pnpm test src/foo.test.ts', 'tc-1'), + makeMsg('pnpm test src/foo.test.ts', 'tc-2'), + makeMsg('pnpm test src/foo.test.ts', 'tc-3'), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thrashingSignals.bashNearDuplicates.length).toBeGreaterThanOrEqual(1); + expect(report.thrashingSignals.bashNearDuplicates[0].count).toBe(3); + }); + + it('detects file edit rework', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-2', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-3', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thrashingSignals.editReworkFiles).toHaveLength(1); + expect(report.thrashingSignals.editReworkFiles[0].filePath).toBe('/foo.ts'); + expect(report.thrashingSignals.editReworkFiles[0].editIndices).toHaveLength(3); + }); + }); + + describe('skills invoked', () => { + it('tracks Skill tool calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Skill', + input: { skill: 'brainstorming', args: '--verbose' }, + isTask: false, + }, + { id: 'tc-2', name: 'Skill', input: { skill: 'writing-plans' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.skillsInvoked).toHaveLength(2); + expect(report.skillsInvoked[0].skill).toBe('brainstorming'); + expect(report.skillsInvoked[0].argsPreview).toBe('--verbose'); + expect(report.skillsInvoked[1].skill).toBe('writing-plans'); + }); + }); + + describe('bash commands', () => { + it('tracks total, unique, and repeated commands', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-2', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-3', name: 'Bash', input: { command: 'git status' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.bashCommands.total).toBe(3); + expect(report.bashCommands.unique).toBe(2); + expect(report.bashCommands.repeated['pnpm test']).toBe(2); + }); + }); + + describe('subagents list', () => { + it('tracks Task tool dispatches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Task', + input: { + description: 'explore auth', + subagent_type: 'Explore', + run_in_background: true, + }, + isTask: true, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.subagentsList).toHaveLength(1); + expect(report.subagentsList[0].description).toBe('explore auth'); + expect(report.subagentsList[0].subagentType).toBe('Explore'); + expect(report.subagentsList[0].runInBackground).toBe(true); + }); + }); + + describe('lifecycle tasks', () => { + it('tracks TaskCreate subjects', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'TaskCreate', input: { subject: 'Add login page' }, isTask: false }, + { id: 'tc-2', name: 'TaskCreate', input: { subject: 'Write tests' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.lifecycleTasks).toEqual(['Add login page', 'Write tests']); + }); + }); + + describe('user questions', () => { + it('tracks AskUserQuestion calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Which auth method?', + options: [{ label: 'JWT' }, { label: 'OAuth' }], + }, + ], + }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.userQuestions).toHaveLength(1); + expect(report.userQuestions[0].question).toBe('Which auth method?'); + expect(report.userQuestions[0].options).toEqual(['JWT', 'OAuth']); + }); + }); + + describe('out-of-scope findings', () => { + it('detects pre-existing and tech debt mentions', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + content: 'This is a pre-existing issue that was there before our changes.', + }), + createMockMessage({ + type: 'assistant', + content: 'I noticed some tech debt in the authentication module.', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.outOfScopeFindings).toHaveLength(2); + expect(report.outOfScopeFindings[0].keyword).toBe('pre-existing'); + expect(report.outOfScopeFindings[1].keyword).toBe('tech debt'); + }); + }); + + describe('compaction', () => { + it('tracks compaction count and summary messages', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ type: 'assistant', isCompactSummary: true }), + createMockMessage({ type: 'assistant', isCompactSummary: true }), + ]; + + const session = createMockSession(); + session.compactionCount = 2; + const report = analyzeSession(createMockDetail({ messages, session })); + + expect(report.compaction.count).toBe(2); + expect(report.compaction.compactSummaryCount).toBe(2); + expect(report.compaction.note).toContain('underwent compaction'); + }); + + it('reports no compaction', () => { + const report = analyzeSession(createMockDetail({})); + + expect(report.compaction.count).toBe(0); + expect(report.compaction.note).toContain('No compaction'); + }); + }); + + // ------------------------------------------------------------------------- + // Assessment computations + // ------------------------------------------------------------------------- + + describe('cost assessments', () => { + it('computes costPerCommitAssessment when commits exist', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 50000, output_tokens: 10000 }, + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: "git commit -m 'fix'" }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.costAnalysis.costPerCommitAssessment).not.toBeNull(); + }); + + it('returns null assessments when no commits', () => { + const report = analyzeSession(createMockDetail()); + expect(report.costAnalysis.costPerCommitAssessment).toBeNull(); + expect(report.costAnalysis.costPerLineAssessment).toBeNull(); + }); + + it('returns null subagentCostShareAssessment when no cost', () => { + const report = analyzeSession(createMockDetail()); + expect(report.costAnalysis.subagentCostSharePct).toBeNull(); + expect(report.costAnalysis.subagentCostShareAssessment).toBeNull(); + }); + }); + + describe('cache assessments', () => { + it('computes cache efficiency assessment', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 100, + cache_read_input_tokens: 9900, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBe('good'); + }); + + it('returns concerning for low cache efficiency', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 500, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBe('concerning'); + }); + + it('returns null when no cache data', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBeNull(); + expect(report.cacheEconomics.cacheRatioAssessment).toBeNull(); + }); + }); + + describe('tool health assessments', () => { + it('computes per-tool assessment', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/b.ts' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'ok', isError: false }, + { toolUseId: 'tc-2', content: 'ok', isError: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.toolUsage.successRates.Read.assessment).toBe('healthy'); + }); + + it('computes overall tool health', () => { + const report = analyzeSession(createMockDetail()); + expect(report.toolUsage.overallToolHealth).toBe('healthy'); + }); + }); + + describe('idle assessment', () => { + it('returns efficient for low idle', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'quick', + timestamp: new Date('2024-01-01T10:00:30Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleAssessment).toBe('efficient'); + }); + + it('returns high_idle for mostly idle session', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'back', + timestamp: new Date('2024-01-01T11:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T11:00:10Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleAssessment).toBe('high_idle'); + }); + }); + + describe('thrashing assessment', () => { + it('returns none when no signals', () => { + const report = analyzeSession(createMockDetail()); + expect(report.thrashingSignals.thrashingAssessment).toBe('none'); + }); + + it('returns mild or severe based on signal count', () => { + const makeEditMsg = (file: string, id: string) => + createMockMessage({ + type: 'assistant', + toolCalls: [{ id, name: 'Edit', input: { file_path: file }, isTask: false }], + }); + + // 3 edits on one file = 1 signal + 3 repeated bash = 1 signal = mild (2) + const messages: ParsedMessage[] = [ + makeEditMsg('/foo.ts', 'e1'), + makeEditMsg('/foo.ts', 'e2'), + makeEditMsg('/foo.ts', 'e3'), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(['mild', 'severe']).toContain(report.thrashingSignals.thrashingAssessment); + }); + }); + + describe('model switch pattern', () => { + it('detects opus_plan_mode', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-opus-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.switchPattern).toBe('opus_plan_mode'); + }); + + it('returns null when no switches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.switchPattern).toBeNull(); + }); + }); + + describe('startup overhead assessment', () => { + it('returns normal for low overhead', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/f.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.startupOverhead.overheadAssessment).toBe('normal'); + }); + + it('returns heavy for high overhead', () => { + const messages: ParsedMessage[] = [ + // Lots of startup tokens, no work tools + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 50000, output_tokens: 10000 }, + toolCalls: [], + }), + // Small work message + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/f.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.startupOverhead.overheadAssessment).toBe('heavy'); + }); + }); + + describe('file read redundancy assessment', () => { + it('returns normal for low redundancy', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/b.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.fileReadRedundancy.redundancyAssessment).toBe('normal'); + }); + + it('returns wasteful for high redundancy', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-3', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-4', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.fileReadRedundancy.redundancyAssessment).toBe('wasteful'); + }); + }); + + describe('model mismatch in subagents', () => { + it('detects mismatch for mechanical tasks on opus', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [], + startTime: new Date('2024-01-01T10:00:00Z'), + endTime: new Date('2024-01-01T10:01:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 5000, costUsd: 0.05 }), + description: 'rename all variables', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession(createMockDetail({ processes })); + // model is 'default (inherits parent)' which doesn't contain 'opus', so no mismatch + expect(report.subagentMetrics.byAgent[0].modelMismatch).toBeNull(); + }); + }); +}); diff --git a/test/shared/utils/costFormatting.test.ts b/test/shared/utils/costFormatting.test.ts new file mode 100644 index 00000000..10679009 --- /dev/null +++ b/test/shared/utils/costFormatting.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for cost formatting utilities + */ + +import { describe, it, expect } from 'vitest'; +import { formatCostUsd, formatCostCompact } from '@shared/utils/costFormatting'; + +describe('Cost Formatting', () => { + describe('formatCostUsd', () => { + describe('Zero values', () => { + it('should format zero as $0.00', () => { + expect(formatCostUsd(0)).toBe('$0.00'); + }); + + it('should format negative zero as $0.00', () => { + expect(formatCostUsd(-0)).toBe('$0.00'); + }); + }); + + describe('Standard amounts (>= $0.01)', () => { + it('should format 1 cent with 2 decimal places', () => { + expect(formatCostUsd(0.01)).toBe('$0.01'); + }); + + it('should format 1 dollar with 2 decimal places', () => { + expect(formatCostUsd(1.0)).toBe('$1.00'); + }); + + it('should format dollars and cents', () => { + expect(formatCostUsd(1.23)).toBe('$1.23'); + }); + + it('should format large amounts', () => { + expect(formatCostUsd(999.99)).toBe('$999.99'); + expect(formatCostUsd(1234.56)).toBe('$1234.56'); + }); + + it('should round to 2 decimal places for amounts >= 1 cent', () => { + expect(formatCostUsd(1.234)).toBe('$1.23'); + expect(formatCostUsd(1.235)).toBe('$1.24'); // Rounds up + expect(formatCostUsd(1.999)).toBe('$2.00'); + }); + }); + + describe('Sub-cent amounts ($0.001 - $0.01)', () => { + it('should format 1 tenth of a cent with 3 decimal places', () => { + expect(formatCostUsd(0.001)).toBe('$0.001'); + }); + + it('should format sub-cent amounts with 3 decimal places', () => { + expect(formatCostUsd(0.005)).toBe('$0.005'); + expect(formatCostUsd(0.009)).toBe('$0.009'); + }); + + it('should round to 3 decimal places for sub-cent amounts', () => { + expect(formatCostUsd(0.0012)).toBe('$0.001'); + expect(formatCostUsd(0.0015)).toBe('$0.002'); // Rounds up + expect(formatCostUsd(0.0099)).toBe('$0.010'); + }); + }); + + describe('Very small amounts (< $0.001)', () => { + it('should format tiny amounts with 4 decimal places', () => { + expect(formatCostUsd(0.0001)).toBe('$0.0001'); + expect(formatCostUsd(0.0005)).toBe('$0.0005'); + expect(formatCostUsd(0.0009)).toBe('$0.0009'); + }); + + it('should round to 4 decimal places for tiny amounts', () => { + expect(formatCostUsd(0.00012)).toBe('$0.0001'); + expect(formatCostUsd(0.00016)).toBe('$0.0002'); // Rounds up + expect(formatCostUsd(0.00099)).toBe('$0.0010'); + }); + + it('should handle very tiny amounts', () => { + expect(formatCostUsd(0.000001)).toBe('$0.0000'); + }); + }); + + describe('Edge cases', () => { + it('should handle negative amounts with 4 decimal places', () => { + // Negative numbers don't match >= comparisons, so they use 4 decimals + expect(formatCostUsd(-1.23)).toBe('$-1.2300'); + expect(formatCostUsd(-0.001)).toBe('$-0.0010'); + expect(formatCostUsd(-0.0001)).toBe('$-0.0001'); + }); + + it('should handle very large amounts', () => { + expect(formatCostUsd(1000000)).toBe('$1000000.00'); + }); + + it('should handle precision boundaries', () => { + // Boundary between 2 and 3 decimal places + expect(formatCostUsd(0.01)).toBe('$0.01'); + expect(formatCostUsd(0.00999)).toBe('$0.010'); // Just below threshold, uses 3 decimals + + // Boundary between 3 and 4 decimal places + expect(formatCostUsd(0.001)).toBe('$0.001'); + expect(formatCostUsd(0.00099)).toBe('$0.0010'); // Just below threshold, uses 4 decimals + }); + }); + + describe('Real-world API cost examples', () => { + it('should format typical Claude API costs', () => { + // 1M input tokens at $3.00/M + expect(formatCostUsd(3.0)).toBe('$3.00'); + + // 100k input tokens at $3.00/M + expect(formatCostUsd(0.3)).toBe('$0.30'); + + // 10k cache read tokens at $0.30/M + expect(formatCostUsd(0.003)).toBe('$0.003'); + + // 1k cache read tokens at $0.30/M + expect(formatCostUsd(0.0003)).toBe('$0.0003'); + }); + + it('should format session totals', () => { + // Small session + expect(formatCostUsd(0.15)).toBe('$0.15'); + + // Medium session + expect(formatCostUsd(5.67)).toBe('$5.67'); + + // Large session + expect(formatCostUsd(29.57)).toBe('$29.57'); + }); + }); + }); + + describe('formatCostCompact', () => { + describe('Zero values', () => { + it('should format zero as 0.00', () => { + expect(formatCostCompact(0)).toBe('0.00'); + }); + + it('should format negative zero as 0.00', () => { + expect(formatCostCompact(-0)).toBe('0.00'); + }); + }); + + describe('Standard amounts (>= $0.01)', () => { + it('should format amounts without $ prefix', () => { + expect(formatCostCompact(0.01)).toBe('0.01'); + expect(formatCostCompact(1.0)).toBe('1.00'); + expect(formatCostCompact(1.23)).toBe('1.23'); + }); + + it('should format large amounts', () => { + expect(formatCostCompact(999.99)).toBe('999.99'); + expect(formatCostCompact(1234.56)).toBe('1234.56'); + }); + + it('should round to 2 decimal places', () => { + expect(formatCostCompact(1.234)).toBe('1.23'); + expect(formatCostCompact(1.235)).toBe('1.24'); // Rounds up + expect(formatCostCompact(1.999)).toBe('2.00'); + }); + }); + + describe('Sub-cent amounts ($0.001 - $0.01)', () => { + it('should format sub-cent amounts with 3 decimal places', () => { + expect(formatCostCompact(0.001)).toBe('0.001'); + expect(formatCostCompact(0.005)).toBe('0.005'); + expect(formatCostCompact(0.009)).toBe('0.009'); + }); + + it('should round to 3 decimal places', () => { + expect(formatCostCompact(0.0012)).toBe('0.001'); + expect(formatCostCompact(0.0015)).toBe('0.002'); // Rounds up + expect(formatCostCompact(0.0099)).toBe('0.010'); + }); + }); + + describe('Very small amounts (< $0.001)', () => { + it('should format tiny amounts with 4 decimal places', () => { + expect(formatCostCompact(0.0001)).toBe('0.0001'); + expect(formatCostCompact(0.0005)).toBe('0.0005'); + expect(formatCostCompact(0.0009)).toBe('0.0009'); + }); + + it('should round to 4 decimal places', () => { + expect(formatCostCompact(0.00012)).toBe('0.0001'); + expect(formatCostCompact(0.00016)).toBe('0.0002'); // Rounds up + expect(formatCostCompact(0.00099)).toBe('0.0010'); + }); + }); + + describe('Edge cases', () => { + it('should handle negative amounts with 4 decimal places', () => { + // Negative numbers don't match >= comparisons, so they use 4 decimals + expect(formatCostCompact(-1.23)).toBe('-1.2300'); + expect(formatCostCompact(-0.001)).toBe('-0.0010'); + expect(formatCostCompact(-0.0001)).toBe('-0.0001'); + }); + + it('should handle very large amounts', () => { + expect(formatCostCompact(1000000)).toBe('1000000.00'); + }); + }); + + describe('Comparison with formatCostUsd', () => { + it('should match formatCostUsd except for $ prefix', () => { + const testCases = [0, 0.0001, 0.001, 0.01, 1.23, 999.99]; + + testCases.forEach((cost) => { + const withPrefix = formatCostUsd(cost); + const compact = formatCostCompact(cost); + + // Compact should equal the USD format without the $ + expect(compact).toBe(withPrefix.substring(1)); + }); + }); + }); + + describe('Badge display use cases', () => { + it('should format for badge display', () => { + // Small per-message costs + expect(formatCostCompact(0.0015)).toBe('0.002'); + expect(formatCostCompact(0.01)).toBe('0.01'); + + // Session totals in badges + expect(formatCostCompact(2.5)).toBe('2.50'); + expect(formatCostCompact(15.0)).toBe('15.00'); + }); + }); + }); +}); diff --git a/test/shared/utils/pricing.test.ts b/test/shared/utils/pricing.test.ts new file mode 100644 index 00000000..f8a807b9 --- /dev/null +++ b/test/shared/utils/pricing.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getPricing, + calculateTieredCost, + calculateMessageCost, + getDisplayPricing, +} from '@shared/utils/pricing'; + +describe('Shared Pricing Module', () => { + describe('getPricing', () => { + it('should find pricing by exact model name', () => { + const pricing = getPricing('claude-3-5-sonnet-20241022'); + expect(pricing).not.toBeNull(); + expect(pricing!.input_cost_per_token).toBeGreaterThan(0); + expect(pricing!.output_cost_per_token).toBeGreaterThan(0); + }); + + it('should find pricing case-insensitively', () => { + const pricing = getPricing('Claude-3-5-Sonnet-20241022'); + expect(pricing).not.toBeNull(); + }); + + it('should return null for unknown models', () => { + const pricing = getPricing('totally-fake-model-xyz'); + expect(pricing).toBeNull(); + }); + }); + + describe('calculateTieredCost', () => { + it('should use base rate for tokens below 200k', () => { + const cost = calculateTieredCost(100_000, 0.000003); + expect(cost).toBeCloseTo(0.3, 6); + }); + + it('should apply tiered rate above 200k', () => { + const cost = calculateTieredCost(250_000, 0.000003, 0.000006); + expect(cost).toBeCloseTo(0.9, 6); + }); + + it('should use base rate when no tiered rate provided', () => { + const cost = calculateTieredCost(250_000, 0.000015); + expect(cost).toBeCloseTo(3.75, 6); + }); + + it('should return 0 for zero or negative tokens', () => { + expect(calculateTieredCost(0, 0.000003)).toBe(0); + expect(calculateTieredCost(-100, 0.000003)).toBe(0); + }); + }); + + describe('calculateMessageCost', () => { + it('should compute cost for a known model', () => { + const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 0, 0); + expect(cost).toBeCloseTo(0.0105, 6); + }); + + it('should return 0 for unknown models', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cost = calculateMessageCost('unknown-model', 1000, 500, 0, 0); + expect(cost).toBe(0); + expect(warnSpy).toHaveBeenCalledWith( + '[pricing] No pricing data for model "unknown-model", cost will be $0' + ); + warnSpy.mockRestore(); + }); + + it('should include cache token costs', () => { + const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 300, 200); + expect(cost).toBeGreaterThan(0.0105); + }); + }); + + describe('getDisplayPricing', () => { + it('should return per-million rates for a known model', () => { + const dp = getDisplayPricing('claude-3-5-sonnet-20241022'); + expect(dp).not.toBeNull(); + expect(dp!.input).toBeCloseTo(3.0, 1); + expect(dp!.output).toBeCloseTo(15.0, 1); + }); + + it('should return null for unknown models', () => { + expect(getDisplayPricing('unknown-model')).toBeNull(); + }); + }); +});