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.
- {tokenjam-0.4.2 → tokenjam-0.5.0}/CLAUDE.md +1 -1
- {tokenjam-0.4.2 → tokenjam-0.5.0}/PKG-INFO +1 -1
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/claude-code-integration.md +2 -2
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/installation.md +3 -3
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/trim.md +1 -1
- {tokenjam-0.4.2 → tokenjam-0.5.0}/pyproject.toml +1 -1
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/package.json +1 -1
- tokenjam-0.5.0/tests/integration/test_analytics.py +191 -0
- tokenjam-0.5.0/tests/integration/test_cmd_route.py +163 -0
- tokenjam-0.5.0/tests/integration/test_cost_charts.py +198 -0
- tokenjam-0.5.0/tests/unit/test_export_route_generators.py +175 -0
- tokenjam-0.5.0/tests/unit/test_lens_ui_regression.py +596 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/app.py +2 -0
- tokenjam-0.5.0/tokenjam/api/routes/analytics.py +289 -0
- tokenjam-0.5.0/tokenjam/api/routes/cost.py +468 -0
- tokenjam-0.5.0/tokenjam/cli/cmd_route.py +239 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/main.py +2 -0
- tokenjam-0.5.0/tokenjam/core/export/ccr.py +106 -0
- tokenjam-0.5.0/tokenjam/core/export/common.py +101 -0
- tokenjam-0.5.0/tokenjam/core/export/litellm.py +102 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/index.html +879 -19
- tokenjam-0.4.2/tests/unit/test_lens_ui_regression.py +0 -291
- tokenjam-0.4.2/tokenjam/api/routes/cost.py +0 -157
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/CODEOWNERS +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/ISSUE_TEMPLATE/integration_request.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/pull_request_template.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/scripts/archive_traffic.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/ci.yml +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/publish-npm.yml +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/publish-pypi.yml +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.github/workflows/traffic-archive.yml +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/.gitignore +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/AGENTS.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/CONTRIBUTING.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/LICENSE +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/Makefile +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/SECURITY.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/alerts.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/architecture.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/helicone.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/langfuse.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/otlp.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/backfill/overview.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/cli-reference.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/configuration.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/export.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/framework-support.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/internal/lens-vendor-versions.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/internal/specs/.gitkeep +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/nemoclaw-integration.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/openclaw.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/cache.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/downsize.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/reuse.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/optimize/script.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/policy/overview.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/python-sdk.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-alerts.png +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-budget.png +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-cost.png +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-status.png +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/screenshots/tj-traces.png +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/docs/typescript-sdk.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/_shared.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/budget_breach_demo.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/drift_demo.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/alerts_and_drift/sensitive_actions_demo.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/rag_pipeline.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/research_team.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/router_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/agent_patterns.txt +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/cost_management.txt +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/observability.txt +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/multi/sample_docs/safety.txt +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/openclaw/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/autogen_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/crewai_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/langchain_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/langgraph_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_framework/llamaindex_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/anthropic_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/bedrock_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/gemini_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/litellm_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/openai_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/examples/single_provider/openai_agents_sdk_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/growth/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/BLOG.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/hallucination-drift/scenario.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/BLOG.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/retry-loop/scenario.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/BLOG.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/incidents/surprise-cost/scenario.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/package-lock.json +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/client.test.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/client.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/index.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/semconv.test.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/semconv.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/span-builder.test.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/span-builder.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/src/types.ts +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/sdk-ts/tsconfig.json +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-post-release-runner.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-post-release-v0.4.0.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-runner.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-v0.4.0.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agent-pre-release-v0.4.1.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_budget_breach.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_drift.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_loop.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/email_agent_normal.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/mock_llm.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/agents/test_mock_scenarios.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/conftest.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/conftest.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/e2e/test_real_llm.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/factories.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/helicone_real_response.json +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/langfuse_real_response.json +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/fixtures/otlp_sample.json +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_api.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cli.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cli_api_framing_parity.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_cost_framing_window.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_db.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_demos.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_full_pipeline.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_litellm_enrichment.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_logs_api.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/integration/test_sdk_config_discovery.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/manual-new-release-tests.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/manual-pre-release-testing.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-post-release-v0.4.0-20260619T221412Z.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.0-20260619T202233Z.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.0-20260619T205108Z.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/results/agent-pre-release-v0.4.1-20260620T002546Z.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_alert_rules.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_cost_tracking.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_drift_detection.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_ingest.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/synthetic/test_schema_validation.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/toy_agent/toy_agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_alerts.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_backfill.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cache_efficacy.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cache_recommend.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_cost.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_policy.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_stop.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cmd_tokenmaxx.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_compare.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_config.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_config_secret_divergence.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cost.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cost_compare_framing.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_cycle.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_db_helpers.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_demo_env.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_demo_scenarios.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_drift.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_export_claude_code.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_formatting.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_framing.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_helicone.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_langfuse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ingest_otlp.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_litellm_client.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_litellm_integration.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_logs_converter.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_mcp_server.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_models.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_no_tracked_dev_secrets.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_codex.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_daemon.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_hygiene.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_onboard_plan.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_openclaw_ingest.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize_recoverable.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_optimize_reuse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_plan_tier_apply.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_pricing_override.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_prompt_bloat.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_provider_attribution_194.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_reuse_skeleton.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_spans_stats_repair.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_time_parse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_transport_401.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_ui_offline.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tests/unit/test_workflow_restructure.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/deps.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/middleware.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/agents.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/alerts.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/budget.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/cost_compare.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/drift.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/logs.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/metrics.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/optimize.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/otlp.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/reuse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/spans.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/status.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/tools.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/traces.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/api/routes/version.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_alerts.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_backfill.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_budget.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_cost.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_demo.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_doctor.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_drift.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_export.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_mcp.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_onboard.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_optimize.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_policy.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_report.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_serve.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_status.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_stop.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_tokenmaxx.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_tools.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_traces.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/cli/cmd_uninstall.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/alerts.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/api_backend.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/backfill.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/config.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/cost.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/cycle.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/db.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/drift.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/claude_code.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/reuse_report.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/export/reuse_skeleton.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/framing.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/helicone.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/langfuse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/ingest_adapters/otlp.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/models.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/README.md +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/budget_projection.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/cache_efficacy.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/cache_recommend.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/model_downgrade.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/plan_reuse.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/prompt_bloat.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/analyzers/workflow_restructure.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/registry.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/runner.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/optimize/types.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/pricing.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/retention.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/core/schema_validator.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/demo/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/demo/env.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/mcp/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/mcp/server.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/exporters.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/otlp_parsing.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/provider.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/otel/semconv.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/pricing/models.toml +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/py.typed +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/agent.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/bootstrap.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/client.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/http_exporter.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/anthropic.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/autogen.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/base.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/bedrock.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/crewai.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/gemini.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/langchain.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/langgraph.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/litellm.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/llamaindex.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/nemoclaw.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/openai.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/integrations/openai_agents_sdk.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/sdk/transport.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/htm.js +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/preact-hooks.js +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/preact.js +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/uplot.css +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/ui/vendor/uplot.js +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/__init__.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/formatting.py +0 -0
- {tokenjam-0.4.2 → tokenjam-0.5.0}/tokenjam/utils/ids.py +0 -0
- {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]`
|
|
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.
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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[
|
|
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 `
|
|
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
|
|
|
@@ -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
|