tokenjam 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. {tokenjam-0.4.2 → tokenjam-0.5.0}/CLAUDE.md +1 -1
  2. {tokenjam-0.4.2 → tokenjam-0.5.0}/PKG-INFO +1 -1
  3. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/claude-code-integration.md +2 -2
  4. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/installation.md +3 -3
  5. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/trim.md +1 -1
  6. {tokenjam-0.4.2 → tokenjam-0.5.0}/pyproject.toml +1 -1
  7. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/package.json +1 -1
  8. tokenjam-0.5.0/tests/integration/test_analytics.py +191 -0
  9. tokenjam-0.5.0/tests/integration/test_cmd_route.py +163 -0
  10. tokenjam-0.5.0/tests/integration/test_cost_charts.py +198 -0
  11. tokenjam-0.5.0/tests/unit/test_export_route_generators.py +175 -0
  12. tokenjam-0.5.0/tests/unit/test_lens_ui_regression.py +596 -0
  13. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/app.py +2 -0
  14. tokenjam-0.5.0/tokenjam/api/routes/analytics.py +289 -0
  15. tokenjam-0.5.0/tokenjam/api/routes/cost.py +468 -0
  16. tokenjam-0.5.0/tokenjam/cli/cmd_route.py +239 -0
  17. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/main.py +2 -0
  18. tokenjam-0.5.0/tokenjam/core/export/ccr.py +106 -0
  19. tokenjam-0.5.0/tokenjam/core/export/common.py +101 -0
  20. tokenjam-0.5.0/tokenjam/core/export/litellm.py +102 -0
  21. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/index.html +879 -19
  22. tokenjam-0.4.2/tests/unit/test_lens_ui_regression.py +0 -291
  23. tokenjam-0.4.2/tokenjam/api/routes/cost.py +0 -157
  24. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/CODEOWNERS +0 -0
  25. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/integration_request.md +0 -0
  28. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/pull_request_template.md +0 -0
  29. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/scripts/archive_traffic.py +0 -0
  30. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/ci.yml +0 -0
  31. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/publish-npm.yml +0 -0
  32. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/publish-pypi.yml +0 -0
  33. {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/traffic-archive.yml +0 -0
  34. {tokenjam-0.4.2 → tokenjam-0.5.0}/.gitignore +0 -0
  35. {tokenjam-0.4.2 → tokenjam-0.5.0}/AGENTS.md +0 -0
  36. {tokenjam-0.4.2 → tokenjam-0.5.0}/CONTRIBUTING.md +0 -0
  37. {tokenjam-0.4.2 → tokenjam-0.5.0}/LICENSE +0 -0
  38. {tokenjam-0.4.2 → tokenjam-0.5.0}/Makefile +0 -0
  39. {tokenjam-0.4.2 → tokenjam-0.5.0}/README.md +0 -0
  40. {tokenjam-0.4.2 → tokenjam-0.5.0}/SECURITY.md +0 -0
  41. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/alerts.md +0 -0
  42. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/architecture.md +0 -0
  43. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/helicone.md +0 -0
  44. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/langfuse.md +0 -0
  45. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/otlp.md +0 -0
  46. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/overview.md +0 -0
  47. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/cli-reference.md +0 -0
  48. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/configuration.md +0 -0
  49. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/export.md +0 -0
  50. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/framework-support.md +0 -0
  51. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/internal/lens-vendor-versions.md +0 -0
  52. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/internal/specs/.gitkeep +0 -0
  53. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/nemoclaw-integration.md +0 -0
  54. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/openclaw.md +0 -0
  55. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/cache.md +0 -0
  56. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/downsize.md +0 -0
  57. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/reuse.md +0 -0
  58. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/script.md +0 -0
  59. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/policy/overview.md +0 -0
  60. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/python-sdk.md +0 -0
  61. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-alerts.png +0 -0
  62. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-budget.png +0 -0
  63. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-cost.png +0 -0
  64. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-status.png +0 -0
  65. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-traces.png +0 -0
  66. {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/typescript-sdk.md +0 -0
  67. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/README.md +0 -0
  68. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/_shared.py +0 -0
  69. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/budget_breach_demo.py +0 -0
  70. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/drift_demo.py +0 -0
  71. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/sensitive_actions_demo.py +0 -0
  72. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/rag_pipeline.py +0 -0
  73. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/research_team.py +0 -0
  74. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/router_agent.py +0 -0
  75. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/agent_patterns.txt +0 -0
  76. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/cost_management.txt +0 -0
  77. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/observability.txt +0 -0
  78. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/safety.txt +0 -0
  79. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/openclaw/README.md +0 -0
  80. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/autogen_agent.py +0 -0
  81. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/crewai_agent.py +0 -0
  82. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/langchain_agent.py +0 -0
  83. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/langgraph_agent.py +0 -0
  84. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/llamaindex_agent.py +0 -0
  85. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/anthropic_agent.py +0 -0
  86. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/bedrock_agent.py +0 -0
  87. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/gemini_agent.py +0 -0
  88. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/litellm_agent.py +0 -0
  89. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/openai_agent.py +0 -0
  90. {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/openai_agents_sdk_agent.py +0 -0
  91. {tokenjam-0.4.2 → tokenjam-0.5.0}/growth/README.md +0 -0
  92. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/BLOG.md +0 -0
  93. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/README.md +0 -0
  94. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/scenario.py +0 -0
  95. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/BLOG.md +0 -0
  96. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/README.md +0 -0
  97. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/scenario.py +0 -0
  98. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/BLOG.md +0 -0
  99. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/README.md +0 -0
  100. {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/scenario.py +0 -0
  101. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/README.md +0 -0
  102. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/package-lock.json +0 -0
  103. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/client.test.ts +0 -0
  104. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/client.ts +0 -0
  105. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/index.ts +0 -0
  106. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/semconv.test.ts +0 -0
  107. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/semconv.ts +0 -0
  108. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/span-builder.test.ts +0 -0
  109. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/span-builder.ts +0 -0
  110. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/types.ts +0 -0
  111. {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/tsconfig.json +0 -0
  112. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/__init__.py +0 -0
  113. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-post-release-runner.md +0 -0
  114. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-post-release-v0.4.0.md +0 -0
  115. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-runner.md +0 -0
  116. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-v0.4.0.md +0 -0
  117. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-v0.4.1.md +0 -0
  118. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/__init__.py +0 -0
  119. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_budget_breach.py +0 -0
  120. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_drift.py +0 -0
  121. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_loop.py +0 -0
  122. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_normal.py +0 -0
  123. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/mock_llm.py +0 -0
  124. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/test_mock_scenarios.py +0 -0
  125. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/conftest.py +0 -0
  126. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/__init__.py +0 -0
  127. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/conftest.py +0 -0
  128. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/test_real_llm.py +0 -0
  129. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/factories.py +0 -0
  130. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/helicone_real_response.json +0 -0
  131. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/langfuse_real_response.json +0 -0
  132. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/otlp_sample.json +0 -0
  133. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/__init__.py +0 -0
  134. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_api.py +0 -0
  135. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cli.py +0 -0
  136. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cli_api_framing_parity.py +0 -0
  137. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cost_framing_window.py +0 -0
  138. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_db.py +0 -0
  139. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_demos.py +0 -0
  140. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_full_pipeline.py +0 -0
  141. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_litellm_enrichment.py +0 -0
  142. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_logs_api.py +0 -0
  143. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_sdk_config_discovery.py +0 -0
  144. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/manual-new-release-tests.md +0 -0
  145. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/manual-pre-release-testing.md +0 -0
  146. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-post-release-v0.4.0-20260619T221412Z.md +0 -0
  147. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.0-20260619T202233Z.md +0 -0
  148. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.0-20260619T205108Z.md +0 -0
  149. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.1-20260620T002546Z.md +0 -0
  150. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/__init__.py +0 -0
  151. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_alert_rules.py +0 -0
  152. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_cost_tracking.py +0 -0
  153. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_drift_detection.py +0 -0
  154. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_ingest.py +0 -0
  155. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_schema_validation.py +0 -0
  156. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/toy_agent/toy_agent.py +0 -0
  157. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/__init__.py +0 -0
  158. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_alerts.py +0 -0
  159. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_backfill.py +0 -0
  160. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cache_efficacy.py +0 -0
  161. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cache_recommend.py +0 -0
  162. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_cost.py +0 -0
  163. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_policy.py +0 -0
  164. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_stop.py +0 -0
  165. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_tokenmaxx.py +0 -0
  166. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_compare.py +0 -0
  167. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_config.py +0 -0
  168. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_config_secret_divergence.py +0 -0
  169. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cost.py +0 -0
  170. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cost_compare_framing.py +0 -0
  171. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cycle.py +0 -0
  172. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_db_helpers.py +0 -0
  173. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_demo_env.py +0 -0
  174. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_demo_scenarios.py +0 -0
  175. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_drift.py +0 -0
  176. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_export_claude_code.py +0 -0
  177. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_formatting.py +0 -0
  178. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_framing.py +0 -0
  179. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_helicone.py +0 -0
  180. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_langfuse.py +0 -0
  181. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_otlp.py +0 -0
  182. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_litellm_client.py +0 -0
  183. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_litellm_integration.py +0 -0
  184. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_logs_converter.py +0 -0
  185. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_mcp_server.py +0 -0
  186. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_models.py +0 -0
  187. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_no_tracked_dev_secrets.py +0 -0
  188. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_codex.py +0 -0
  189. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_daemon.py +0 -0
  190. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_hygiene.py +0 -0
  191. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_plan.py +0 -0
  192. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_openclaw_ingest.py +0 -0
  193. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize.py +0 -0
  194. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize_recoverable.py +0 -0
  195. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize_reuse.py +0 -0
  196. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_plan_tier_apply.py +0 -0
  197. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_pricing_override.py +0 -0
  198. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_prompt_bloat.py +0 -0
  199. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_provider_attribution_194.py +0 -0
  200. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_reuse_skeleton.py +0 -0
  201. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_spans_stats_repair.py +0 -0
  202. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_time_parse.py +0 -0
  203. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_transport_401.py +0 -0
  204. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ui_offline.py +0 -0
  205. {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_workflow_restructure.py +0 -0
  206. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/__init__.py +0 -0
  207. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/__init__.py +0 -0
  208. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/deps.py +0 -0
  209. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/middleware.py +0 -0
  210. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/__init__.py +0 -0
  211. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/agents.py +0 -0
  212. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/alerts.py +0 -0
  213. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/budget.py +0 -0
  214. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/cost_compare.py +0 -0
  215. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/drift.py +0 -0
  216. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/logs.py +0 -0
  217. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/metrics.py +0 -0
  218. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/optimize.py +0 -0
  219. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/otlp.py +0 -0
  220. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/reuse.py +0 -0
  221. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/spans.py +0 -0
  222. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/status.py +0 -0
  223. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/tools.py +0 -0
  224. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/traces.py +0 -0
  225. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/version.py +0 -0
  226. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/__init__.py +0 -0
  227. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_alerts.py +0 -0
  228. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_backfill.py +0 -0
  229. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_budget.py +0 -0
  230. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_cost.py +0 -0
  231. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_demo.py +0 -0
  232. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_doctor.py +0 -0
  233. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_drift.py +0 -0
  234. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_export.py +0 -0
  235. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_mcp.py +0 -0
  236. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_onboard.py +0 -0
  237. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_optimize.py +0 -0
  238. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_policy.py +0 -0
  239. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_report.py +0 -0
  240. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_serve.py +0 -0
  241. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_status.py +0 -0
  242. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_stop.py +0 -0
  243. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_tokenmaxx.py +0 -0
  244. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_tools.py +0 -0
  245. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_traces.py +0 -0
  246. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_uninstall.py +0 -0
  247. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/__init__.py +0 -0
  248. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/alerts.py +0 -0
  249. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/api_backend.py +0 -0
  250. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/backfill.py +0 -0
  251. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/config.py +0 -0
  252. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/cost.py +0 -0
  253. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/cycle.py +0 -0
  254. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/db.py +0 -0
  255. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/drift.py +0 -0
  256. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/__init__.py +0 -0
  257. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/claude_code.py +0 -0
  258. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/reuse_report.py +0 -0
  259. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/reuse_skeleton.py +0 -0
  260. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/framing.py +0 -0
  261. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest.py +0 -0
  262. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/__init__.py +0 -0
  263. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/helicone.py +0 -0
  264. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/langfuse.py +0 -0
  265. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/otlp.py +0 -0
  266. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/models.py +0 -0
  267. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/README.md +0 -0
  268. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/__init__.py +0 -0
  269. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/__init__.py +0 -0
  270. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/budget_projection.py +0 -0
  271. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/cache_efficacy.py +0 -0
  272. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/cache_recommend.py +0 -0
  273. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/model_downgrade.py +0 -0
  274. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/plan_reuse.py +0 -0
  275. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/prompt_bloat.py +0 -0
  276. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/workflow_restructure.py +0 -0
  277. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/registry.py +0 -0
  278. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/runner.py +0 -0
  279. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/types.py +0 -0
  280. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/pricing.py +0 -0
  281. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/retention.py +0 -0
  282. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/schema_validator.py +0 -0
  283. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/demo/__init__.py +0 -0
  284. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/demo/env.py +0 -0
  285. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/mcp/__init__.py +0 -0
  286. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/mcp/server.py +0 -0
  287. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/__init__.py +0 -0
  288. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/exporters.py +0 -0
  289. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/otlp_parsing.py +0 -0
  290. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/provider.py +0 -0
  291. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/semconv.py +0 -0
  292. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/pricing/models.toml +0 -0
  293. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/py.typed +0 -0
  294. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/__init__.py +0 -0
  295. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/agent.py +0 -0
  296. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/bootstrap.py +0 -0
  297. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/client.py +0 -0
  298. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/http_exporter.py +0 -0
  299. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/__init__.py +0 -0
  300. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/anthropic.py +0 -0
  301. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/autogen.py +0 -0
  302. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/base.py +0 -0
  303. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/bedrock.py +0 -0
  304. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/crewai.py +0 -0
  305. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/gemini.py +0 -0
  306. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/langchain.py +0 -0
  307. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/langgraph.py +0 -0
  308. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/litellm.py +0 -0
  309. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/llamaindex.py +0 -0
  310. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/nemoclaw.py +0 -0
  311. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/openai.py +0 -0
  312. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/openai_agents_sdk.py +0 -0
  313. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/transport.py +0 -0
  314. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/htm.js +0 -0
  315. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/preact-hooks.js +0 -0
  316. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/preact.js +0 -0
  317. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/uplot.css +0 -0
  318. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/uplot.js +0 -0
  319. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/__init__.py +0 -0
  320. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/formatting.py +0 -0
  321. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/ids.py +0 -0
  322. {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/time_parse.py +0 -0
@@ -376,7 +376,7 @@ Key runtime dependency: `pytz` is required by DuckDB for `TIMESTAMPTZ` column ha
376
376
  - `tokenjam[bloat]` — `llmlingua>=0.2`, used by the Trim analyzer. Pulls PyTorch + transformers (~2GB). Kept out of base install. The analyzer self-registers without the extra installed; the deferred `import llmlingua` inside the analysis function body raises a typed message pointing the user at the install command.
377
377
  - Framework extras `[langchain]`, `[crewai]`, `[autogen]`, `[litellm]` for SDK patches.
378
378
  - `[dev]` for local development (`pytest`, `ruff`, `mypy`, `httpx`).
379
- - `[mcp]` for the FastMCP stdio server.
379
+ - `[mcp]` empty no-op alias. `fastmcp` moved into the base install in v0.3.5 (#101), so the FastMCP stdio server (`tj mcp`) works on a plain `pipx install tokenjam`. The extra is kept so old `tokenjam[mcp]` install commands still succeed; it pulls nothing extra.
380
380
 
381
381
  ## Further Reading
382
382
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenjam
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: TokenJam — local-first OTel-native observability for Autonomous AI agents
5
5
  Project-URL: Homepage, https://opencla.watch
6
6
  Project-URL: Repository, https://github.com/Metabuilder-Labs/openclawwatch
@@ -3,7 +3,7 @@
3
3
  Monitor every Claude Code session — costs, tool calls, API requests, errors — with two commands:
4
4
 
5
5
  ```bash
6
- pipx install 'tokenjam[mcp]'
6
+ pipx install tokenjam
7
7
  tj onboard --claude-code
8
8
  # Restart Claude Code, then:
9
9
  tj status --agent claude-code-<project>
@@ -35,7 +35,7 @@ Claude Code emits OTLP log events which `tj serve` converts into spans — every
35
35
 
36
36
  ## MCP server
37
37
 
38
- The MCP server is included in the `[mcp]` extra and registered automatically by `tj onboard --claude-code`. It gives Claude Code direct access to your observability data inside the session itself. After restarting Claude Code you have 13 tools available in every session:
38
+ The MCP server ships in the base install (`pipx install tokenjam` — no extra needed) and is registered automatically by `tj onboard --claude-code`. It gives Claude Code direct access to your observability data inside the session itself. After restarting Claude Code you have 13 tools available in every session:
39
39
 
40
40
  | Tool | What it does |
41
41
  |---|---|
@@ -52,8 +52,8 @@ TokenJam keeps heavyweight ML dependencies, framework adapters, and the MCP serv
52
52
 
53
53
  | Extra | What it pulls in | Why it's optional |
54
54
  |---|---|---|
55
- | `tokenjam[mcp]` | `fastmcp` | Only needed for the Claude Code / Codex MCP server (`tj mcp`). Pulled by `tj onboard --claude-code` automatically when invoked through the documented one-liner. |
56
- | `tokenjam[bloat]` | `llmlingua>=0.2`, transitively PyTorch + transformers (~2GB) | The Trim analyzer (`tj optimize trim`) scores token significance with LLMLingua-2. Most users don't run it; keeping torch out of the base install means `pip install tokenjam` stays small and fast on machines that don't have a GPU/CPU build of torch already. |
55
+ | `tokenjam[mcp]` | nothing (no-op alias) | **No longer needed.** `fastmcp` moved into the base install in v0.3.5, so the Claude Code / Codex MCP server (`tj mcp`) works on a plain `pipx install tokenjam`. The extra is kept as an empty no-op so old `pipx install 'tokenjam[mcp]'` commands still succeed; it pulls nothing extra. |
56
+ | `tokenjam[bloat]` | `llmlingua>=0.2`, transitively PyTorch + transformers (~2GB) | The Trim analyzer (`tj optimize trim`) scores token significance with LLMLingua-2. Most users don't run it; keeping torch out of the base install keeps it small and fast on machines that don't have a GPU/CPU build of torch already. |
57
57
  | `tokenjam[langchain]` | `langchain>=0.2` | Convenience pin for `patch_langchain()`; you can also install langchain yourself. |
58
58
  | `tokenjam[crewai]` | `crewai>=0.28` | Convenience pin for `patch_crewai()`. |
59
59
  | `tokenjam[autogen]` | `pyautogen>=0.2` | Convenience pin for `patch_autogen()`. |
@@ -63,7 +63,7 @@ TokenJam keeps heavyweight ML dependencies, framework adapters, and the MCP serv
63
63
  Combine multiple extras:
64
64
 
65
65
  ```bash
66
- pipx install 'tokenjam[mcp,bloat]'
66
+ pipx install 'tokenjam[bloat,litellm]'
67
67
  ```
68
68
 
69
69
  ### Bloat extra details
@@ -25,7 +25,7 @@ base install:
25
25
  pipx install 'tokenjam[bloat]'
26
26
  ```
27
27
 
28
- The base `pip install tokenjam` does NOT pull torch. Trim shows up in
28
+ The base install (`pipx install tokenjam`) does NOT pull torch. Trim shows up in
29
29
  `tj optimize` analyzer choices regardless, but running it without the
30
30
  extra prints a clear install hint and exits.
31
31
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tokenjam"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "TokenJam — local-first OTel-native observability for Autonomous AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenjam/sdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for TokenJam — local-first observability for AI agents",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,191 @@
1
+ """Endpoint tests for the Analytics pivot explorer (#210).
2
+
3
+ Covers the generalized group-by: metric × group_by × stack_by × filters → a
4
+ grouped series + KPI totals + the plan-tier framing block. The explorer's
5
+ line / bar / hbar views are all client-side pivots of this one `rows` shape.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import timedelta
10
+
11
+ import httpx
12
+ import pytest
13
+
14
+ from tokenjam.api.app import create_app
15
+ from tokenjam.core.config import ProviderBudget, TjConfig
16
+ from tokenjam.core.db import InMemoryBackend
17
+ from tokenjam.core.ingest import IngestPipeline
18
+ from tokenjam.utils.time_parse import utcnow
19
+ from tests.factories import make_llm_span, make_session, make_tool_span
20
+
21
+
22
+ def _app(db, config):
23
+ return create_app(config=config, db=db, ingest_pipeline=IngestPipeline(db=db, config=config))
24
+
25
+
26
+ def _seed(db, plan="api"):
27
+ now = utcnow()
28
+ for d in range(4):
29
+ for model, provider, cost in [("claude-opus-4-7", "anthropic", 0.06),
30
+ ("gpt-4o", "openai", 0.03)]:
31
+ sid = f"s{d}-{model}"
32
+ db.upsert_session(make_session(session_id=sid, plan_tier=plan, agent_id="cc"))
33
+ llm = make_llm_span(
34
+ session_id=sid, agent_id="cc", model=model, provider=provider,
35
+ input_tokens=2000, output_tokens=300, cost_usd=cost,
36
+ start_time=now - timedelta(days=d),
37
+ )
38
+ db.insert_span(llm)
39
+ for tool in ["Read", "Bash", "Grep"]:
40
+ t = make_tool_span(agent_id="cc", tool_name=tool, trace_id=llm.trace_id)
41
+ t.session_id = sid
42
+ t.start_time = now - timedelta(days=d)
43
+ db.insert_span(t)
44
+
45
+
46
+ async def _get(db, cfg, qs):
47
+ transport = httpx.ASGITransport(app=_app(db, cfg))
48
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
49
+ return (await c.get("/api/v1/analytics?" + qs)).json()
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_spend_by_model_grouped_series_and_kpis():
54
+ db = InMemoryBackend()
55
+ cfg = TjConfig(version="1")
56
+ _seed(db)
57
+ d = await _get(db, cfg, "metric=spend&group_by=model&since=30d")
58
+ assert d["metric"] == "spend" and d["value_unit"] == "usd"
59
+ # groups sorted by total desc; opus (0.06×4) > gpt (0.03×4)
60
+ assert d["groups"] == ["claude-opus-4-7", "gpt-4o"]
61
+ assert d["rows"], "expected grouped rows"
62
+ for r in d["rows"]:
63
+ assert {"bucket", "group", "stack", "value", "tokens"} <= set(r)
64
+ # KPIs are window totals, independent of group_by
65
+ assert d["kpis"]["spend"] == pytest.approx(0.36)
66
+ assert d["kpis"]["sessions"] == 8
67
+ assert d["framing"]["pricing_mode"] == "api"
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ @pytest.mark.parametrize("metric,unit", [
72
+ ("tokens", "tokens"), ("sessions", "count"), ("events", "count"),
73
+ ])
74
+ async def test_metric_dimension_matrix(metric, unit):
75
+ db = InMemoryBackend()
76
+ cfg = TjConfig(version="1")
77
+ _seed(db)
78
+ d = await _get(db, cfg, f"metric={metric}&group_by=agent&since=30d")
79
+ assert d["value_unit"] == unit
80
+ assert d["groups"] == ["cc"]
81
+ assert d["rows"]
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_tool_category_dimension_includes_tool_spans():
86
+ """tool / tool_category breakdowns must see tool spans (NULL model), which
87
+ the LLM-cost `model IS NOT NULL` gate would otherwise hide."""
88
+ db = InMemoryBackend()
89
+ cfg = TjConfig(version="1")
90
+ _seed(db)
91
+ d = await _get(db, cfg, "metric=events&group_by=tool_category&since=30d")
92
+ assert set(d["groups"]) == {"file", "shell", "search"} # Read/Edit, Bash, Grep
93
+ assert "(none)" not in d["groups"]
94
+ # KPI totals reflect the FULL window (LLM spend included), not the tool-only
95
+ # subtype gate the breakdown applies — so "events by tool" never zeroes Spend.
96
+ assert d["kpis"]["spend"] > 0
97
+ assert d["kpis"]["tokens"] > 0
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_stack_by_returns_second_dimension():
102
+ db = InMemoryBackend()
103
+ cfg = TjConfig(version="1")
104
+ _seed(db)
105
+ d = await _get(db, cfg, "metric=spend&group_by=provider&stack_by=model&since=30d")
106
+ assert d["stack_by"] == "model"
107
+ assert set(d["groups"]) == {"anthropic", "openai"}
108
+ assert set(d["stacks"]) == {"claude-opus-4-7", "gpt-4o"}
109
+ assert any(r["stack"] for r in d["rows"])
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_filters_scope_the_window():
114
+ db = InMemoryBackend()
115
+ cfg = TjConfig(version="1")
116
+ _seed(db)
117
+ d = await _get(db, cfg, "metric=spend&group_by=model&provider=anthropic&since=30d")
118
+ assert d["groups"] == ["claude-opus-4-7"]
119
+ assert "gpt-4o" not in d["groups"]
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_subscription_framing_suppresses_dollars():
124
+ db = InMemoryBackend()
125
+ cfg = TjConfig(version="1", budgets={"anthropic": ProviderBudget(plan="max_20x")})
126
+ _seed(db, plan="max_20x")
127
+ d = await _get(db, cfg, "metric=spend&group_by=model&since=30d")
128
+ assert d["framing"]["pricing_mode"] == "subscription"
129
+ # token volume is present per row so the UI renders token-share, not $
130
+ assert all("tokens" in r for r in d["rows"])
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_unknown_metric_and_dimension_rejected():
135
+ db = InMemoryBackend()
136
+ cfg = TjConfig(version="1")
137
+ transport = httpx.ASGITransport(app=_app(db, cfg))
138
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
139
+ assert (await c.get("/api/v1/analytics?metric=bogus")).status_code == 400
140
+ assert (await c.get("/api/v1/analytics?group_by=bogus")).status_code == 400
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_empty_window_is_safe():
145
+ db = InMemoryBackend()
146
+ cfg = TjConfig(version="1")
147
+ d = await _get(db, cfg, "metric=spend&group_by=model&since=7d")
148
+ assert d["rows"] == [] and d["groups"] == []
149
+ assert d["kpis"]["spend"] == 0
150
+ assert "framing" in d
151
+ # #217: the sparkline/delta fields are always present. With a window set,
152
+ # the delta keys exist but are null (no prior data → no divide-by-zero).
153
+ assert d["kpi_series"] == []
154
+ assert set(d["kpi_deltas"]) == {"spend", "tokens", "events", "sessions"}
155
+ assert all(v is None for v in d["kpi_deltas"].values())
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_kpi_series_and_deltas_for_sparklines():
160
+ """#217: the explorer returns a per-bucket `kpi_series` (all four metrics)
161
+ and a period-over-period `kpi_deltas` vs the prior equal-length window —
162
+ both computed server-side so the UI never aggregates per-bucket in JS."""
163
+ db = InMemoryBackend()
164
+ cfg = TjConfig(version="1")
165
+ _seed(db) # 4 days of data, two models/day
166
+ d = await _get(db, cfg, "metric=spend&group_by=model&since=30d")
167
+ # Per-bucket series carries every KPI metric for each bucket.
168
+ assert d["kpi_series"], "expected a non-empty kpi_series"
169
+ for b in d["kpi_series"]:
170
+ assert {"bucket", "spend", "tokens", "events", "sessions"} <= set(b)
171
+ # Series spend reconciles with the window KPI total.
172
+ assert sum(b["spend"] for b in d["kpi_series"]) == pytest.approx(d["kpis"]["spend"])
173
+ # Prior window had no data (seed is the most recent 4 days) → prev=0 → null
174
+ # delta, never a divide-by-zero.
175
+ assert set(d["kpi_deltas"]) == {"spend", "tokens", "events", "sessions"}
176
+ assert d["kpi_deltas"]["spend"] is None
177
+ assert d["kpi_prev"]["spend"] == 0
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_kpi_delta_non_null_when_prior_window_has_data():
182
+ """#217: a real period-over-period % when both the current and prior
183
+ equal-length windows carry data (the seed spans 4 consecutive days, so a
184
+ 2-day window's prior 2 days are non-empty)."""
185
+ db = InMemoryBackend()
186
+ cfg = TjConfig(version="1")
187
+ _seed(db)
188
+ d = await _get(db, cfg, "metric=spend&group_by=model&since=2d")
189
+ assert d["kpi_prev"]["spend"] > 0, "prior window should have seeded data"
190
+ assert d["kpi_deltas"]["spend"] is not None
191
+ assert isinstance(d["kpi_deltas"]["spend"], float)
@@ -0,0 +1,163 @@
1
+ """Integration tests for the `tj route` CLI surface (CliRunner)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import timedelta
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+ from click.testing import CliRunner
10
+
11
+ from tokenjam.cli.main import cli
12
+ from tokenjam.core.config import TjConfig
13
+ from tokenjam.core.db import InMemoryBackend
14
+ from tokenjam.utils.time_parse import utcnow
15
+ from tests.factories import make_llm_span, make_tool_span
16
+
17
+
18
+ @pytest.fixture
19
+ def db():
20
+ backend = InMemoryBackend()
21
+ yield backend
22
+ backend.close()
23
+
24
+
25
+ @pytest.fixture
26
+ def config():
27
+ return TjConfig(version="1")
28
+
29
+
30
+ @pytest.fixture
31
+ def runner():
32
+ return CliRunner()
33
+
34
+
35
+ @pytest.fixture(autouse=True)
36
+ def _exports_in_tmp(monkeypatch, tmp_path):
37
+ """Redirect ~/.config/tokenjam/exports to a tmp dir so tests never touch
38
+ the real home directory."""
39
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
40
+ return tmp_path
41
+
42
+
43
+ def _seed_downsize_candidate(db, session_id="s-small"):
44
+ """One small Opus session + 2 tool spans — matches the downsize heuristic,
45
+ so build_report yields a downgrade finding with a suggestion."""
46
+ start = utcnow() - timedelta(days=2)
47
+ llm = make_llm_span(
48
+ agent_id="claude-code-x", model="claude-opus-4-7", provider="anthropic",
49
+ input_tokens=1000, output_tokens=200, cost_usd=0.030,
50
+ session_id=session_id, start_time=start,
51
+ )
52
+ db.insert_span(llm)
53
+ for _ in range(2):
54
+ tool = make_tool_span(agent_id="claude-code-x", tool_name="Read",
55
+ trace_id=llm.trace_id)
56
+ tool.session_id = session_id
57
+ tool.start_time = start
58
+ db.insert_span(tool)
59
+
60
+
61
+ def _invoke(runner, db, config, args):
62
+ with patch("tokenjam.cli.main.load_config", return_value=config), \
63
+ patch("tokenjam.cli.main.open_db", return_value=db):
64
+ return runner.invoke(cli, args)
65
+
66
+
67
+ # --- export writes a valid config with honesty comments --------------------
68
+
69
+ @pytest.mark.parametrize("target,ext", [("ccr", "jsonc"), ("litellm", "yaml")])
70
+ def test_export_writes_config_with_caveat(runner, db, config, _exports_in_tmp, target, ext):
71
+ _seed_downsize_candidate(db)
72
+ result = _invoke(runner, db, config, ["route", "export", "--target", target])
73
+ assert result.exit_code == 0, result.output
74
+
75
+ exports = _exports_in_tmp / ".config" / "tokenjam" / "exports"
76
+ written = list(exports.glob(f"{target}-*.{ext}"))
77
+ assert len(written) == 1, f"expected one {target} export, got {written}"
78
+ body = written[0].read_text()
79
+ # honesty strings embedded
80
+ assert "Candidate-flagging heuristic" in body # MODEL_DOWNGRADE_CAVEAT
81
+ assert "evidence: L1" in body
82
+ assert "Derivation window:" in body
83
+ assert "Derived at:" in body
84
+ # the suggestion made it in
85
+ assert "claude-haiku-4-5" in body
86
+
87
+
88
+ def test_export_only_writes_to_exports_dir(runner, db, config, _exports_in_tmp):
89
+ """Nothing outside ~/.config/tokenjam/exports/ is created."""
90
+ _seed_downsize_candidate(db)
91
+ _invoke(runner, db, config, ["route", "export", "--target", "litellm"])
92
+ # The only thing under the fake home is the exports tree.
93
+ created = [p for p in _exports_in_tmp.rglob("*") if p.is_file()]
94
+ assert created, "expected an export file"
95
+ for p in created:
96
+ assert ".config/tokenjam/exports" in str(p), p
97
+
98
+
99
+ def test_export_json_output(runner, db, config):
100
+ _seed_downsize_candidate(db)
101
+ result = _invoke(runner, db, config,
102
+ ["route", "export", "--target", "ccr", "--json"])
103
+ assert result.exit_code == 0, result.output
104
+ payload = json.loads(result.output)
105
+ assert payload["target"] == "ccr"
106
+ assert payload["rule_count"] == 1
107
+ assert payload["path"].endswith(".jsonc")
108
+
109
+
110
+ def test_export_requires_target_or_check(runner, db, config):
111
+ _seed_downsize_candidate(db)
112
+ result = _invoke(runner, db, config, ["route", "export"])
113
+ assert result.exit_code != 0
114
+ assert "--target" in result.output
115
+
116
+
117
+ # --- --check staleness ------------------------------------------------------
118
+
119
+ def test_check_reports_no_export_then_current_then_stale(runner, db, config):
120
+ _seed_downsize_candidate(db)
121
+
122
+ # 1. No export yet.
123
+ r1 = _invoke(runner, db, config,
124
+ ["route", "export", "--check", "--target", "ccr", "--json"])
125
+ assert r1.exit_code == 0, r1.output
126
+ assert json.loads(r1.output)["results"][0]["status"] == "no_export"
127
+
128
+ # 2. Export, then check → current.
129
+ _invoke(runner, db, config, ["route", "export", "--target", "ccr"])
130
+ r2 = _invoke(runner, db, config,
131
+ ["route", "export", "--check", "--target", "ccr", "--json"])
132
+ assert json.loads(r2.output)["results"][0]["status"] == "current"
133
+
134
+ # 3. Findings change (add a second distinct candidate model) → stale.
135
+ start = utcnow() - timedelta(days=1)
136
+ llm = make_llm_span(
137
+ agent_id="claude-code-x", model="claude-sonnet-4-5", provider="anthropic",
138
+ input_tokens=900, output_tokens=150, cost_usd=0.02,
139
+ session_id="s-small-2", start_time=start,
140
+ )
141
+ db.insert_span(llm)
142
+ for _ in range(2):
143
+ t = make_tool_span(agent_id="claude-code-x", tool_name="Read",
144
+ trace_id=llm.trace_id)
145
+ t.session_id = "s-small-2"
146
+ t.start_time = start
147
+ db.insert_span(t)
148
+ r3 = _invoke(runner, db, config,
149
+ ["route", "export", "--check", "--target", "ccr", "--json"])
150
+ assert json.loads(r3.output)["results"][0]["status"] == "stale"
151
+
152
+
153
+ # --- the existing claude_code alias must still work ------------------------
154
+
155
+ def test_optimize_export_config_still_works(runner, db, config, _exports_in_tmp):
156
+ _seed_downsize_candidate(db)
157
+ result = _invoke(runner, db, config,
158
+ ["optimize", "--export-config", "claude-code"])
159
+ assert result.exit_code == 0, result.output
160
+ exports = _exports_in_tmp / ".config" / "tokenjam" / "exports"
161
+ written = list(exports.glob("claude-code-*.jsonc"))
162
+ assert len(written) == 1, written
163
+ assert "routing_recommendations" in written[0].read_text()
@@ -0,0 +1,198 @@
1
+ """Endpoint tests for the Lens Visualizations Wave 1 cost charts (#212, #213).
2
+
3
+ Covers the server-side data shaping the charts consume:
4
+ - /cost `series` carries the reusable (bucket, agent, model, provider) group-by
5
+ shape with the full token-component split (#213 stacked + #210 explorer reuse).
6
+ - /cost/cache returns per-bucket hit-rate + measured captured savings + the
7
+ cache analyzer's *estimated recoverable*, plus a plan-tier framing block.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import timedelta
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from tokenjam.api.app import create_app
17
+ from tokenjam.core.config import ProviderBudget, TjConfig
18
+ from tokenjam.core.db import InMemoryBackend
19
+ from tokenjam.core.ingest import IngestPipeline
20
+ from tokenjam.utils.time_parse import utcnow
21
+ from tests.factories import make_llm_span, make_session, make_tool_span
22
+
23
+
24
+ def _app(db, config):
25
+ return create_app(config=config, db=db, ingest_pipeline=IngestPipeline(db=db, config=config))
26
+
27
+
28
+ def _seed(db, plan_tier="api"):
29
+ now = utcnow()
30
+ for i in range(3):
31
+ s = make_session(session_id=f"s{i}", plan_tier=plan_tier)
32
+ db.upsert_session(s)
33
+ db.insert_span(make_llm_span(
34
+ session_id=f"s{i}", model="claude-haiku-4-5", provider="anthropic",
35
+ input_tokens=10_000, output_tokens=500,
36
+ cache_tokens=4_000, cache_write_tokens=1_000,
37
+ cost_usd=0.02, start_time=now - timedelta(days=i),
38
+ ))
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_cost_series_carries_reusable_groupby_shape():
43
+ db = InMemoryBackend()
44
+ cfg = TjConfig(version="1")
45
+ _seed(db)
46
+ transport = httpx.ASGITransport(app=_app(db, cfg))
47
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
48
+ d = (await c.get("/api/v1/cost?since=30d&group_by=model")).json()
49
+ assert d["series"], "expected a window series"
50
+ row = d["series"][0]
51
+ # The group-by shape carries every dimension + the full token split so the
52
+ # stacked chart and the future explorer can pivot client-side.
53
+ for key in ("bucket", "agent_id", "model", "provider", "cost_usd",
54
+ "input_tokens", "output_tokens", "cache_tokens", "cache_write_tokens"):
55
+ assert key in row, f"series row missing {key}"
56
+ assert row["provider"] == "anthropic"
57
+ assert row["cache_tokens"] == 4_000
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_cost_cache_endpoint_hitrate_and_captured():
62
+ db = InMemoryBackend()
63
+ cfg = TjConfig(version="1")
64
+ _seed(db)
65
+ transport = httpx.ASGITransport(app=_app(db, cfg))
66
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
67
+ d = (await c.get("/api/v1/cost/cache?since=30d")).json()
68
+
69
+ assert d["series"], "expected per-bucket cache series"
70
+ p = d["series"][0]
71
+ # hit-rate = cache_read / (cache_read + input) = 4000 / 14000
72
+ assert abs(p["hit_rate"] - (4000 / 14000)) < 1e-3
73
+ # captured = cache_read * (input_rate - cache_read_rate) / 1e6
74
+ # = 4000 * (0.80 - 0.08) / 1e6 = 0.00288 (Haiku 4.5)
75
+ assert abs(p["captured_usd"] - 0.00288) < 1e-6
76
+ assert p["captured_tokens"] == 4000
77
+ # window totals + estimated recoverable from the cache analyzer
78
+ assert d["total_captured_tokens"] == 12_000
79
+ assert d["estimated_recoverable_usd"] is not None
80
+ # framing block present (single compute path)
81
+ assert d["framing"]["pricing_mode"] == "api"
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_cost_cache_framing_subscription_suppresses_dollars():
86
+ """Subscription plan → framing suppresses dollars; tokens stay meaningful."""
87
+ db = InMemoryBackend()
88
+ cfg = TjConfig(version="1", budgets={"anthropic": ProviderBudget(plan="max_20x")})
89
+ _seed(db, plan_tier="max_20x")
90
+ transport = httpx.ASGITransport(app=_app(db, cfg))
91
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
92
+ d = (await c.get("/api/v1/cost/cache?since=30d")).json()
93
+ assert d["framing"]["pricing_mode"] == "subscription"
94
+ # token figures are still present so the UI can render the tokens framing
95
+ assert d["total_captured_tokens"] == 12_000
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_cost_cache_empty_window_is_safe():
100
+ db = InMemoryBackend()
101
+ cfg = TjConfig(version="1")
102
+ transport = httpx.ASGITransport(app=_app(db, cfg))
103
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
104
+ d = (await c.get("/api/v1/cost/cache?since=7d")).json()
105
+ assert d["series"] == []
106
+ assert d["total_captured_usd"] == 0
107
+ assert "framing" in d
108
+
109
+
110
+ # --- #211: cost-by-component + recoverable-waste overlay ------------------- #
111
+ def _seed_downsize_and_cache(db, plan_tier="api"):
112
+ """Small Opus sessions (downsize candidate) with cache tokens (cache
113
+ recoverable) + tool spans, so build_report yields multiple recoverables."""
114
+ now = utcnow()
115
+ for i in range(6):
116
+ s = make_session(session_id=f"s{i}", plan_tier=plan_tier, agent_id="cc")
117
+ db.upsert_session(s)
118
+ llm = make_llm_span(
119
+ session_id=f"s{i}", agent_id="cc", model="claude-opus-4-7",
120
+ provider="anthropic", input_tokens=1200, output_tokens=200,
121
+ cache_tokens=3000, cache_write_tokens=800, cost_usd=0.05,
122
+ start_time=now - timedelta(days=i),
123
+ )
124
+ db.insert_span(llm)
125
+ for _ in range(2):
126
+ t = make_tool_span(agent_id="cc", tool_name="Read", trace_id=llm.trace_id)
127
+ t.session_id = f"s{i}"
128
+ t.start_time = now - timedelta(days=i)
129
+ db.insert_span(t)
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_components_split_priced_per_component():
134
+ db = InMemoryBackend()
135
+ cfg = TjConfig(version="1")
136
+ _seed_downsize_and_cache(db)
137
+ transport = httpx.ASGITransport(app=_app(db, cfg))
138
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
139
+ d = (await c.get("/api/v1/cost/components?since=30d")).json()
140
+ keys = [c["key"] for c in d["components"]]
141
+ assert keys == ["input", "output", "cache_read", "cache_write"]
142
+ by = {c["key"]: c for c in d["components"]}
143
+ # Opus 4.7 input rate; 6 × 1200 = 7200 input tokens. Cost must be > 0 and
144
+ # token volume exact.
145
+ assert by["input"]["tokens"] == 7200
146
+ assert by["cache_read"]["tokens"] == 18000
147
+ assert by["input"]["cost_usd"] > 0
148
+ assert d["framing"]["pricing_mode"] == "api"
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_components_recoverable_is_registry_driven_and_honest():
153
+ db = InMemoryBackend()
154
+ cfg = TjConfig(version="1")
155
+ _seed_downsize_and_cache(db)
156
+ transport = httpx.ASGITransport(app=_app(db, cfg))
157
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
158
+ d = (await c.get("/api/v1/cost/components?since=30d")).json()
159
+ rec = {r["analyzer"]: r for r in d["recoverable"]}
160
+ # downsize is the typed slot; reuse comes from the findings dict — its
161
+ # presence proves the collector is registry-driven (not a hard-coded list).
162
+ assert "downsize" in rec
163
+ assert "reuse" in rec
164
+ # each carries a component attribution
165
+ assert rec["downsize"]["component"] == "call"
166
+ assert rec["cache"]["component"] == "input" if "cache" in rec else True
167
+ # downsize keeps its mandatory caveat verbatim (Rule 14)
168
+ from tokenjam.core.optimize.types import MODEL_DOWNGRADE_CAVEAT
169
+ assert rec["downsize"]["caveat"] == MODEL_DOWNGRADE_CAVEAT
170
+ # honesty: the payload never calls recoverable "saved"
171
+ import json
172
+ assert "saved" not in json.dumps(d).lower()
173
+ assert d["total_recoverable_usd"] > 0
174
+
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_components_subscription_framing_suppresses_dollars():
178
+ db = InMemoryBackend()
179
+ cfg = TjConfig(version="1", budgets={"anthropic": ProviderBudget(plan="max_20x")})
180
+ _seed_downsize_and_cache(db, plan_tier="max_20x")
181
+ transport = httpx.ASGITransport(app=_app(db, cfg))
182
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
183
+ d = (await c.get("/api/v1/cost/components?since=30d")).json()
184
+ assert d["framing"]["pricing_mode"] == "subscription"
185
+ # token volumes stay present so the UI can render the tokens framing
186
+ assert sum(c["tokens"] for c in d["components"]) > 0
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_components_empty_window_is_safe():
191
+ db = InMemoryBackend()
192
+ cfg = TjConfig(version="1")
193
+ transport = httpx.ASGITransport(app=_app(db, cfg))
194
+ async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
195
+ d = (await c.get("/api/v1/cost/components?since=7d")).json()
196
+ assert d["total_cost_usd"] == 0
197
+ assert d["recoverable"] == []
198
+ assert "framing" in d