diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6fe9ad..c708b93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,7 +122,7 @@ jobs: run: go test ./internal/cli -run '^TestReleaseBinary' -count=1 -v - name: Pinned SQL export through the packaged importer if: matrix.os == 'linux' && matrix.arch == 'amd64' - timeout-minutes: 10 + timeout-minutes: 15 env: FLEETDIFF_BIN: ${{ github.workspace }}/unpacked/fleetdiff FLEETDIFF_LINUX_BIN: ${{ github.workspace }}/unpacked/fleetdiff diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d38bca..e1baa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ Notable user-visible changes are recorded here, newest first. ## [Unreleased] +## [0.14.0] - Unreleased + +Release preparation; no tag or publication yet. See [draft release notes](docs/releases/v0.14.0.md). + +- Add explicit agent attribution from LiteLLM `agent_id` or prefixed request + tags on LiteLLM and Langfuse, with exclusive known/unknown/ambiguous totals, + repeated/empty-tag coverage and an exact 1,024-agent bound. +- Add opt-in request-tag export through the shared psql/native projection, + and an agent table with exact recorded USD, coverage, period shares and + `--rank-by recorded-spend-change`. Default exports and reports are unchanged. +- **Breaking change:** routing `--show-labels` now also requires + `--acknowledge-sensitive-labels`. Replace `--routing-audit --show-labels` with + `--routing-audit --show-labels --acknowledge-sensitive-labels` on existing + `fleetdiff investigate` commands. Raw agent labels require the same two flags. + User/session/end-user/key rankings remain aliased. + See the [agent attribution contract](docs/AGENT_ATTRIBUTION.md). +- Include escaped selected agent labels in the answer sentence, distinguish + identical Langfuse labels with project aliases, and keep raw project names private. +- Keep agent text compact: omit tag-only coverage for agent IDs, show cost + coverage columns only for incomplete displayed costs, and name unknown and + ambiguous identity explicitly. Agent JSON and default reports are unchanged. + ## [0.13.1] - 2026-10-27 See the [release notes](docs/releases/v0.13.1.md). diff --git a/README.md b/README.md index e6c6221..2b9872e 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,17 @@ fleetdiff reads LiteLLM databases and exports or Langfuse exports to show what c + -### 1. Install v0.13.1 +### 1. Install v0.14.0 + +These v0.14.0 instructions require its published release assets. Until +publication, use [v0.13.1](https://github.com/llm-measurement/fleetdiff/releases/tag/v0.13.1). Linux or macOS; needs curl, gh, shasum and tar. The installer checks the release before unpacking it. ```sh -bash -o pipefail -c 'curl --fail --location --proto "=https" --tlsv1.2 https://raw.githubusercontent.com/llm-measurement/fleetdiff/0d7db95c5bb3013f8c564c91fbb07b6235a2664e/scripts/install.sh | sh -s -- v0.13.1 ./fleetdiff-install' +bash -o pipefail -c 'curl --fail --location --proto "=https" --tlsv1.2 https://raw.githubusercontent.com/llm-measurement/fleetdiff/0d7db95c5bb3013f8c564c91fbb07b6235a2664e/scripts/install.sh | sh -s -- v0.14.0 ./fleetdiff-install' ./fleetdiff-install/fleetdiff --version ``` @@ -85,7 +89,7 @@ Install fleetdiff using [step 1](#1-install-v0120), then run the synthetic examp ```sh curl --fail --location --proto '=https' --tlsv1.2 \ - https://raw.githubusercontent.com/llm-measurement/fleetdiff/v0.13.1/examples/litellm-spend/synthetic.csv \ + https://raw.githubusercontent.com/llm-measurement/fleetdiff/v0.14.0/examples/litellm-spend/synthetic.csv \ --output synthetic.csv ./fleetdiff-install/fleetdiff investigate --litellm-spend synthetic.csv \ --before-period 2026-10-07 --after-period 2026-10-08 @@ -118,16 +122,16 @@ The summary-file path also shows session concentration: ## Install -The verified v0.13.1 installer selects the platform binary for your machine. +The installer verifies the v0.14.0 platform binary for your machine after publication. For public release downloads, `gh` need not be signed in. -For manual downloads, use the [v0.13.1 archives](https://github.com/llm-measurement/fleetdiff/releases/tag/v0.13.1) +For manual downloads, use the [v0.14.0 archives](https://github.com/llm-measurement/fleetdiff/releases/tag/v0.14.0) and [verification instructions](docs/OPERATIONS.md#install-and-verify). An administrator can verify and transfer artifacts through an approved internal mirror. With a security-patched Go 1.26 toolchain, install the same version from its module: ```sh -go install github.com/llm-measurement/fleetdiff/cmd/fleetdiff@v0.13.1 +go install github.com/llm-measurement/fleetdiff/cmd/fleetdiff@v0.14.0 ``` The executable goes to `GOBIN`, or `$(go env GOPATH)/bin` when unset. @@ -174,7 +178,7 @@ Guides: [LiteLLM spend](https://llm-measurement.github.io/litellm-spend.html) | - [Questions and contributor rankings](docs/WORKFLOWS.md#questions-it-answers). - [How summary comparison works](docs/WORKFLOWS.md#how-it-works), [comparison contract](docs/COMPARISON.md) and [investigation contract](docs/INVESTIGATION.md). - [Operations](docs/OPERATIONS.md), [resource measurements](docs/BENCHMARKS.md) and [checks](docs/WORKFLOWS.md#built-to-be-checked). -- [FAQ](docs/FAQ.md), [security](SECURITY.md), [release notes](docs/releases/v0.13.1.md) and [release procedure](docs/RELEASING.md). +- [FAQ](docs/FAQ.md), [security](SECURITY.md), [release notes](docs/releases/v0.14.0.md) and [release procedure](docs/RELEASING.md). Questions or feedback: [open an issue](https://github.com/llm-measurement/fleetdiff/issues). See [Contributing](CONTRIBUTING.md) for checks and signed, signed-off commits. diff --git a/SECURITY.md b/SECURITY.md index 4060fde..e706f00 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,6 +3,7 @@ ## Supported Versions Security fixes target the latest patch in the `0.13.x` release line and `main`. +Once v0.14.0 is published, fixes target the `0.14.x` release line and `main`. Use the newest patch and record its version and revision when running trials. Older patches and development snapshots do not receive routine backports. Dependencies and the Go toolchain are reviewed on an ongoing basis. diff --git a/docs/AGENT_ATTRIBUTION.md b/docs/AGENT_ATTRIBUTION.md new file mode 100644 index 0000000..a3d11c0 --- /dev/null +++ b/docs/AGENT_ATTRIBUTION.md @@ -0,0 +1,93 @@ +# Agent Spend Attribution + +Find which recorded agents drove a token or spend change between two periods +of LiteLLM spend logs or Langfuse exports. + +The included synthetic fixture goes from **300 to 620 tokens** and **USD 3 to +6.20**. Alpha adds **200 of the 320-token net increase (62.5%)**. After the +change, known agents account for 500 tokens/USD5, unknown identity for +50 tokens/USD 0.50, and ambiguous identity for 70 tokens/USD 0.70. + +## Run The Fixture + +These options are prepared for v0.14.0, not available in v0.13.1. Build this +checkout while release publication is pending: + +```sh +go build -o bin/fleetdiff ./cmd/fleetdiff +bin/fleetdiff investigate --litellm-spend examples/litellm-spend/agent-tags.csv \ + --before-period 2026-10-07 --after-period 2026-10-08 \ + --group-by agent --agent-source request-tag --agent-tag-prefix 'agent:' \ + --rank-by recorded-spend-change +``` + +The answer includes: + +```text +agent-1 accounts for 62.5% of the net recorded increase (+200 of +320 tokens). +``` + +The table shows before/after tokens, recorded USD, USD change and each period's +spend share. Unknown and ambiguous changes appear separately below the ranking. +The four cost-coverage columns appear only when a displayed row has missing, +invalid or zero-origin-unknown cost; otherwise one line confirms usable recorded +USD in both periods. Known agents outside `--top` remain in a separate total. +Agents whose USD change is unavailable are always listed, even with `--top`. + +## Choose The Source + +- LiteLLM recorded IDs: `--group-by agent --agent-source agent_id`. +- LiteLLM or Langfuse tags: `--group-by agent --agent-source request-tag + --agent-tag-prefix 'agent:'`. + +The default ranking is signed recorded-token change. Add +`--rank-by recorded-spend-change` for signed recorded-USD change, descending; +first appearance breaks ties. + +For a LiteLLM database, replace the file option with `--litellm-db`. Tag +attribution enables the required tag projection automatically. Standalone +native export needs `--request-tags`; psql needs `-v request_tags=true`. +Exporting tags retains all tag values, not just the selected prefix. Review +the [export limits](NATIVE_EXPORT.md#limits-and-failures) and +[optional column grant](NATIVE_EXPORT.md#administrator-setup) before sharing an +export. These transport options do not disclose unrelated tags in reports. + +## Include Selected Labels + +Add both `--show-labels --acknowledge-sensitive-labels`; either alone fails. +The selected label is escaped in the answer and table, for example: + +```text +agent-1 "alpha" accounts for 62.5% of the net recorded increase (+200 of +320 tokens). +``` + +Langfuse identities stay project-scoped. Identical labels are distinguished as +`agent-1 "alpha" (project-1)` and `agent-2 "alpha" (project-2)`, never with raw +project names. Project aliases describe this report's encounter order, not a +cross-report project ID. Unrelated tags and user/session/end-user/key identities +remain hidden; the text warns before sharing disclosed labels. + +## Limits + +- The selector is explicit: no field fallback, agent discovery, friendly-name + lookup or request/observation join. Langfuse counts GENERATION observations. +- Prefixes are exact and case-sensitive. Zero distinct nonblank suffixes means + unknown, one means known, two or more means ambiguous. Repeated identical tags + are deduplicated; repetitions and empty suffixes have separate coverage. + Each row contributes once, and all identity buckets retain their accounting. +- LiteLLM arrays with any non-string element remain unknown with + `non_string_tag_rows` coverage; no string label is salvaged. Langfuse retains + string-only arrays. Missing source columns remain column-absent unknown. +- Malformed JSON, non-array tags, invalid UTF-8 and source fields over 8 KiB fail + without dumping rows. More than 1,024 project-scoped known agents fails and + asks for a narrower prefix or export; identities are not silently dropped. +- Shares use usable recorded USD from all analyzed rows, including unknown, + ambiguous and undisplayed agents. Missing/invalid costs stay unquantified; + zero-cost origin stays unknown. A period with no usable cost is unavailable, + not zero. Costs retain source decimals; JSON shares use 12 decimal places and + text percentages use two. Recorded USD is source accounting, not an invoice. +- The optional JSON section remains `fleetdiff-agent-attribution/v1`. Its + fields, base accounting fingerprint, default export projection and reports + without agent mode are unchanged. Project display aliases are text-only. + Unsupported LiteLLM call types and partial Langfuse exports retain their + existing boundaries. diff --git a/docs/LANGFUSE.md b/docs/LANGFUSE.md index b8859ef..6980429 100644 --- a/docs/LANGFUSE.md +++ b/docs/LANGFUSE.md @@ -21,11 +21,11 @@ It reads local files only, not Langfuse services or provider APIs. ## Try the synthetic example -Install using the [README](../README.md#1-install-v0131), then get the example -files (the installer does not include a source checkout): +After v0.14.0 is published, install using the [README](../README.md#1-install-v0140), +then get the example files (the installer does not include a source checkout): ```sh -git clone --depth 1 --branch v0.13.1 https://github.com/llm-measurement/fleetdiff +git clone --depth 1 --branch v0.14.0 https://github.com/llm-measurement/fleetdiff ``` From the directory containing `fleetdiff-install` and this checkout, compare diff --git a/docs/NATIVE_EXPORT.md b/docs/NATIVE_EXPORT.md index c11777f..6c72695 100644 --- a/docs/NATIVE_EXPORT.md +++ b/docs/NATIVE_EXPORT.md @@ -97,6 +97,14 @@ GRANT SELECT ( ALTER ROLE fleetdiff_reader SET default_transaction_read_only = on; ``` +Only for opt-in request-tag exports, also grant the new column below. +This exports every request tag on a row, not only agent tags; keep the file private. + +```sql +GRANT SELECT (request_tags) + ON TABLE public."LiteLLM_SpendLogs" TO fleetdiff_reader; +``` + Only when daily counters are needed, also grant: ```sql @@ -201,14 +209,22 @@ fleetdiff issues no database writes or schema migrations. The v0.12.0 shared SQL orders spend rows by `"startTime", request_id COLLATE "C"` and daily rows by `date COLLATE "C"`. For the same database snapshot, bounds, -format and detail options, native and v0.12.0 `psql` exports must be -byte-for-byte identical. Sequential exports from a changing live database are +format and detail options, native and matching `psql` exports must be +byte-for-byte identical. Default projections retain the v0.12.0 recipe's bytes; +opt-in request tags require the matching recipe from the same build. +Sequential exports from a changing live database are not the same snapshot. This ordering is new relative to the v0.11.0 recipe: it can change bytes, first-seen aliases and tied report order, not recorded totals or identity hashing. Database sorting can also affect export time. ## Limits And Failures +- `--request-tags` includes every request tag on a row, not only agent tags. + This option targets the next minor release; use the matching source checkout + until it is released. + It is opt-in; default exports omit the column. The manual recipe uses + `-v request_tags=true` for the same JSON-array-text projection. + | Native export bound | Value | | --- | --- | | Spend rows | 10,000,000 | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0ae6fbb..86012e8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -10,8 +10,9 @@ private local exports before analysis. The initial supported targets are Linux and macOS on AMD64 and ARM64. Native Windows binaries are not provided. Download a binary from the -[release page](https://github.com/llm-measurement/fleetdiff/releases/tag/v0.13.1). -Go is not needed to run it. The commands below use `curl` for downloading and +[release page](https://github.com/llm-measurement/fleetdiff/releases/tag/v0.14.0). +These v0.14.0 download commands require its published release assets; use the +v0.13.1 release until publication. Go is not needed to run it. The commands below use `curl` for downloading and the [GitHub CLI](https://cli.github.com/) for provenance verification. The commands select Linux or macOS and AMD64 or ARM64 from the current shell's @@ -22,7 +23,7 @@ subshell stops on any download or verification failure: ```sh ( set -eu -version=v0.13.1 +version=v0.14.0 case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; @@ -56,7 +57,7 @@ From a reviewed repository checkout, the same download and verification steps are available as a script (no Go compiler needed): ```sh -sh scripts/install.sh v0.13.1 ./fleetdiff-install +sh scripts/install.sh v0.14.0 ./fleetdiff-install ./fleetdiff-install/fleetdiff --version ``` @@ -67,14 +68,14 @@ curl --fail --location --proto '=https' --tlsv1.2 \ https://raw.githubusercontent.com/llm-measurement/fleetdiff/0d7db95c5bb3013f8c564c91fbb07b6235a2664e/scripts/install.sh \ --output fleetdiff-install.sh less fleetdiff-install.sh -sh fleetdiff-install.sh v0.13.1 ./fleetdiff-install +sh fleetdiff-install.sh v0.14.0 ./fleetdiff-install ./fleetdiff-install/fleetdiff --version ``` -The immutable script pin is a reviewed installer; the explicit `v0.13.1` +The immutable script pin is a reviewed installer; the explicit `v0.14.0` argument selects the release independently of that script's older default. -The v0.13.1 binary includes [native LiteLLM export](NATIVE_EXPORT.md), +The v0.14.0 binary includes [native LiteLLM export](NATIVE_EXPORT.md), [model-mix what-if repricing](WHAT_IF.md), the [LiteLLM spend importer](../examples/litellm-spend/README.md), optional [daily row coverage](ROW_COVERAGE.md), [Langfuse local exports](LANGFUSE.md) @@ -124,7 +125,7 @@ Install a published version with a currently patched Go 1.26 or later toolchain: go install github.com/llm-measurement/fleetdiff/cmd/fleetdiff@latest ``` -Use `@v0.13.1` instead of `@latest` to pin that release. The binary is installed +Use `@v0.14.0` instead of `@latest` to pin that release. The binary is installed under `GOBIN`, or `$(go env GOPATH)/bin` when unset; put that directory on `PATH`. This path uses Go module verification rather than the archive attestation above, and does not include unreleased checkout features. @@ -145,7 +146,7 @@ Check `--version` on the binary you actually run: | Build | Version | Revision | |---|---|---| | Unstamped checkout build above | Go's embedded module/VCS version, or `dev` when unavailable | `unknown` | -| Tagged `go install ...@v0.13.1` | `v0.13.1`, from Go's module metadata | `unknown` | +| Tagged `go install ...@v0.14.0` | `v0.14.0`, from Go's module metadata | `unknown` | | Verified release archive | Stamped release version | Stamped source revision | A checkout may report a pseudo-version and `+dirty`, depending on the Go toolchain diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0b960cc..d7cec26 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -97,7 +97,7 @@ verify that every advertised version is downloadable. Do not replace immutable t or artifacts to correct a failed publication; fix forward with a new version if any release content must change. -## After Publishing v0.13.1 +## After Publishing v0.14.0 From an anonymous clone in a fresh directory outside any synced workspace: @@ -106,7 +106,7 @@ From an anonymous clone in a fresh directory outside any synced workspace: [OPERATIONS](OPERATIONS.md#install-and-verify). Confirm the signed tag and recorded build revision match the approved commit. 2. Run the README's verified installer into a new directory. Confirm the native - executable reports `v0.13.1`, then run the packaged-binary tests above. + executable reports `v0.14.0`, then run the packaged-binary tests above. 3. Set `FLEETDIFF_BIN` to that freshly installed executable and run the exact [pinned database test](../examples/litellm-spend/TESTING.md#pinned-database-integration). It exercises the real pinned writer, a SELECT-only SQL export and the released @@ -141,7 +141,7 @@ From an anonymous clone in a fresh directory outside any synced workspace: subset, and absent agents read as unknown. Keep the v0.8.0 import-refactor output goldens and legacy identity-hash tests unchanged; reports without these conditions must remain byte-identical. -11. Run the routing guide with default aliases and with `--show-labels`. Verify +11. Run the routing guide with default aliases and with `--show-labels --acknowledge-sensitive-labels`. Verify public model names, explicit configuration-label warnings, missing serving identity, classifier overhead and raw-identity suppression. Exercise a pinned-writer `openai/` export: price it only when the row provider @@ -154,6 +154,13 @@ From an anonymous clone in a fresh directory outside any synced workspace: Exercise the README's database investigation, private cleanup and retained exports. Record the exact executable and distinguish native-driver tests from the older writer/psql tests; skipped database checks do not pass this gate. +13. Run the agent-tag fixture from AGENT_ATTRIBUTION with the installed binary. + Check the 620-token/USD6.20 totals, alpha's +200 of +320-token answer, + unknown/ambiguous buckets, unavailable-cost visibility and two-flag escaped + label disclosure. Check project aliases on a two-project Langfuse export. + Retain the 18 default text/JSON byte comparisons against v0.13.1 separately + from these opt-in agent checks. Publish site pins and guide text only after + these released-artifact gates pass; do not treat source tests as that proof. The release workflow tests every native platform before publication. The local outsider run proves the post-publication download and database path on its own diff --git a/docs/ROUTING.md b/docs/ROUTING.md index 51c255d..31ac708 100644 --- a/docs/ROUTING.md +++ b/docs/ROUTING.md @@ -21,7 +21,9 @@ fleetdiff investigate --litellm-spend ./spend.csv \ ``` Add `--routing-by route-agent` to separate the known agents within each route. -Add `--show-labels` for a local report with the recorded route and agent names. +Add `--show-labels --acknowledge-sensitive-labels` for a local report with the recorded route and agent names. +The two-flag form targets the next minor release. Released v0.13.1 uses +`--show-labels` alone for routing labels; agent contributor labels are unreleased. The usual `--group-by` still controls the existing contributor rankings; it does not change the routing table. `--format json` includes the same evidence as an additive `routing_audit` object. Default reports omit this object and section. @@ -96,13 +98,14 @@ other model and deployment names stay aliased, including under `--show-labels`. The same public-model rule applies to `investigate`'s model totals. A catalog match identifies a public name, not independent proof of the serving model. Routes and agents stay aliased by default; `--show-hashes` adds keyed digests. -`--show-labels` adds their recorded labels beside the aliases (JSON: `label`), +`--show-labels --acknowledge-sensitive-labels` adds their recorded labels beside the aliases (JSON: `label`), with terminal controls escaped in text. Reports made with it contain configuration labels: keep them local and review them before sharing. Unknown identities and groups with conflicting raw spellings remain aliased. Distinct source groups resolving to the same public model keep an alias suffix so they remain distinguishable; their accounting groups and hashes are not merged. -Selector arguments are not echoed; errors never expose configuration labels. +Routing selector arguments are not echoed. Agent-tag cardinality errors name +the selected prefix, with terminal controls escaped, to suggest a narrower one. An absent route stays unknown, even if `model_group` names a recovery route. The original route comes only from `metadata.original_model_group`. diff --git a/docs/ROW_COVERAGE.md b/docs/ROW_COVERAGE.md index 671c92d..4ba8bbf 100644 --- a/docs/ROW_COVERAGE.md +++ b/docs/ROW_COVERAGE.md @@ -8,7 +8,7 @@ fixture reproduces a real response-ID collision through pinned LiteLLM's proxy and database writers. The next UTC day matches: 1 row and 1 daily request. The check leaves token and spend totals exactly as recorded in the export. -Install [fleetdiff v0.9.0](../README.md#2-install-v090). From the repository +Install [fleetdiff v0.14.0](../README.md#1-install-v0140) after publication. From the repository root, select the verified binary for the commands below: ```sh diff --git a/docs/releases/v0.14.0.md b/docs/releases/v0.14.0.md new file mode 100644 index 0000000..ae362fd --- /dev/null +++ b/docs/releases/v0.14.0.md @@ -0,0 +1,62 @@ +# fleetdiff v0.14.0 + +Draft release preparation. Not tagged or published; merge, tagging and release +each require maintainer approval. + +Compare two periods of LiteLLM spend logs or Langfuse exports to see which +recorded agents drove token and spend changes. Choose an explicit agent ID or +request-tag source; the report does not guess agent identities. + +## Agent Attribution + +- Use `--group-by agent --agent-source agent_id` for LiteLLM's recorded agent IDs. +- Use `--agent-source request-tag --agent-tag-prefix 'agent:'` for LiteLLM or + Langfuse tags. Identical repeated tags resolve to one agent; distinct matching + labels remain ambiguous, and unknown/ambiguous rows retain their accounting. +- Rank signed token changes, or add `--rank-by recorded-spend-change` for exact + recorded-USD changes. USD-unavailable agents stay visible even with `--top`. +- See the answer sentence, before/after tokens and USD, period shares and + unattributed changes. Cost-coverage columns appear only for incomplete + displayed costs. Recorded USD is not a provider invoice. +- Raw selected labels require both disclosure flags and are escaped in text. + Identical Langfuse labels remain project-scoped and use project aliases; + raw project names are not displayed. +- Keep invalid LiteLLM tag-element rows as unknown identity with separate + coverage. Langfuse retains string-only validation. Exact agent counting is + bounded to 1,024 identities; no silent truncation occurs. + +The synthetic fixture records 300 -> 620 tokens and USD 3 -> 6.20. Alpha adds +200 of the 320-token net increase (62.5%). See the +[agent guide](../AGENT_ATTRIBUTION.md) for commands and limits. + +## Breaking Routing Label Disclosure + +Routing `--show-labels` now also requires `--acknowledge-sensitive-labels`. +Replace an existing routing command such as: + +```sh +fleetdiff investigate --litellm-spend spend.csv --routing-audit --show-labels +``` + +with: + +```sh +fleetdiff investigate --litellm-spend spend.csv --routing-audit \ + --show-labels --acknowledge-sensitive-labels +``` + +The same two flags disclose selected agent labels. User, session, end-user and +key rankings remain aliased. Default exports and reports remain byte-identical +to v0.13.1; the agent JSON section retains its independently versioned contract. +No new runtime dependency is introduced. + +## Publication Gates + +Follow [RELEASING](../RELEASING.md). Finalize these draft notes and the changelog +date only after release approval. Published archives, checksums, SBOM/provenance, +anonymous installer tests, packaged-binary and pinned database checks, and site +version/provenance verification cannot pass from an unpublished local checkout. +Site re-pinning and the new agent guide remain unpublished until the released +artifact checks pass. Do not create a tag merely to test packaging. + +Code authors: Vijay and Codex diff --git a/examples/litellm-spend/README.md b/examples/litellm-spend/README.md index 51976eb..f38d79b 100644 --- a/examples/litellm-spend/README.md +++ b/examples/litellm-spend/README.md @@ -7,7 +7,7 @@ increase, and two keys account for all of it.** The report separates request count from request size where the usage records support that comparison. -Install [fleetdiff v0.13.1](../../README.md#1-install-v0131), then run this fixture +Install [fleetdiff v0.14.0](../../README.md#1-install-v0140) after publication, then run this fixture from the repository root with the verified binary: ```sh @@ -216,6 +216,11 @@ default to zero. No missing-path `NULL` should be interpreted as zero cache use. ### Agent Identity And Rejection Evidence +For next-minor tag-based attribution, opt in with `-v request_tags=true` in the same psql +export, or `--request-tags` in native export. Both add the nullable `request_tags` +column as JSON-array text, without expanding rows. The read-only column grant +is documented in [Administrator Setup](../../docs/NATIVE_EXPORT.md#administrator-setup). + The projection also includes the pinned schema's `agent_id` and a SQL-derived `rejection_kind`. Both remain present with `usage_details=false`. Agent identity, like key/team/session identity, is sensitive: keep the export private. No raw @@ -276,6 +281,9 @@ the call types and asks for a supported request-level export. ## Data And Limits +- Opt-in tag export includes every request tag on a row, not only agent tags. + Keep the file private; tags can contain personal identifiers. + - Accepted inputs are request-level SQL projections as headered CSV, a JSON array, or JSONL. LiteLLM Usage dashboard CSV/JSON exports are daily aggregates: the importer recognizes them and requests row-level data. API page envelopes @@ -349,7 +357,7 @@ the call types and asks for a supported request-level export. [session-budget.csv](session-budget.csv) is a newly authored, deterministic Apache-2.0 fixture with four sessions and no production data. The companion [license notice](session-budget.csv.license) keeps the CSV directly importable. -Use fleetdiff v0.13.1 for the session-budget command: +Use fleetdiff v0.14.0 after publication for the session-budget command: ```sh fleetdiff investigate --litellm-spend examples/litellm-spend/session-budget.csv \ diff --git a/examples/litellm-spend/TESTING.md b/examples/litellm-spend/TESTING.md index d5f477c..7e5c5b2 100644 --- a/examples/litellm-spend/TESTING.md +++ b/examples/litellm-spend/TESTING.md @@ -3,6 +3,33 @@ # LiteLLM Spend Export Checks +The captured row-coverage CSVs and `coverage_runtime_expected.json` retain their +original observations and provenance. The captured export and runtime harness +source hashes refer to commit +`73df8c41de98e55758291d5282314d00347e4a27`, not this evolving checkout. Inspect +that commit's `examples/litellm-spend/` source to reproduce those historical +digests; a later source edit is not a new historical run. No archived source +blob is needed. Current native export and psql recipe compatibility are +exercised separately by the pinned database checks below. + +## Agent Tag Shape + +LiteLLM v1.104.0 can retain non-string request-tag elements. Its +[`LiteLLM_SpendLogs` schema](https://github.com/BerriAI/litellm/blob/v1.104.0/schema.prisma#L667) +declares `request_tags` as nullable JSON, not a string array. Its +[spend writer](https://github.com/BerriAI/litellm/blob/v1.104.0/litellm/proxy/spend_tracking/spend_tracking_utils.py#L576) +serializes `metadata.tags` when it is a list without validating its elements; +the standard-logging-payload override is likewise JSON-serialized. The payload +type stores the result as a JSON string. Thus a mixed array is a possible +stored source value, not proof that the export itself is corrupt. + +Agent attribution keeps the entire such LiteLLM row in unknown identity with +`non_string_tag_rows` coverage; it does not salvage the string elements. Tests +exercise null, numeric, boolean, object and nested-array elements, mixed valid +labels, exact token/USD preservation and text/JSON privacy across CSV/JSON/JSONL. +Malformed JSON, invalid Unicode and oversized fields still fail. The Langfuse +adapter keeps its existing string-only tag validation and GENERATION boundary. + ## Documentation Checks Run the standard-library documentation checks without building or connecting to diff --git a/examples/litellm-spend/agent-tags.csv b/examples/litellm-spend/agent-tags.csv new file mode 100644 index 0000000..dc25b48 --- /dev/null +++ b/examples/litellm-spend/agent-tags.csv @@ -0,0 +1,9 @@ +request_id,call_type,startTime,endTime,status,model,prompt_tokens,completion_tokens,total_tokens,spend,agent_id,request_tags +before-a,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,different-id,"[""agent:alpha"",""other:private""]" +before-b,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,different-id,"[""agent:beta""]" +before-c,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,different-id,"[""agent:gamma""]" +after-a,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,300,0,300,3,different-id,"[""agent:alpha"",""agent:alpha"",""agent:""]" +after-b,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,100,0,100,1,different-id,"[""agent:beta""]" +after-c,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,100,0,100,1,different-id,"[""agent:gamma""]" +after-untagged,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,50,0,50,0.50,,"[""other:private""]" +after-ambiguous,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,70,0,70,0.70,,"[""agent:alpha"",""agent:beta""]" diff --git a/examples/litellm-spend/agent-tags.csv.license b/examples/litellm-spend/agent-tags.csv.license new file mode 100644 index 0000000..0f217b6 --- /dev/null +++ b/examples/litellm-spend/agent-tags.csv.license @@ -0,0 +1,5 @@ +SPDX-FileCopyrightText: 2026 Vijay and Codex +SPDX-License-Identifier: Apache-2.0 + +Newly authored deterministic synthetic data. No production records or private +source material are included. See the repository LICENSE for license terms. diff --git a/examples/litellm-spend/agents.csv b/examples/litellm-spend/agents.csv new file mode 100644 index 0000000..64d700c --- /dev/null +++ b/examples/litellm-spend/agents.csv @@ -0,0 +1,7 @@ +request_id,call_type,startTime,endTime,status,model,prompt_tokens,completion_tokens,total_tokens,spend,agent_id +before-a,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,alpha +before-b,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,beta +before-c,completion,2026-10-07T12:00:00Z,2026-10-07T12:00:01Z,success,synthetic-model,100,0,100,1,gamma +after-a,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,300,0,300,3,alpha +after-b,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,100,0,100,1,beta +after-c,completion,2026-10-08T12:00:00Z,2026-10-08T12:00:01Z,success,synthetic-model,100,0,100,1,gamma diff --git a/examples/litellm-spend/agents.csv.license b/examples/litellm-spend/agents.csv.license new file mode 100644 index 0000000..0f217b6 --- /dev/null +++ b/examples/litellm-spend/agents.csv.license @@ -0,0 +1,5 @@ +SPDX-FileCopyrightText: 2026 Vijay and Codex +SPDX-License-Identifier: Apache-2.0 + +Newly authored deterministic synthetic data. No production records or private +source material are included. See the repository LICENSE for license terms. diff --git a/examples/litellm-spend/export.sql b/examples/litellm-spend/export.sql index 07342c0..c6377b7 100644 --- a/examples/litellm-spend/export.sql +++ b/examples/litellm-spend/export.sql @@ -13,6 +13,10 @@ \else \set routing_details false \endif +\if :{?request_tags} +\else +\set request_tags false +\endif BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; SET LOCAL TIME ZONE 'UTC'; SET LOCAL search_path = pg_catalog; @@ -76,6 +80,11 @@ SELECT CASE WHEN :'routing_details'::boolean THEN $routing$ $routing$ ELSE '' END AS routing_projection \gset +SELECT CASE WHEN :'request_tags'::boolean THEN $tags$ + , request_tags::text AS request_tags +$tags$ ELSE '' END AS tag_projection +\gset + -- Define the projection once, quoting bounds as SQL literals with format(%L). -- Pinned Prisma DateTime columns are timestamp(3) without time zone, stored UTC. -- Do not copy this interpretation to a differently configured database. @@ -118,7 +127,7 @@ SELECT format($projection$ WHERE "startTime" >= (%L::timestamptz AT TIME ZONE 'UTC') AND "startTime" < (%L::timestamptz AT TIME ZONE 'UTC') ORDER BY "startTime", request_id COLLATE "C" -$projection$, :'detail_projection' || :'routing_projection', :'start_utc', :'end_utc') AS export_query +$projection$, :'detail_projection' || :'routing_projection' || :'tag_projection', :'start_utc', :'end_utc') AS export_query \gset -- Write one client-local file in one pass; JSONL replaces the default CSV. diff --git a/examples/litellm-spend/test-schema.sql b/examples/litellm-spend/test-schema.sql index 4716182..687ab5b 100644 --- a/examples/litellm-spend/test-schema.sql +++ b/examples/litellm-spend/test-schema.sql @@ -21,6 +21,7 @@ CREATE TABLE public."LiteLLM_SpendLogs" ( status text, session_id text, agent_id text, + request_tags jsonb DEFAULT '[]', "startTime" timestamp(3) NOT NULL, "endTime" timestamp(3) NOT NULL, metadata jsonb DEFAULT '{}', diff --git a/examples/litellm-spend/test_docs.py b/examples/litellm-spend/test_docs.py index 9ecfc1c..f837cb6 100644 --- a/examples/litellm-spend/test_docs.py +++ b/examples/litellm-spend/test_docs.py @@ -67,7 +67,7 @@ def test_readme_shape_and_previous_anchors(self): def test_native_release_promotion(self): readme = (ROOT / "README.md").read_text() - self.assertLess(readme.index("### 1. Install v0.13.1"), + self.assertLess(readme.index("### 1. Install v0.14.0"), readme.index("investigate --litellm-db")) self.assertIn("Manual Alternative: Export With psql", readme) self.assertNotIn("go build", readme) @@ -86,6 +86,23 @@ def test_native_release_promotion(self): self.assertNotRegex(content.lower(), r"unreleased v0\.12|native candidate|candidate (?:build|recipe|checkout)") + def test_v014_preparation_and_agent_guide(self): + readme = (ROOT / "README.md").read_text() + installer = (ROOT / "scripts/install.sh").read_text() + self.assertIn("v0.14.0", installer) + self.assertNotIn("v0.13.1", installer) + self.assertIn("publication", readme) + self.assertIn("v0.14.0", (ROOT / "docs/ROW_COVERAGE.md").read_text()) + guide = (ROOT / "docs/AGENT_ATTRIBUTION.md").read_text() + self.assertLess(guide.index("300 to 620 tokens"), guide.index("## Run")) + for phrase in ("200 of the 320-token", "62.5%", "project-1", "project-2", + "--show-labels --acknowledge-sensitive-labels"): + self.assertIn(phrase, guide) + notes = (ROOT / "docs/releases/v0.14.0.md").read_text() + self.assertIn("## Breaking Routing Label Disclosure", notes) + self.assertIn("--show-labels --acknowledge-sensitive-labels", notes) + self.assertIn("Not tagged or published", notes) + def test_langfuse_guide_release_pins(self): readme = (ROOT / "README.md").read_text() versions = re.findall(r"^### 1\. Install (v\d+\.\d+\.\d+)$", readme, re.M) @@ -112,7 +129,8 @@ def test_document_links(self): "SECURITY.md", "CHANGELOG.md", "docs/NATIVE_EXPORT.md", "docs/ROUTING.md", "docs/INSPECT.md", "docs/COMPARISON.md", "docs/releases/v0.11.0.md", "docs/releases/v0.12.0.md", "docs/releases/v0.13.1.md", - "docs/LANGFUSE.md", "examples/litellm-spend/TESTING.md")) + "docs/LANGFUSE.md", "docs/AGENT_ATTRIBUTION.md", "docs/releases/v0.14.0.md", + "examples/litellm-spend/TESTING.md")) for path in paths: for link in re.findall(r"\]\(([^)\s]+)\)", path.read_text()): parsed = urlsplit(link) diff --git a/examples/litellm-spend/test_native_export.py b/examples/litellm-spend/test_native_export.py index d18993e..a6590e3 100644 --- a/examples/litellm-spend/test_native_export.py +++ b/examples/litellm-spend/test_native_export.py @@ -112,8 +112,8 @@ def apply_documented_grants(container): grants = re.findall( r'GRANT SELECT\s*\([^;]+\)\s+ON TABLE public\."LiteLLM_(?:SpendLogs|DailyUserSpend)"' r"\s+TO fleetdiff_reader;", "\n".join(blocks)) - require(len(grants) == 2 and - sum('public."LiteLLM_SpendLogs"' in grant for grant in grants) == 1, + require(len(grants) == 3 and + sum('public."LiteLLM_SpendLogs"' in grant for grant in grants) == 2, "documented export column grants are missing or ambiguous") psql(container, "-c", '''REVOKE SELECT ON TABLE public."LiteLLM_SpendLogs", public."LiteLLM_DailyUserSpend" FROM fixture_reader;''' + @@ -188,7 +188,7 @@ def setUp(self): def destination(self): return self.parent + "/out-" + uuid.uuid4().hex - def command(self, directory, *, fmt=None, daily=False, routing=False, usage=True, + def command(self, directory, *, fmt=None, daily=False, routing=False, usage=True, tags=False, credential_file=None, url=URL, environment=None, start=START, end=END, extra=(), pid_file=None, user=None): env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": self.parent, @@ -204,6 +204,8 @@ def command(self, directory, *, fmt=None, daily=False, routing=False, usage=True args.append("--daily") if routing: args.append("--routing-details") + if tags: + args.append("--request-tags") if usage is not None: args.append("--usage-details=" + str(usage).lower()) if credential_file is not None: @@ -295,6 +297,16 @@ def test_all_format_routing_usage_combinations_exact_bytes(self): default = self.export(usage=None) assert_bytes(default["spend.csv"], spend.export_database(self.db)["csv"], "default spend") + def test_request_tag_column_type_is_opt_in(self): + psql(self.db, data=b'''ALTER TABLE public."LiteLLM_SpendLogs" + ALTER COLUMN request_tags DROP DEFAULT, + ALTER COLUMN request_tags TYPE text USING request_tags::text;''') + self.export(tags=True, success=False) + self.export() + psql(self.db, data=b'''ALTER TABLE public."LiteLLM_SpendLogs" DROP COLUMN request_tags;''') + self.export(tags=True, success=False) + self.export() + def test_exact_documented_column_grants_preserve_export_bytes(self): expected = spend.export_database(self.db, details=True, routing=True) daily = self.daily_oracle()["daily.csv"] @@ -304,6 +316,83 @@ def test_exact_documented_column_grants_preserve_export_bytes(self): assert_bytes(actual["spend." + fmt], expected[fmt], "column-granted spend") assert_bytes(actual["daily.csv"], daily, "column-granted daily") + def test_request_tags_exact_bytes_and_optional_grant(self): + psql(self.db, data=b'''UPDATE public."LiteLLM_SpendLogs" SET + request_tags='["agent:alpha", "unrelated-private-tag"]'::jsonb;''') + apply_documented_grants(self.db) + for routing in (False, True): + for usage in (False, True): + oracle = spend.export_database(self.db, details=usage, routing=routing, tags=True) + for fmt in ("csv", "jsonl"): + actual = self.export(fmt=fmt, routing=routing, usage=usage, tags=True) + assert_bytes(actual["spend." + fmt], oracle[fmt], "request tags") + if fmt == "csv": + rows = list(csv.DictReader(io.StringIO(actual["spend.csv"].decode()))) + else: + rows = [json.loads(line) for line in actual["spend.jsonl"].splitlines()] + require(all(json.loads(row["request_tags"]) == + ["agent:alpha", "unrelated-private-tag"] for row in rows), + "tag export lost or expanded tags") + offline = self.parent + "/agent-report.csv" + private_file(self.db, offline, oracle["csv"]) + for source in ("agent_id", "request-tag"): + flags = ["--before-period", "2026-10-07", "--after-period", "2026-10-08", + "--group-by", "agent", "--agent-source", source, + "--rank-by", "recorded-spend-change", "--format", "json", + "--hash-secret-env", "AGENT_TEST_SECRET"] + if source == "request-tag": + flags.extend(("--agent-tag-prefix", "agent:")) + prefix = ["docker", "exec", self.db, "env", "-i", + "PATH=/usr/local/bin:/usr/bin:/bin", "GOMAXPROCS=1", + "FLEETDIFF_LITELLM_DATABASE_URL=" + URL, + "AGENT_TEST_SECRET=synthetic-agent-test-secret-at-least-32-bytes", + "/native/fleetdiff", "investigate"] + local = run([*prefix, "--litellm-spend", offline, *flags]) + native = run([*prefix, "--litellm-db", *flags]) + self.check_result(local, True) + self.check_result(native, True) + assert_bytes(native.stdout, local.stdout, "agent database/file report") + psql(self.db, data=b'''REVOKE SELECT (request_tags) + ON public."LiteLLM_SpendLogs" FROM fixture_reader;''') + self.export() # Default projection still works without the optional grant. + self.export(tags=True, success=False) + + def test_non_string_request_tags_preserve_database_file_parity(self): + psql(self.db, data=b'''UPDATE public."LiteLLM_SpendLogs" SET + request_tags='["agent:PRIVATE_INVALID_LABEL", 1, null, false, {}, []]'::jsonb;''') + apply_documented_grants(self.db) + oracle = spend.export_database(self.db, tags=True) + for fmt in ("csv", "jsonl"): + actual = self.export(fmt=fmt, tags=True) + assert_bytes(actual["spend." + fmt], oracle[fmt], "mixed request tags") + offline = self.parent + "/mixed-agent-report.csv" + private_file(self.db, offline, oracle["csv"]) + flags = ["--before-period", "2026-10-07", "--after-period", "2026-10-08", + "--group-by", "agent", "--agent-source", "request-tag", + "--agent-tag-prefix", "agent:", "--rank-by", "recorded-spend-change", + "--show-labels", "--acknowledge-sensitive-labels", "--format", "json", + "--hash-secret-env", "AGENT_TEST_SECRET"] + prefix = ["docker", "exec", self.db, "env", "-i", + "PATH=/usr/local/bin:/usr/bin:/bin", "GOMAXPROCS=1", + "FLEETDIFF_LITELLM_DATABASE_URL=" + URL, + "AGENT_TEST_SECRET=synthetic-agent-test-secret-at-least-32-bytes", + "/native/fleetdiff", "investigate"] + local = run([*prefix, "--litellm-spend", offline, *flags]) + native = run([*prefix, "--litellm-db", *flags]) + self.check_result(local, True) + self.check_result(native, True) + assert_bytes(native.stdout, local.stdout, "mixed-tag database/file report") + require(b"PRIVATE_INVALID_LABEL" not in native.stdout, + "non-string row salvaged or disclosed a label") + report = json.loads(native.stdout) + for side in ("before", "after"): + bucket = report["agents"][side] + require(bucket["coverage"]["non_string_tag_rows"] == report[side]["logged_model_requests"], + "non-string coverage dropped analyzed rows") + require(bucket["unknown"]["recorded_tokens"] == report[side]["recorded_tokens"] + and bucket["unknown"]["recorded_spend_usd"] == report[side]["recorded_spend_usd"], + "non-string identity changed accounting") + def test_half_open_bounds_empty_windows_and_repeatability(self): for start, end in ((START, "2026-10-08T00:00:00Z"), ("2026-10-08T00:00:00Z", END), diff --git a/examples/litellm-spend/test_row_coverage.py b/examples/litellm-spend/test_row_coverage.py index fd1f35f..a1b8bba 100644 --- a/examples/litellm-spend/test_row_coverage.py +++ b/examples/litellm-spend/test_row_coverage.py @@ -67,10 +67,10 @@ class RowCoverageTests(unittest.TestCase): def test_recorded_fixture(self): coverage_runtime_fixture.check() expected = json.loads((ROOT / "coverage_runtime_expected.json").read_text()) - for name, digest in expected["provenance"]["export_source_sha256"].items(): - self.assertEqual(coverage_runtime_fixture.digest(ROOT / name), digest) - for name, digest in expected["provenance"]["runtime_harness_sha256"].items(): - self.assertEqual(coverage_runtime_fixture.digest(ROOT / name), digest) + # Historical source provenance is pinned in TESTING.md. Current native + # export/psql parity is exercised separately by test_native_export.py. + self.assertIn("export_source_sha256", expected["provenance"]) + self.assertIn("runtime_harness_sha256", expected["provenance"]) @unittest.skipUnless(BIN, "set FLEETDIFF_BIN for built CLI tests") def test_collision_fixture_and_safe_output(self): diff --git a/examples/litellm-spend/test_spend.py b/examples/litellm-spend/test_spend.py index 4b13405..7a2cdb2 100644 --- a/examples/litellm-spend/test_spend.py +++ b/examples/litellm-spend/test_spend.py @@ -318,7 +318,7 @@ def seed_database(container, records): def export_database(container, start="2026-10-07T00:00:00Z", end="2026-10-09T00:00:00Z", details=None, - routing=None): + routing=None, tags=None): exports = {} for fmt in ("csv", "jsonl"): directory = "/tmp/export-" + uuid.uuid4().hex @@ -332,6 +332,8 @@ def export_database(container, start="2026-10-07T00:00:00Z", end="2026-10-09T00: arguments.extend(("-v", "usage_details=" + str(details).lower())) if routing is not None: arguments.extend(("-v", "routing_details=" + str(routing).lower())) + if tags is not None: + arguments.extend(("-v", "request_tags=" + str(tags).lower())) output = psql(container, *arguments, "-f", "/example/export.sql", reader=True) require(not output.strip(), "SQL export leaked output outside its selected file") files = docker("exec", container, "ls", "-1", directory).splitlines() diff --git a/internal/cli/agents.go b/internal/cli/agents.go new file mode 100644 index 0000000..65d9c7e --- /dev/null +++ b/internal/cli/agents.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package cli + +import ( + "fmt" + "io" + "math/big" + "text/tabwriter" + + "github.com/llm-measurement/fleetdiff/internal/accounting" + "github.com/llm-measurement/fleetdiff/internal/compare" + "github.com/llm-measurement/fleetdiff/internal/spend" +) + +func agentUSD(v *string) string { + if v == nil { + return "unavailable" + } + return *v +} + +func agentShare(v *string) string { + if v == nil { + return "unavailable" + } + n, ok := new(big.Rat).SetString(*v) + if !ok { + return "unavailable" + } + return new(big.Rat).Mul(n, big.NewRat(100, 1)).FloatString(2) + "%" +} + +func agentLabel(c spend.AgentContributor) string { + label := routingLabel(spend.RoutingIdentity{Item: c.Item, Label: c.Label, Hash: c.Hash}) + if c.Label != "" && c.ProjectAlias != "" { + label += " (" + c.ProjectAlias + ")" + } + return label +} + +func renderAgents(out io.Writer, a *spend.AgentReport) { + fmt.Fprintf(out, "\nAgent attribution (%s); ranking: %s.\n", a.Source, a.RankBy) + if a.LabelsIncluded { + fmt.Fprintln(out, " Sensitive agent labels included; review before sharing.") + } + for _, side := range []struct { + name string + w spend.AgentWindow + }{{"Before", a.Before}, {"After", a.After}} { + c := side.w.Coverage + if a.Source == "agent_id" { + fmt.Fprintf(out, " %s: %d known, %d unknown rows; %d column-absent, %d empty/null.\n", side.name, c.KnownRows, c.UnknownRows, c.ColumnAbsentRows, c.EmptyOrNullRows) + } else { + fmt.Fprintf(out, " %s: %d known, %d unknown, %d ambiguous rows; %d column-absent, %d empty/null, %d without a matching tag.\n", side.name, c.KnownRows, c.UnknownRows, c.AmbiguousRows, c.ColumnAbsentRows, c.EmptyOrNullRows, c.NoMatchingTagRows) + fmt.Fprintf(out, " Repeated agent tags: %d in %d rows; empty suffixes: %d in %d rows.\n", c.RepeatedAgentTags, c.RepeatedAgentTagRows, c.EmptySuffixTags, c.EmptySuffixRows) + } + if c.NonStringTagRows > 0 { + fmt.Fprintf(out, " Non-string tag elements: %d rows counted as unknown identity.\n", c.NonStringTagRows) + } + usd := func(w spend.Window) string { + if w.Requests > 0 && w.Quality.SpendMissing+w.Quality.SpendInvalid == w.Requests { + return "unavailable" + } + return w.RecordedSpend + } + fmt.Fprintf(out, " Unknown: %d tokens / %s recorded USD", side.w.Unknown.Tokens, usd(side.w.Unknown)) + if a.Source != "agent_id" { + fmt.Fprintf(out, "; ambiguous: %d tokens / %s recorded USD", side.w.Ambiguous.Tokens, usd(side.w.Ambiguous)) + } + fmt.Fprintln(out, ".") + } + fmt.Fprintln(out, " Shares use all usable recorded USD in each period, including unknown, ambiguous and undisplayed agents. Missing/invalid costs remain unquantified; recorded USD is not an invoice.") + showCosts := false + for _, contributors := range [][]spend.AgentContributor{a.Contributors, a.UnavailableCosts} { + for _, c := range contributors { + for _, v := range []spend.AgentValues{c.Before, c.After} { + showCosts = showCosts || v.CostCoverage.Missing+v.CostCoverage.Invalid+v.CostCoverage.ZeroUnknown != 0 + } + } + } + if showCosts { + fmt.Fprintln(out, " Cost coverage columns show before -> after row counts.") + } else { + fmt.Fprintln(out, " All displayed agents have usable recorded USD in both periods.") + } + w := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprint(w, " Agent\tTokens before\tTokens after\tUSD before\tUSD after\tUSD change\tShare before\tShare after") + if showCosts { + fmt.Fprint(w, "\tUsable rows\tMissing rows\tInvalid rows\tZero-origin-unknown rows") + } + fmt.Fprintln(w) + writeContributor := func(c spend.AgentContributor) { + fmt.Fprintf(w, " %s\t%d\t%d\t%s\t%s\t%s\t%s\t%s", agentLabel(c), c.Before.Tokens, c.After.Tokens, agentUSD(c.Before.RecordedUSD), agentUSD(c.After.RecordedUSD), agentUSD(c.Delta), agentShare(c.Before.Share), agentShare(c.After.Share)) + if showCosts { + b, a := c.Before.CostCoverage, c.After.CostCoverage + fmt.Fprintf(w, "\t%d -> %d\t%d -> %d\t%d -> %d\t%d -> %d", b.Usable, a.Usable, b.Missing, a.Missing, b.Invalid, a.Invalid, b.ZeroUnknown, a.ZeroUnknown) + } + fmt.Fprintln(w) + } + for _, c := range a.Contributors { + writeContributor(c) + } + _ = w.Flush() + if len(a.UnavailableCosts) > 0 { + fmt.Fprintln(out, " USD change unavailable (not ranked; shown regardless of --top):") + for _, c := range a.UnavailableCosts { + writeContributor(c) + } + _ = w.Flush() + for _, c := range a.UnavailableCosts { + delta := int64(c.After.Tokens) - int64(c.Before.Tokens) + fmt.Fprintf(out, " %s: token change %s; recorded USD change unavailable.\n", c.Item, signedInterval(compare.Interval{Lower: delta, Upper: delta})) + } + } + buckets := []struct { + name string + before, after spend.Window + }{{"Unknown", a.Before.Unknown, a.After.Unknown}, {"Ambiguous", a.Before.Ambiguous, a.After.Ambiguous}} + if a.Source == "agent_id" { + buckets = buckets[:1] + } + for _, bucket := range buckets { + cost := func(v spend.Window) *big.Rat { + if v.Requests > 0 && v.Quality.SpendMissing+v.Quality.SpendInvalid == v.Requests { + return nil + } + n, _ := new(big.Rat).SetString(v.RecordedSpend) + return n + } + x, y := cost(bucket.before), cost(bucket.after) + delta := "unavailable" + if x != nil && y != nil { + delta = accounting.CostText(new(big.Rat).Sub(y, x)) + } + tokens := int64(bucket.after.Tokens) - int64(bucket.before.Tokens) + fmt.Fprintf(out, " %s (not ranked): %d -> %d rows; token change %s; recorded USD change %s.\n", bucket.name, bucket.before.Requests, bucket.after.Requests, signedInterval(compare.Interval{Lower: tokens, Upper: tokens}), delta) + } + fmt.Fprintf(out, " Known agents not displayed: %d -> %d rows; %d -> %d tokens; %s -> %s recorded USD.\n", a.UndisplayedBefore.Requests, a.UndisplayedAfter.Requests, a.UndisplayedBefore.Tokens, a.UndisplayedAfter.Tokens, agentUSD(a.UndisplayedBefore.RecordedUSD), agentUSD(a.UndisplayedAfter.RecordedUSD)) +} diff --git a/internal/cli/agents_test.go b/internal/cli/agents_test.go new file mode 100644 index 0000000..b992ee2 --- /dev/null +++ b/internal/cli/agents_test.go @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package cli + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/llm-measurement/fleetdiff/internal/exportdata" + "github.com/llm-measurement/fleetdiff/internal/spend" +) + +func agentCLIArgs(path string) []string { + return []string{"investigate", "--litellm-spend", path, "--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "request-tag", "--agent-tag-prefix", "agent:", "--rank-by", "recorded-spend-change", "--hash-secret-env", "AGENT_CLI_SECRET"} +} + +func TestAgentCLIFormatsAndPrivacy(t *testing.T) { + t.Setenv("AGENT_CLI_SECRET", "synthetic-agent-secret-for-tests-at-least-32-bytes") + data, err := os.ReadFile("../../examples/litellm-spend/agent-tags.csv") + if err != nil { + t.Fatal(err) + } + rows, err := csv.NewReader(bytes.NewReader(data)).ReadAll() + if err != nil { + t.Fatal(err) + } + var records []map[string]string + for _, row := range rows[1:] { + r := map[string]string{} + for i, k := range rows[0] { + r[k] = row[i] + } + records = append(records, r) + } + var jsonl bytes.Buffer + for _, r := range records { + if json.NewEncoder(&jsonl).Encode(r) != nil { + t.Fatal("encode fixture") + } + } + array, _ := json.Marshal(records) + var baseline string + for _, input := range []struct { + ext string + data []byte + }{{"csv", data}, {"json", array}, {"jsonl", jsonl.Bytes()}} { + path := filepath.Join(t.TempDir(), "agents."+input.ext) + if err := os.WriteFile(path, input.data, 0600); err != nil { + t.Fatal(err) + } + for _, format := range []string{"text", "json"} { + for _, disclose := range []bool{false, true} { + args := append(agentCLIArgs(path), "--format", format) + if disclose { + args = append(args, "--show-labels", "--acknowledge-sensitive-labels", "--routing-audit") + } + code, out, errs := databaseRun(args, nil) + if code != 0 || errs != "" { + t.Fatal("agent report failed", code, errs) + } + for _, secret := range []string{"other:private", "different-id", "before-a", path} { + if strings.Contains(out+errs, secret) { + t.Fatal("unselected identity leaked") + } + } + if strings.Contains(out, "alpha") != disclose { + t.Fatal("label disclosure mismatch") + } + if format == "json" { + var r spend.Report + if json.Unmarshal([]byte(out), &r) != nil || r.Agents.After.Coverage.RepeatedAgentTagRows != 1 || r.Agents.After.Coverage.EmptySuffixRows != 1 || r.After.RecordedSpend != "6.2" { + t.Fatal("partition/coverage mismatch") + } + if !disclose { + if baseline == "" { + baseline = out + } else if out != baseline { + t.Fatal("source format changed report") + } + } + if disclose && r.RoutingAudit.ConfigurationLabelsIncluded { + t.Fatal("agent disclosure expanded to routing labels") + } + } else if !strings.Contains(out, "USD change") || !strings.Contains(out, "Share after") || disclose && !strings.Contains(out, "review before sharing") { + t.Fatal("required columns or warning missing") + } + if format == "text" { + answer := "agent-1" + if disclose { + answer += ` "alpha"` + } + for _, want := range []string{ + answer + " accounts for 62.5% of the net recorded increase (+200 of +320 tokens)", + "Unknown (not ranked): 0 -> 1 rows; token change +50; recorded USD change 0.5", + "Ambiguous (not ranked): 0 -> 1 rows; token change +70; recorded USD change 0.7", + } { + if !strings.Contains(out, want) { + t.Fatal("answer or unattributed change missing", want) + } + } + } + } + } + } +} + +func TestAgentUnavailableCostReproduction(t *testing.T) { + t.Setenv("AGENT_CLI_SECRET", "synthetic-agent-secret-for-tests-at-least-32-bytes") + var input bytes.Buffer + w := csv.NewWriter(&input) + _ = w.Write([]string{"request_id", "call_type", "startTime", "endTime", "status", "model", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "agent_id", "request_tags"}) + for i, row := range []struct{ day, agent, tokens, cost string }{ + {"07", "alpha", "100", ""}, {"07", "beta", "100", "1"}, + {"08", "alpha", "900", "9"}, {"08", "beta", "100", "2"}, + {"08", "newcomer", "50", "0.50"}, + } { + start := "2026-10-" + row.day + "T12:00:00Z" + _ = w.Write([]string{string(rune('a' + i)), "completion", start, start, "success", "private-model", row.tokens, "0", row.tokens, row.cost, row.agent, "[]"}) + } + w.Flush() + path := filepath.Join(t.TempDir(), "unavailable.csv") + if err := os.WriteFile(path, input.Bytes(), 0600); err != nil { + t.Fatal(err) + } + args := []string{"investigate", "--litellm-spend", path, "--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "agent_id", "--rank-by", "recorded-spend-change", "--top", "1", "--hash-secret-env", "AGENT_CLI_SECRET"} + for _, disclose := range []bool{false, true} { + for _, format := range []string{"text", "json"} { + a := append(append([]string{}, args...), "--format", format) + if disclose { + a = append(a, "--show-labels", "--acknowledge-sensitive-labels") + } + code, out, errs := databaseRun(a, nil) + if code != 0 || errs != "" || strings.Contains(out, "alpha") != disclose { + t.Fatal("unavailable reproduction failed or leaked", code, errs) + } + if format == "json" { + var r spend.Report + if json.Unmarshal([]byte(out), &r) != nil || len(r.Agents.Contributors) != 1 || r.Agents.Contributors[0].Item != "agent-2" || len(r.Agents.UnavailableCosts) != 1 || r.Agents.UnavailableCosts[0].Item != "agent-1" || r.Agents.UnavailableCosts[0].After.Tokens-r.Agents.UnavailableCosts[0].Before.Tokens != 800 || r.Agents.UndisplayedAfter.Tokens != 50 { + t.Fatal("unavailable agent hidden in JSON") + } + } else if !strings.Contains(out, "USD change unavailable (not ranked; shown regardless of --top)") || !strings.Contains(out, "agent-1: token change +800; recorded USD change unavailable") { + t.Fatal("unavailable agent hidden in text") + } + } + } +} + +func TestAgentNonStringTagFormats(t *testing.T) { + t.Setenv("AGENT_CLI_SECRET", "synthetic-agent-secret-for-tests-at-least-32-bytes") + for _, raw := range []string{`["agent:PRIVATE_INVALID_LABEL",null]`, `[1,false,{},[]]`} { + rows := []map[string]string{} + for _, day := range []string{"07", "08"} { + rows = append(rows, map[string]string{"request_id": day, "call_type": "completion", "startTime": "2026-10-" + day + "T12:00:00Z", "endTime": "2026-10-" + day + "T12:00:01Z", "status": "success", "model": "private-model", "prompt_tokens": "100", "completion_tokens": "0", "total_tokens": "100", "spend": "1", "request_tags": raw}) + } + var csvData, jsonl bytes.Buffer + writer := csv.NewWriter(&csvData) + keys := []string{"request_id", "call_type", "startTime", "endTime", "status", "model", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "request_tags"} + _ = writer.Write(keys) + for _, row := range rows { + values := []string{} + for _, k := range keys { + values = append(values, row[k]) + } + _ = writer.Write(values) + _ = json.NewEncoder(&jsonl).Encode(row) + } + writer.Flush() + array, _ := json.Marshal(rows) + for ext, data := range map[string][]byte{"csv": csvData.Bytes(), "json": array, "jsonl": jsonl.Bytes()} { + path := filepath.Join(t.TempDir(), "non-string."+ext) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + for _, format := range []string{"json", "text"} { + code, out, errs := databaseRun(append(agentCLIArgs(path), "--format", format, "--show-labels", "--acknowledge-sensitive-labels"), nil) + if code != 0 || errs != "" || strings.Contains(out, "PRIVATE_INVALID_LABEL") { + t.Fatal("non-string tag rejected or label salvaged", code, errs) + } + if format == "json" { + var r spend.Report + if json.Unmarshal([]byte(out), &r) != nil || r.Agents.After.Coverage.NonStringTagRows != 1 || r.Agents.After.Unknown.Tokens != 100 || r.After.RecordedSpend != "1" { + t.Fatal("non-string coverage or totals wrong") + } + } else if !strings.Contains(out, "Non-string tag elements: 1 rows counted as unknown identity") { + t.Fatal("non-string coverage not explained") + } + } + } + } +} + +func TestAgentCLIRejectsBeforeDatabaseDispatch(t *testing.T) { + for _, flags := range [][]string{ + {"--group-by", "agent"}, + {"--group-by", "agent", "--agent-source", "request-tag"}, + {"--group-by", "agent", "--agent-source", "end-user"}, + {"--group-by", "agent", "--agent-source", "agent_id", "--agent-tag-prefix", "PRIVATE_PREFIX"}, + {"--group-by", "agent", "--agent-source", "agent_id", "--rank-by", "recorded-spend"}, + {"--group-by", "agent", "--agent-source", "request-tag", "--agent-tag-prefix", "agent:", "--request-tags=false"}, + {"--show-labels"}, {"--acknowledge-sensitive-labels"}, + {"--show-labels", "--acknowledge-sensitive-labels", "--group-by", "end-user"}, + } { + called := false + code, out, errs := databaseRun(append([]string{"investigate", "--litellm-db"}, flags...), func(context.Context, exportdata.Options) (exportdata.Result, error) { + called = true + return exportdata.Result{}, nil + }) + if code != 2 || called || out != "" || strings.Contains(errs, "PRIVATE_PREFIX") { + t.Fatal("invalid selector reached DB or leaked", code, errs) + } + } +} + +func TestAgentTextEscapesSelectedLabel(t *testing.T) { + var b bytes.Buffer + renderAgents(&b, &spend.AgentReport{Contributors: []spend.AgentContributor{{Item: "agent-1", Label: "name\n\x1b[31m\u202eend"}}}) + if !strings.Contains(b.String(), `"name\n\x1b[31m\u202eend"`) || strings.ContainsAny(b.String(), "\x1b\u202e") { + t.Fatal("label changed terminal structure") + } +} + +func TestAgentIDTextPolishAndEscapedAnswer(t *testing.T) { + t.Setenv("AGENT_CLI_SECRET", "synthetic-agent-secret-for-tests-at-least-32-bytes") + label := "alpha\n\x1b[31m\u202e" + var input bytes.Buffer + w := csv.NewWriter(&input) + _ = w.Write([]string{"request_id", "call_type", "startTime", "endTime", "status", "model", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "agent_id", "request_tags"}) + for i, row := range []struct{ day, agent, tokens, cost string }{ + {"07", label, "100", "1"}, {"07", "beta", "100", "1"}, + {"08", label, "300", "3"}, {"08", "beta", "100", "100"}, {"08", "", "50", "0.5"}, + } { + start := "2026-10-" + row.day + "T12:00:00Z" + _ = w.Write([]string{string(rune('a' + i)), "completion", start, start, "success", "private-model", row.tokens, "0", row.tokens, row.cost, row.agent, `["agent:x","agent:y"]`}) + } + w.Flush() + path := filepath.Join(t.TempDir(), "agents.csv") + if err := os.WriteFile(path, input.Bytes(), 0600); err != nil { + t.Fatal(err) + } + args := []string{"investigate", "--litellm-spend", path, "--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "agent_id", "--rank-by", "recorded-spend-change", "--top", "1", "--hash-secret-env", "AGENT_CLI_SECRET", "--show-labels", "--acknowledge-sensitive-labels"} + code, out, errs := databaseRun(args, nil) + if code != 0 || errs != "" { + t.Fatal(code, errs) + } + for _, want := range []string{`agent-1 "alpha\n\x1b[31m\u202e" accounts for`, "Agent identity unknown or ambiguous: 0 -> 1.", "All displayed agents have usable recorded USD in both periods."} { + if !strings.Contains(out, want) { + t.Fatal("agent polish missing", want, out) + } + } + for _, forbidden := range []string{"\x1b", "\u202e", "without a matching tag", "Repeated agent tags", "empty suffixes", "Ambiguous (not ranked)", "; ambiguous:", "Group identities missing", "Missing rows", "Zero-origin-unknown rows"} { + if strings.Contains(out, forbidden) { + t.Fatal("agent_id text includes inapplicable coverage or unsafe label", forbidden) + } + } + code, data, errs := databaseRun(append(args, "--format", "json"), nil) + var r spend.Report + if code != 0 || errs != "" || json.Unmarshal([]byte(data), &r) != nil { + t.Fatal("JSON failed", code, errs) + } + for _, field := range []string{`"ambiguous_rows": 0`, `"no_matching_tag_rows": 0`, `"empty_suffix_rows": 0`} { + if !strings.Contains(data, field) { + t.Fatal("JSON coverage was removed", field) + } + } + if r.Agents.Contributors[0].Item != "agent-2" { + t.Fatal("test must keep the token leader outside the displayed cost ranking") + } +} + +func TestAgentIncreaseNamesPositiveMoverWithOffsets(t *testing.T) { + t.Setenv("AGENT_CLI_SECRET", "synthetic-agent-secret-for-tests-at-least-32-bytes") + var input bytes.Buffer + w := csv.NewWriter(&input) + _ = w.Write([]string{"request_id", "call_type", "startTime", "endTime", "status", "model", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "agent_id"}) + for i, row := range []struct{ day, agent, tokens string }{ + {"07", "alpha", "1000"}, {"07", "beta", "0"}, + {"08", "alpha", "300"}, {"08", "beta", "600"}, {"08", "", "200"}, + } { + start := "2026-10-" + row.day + "T12:00:00Z" + _ = w.Write([]string{string(rune('a' + i)), "completion", start, start, "success", "private-model", row.tokens, "0", row.tokens, "0", row.agent}) + } + w.Flush() + path := filepath.Join(t.TempDir(), "agents.csv") + if err := os.WriteFile(path, input.Bytes(), 0600); err != nil { + t.Fatal(err) + } + for _, labels := range []bool{false, true} { + args := []string{"investigate", "--litellm-spend", path, "--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "agent_id", "--hash-secret-env", "AGENT_CLI_SECRET"} + name := "agent-2" + if labels { + args = append(args, "--show-labels", "--acknowledge-sensitive-labels") + name += ` "beta"` + } + code, out, errs := databaseRun(args, nil) + want := name + " added 600 tokens while others fell by 500 (net +100)." + if code != 0 || errs != "" || !strings.Contains(out, want) || strings.Contains(out, "600%") { + t.Fatal("wrong increase attribution", code, errs, out) + } + code, data, errs := databaseRun(append(args, "--format", "json"), nil) + var r spend.Report + if code != 0 || errs != "" || json.Unmarshal([]byte(data), &r) != nil { + t.Fatal("JSON failed", code, errs) + } + if r.Before.Tokens != 1000 || r.After.Tokens != 1100 || r.Increase.Count != 1 || r.Increase.Delta.Lower != 600 || r.Increase.Delta.Upper != 600 || r.Increase.Share.Lower != 6 || r.Rankings.Movers[0].Item != "agent-1" || r.Rankings.Movers[0].Delta.Lower != -700 { + t.Fatal("text correction changed JSON accounting or rankings", data) + } + } +} + +func TestAgentCostColumnsOnlyForDisplayedIncompleteCosts(t *testing.T) { + usd := "1" + value := spend.AgentValues{RecordedUSD: &usd, CostCoverage: spend.AgentCostCoverage{Usable: 1}} + for _, kind := range []string{"complete", "missing", "invalid", "zero-origin", "unavailable"} { + t.Run(kind, func(t *testing.T) { + c := spend.AgentContributor{Item: "agent-1", Before: value, After: value, Delta: &usd} + r := &spend.AgentReport{Source: "agent_id", Contributors: []spend.AgentContributor{c}} + r.UndisplayedAfter.CostCoverage.Missing = 99 + r.After.Unknown.Quality.SpendMissing = 10 + switch kind { + case "missing": + r.Contributors[0].Before.CostCoverage.Missing = 1 + case "invalid": + r.Contributors[0].After.CostCoverage.Invalid = 1 + case "zero-origin": + r.Contributors[0].After.CostCoverage.ZeroUnknown = 1 + case "unavailable": + c.Item, c.Delta, c.Before.RecordedUSD = "agent-2", nil, nil + c.Before.CostCoverage.Missing = 1 + r.UnavailableCosts = []spend.AgentContributor{c} + } + var out bytes.Buffer + renderAgents(&out, r) + for _, column := range []string{"Usable rows", "Missing rows", "Invalid rows", "Zero-origin-unknown rows"} { + if strings.Contains(out.String(), column) != (kind != "complete") { + t.Fatal("coverage columns did not follow displayed rows", column, out.String()) + } + } + if strings.Contains(out.String(), "All displayed agents have usable recorded USD in both periods.") != (kind == "complete") { + t.Fatal("complete-cost line mismatch") + } + }) + } +} + +func TestAgentTextProjectAliases(t *testing.T) { + var out bytes.Buffer + renderAgents(&out, &spend.AgentReport{Source: "request-tag", LabelsIncluded: true, + Contributors: []spend.AgentContributor{ + {Item: "agent-1", Label: "alpha", ProjectAlias: "project-1"}, + {Item: "agent-2", Label: "alpha", ProjectAlias: "project-2"}, + }}) + for _, want := range []string{`agent-1 "alpha" (project-1)`, `agent-2 "alpha" (project-2)`} { + if !strings.Contains(out.String(), want) { + t.Fatal("identical project-scoped labels are ambiguous", out.String()) + } + } +} diff --git a/internal/cli/database.go b/internal/cli/database.go index 90daf34..b132db8 100644 --- a/internal/cli/database.go +++ b/internal/cli/database.go @@ -31,6 +31,7 @@ Options: --format csv|jsonl Spend file format (default csv) --daily Include daily row counters --routing-details Include original-route and agent details + --request-tags Include every request tag as private JSON-array text --usage-details=false Omit default-on cache and usage details --database-url-file FILE Read credentials from an owner-only regular file @@ -45,6 +46,7 @@ private temporary data behind. type databaseFlags struct { enabled, daily, routing, usage bool + requestTags bool keep, credentials string } @@ -57,13 +59,14 @@ func (d *databaseFlags) register(f *flag.FlagSet) { f.BoolVar(&d.daily, "daily", false, "") f.BoolVar(&d.routing, "routing-details", false, "") f.BoolVar(&d.usage, "usage-details", true, "") + f.BoolVar(&d.requestTags, "request-tags", false, "") } func (d databaseFlags) validate(f *flag.FlagSet) error { var err error f.Visit(func(v *flag.Flag) { switch v.Name { - case "keep-export", "database-url-file", "daily", "routing-details", "usage-details": + case "keep-export", "database-url-file", "daily", "routing-details", "usage-details", "request-tags": if !d.enabled { err = errors.New("database export options require --litellm-db") } else if (v.Name == "keep-export" || v.Name == "database-url-file") && strings.TrimSpace(v.Value.String()) == "" { @@ -165,6 +168,7 @@ func (d databaseFlags) export(ctx context.Context, p spend.Period, exporter expo r, err := exporter(ctx, exportdata.Options{ Directory: directory, CredentialFile: d.credentials, Start: p.Start, End: p.End, Format: "csv", Daily: d.daily, Routing: true, UsageDetails: d.usage, + RequestTags: d.requestTags, }) if err != nil || ctx.Err() != nil { cleanup() @@ -280,6 +284,7 @@ func runExport(ctx context.Context, args []string, out, errout io.Writer, export f.BoolVar(&opts.Daily, "daily", false, "") f.BoolVar(&opts.Routing, "routing-details", false, "") f.BoolVar(&opts.UsageDetails, "usage-details", true, "") + f.BoolVar(&opts.RequestTags, "request-tags", false, "") if err := f.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { if _, err := io.WriteString(out, exportHelp); err != nil { diff --git a/internal/cli/database_test.go b/internal/cli/database_test.go index 4ed29bd..7b6b9bb 100644 --- a/internal/cli/database_test.go +++ b/internal/cli/database_test.go @@ -78,7 +78,7 @@ func TestDatabaseExportOptions(t *testing.T) { t.Run(map[bool]string{false: "defaults", true: "explicit"}[explicit], func(t *testing.T) { args := []string{"export", "litellm", "--out", filepath.Join(t.TempDir(), "SENTINEL_DIRECTORY")} if explicit { - args = append(args, "--from", "2026-10-01", "--to", "2026-10-15T00:00:00Z", "--format", "jsonl", "--daily", "--routing-details", "--usage-details=false", "--database-url-file", "SENTINEL_CREDENTIAL_FILE") + args = append(args, "--from", "2026-10-01", "--to", "2026-10-15T00:00:00Z", "--format", "jsonl", "--daily", "--routing-details", "--request-tags", "--usage-details=false", "--database-url-file", "SENTINEL_CREDENTIAL_FILE") } var got exportdata.Options calls := 0 @@ -95,10 +95,10 @@ func TestDatabaseExportOptions(t *testing.T) { t.Fatalf("incorrect range/destination: %+v", got) } if explicit { - if got.Format != "jsonl" || !got.Daily || !got.Routing || got.UsageDetails || got.CredentialFile != "SENTINEL_CREDENTIAL_FILE" || got.Start.Format(time.DateOnly) != "2026-10-01" { + if got.Format != "jsonl" || !got.Daily || !got.Routing || !got.RequestTags || got.UsageDetails || got.CredentialFile != "SENTINEL_CREDENTIAL_FILE" || got.Start.Format(time.DateOnly) != "2026-10-01" { t.Fatalf("explicit options lost: %+v", got) } - } else if got.Format != "csv" || got.Daily || got.Routing || !got.UsageDetails || !got.End.Equal(before.UTC().Truncate(24*time.Hour)) && !got.End.Equal(time.Now().UTC().Truncate(24*time.Hour)) { + } else if got.Format != "csv" || got.Daily || got.Routing || got.RequestTags || !got.UsageDetails || !got.End.Equal(before.UTC().Truncate(24*time.Hour)) && !got.End.Equal(time.Now().UTC().Truncate(24*time.Hour)) { t.Fatalf("wrong defaults: %+v", got) } }) @@ -269,6 +269,8 @@ func TestDatabaseReportsMatchExistingFilePaths(t *testing.T) { flags []string }{ {"spend", "investigate", "litellm-spend/synthetic.csv", "", []string{"--before-period", "2026-10-07", "--after-period", "2026-10-08"}}, + {"agent-id", "investigate", "litellm-spend/agents.csv", "", []string{"--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "agent_id", "--rank-by", "recorded-spend-change"}}, + {"agent-tags", "investigate", "litellm-spend/agent-tags.csv", "", []string{"--before-period", "2026-10-07", "--after-period", "2026-10-08", "--group-by", "agent", "--agent-source", "request-tag", "--agent-tag-prefix", "agent:", "--show-labels", "--acknowledge-sensitive-labels"}}, {"coverage", "investigate", "litellm-spend/coverage_runtime_spend.csv", "litellm-spend/coverage_runtime_daily.csv", []string{"--before-period", "2026-10-07", "--after-period", "2026-10-08"}}, {"routing", "investigate", "routing/synthetic.csv", "", []string{"--before-period", "2026-10-01", "--after-period", "2026-10-02", "--routing-audit"}}, {"scope", "investigate", "routing/synthetic.csv", "", []string{"--before-period", "2026-10-01", "--after-period", "2026-10-02", "--scope-route", "coding-default"}}, @@ -308,6 +310,9 @@ func TestDatabaseReportsMatchExistingFilePaths(t *testing.T) { if !opts.Routing || !opts.UsageDetails || opts.Daily != (tc.daily != "") { t.Fatalf("wrong options: %+v", opts) } + if opts.RequestTags != (tc.name == "agent-tags") { + t.Fatal("tag source did not control tag export") + } if _, err := os.Stat(filepath.Dir(opts.Directory)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("temporary parent remains: %v", err) } diff --git a/internal/cli/routing_labels_test.go b/internal/cli/routing_labels_test.go index e13cd4c..857b6a7 100644 --- a/internal/cli/routing_labels_test.go +++ b/internal/cli/routing_labels_test.go @@ -17,7 +17,7 @@ func TestRoutingShowLabelsPrivacyBoundary(t *testing.T) { path := routingCLIInput(t, true) for _, format := range []string{"text", "json"} { var out, errs bytes.Buffer - args := append(routingCLIArgs(path), "--routing-audit", "--routing-by", "route-agent", "--show-labels", "--show-hashes", "--format", format) + args := append(routingCLIArgs(path), "--routing-audit", "--routing-by", "route-agent", "--show-labels", "--acknowledge-sensitive-labels", "--show-hashes", "--format", format) if code := Run(args, &out, &errs); code != 0 || errs.Len() != 0 { t.Fatal("opted-in report failed", code, errs.String()) } diff --git a/internal/cli/run.go b/internal/cli/run.go index c41f369..2652ba4 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -82,10 +82,14 @@ Options: --provider-counts-contract FILE UTC scope and primary counting-basis JSON --before-period PERIOD UTC date or RFC3339 start/end (requires after-period) --after-period PERIOD UTC date or RFC3339 start/end (equal duration) - --group-by KIND key (default), team, user, end-user or session + --group-by KIND key (default), team, user, end-user, session or agent + --agent-source SOURCE agent_id (LiteLLM) or request-tag (explicit) + --agent-tag-prefix PREFIX Exact, case-sensitive prefix for request-tag attribution + --rank-by KIND recorded-spend-change for agent ranking --routing-audit Show original-route distributions and attribution coverage --routing-by KIND route (default) or route-agent; requires routing-audit - --show-labels Include route/agent configuration labels; requires routing-audit + --show-labels Include selected agent labels (or routing-audit labels) + --acknowledge-sensitive-labels Required with show-labels; review before sharing --scope-route ID Investigate one recorded original route (explicit periods) --scope-agent ID Investigate one recorded agent (explicit periods) --period PERIOD Single-period session review: UTC date or RFC3339 start/end @@ -175,6 +179,9 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro beforePeriod := flags.String("before-period", "", "") afterPeriod := flags.String("after-period", "", "") group := flags.String("group-by", "key", "") + agentSource := flags.String("agent-source", "", "") + agentPrefix := flags.String("agent-tag-prefix", "", "") + rankBy := flags.String("rank-by", "", "") secret := flags.String("hash-secret-env", "", "") period := flags.String("period", "", "") budget := flags.String("session-budget-usd", "", "") @@ -182,6 +189,7 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro routingAudit := flags.Bool("routing-audit", false, "") routingBy := flags.String("routing-by", "", "") showLabels := flags.Bool("show-labels", false, "") + ackLabels := flags.Bool("acknowledge-sensitive-labels", false, "") var scopeRoute, scopeAgent string var routeSet, agentSet bool flags.Func("scope-route", "", func(value string) error { @@ -218,6 +226,12 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro if err := database.validate(flags); err != nil { return fail(2, err.Error()) } + if *showLabels != *ackLabels { + return fail(2, "show-labels and acknowledge-sensitive-labels must be supplied together") + } + if *showLabels && !*routingAudit && *group != "agent" { + return fail(2, "show-labels requires agent grouping or routing-audit; identity rankings stay alias-only") + } spendMode, incompatible := database.enabled, false sessionMode, windowSet, emptySessionOption, twoPeriods := false, false, false, false groupExplicit := false @@ -226,8 +240,8 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro routingSet, routingBySet := false, false flags.Visit(func(f *flag.Flag) { switch f.Name { - case "litellm-db", "keep-export", "database-url-file", "daily", "routing-details", "usage-details": - case "routing-audit", "routing-by", "scope-route", "scope-agent", "show-labels": + case "litellm-db", "keep-export", "database-url-file", "daily", "routing-details", "usage-details", "request-tags": + case "routing-audit", "routing-by", "scope-route", "scope-agent": spendMode, routingSet = true, true routingBySet = routingBySet || f.Name == "routing-by" case "litellm-daily-spend": @@ -243,6 +257,8 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro spendMode, windowSet = true, true case "group-by": spendMode, groupExplicit = true, true + case "agent-source", "agent-tag-prefix", "rank-by", "show-labels", "acknowledge-sensitive-labels": + spendMode = true case "litellm-spend", "langfuse-export", "langfuse-tag", "before-period", "after-period", "hash-secret-env": spendMode = true case "format", "top", "show-hashes": @@ -254,6 +270,22 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro } }) if spendMode { + agentOpts := spend.Options{GroupBy: *group, AgentSource: *agentSource, AgentTagPrefix: *agentPrefix, RankBy: *rankBy, ScopeAgent: scopeAgent} + if err := spend.ValidateAgentOptions(agentOpts); err != nil { + return fail(2, err.Error()) + } + if *group == "agent" && (sessionMode || windowSet) { + return fail(2, "agent grouping requires a two-period investigation") + } + tagOptOut := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "request-tags" && !database.requestTags && *agentSource == "request-tag" { + tagOptOut = true + } + }) + if tagOptOut { + return fail(2, "request-tag attribution requires tag export; do not combine with request-tags=false") + } langfuseSet := false flags.Visit(func(f *flag.Flag) { if f.Name == "langfuse-export" || f.Name == "langfuse-tag" { @@ -267,7 +299,7 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro if !groupExplicit { *group = "user_id" } - return runLangfuse(*langfusePath, *group, *langfuseTag, *format, spend.Options{BeforePeriod: *beforePeriod, AfterPeriod: *afterPeriod, SecretEnv: *secret, Top: *top, ShowHashes: *hashes}, out, errout) + return runLangfuse(*langfusePath, *group, *langfuseTag, *format, spend.Options{BeforePeriod: *beforePeriod, AfterPeriod: *afterPeriod, SecretEnv: *secret, Top: *top, ShowHashes: *hashes, AgentSource: *agentSource, AgentTagPrefix: *agentPrefix, RankBy: *rankBy, ShowLabels: *showLabels, AcknowledgeSensitiveLabels: *ackLabels}, out, errout) } if providerSet && (emptyProviderOption || *providerCountsPath == "" || sessionMode || windowSet || routeSet || agentSet) { return fail(2, "provider coverage needs a nonempty aggregate CSV and an unscoped two-period spend investigation") @@ -285,9 +317,6 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro if routingBySet && (!*routingAudit || *routingBy != "route" && *routingBy != "route-agent") { return fail(2, "routing-by must be route or route-agent and requires routing-audit") } - if *showLabels && !*routingAudit { - return fail(2, "show-labels requires routing-audit") - } if routeSet && agentSet || routeSet && (strings.TrimSpace(scopeRoute) == "" || len(scopeRoute) > spend.MaxFieldBytes || database.enabled && !utf8.ValidString(scopeRoute)) || agentSet && (strings.TrimSpace(scopeAgent) == "" || len(scopeAgent) > spend.MaxFieldBytes || database.enabled && !utf8.ValidString(scopeAgent)) { return fail(2, "choose one nonempty route or agent scope of at most 8192 bytes") } @@ -303,7 +332,7 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro } } if database.enabled { - if *format != "text" && *format != "json" || *top < 1 || *top > 100 || !slices.Contains([]string{"key", "team", "user", "end-user", "session"}, *group) { + if *format != "text" && *format != "json" || *top < 1 || *top > 100 || !slices.Contains([]string{"key", "team", "user", "end-user", "session", "agent"}, *group) { return fail(2, "use text or json output, top between 1 and 100, and a supported spend group") } key, _, err := attribution.Secret(*secret) @@ -336,6 +365,9 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro if err := validateDatabaseDaily(p, database.daily); err != nil { return fail(2, err.Error()) } + if *group == "agent" && *agentSource == "request-tag" { + database.requestTags = true + } r, cleanup, err := database.export(ctx, p, exporter) defer cleanup() if err != nil { @@ -350,7 +382,9 @@ func RunWithExporter(ctx context.Context, args []string, in io.Reader, out, erro BeforePeriod: *beforePeriod, AfterPeriod: *afterPeriod, GroupBy: *group, SecretEnv: *secret, Top: *top, ShowHashes: *hashes, DailySpendPath: *dailySpendPath, RoutingAudit: *routingAudit, RoutingBy: *routingBy, ScopeRoute: scopeRoute, ScopeAgent: scopeAgent, - ShowLabels: *showLabels, + ShowLabels: *showLabels, + AcknowledgeSensitiveLabels: *ackLabels, + AgentSource: *agentSource, AgentTagPrefix: *agentPrefix, RankBy: *rankBy, ProviderCountsPath: *providerCountsPath, ProviderModelMapPath: *providerModelMapPath, ProviderContractPath: *providerContractPath, }, out, errout) diff --git a/internal/cli/spend.go b/internal/cli/spend.go index 22ce7f3..c55b0ea 100644 --- a/internal/cli/spend.go +++ b/internal/cli/spend.go @@ -113,14 +113,54 @@ func renderSpend(out io.Writer, r spend.Report) { } fmt.Fprintln(out) } + if r.Agents != nil { + if c := r.Increase; c != nil && !r.Incomplete { + label, verb := fmt.Sprintf("%d leading tracked agents", c.Count), "account" + if c.Count == 1 { + for _, m := range r.Rankings.Movers { + if m.Delta.Lower > 0 { + label, verb = m.Item, "accounts" + break + } + } + if leading := r.Agents.LeadingContributor; leading != nil && leading.Item == label { + label = agentLabel(*leading) + } + } + net := int64(r.After.Tokens - r.Before.Tokens) + if c.Delta.Upper > net { + if c.Delta.Lower >= net { + fell := compare.Interval{Lower: c.Delta.Lower - net, Upper: c.Delta.Upper - net} + fmt.Fprintf(out, " %s added %s tokens while others fell by %s (net +%s).\n", label, spendInterval(c.Delta), spendInterval(fell), spendNumber(net)) + } else { + changed := compare.Interval{Lower: net - c.Delta.Upper, Upper: net - c.Delta.Lower} + fmt.Fprintf(out, " %s added %s tokens while others changed by %s (net +%s).\n", label, spendInterval(c.Delta), signedInterval(changed), spendNumber(net)) + } + } else { + share := spendPercent(100 * c.Share.Lower) + if c.Delta.Lower != c.Delta.Upper { + lower, upper := c.Share.Lower*10000, c.Share.Upper*10000 + if c.Share.Lower == c.Share.Upper { + lower, upper = math.Nextafter(lower, math.Inf(-1)), math.Nextafter(upper, math.Inf(1)) + } + share = fmt.Sprintf("[%s, %s]", spendPercent(math.Floor(lower)/100), spendPercent(math.Ceil(upper)/100)) + } + fmt.Fprintf(out, " %s %s for %s of the net recorded increase (%s of +%s tokens).\n", label, verb, share, signedInterval(c.Delta), spendNumber(r.After.Tokens-r.Before.Tokens)) + } + } + renderAgents(out, r.Agents) + } for _, m := range r.Rankings.Movers { + if r.Agents != nil { + break + } label := m.Item if m.Hash != "" { label += " (" + m.Hash + ")" } fmt.Fprintf(out, " %s: %s -> %s tokens; change %s.\n", label, spendInterval(m.Before), spendInterval(m.After), signedInterval(m.Delta)) } - if c := r.Increase; c != nil && !r.Incomplete { + if c := r.Increase; c != nil && !r.Incomplete && r.Agents == nil { kind := r.GroupBy verb := "accounts" if c.Count != 1 { @@ -149,7 +189,7 @@ func renderSpend(out io.Writer, r spend.Report) { fmt.Fprintf(out, " %s leading tracked %s %s for %s of the net recorded increase.\n", spendNumber(c.Count), kind, verb, share) } } - if len(r.Rankings.Movers) == 0 { + if len(r.Rankings.Movers) == 0 && r.Agents == nil { fmt.Fprintln(out, " No tracked contributors with attributable recorded tokens.") } fmt.Fprintf(out, " Attributed tokens: %s -> %s.\n", spendNumber(r.Before.AttributedTokens), spendNumber(r.After.AttributedTokens)) @@ -199,6 +239,10 @@ func renderSpend(out io.Writer, r spend.Report) { } } a, b := r.Before.Quality, r.After.Quality + identityLabel := "Group identities missing" + if r.Agents != nil { + identityLabel = "Agent identity unknown or ambiguous" + } var levels []string for _, level := range []string{"DEFAULT", "DEBUG", "WARNING", "ERROR", "unknown"} { if a.ObservationLevels[level] != 0 || b.ObservationLevels[level] != 0 { @@ -230,7 +274,7 @@ func renderSpend(out io.Writer, r spend.Report) { {"Total field absent", a.TotalMissing, b.TotalMissing}, {"Failed records (overlap usage categories)", a.Failed, b.Failed}, {"Unknown status", a.UnknownStatus, b.UnknownStatus}, - {"Group identities missing", a.MissingIdentity, b.MissingIdentity}, + {identityLabel, a.MissingIdentity, b.MissingIdentity}, } { if v.a != 0 || v.b != 0 { problems = true diff --git a/internal/exportdata/export.go b/internal/exportdata/export.go index 413b146..4a91d59 100644 --- a/internal/exportdata/export.go +++ b/internal/exportdata/export.go @@ -14,6 +14,7 @@ type Options struct { Start, End time.Time Format string Daily, Routing, UsageDetails bool + RequestTags bool } type Result struct { diff --git a/internal/langfuse/agents_test.go b/internal/langfuse/agents_test.go new file mode 100644 index 0000000..10effd7 --- /dev/null +++ b/internal/langfuse/agents_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package langfuse + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAgentTagsProjectScopeAndCoverage(t *testing.T) { + root := t.TempDir() + for _, project := range []string{"one", "two"} { + body := strings.ReplaceAll(rowJSON("a", project, "2026-10-10"), `["PRIVATE_TAG"]`, `["agent:alpha","agent:alpha","agent:"]`) + body += strings.ReplaceAll(rowJSON("b", project, "2026-10-17"), `["PRIVATE_TAG"]`, `["agent:alpha","agent:beta"]`) + addRun(t, root, project, "run", "2026-10-10T00:00:00Z", "2026-10-18T00:00:00Z", body, 2) + } + o := options() + o.AgentSource, o.AgentTagPrefix = "request-tag", "agent:" + r, err := Investigate(root, "agent", "", o) + if err != nil { + t.Fatal(err) + } + if r.Before.Tokens != 280 || r.After.Tokens != 280 || r.Agents.Before.Known.Requests != 2 || r.Agents.Before.Coverage.RepeatedAgentTagRows != 2 || r.Agents.Before.Coverage.EmptySuffixTags != 2 || r.Agents.After.Ambiguous.Requests != 2 || len(r.Rankings.Movers) != 2 { + t.Fatal("project or tag attribution lost", r.Agents) + } + if len(r.Agents.Contributors) != 2 || r.Agents.Contributors[0].Item == r.Agents.Contributors[1].Item { + t.Fatal("projects merged in exact table") + } + o.ShowLabels, o.AcknowledgeSensitiveLabels = true, true + shown, err := Investigate(root, "agent", "", o) + if err != nil || shown.Agents.Contributors[0].Label != "alpha" || shown.Agents.Contributors[1].Label != "alpha" || shown.Before.Tokens != r.Before.Tokens { + t.Fatal("Langfuse disclosure or scope changed", err) + } + if shown.Agents.Contributors[0].ProjectAlias != "project-1" || shown.Agents.Contributors[1].ProjectAlias != "project-2" { + t.Fatal("project labels lack stable aliases", shown.Agents.Contributors) + } + data, _ := json.Marshal(shown.Agents) + if strings.Contains(string(data), "project-") || strings.Contains(string(data), "ProjectAlias") || strings.Contains(string(data), "LeadingContributor") { + t.Fatal("text-only disambiguation changed JSON") + } + for _, c := range r.Agents.Contributors { + if c.ProjectAlias != "" || c.Label != "" { + t.Fatal("undisclosed project metadata escaped") + } + } + o.AgentSource, o.AgentTagPrefix = "agent_id", "" + if _, err := Investigate(root, "agent", "", o); err == nil { + t.Fatal("Langfuse agent_id inference accepted") + } +} + +func TestAgentTagDuplicateNormalizationAndValidation(t *testing.T) { + root := t.TempDir() + body := strings.ReplaceAll(rowJSON("a", "project", "2026-10-10"), `["PRIVATE_TAG"]`, `["agent:alpha", "other:b", "agent:alpha"]`) + duplicate := strings.ReplaceAll(body, `["agent:alpha", "other:b", "agent:alpha"]`, `["other:b","agent:alpha","agent:alpha"]`) + addRun(t, root, "project", "run", "2026-10-10T00:00:00Z", "2026-10-18T00:00:00Z", body+duplicate, 2) + o := options() + o.AgentSource, o.AgentTagPrefix = "request-tag", "agent:" + r, err := Investigate(root, "agent", "", o) + if err != nil || r.Before.Requests != 1 || r.Coverage.Duplicates != 1 || r.Agents.Before.Coverage.RepeatedAgentTags != 1 { + t.Fatal("duplicate tag normalization changed accounting", err) + } + root = t.TempDir() + body = strings.ReplaceAll(rowJSON("a", "project", "2026-10-10"), `["PRIVATE_TAG"]`, `[null]`) + addRun(t, root, "project", "run", "2026-10-10T00:00:00Z", "2026-10-18T00:00:00Z", body, 1) + if _, err := Investigate(root, "agent", "", o); err == nil { + t.Fatal("malformed observation tag array accepted") + } +} diff --git a/internal/langfuse/directory.go b/internal/langfuse/directory.go index 5fe19b0..0387d25 100644 --- a/internal/langfuse/directory.go +++ b/internal/langfuse/directory.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "io/fs" + "maps" "math/big" "os" "path" @@ -453,6 +454,12 @@ func mappedRow(p projection, group, tag string) (spend.Row, [32]byte, projection } } // All group identities are project-scoped; raw labels never reach output. + if group == "agent" { + row.AgentNamespace = project + if raw := p.fields["tags"]; len(raw) > 0 { + v["request_tags"] = string(raw) + } + } v["api_key"] = framed("langfuse", project) if group == "user_id" { if v["user"] != "" { @@ -478,6 +485,19 @@ func mappedRow(p projection, group, tag string) (spend.Row, [32]byte, projection } // Compare normalized approved fields, not encoding-specific raw JSON/CSV. canonical := map[string]any{"accounting/grouping": v, "type": kind, "level": row.ObservationLevel, "start_time": v["startTime"], "end_time": v["endTime"], "total_cost": v["spend"]} + if group == "agent" { + grouping := maps.Clone(v) + if raw, ok := v["request_tags"]; ok { + tags, err := spend.ParseRequestTags(raw) + if err != nil { + return row, digest, projectionFingerprint{}, err + } + slices.Sort(tags) + normalized, _ := json.Marshal(tags) + grouping["request_tags"] = string(normalized) + } + canonical["accounting/grouping"] = grouping + } for _, name := range []string{"user_id", "session_id", "provided_model_name", "environment", "release", "trace_name", "prompt_name"} { s, _ := text(p.fields[name]) canonical[name] = s @@ -532,9 +552,17 @@ func Investigate(dir, group, tag string, opts spend.Options) (Report, error) { if group == "" { group = "user_id" } - if !slices.Contains([]string{"user_id", "session_id", "provided_model_name", "environment", "release", "trace_name", "prompt_name", "tag"}, group) || group == "tag" && (strings.TrimSpace(tag) == "" || len(tag) > MaxFieldBytes) || group != "tag" && tag != "" { + if !slices.Contains([]string{"user_id", "session_id", "provided_model_name", "environment", "release", "trace_name", "prompt_name", "tag", "agent"}, group) || group == "tag" && (strings.TrimSpace(tag) == "" || len(tag) > MaxFieldBytes) || group != "tag" && tag != "" { return Report{}, errors.New("unsupported Langfuse group; tag grouping requires --langfuse-tag") } + if group == "agent" && opts.AgentSource != "request-tag" { + return Report{}, errors.New("Langfuse agent grouping requires agent-source request-tag") + } + check := opts + check.GroupBy = group + if err := spend.ValidateAgentOptions(check); err != nil { + return Report{}, err + } snap, err := loadDirectory(dir, group, tag) if err != nil { return Report{}, err @@ -543,6 +571,9 @@ func Investigate(dir, group, tag string, opts spend.Options) (Report, error) { if opts.GroupBy == "" { opts.GroupBy = "key" } + if group == "agent" { + opts.GroupBy = "agent" + } opts.ModelAliases = group == "provided_model_name" r, err := spend.InvestigateRows(func(visit func(spend.Row) error) error { for _, row := range snap.rows { diff --git a/internal/postgres/export.go b/internal/postgres/export.go index e7402ba..33a3f86 100644 --- a/internal/postgres/export.go +++ b/internal/postgres/export.go @@ -70,7 +70,7 @@ SET LOCAL idle_in_transaction_session_timeout = '120s'; SET LOCAL client_encoding = 'UTF8';`); err != nil { return result, err } - if err = checkSchema(ctx, conn, o.Daily); err != nil { + if err = checkSchema(ctx, conn, o.Daily, o.RequestTags); err != nil { return result, err } name := "spend." + o.Format diff --git a/internal/postgres/query.go b/internal/postgres/query.go index 901fb14..9ebcbcc 100644 --- a/internal/postgres/query.go +++ b/internal/postgres/query.go @@ -40,16 +40,23 @@ func queries(o exportdata.Options) (string, string, error) { if err != nil { return "", "", err } + tags, err := recipeBlock("$tags$") + if err != nil { + return "", "", err + } if !o.UsageDetails { details = "" } if !o.Routing { routing = "" } + if !o.RequestTags { + tags = "" + } if strings.Count(query, "%s") != 1 || strings.Count(query, "%L") != 2 { return "", "", errProjection } - query = strings.Replace(query, "%s", details+routing, 1) + query = strings.Replace(query, "%s", details+routing+tags, 1) query = strings.Replace(query, "%L", sqlTime(o.Start), 1) query = strings.Replace(query, "%L", sqlTime(o.End), 1) daily := litellmsql.DailyRecipe diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index 7ef7f2d..51bc86a 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -13,7 +13,7 @@ import ( var errSchema = exportdata.ErrSchema -func checkSchema(ctx context.Context, c *pgconn.PgConn, daily bool) error { +func checkSchema(ctx context.Context, c *pgconn.PgConn, daily, requestTags bool) error { spend := map[string]uint32{} for _, name := range []string{"request_id", "call_type", "api_key", "user", "team_id", "organization_id", "end_user", "model", "model_id", "model_group", "custom_llm_provider", "status", "session_id", "agent_id"} { spend[name] = 25 @@ -22,6 +22,9 @@ func checkSchema(ctx context.Context, c *pgconn.PgConn, daily bool) error { spend[name] = 23 } spend["spend"], spend["metadata"], spend["startTime"], spend["endTime"] = 701, 3802, 1114, 1114 + if requestTags { + spend["request_tags"] = 3802 + } notNull := map[string]bool{} for _, name := range []string{"request_id", "call_type", "api_key", "model", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "startTime", "endTime"} { notNull[name] = true diff --git a/internal/postgres/tags_test.go b/internal/postgres/tags_test.go new file mode 100644 index 0000000..c788f43 --- /dev/null +++ b/internal/postgres/tags_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package postgres + +import ( + "strings" + "testing" + "time" + + "github.com/llm-measurement/fleetdiff/internal/exportdata" +) + +func TestRequestTagsProjectionOptIn(t *testing.T) { + o := exportdata.Options{Start: time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC), End: time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC)} + for _, usage := range []bool{false, true} { + for _, routing := range []bool{false, true} { + o.UsageDetails, o.Routing, o.RequestTags = usage, routing, false + base, daily, err := queries(o) + if err != nil || strings.Contains(base, "request_tags") { + t.Fatal("default projection changed", err) + } + o.RequestTags = true + with, withDaily, err := queries(o) + tags, _ := recipeBlock("$tags$") + if err != nil || strings.Replace(with, tags, "", 1) != base || withDaily != daily || strings.Contains(with, "jsonb_array_elements") { + t.Fatal("tag option changes rows or other projection bytes", err) + } + } + } +} diff --git a/internal/spend/agent_spend_test.go b/internal/spend/agent_spend_test.go new file mode 100644 index 0000000..9b852ba --- /dev/null +++ b/internal/spend/agent_spend_test.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package spend + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestAgentSpendChangeAndShares(t *testing.T) { + rows := agentFixtureRows() + o := agentOptions(t) + o.RankBy = "recorded-spend-change" + r := routingReport(t, rows, o) + first := r.Agents.Contributors[0] + if first.Item != "agent-1" || *first.Before.RecordedUSD != "1" || *first.After.RecordedUSD != "3" || *first.Delta != "2" || *first.Before.Share != "0.333333333333" || *first.After.Share != "0.483870967742" { + t.Fatal("USD change or denominator wrong", first) + } + o.Top = 1 + top := routingReport(t, rows, o) + if *top.Agents.Contributors[0].After.Share != *first.After.Share || top.Agents.UndisplayedAfter.Requests != 2 || *top.Agents.UndisplayedAfter.RecordedUSD != "2" { + t.Fatal("top changes accounting or share") + } + // A low-token agent has the largest cost increase, not the largest after cost. + rows = rows[:6] + for i, row := range rows { + row["spend"] = []string{"100", "1", "1", "100", "3", "2"}[i] + } + r = routingReport(t, rows, o) + if r.Agents.Contributors[0].Item != "agent-2" || *r.Agents.Contributors[0].Delta != "2" { + t.Fatal("ranked by after spend or token candidates") + } + for i, row := range rows { + row["spend"] = []string{"0", "0", "0", "94", "3", "3"}[i] + } + r = routingReport(t, rows, o) + if *r.Agents.Contributors[0].After.Share != "0.940000000000" || r.Agents.Contributors[0].Before.Share != nil { + t.Fatal("94 percent or zero denominator wrong") + } +} + +func TestAgentSpendMissingAndDisclosure(t *testing.T) { + rows := agentFixtureRows() + delete(rows[0], "spend") + rows[1]["spend"] = "invalid" + o := agentOptions(t) + r := routingReport(t, rows, o) + for _, c := range r.Agents.Contributors { + if c.Item == "agent-1" && (c.Before.RecordedUSD != nil || c.Delta != nil || c.Before.CostCoverage.Missing != 1) { + t.Fatal("missing cost became zero") + } + } + for _, flags := range [][2]bool{{true, false}, {false, true}} { + o.ShowLabels, o.AcknowledgeSensitiveLabels = flags[0], flags[1] + if _, err := Investigate(writeRows(t, rows), o); err == nil { + t.Fatal("one disclosure flag accepted") + } + } + o.ShowLabels, o.AcknowledgeSensitiveLabels = true, true + r = routingReport(t, rows, o) + b, _ := json.Marshal(r) + if !r.Agents.LabelsIncluded || !strings.Contains(string(b), `"label":"alpha"`) { + t.Fatal("selected label missing") + } + for _, secret := range []string{"different-id", "unrelated-private-tag", "PRIVATE_KEY", "PRIVATE_ROUTE"} { + if strings.Contains(string(b), secret) { + t.Fatal("unselected value leaked") + } + } +} + +func TestAgentTextMetadataDoesNotChangeJSON(t *testing.T) { + r := routingReport(t, agentFixtureRows(), agentOptions(t)) + before, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + r.Agents.LeadingContributor = &AgentContributor{Item: "agent-1", Label: "text-only", ProjectAlias: "project-1"} + for i := range r.Agents.Contributors { + r.Agents.Contributors[i].ProjectAlias = "project-1" + } + after, err := json.Marshal(r) + if err != nil || string(before) != string(after) { + t.Fatal("text-only metadata changed JSON", err) + } +} + +func TestAgentLeadingContributorIsPositiveMover(t *testing.T) { + var rows []map[string]string + for i, row := range []struct { + day, agent string + tokens int + }{ + {"2026-09-01", "alpha", 1000}, {"2026-09-01", "beta", 0}, + {"2026-09-02", "alpha", 300}, {"2026-09-02", "beta", 600}, {"2026-09-02", "", 200}, + } { + r := routingSample(fmt.Sprint(i), row.day, row.tokens, 0) + r["request_tags"] = "[]" + if row.agent != "" { + r["request_tags"] = fmt.Sprintf(`["agent:%s"]`, row.agent) + } + rows = append(rows, r) + } + o := agentOptions(t) + o.ShowLabels, o.AcknowledgeSensitiveLabels = true, true + r := routingReport(t, rows, o) + if r.Increase == nil || r.Increase.Count != 1 || r.Rankings.Movers[0].Item != "agent-1" { + t.Fatal("fixture must have one positive mover behind a larger decline") + } + if c := r.Agents.LeadingContributor; c == nil || c.Item != "agent-2" || c.Label != "beta" { + t.Fatal("leading contributor is not the positive mover", c) + } + before, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + // Restore the old text-only selection: serialized accounting must be identical. + r.Agents.LeadingContributor = &AgentContributor{Item: "agent-1", Label: "alpha"} + after, err := json.Marshal(r) + if err != nil || string(before) != string(after) { + t.Fatal("positive-mover selection changed JSON", err) + } +} + +func TestAgentCapNamesPrefix(t *testing.T) { + var rows []map[string]string + for i := 0; i <= MaxAgents; i++ { + r := routingSample(fmt.Sprint(i), "2026-09-01", 1, 0) + r["request_tags"] = fmt.Sprintf(`["agent:%d"]`, i) + rows = append(rows, r) + } + _, err := Investigate(writeRows(t, rows), agentOptions(t)) + if err == nil || !strings.Contains(err.Error(), `prefix "agent:"`) || !strings.Contains(err.Error(), "narrower") { + t.Fatal("agent cap missing actionable selector", err) + } +} + +func TestAgentCostAggregateExceedsPerRowBound(t *testing.T) { + rows := agentFixtureRows()[:6] + for _, row := range rows { + row["spend"] = "1000000000000000000" + } + r := routingReport(t, rows, agentOptions(t)) + if r.After.RecordedSpend != "3000000000000000000" || *r.Agents.Contributors[0].After.Share != "0.333333333333" { + t.Fatal("aggregate cost reapplied per-row validation") + } +} + +func TestAgentUnavailableCostNeverHiddenByTop(t *testing.T) { + rows := agentFixtureRows()[:6] + for i, row := range rows { + row["spend"] = []string{"", "1", "", "9", "2", ""}[i] + } + o := agentOptions(t) + o.RankBy, o.Top = "recorded-spend-change", 1 + r := routingReport(t, rows, o) + if len(r.Agents.Contributors) != 1 || r.Agents.Contributors[0].Item != "agent-2" || len(r.Agents.UnavailableCosts) != 2 || r.Agents.UndisplayedBefore.Requests != 0 || r.Agents.UndisplayedAfter.Requests != 0 { + t.Fatal("unavailable agent hidden by --top", r.Agents) + } + for _, c := range r.Agents.UnavailableCosts { + if c.Delta != nil || c.Before.RecordedUSD != nil || c.Before.CostCoverage.Missing != 1 { + t.Fatal("unavailable cost became a zero delta", c) + } + } + // With every cost unavailable, no agents are silently folded into a remainder. + for _, row := range rows { + row["spend"] = "" + } + r = routingReport(t, rows, o) + if len(r.Agents.Contributors) != 0 || len(r.Agents.UnavailableCosts) != 3 || r.Agents.UndisplayedAfter.Requests != 0 { + t.Fatal("all-unavailable ranking dropped agents") + } +} diff --git a/internal/spend/agents.go b/internal/spend/agents.go new file mode 100644 index 0000000..8861ef1 --- /dev/null +++ b/internal/spend/agents.go @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package spend + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/llm-measurement/fleetdiff/internal/accounting" +) + +const MaxAgents = 1024 + +type AgentCoverage struct { + KnownRows uint64 `json:"known_rows"` + UnknownRows uint64 `json:"unknown_rows"` + AmbiguousRows uint64 `json:"ambiguous_rows"` + ColumnAbsentRows uint64 `json:"column_absent_rows"` + EmptyOrNullRows uint64 `json:"empty_or_null_rows"` + NoMatchingTagRows uint64 `json:"no_matching_tag_rows"` + NonStringTagRows uint64 `json:"non_string_tag_rows"` + RepeatedAgentTagRows uint64 `json:"repeated_agent_tag_rows"` + RepeatedAgentTags uint64 `json:"repeated_agent_tags"` + EmptySuffixRows uint64 `json:"empty_suffix_rows"` + EmptySuffixTags uint64 `json:"empty_suffix_tags"` +} + +type AgentWindow struct { + Known Window `json:"known"` + Unknown Window `json:"unknown"` + Ambiguous Window `json:"ambiguous"` + Coverage AgentCoverage `json:"coverage"` +} + +type AgentReport struct { + Schema string `json:"schema"` + Source string `json:"label_source"` + Before AgentWindow `json:"before"` + After AgentWindow `json:"after"` + RankBy string `json:"rank_by"` + LabelsIncluded bool `json:"sensitive_labels_included"` + Contributors []AgentContributor `json:"contributors"` + UnavailableCosts []AgentContributor `json:"unavailable_cost_contributors,omitempty"` + UndisplayedBefore AgentValues `json:"known_undisplayed_before"` + UndisplayedAfter AgentValues `json:"known_undisplayed_after"` + ShareBasis string `json:"spend_share_basis"` + LeadingContributor *AgentContributor `json:"-"` +} + +type AgentCostCoverage struct { + Usable uint64 `json:"usable_rows"` + Missing uint64 `json:"missing_rows"` + Invalid uint64 `json:"invalid_rows"` + ZeroUnknown uint64 `json:"zero_origin_unknown_rows"` +} + +type AgentValues struct { + Requests uint64 `json:"logged_rows"` + Tokens uint64 `json:"recorded_tokens"` + RecordedUSD *string `json:"recorded_usd"` + Share *string `json:"share_of_recorded_usd"` + CostCoverage AgentCostCoverage `json:"cost_coverage"` +} + +type AgentContributor struct { + Item string `json:"item"` + Hash string `json:"hash,omitempty"` + Label string `json:"label,omitempty"` + Before AgentValues `json:"before"` + After AgentValues `json:"after"` + Delta *string `json:"recorded_usd_delta"` + ProjectAlias string `json:"-"` +} + +type agentResolution struct { + state, label, source, prefix string + absent, empty, nonString bool + repeated, emptySuffix uint64 +} + +// ValidateAgentOptions is shared by the CLI before database dispatch and the +// offline reader. A selector never infers or falls back to a different source. +func ValidateAgentOptions(o Options) error { + if o.GroupBy != "agent" { + if o.AgentSource != "" || o.AgentTagPrefix != "" || o.RankBy != "" { + return errors.New("agent-source, agent-tag-prefix and rank-by require group-by agent") + } + return nil + } + if o.AgentSource != "agent_id" && o.AgentSource != "request-tag" { + return errors.New("group-by agent requires explicit agent-source agent_id or request-tag") + } + if o.AgentSource == "request-tag" { + if strings.TrimSpace(o.AgentTagPrefix) == "" || len(o.AgentTagPrefix) > MaxFieldBytes || !utf8.ValidString(o.AgentTagPrefix) { + return errors.New("request-tag requires a nonempty UTF-8 agent-tag-prefix of at most 8192 bytes") + } + } else if o.AgentTagPrefix != "" { + return errors.New("agent-tag-prefix requires agent-source request-tag") + } + if o.RankBy != "" && o.RankBy != "recorded-spend-change" { + return errors.New("rank-by supports recorded-spend-change for agent grouping") + } + if o.ScopeAgent != "" && o.AgentSource == "request-tag" { + return errors.New("scope-agent selects agent_id; do not combine it with request-tag attribution") + } + return nil +} + +func resolveAgent(row Row, o Options) (*agentResolution, error) { + a := &agentResolution{state: "unknown", source: o.AgentSource, prefix: o.AgentTagPrefix} + field := "agent_id" + if o.AgentSource == "request-tag" { + field = "request_tags" + } + raw, present := row.Values[field] + a.absent = !present + if !present { + return a, nil + } + if len(raw) > MaxFieldBytes || !utf8.ValidString(raw) { + return nil, errors.New("invalid agent source field; use UTF-8 within the 8192-byte field limit") + } + if strings.TrimSpace(raw) == "" || field == "request_tags" && strings.TrimSpace(raw) == "null" { + a.empty = true + return a, nil + } + if field == "agent_id" { + a.state, a.label = "known", raw + return a, nil + } + tags, err := ParseRequestTags(raw) + if err != nil { + if errors.Is(err, ErrNonStringRequestTags) && row.AgentNamespace == "" { + a.nonString = true + return a, nil + } + return nil, err + } + // Deduplicate matching suffixes; retain no unselected tag values. + labels := map[string]bool{} + for _, tag := range tags { + label, match := strings.CutPrefix(tag, o.AgentTagPrefix) + if !match { + continue + } + if strings.TrimSpace(label) == "" { + a.emptySuffix++ + continue + } + if labels[label] { + a.repeated++ + } + labels[label] = true + } + if len(labels) == 1 { + a.state = "known" + for label := range labels { + a.label = label + } + } else if len(labels) > 1 { + a.state = "ambiguous" + } + return a, nil +} + +var ErrNonStringRequestTags = errors.New("invalid request_tags; every array element must be a string") + +// ParseRequestTags validates bounded scalar-array text for SQL and observation +// adapters before attribution or duplicate normalization can discard evidence. +func ParseRequestTags(raw string) ([]string, error) { + if len(raw) > MaxFieldBytes { + return nil, errors.New("request_tags exceeds the 8192-byte field limit") + } + if strings.TrimSpace(raw) == "null" { + return nil, nil + } + var elements []json.RawMessage + if len(raw) > MaxFieldBytes || preflightSpendJSON([]byte(raw), 0) != nil || json.Unmarshal([]byte(raw), &elements) != nil { + return nil, errors.New("invalid request_tags; use a JSON array of strings within the 8192-byte field limit") + } + tags := make([]string, 0, len(elements)) + for _, element := range elements { + var tag string + if len(element) == 0 || element[0] != '"' || json.Unmarshal(element, &tag) != nil { + return nil, ErrNonStringRequestTags + } + tags = append(tags, tag) + } + return tags, nil +} + +type agentPair [2]*aggregate + +func newAgentPair(before, after Period) agentPair { + return agentPair{newAggregate(before, false), newAggregate(after, false)} +} + +type agentEntry struct { + digest [32]byte + label string + project string + pair agentPair + order int +} + +type agentAccumulator struct { + opts Options + before, after Period + known, unknown, ambiguous agentPair + coverage [2]AgentCoverage + entries map[[32]byte]*agentEntry +} + +func newAgentAccumulator(o Options, before, after Period) *agentAccumulator { + if o.GroupBy != "agent" { + return nil + } + return &agentAccumulator{opts: o, before: before, after: after, known: newAgentPair(before, after), unknown: newAgentPair(before, after), ambiguous: newAgentPair(before, after), entries: map[[32]byte]*agentEntry{}} +} + +func (a *agentAccumulator) add(row Row, side int, digest [32]byte, obs accounting.Observation, q Quality) error { + if a == nil { + return nil + } + r, c := row.agent, &a.coverage[side] + if r.absent { + c.ColumnAbsentRows++ + } + if r.empty { + c.EmptyOrNullRows++ + } + if r.nonString { + c.NonStringTagRows++ + } + if r.repeated > 0 { + c.RepeatedAgentTagRows++ + c.RepeatedAgentTags += r.repeated + } + if r.emptySuffix > 0 { + c.EmptySuffixRows++ + c.EmptySuffixTags += r.emptySuffix + } + var p agentPair + switch r.state { + case "known": + c.KnownRows++ + p = a.known + e := a.entries[digest] + if e == nil { + if len(a.entries) == MaxAgents { + if a.opts.AgentSource == "request-tag" { + return fmt.Errorf("agent attribution exceeds 1024 agents for prefix %s; select a narrower agent-tag-prefix or export", strconv.QuoteToASCII(a.opts.AgentTagPrefix)) + } + return errors.New("agent attribution exceeds 1024 agents; select a narrower export") + } + e = &agentEntry{digest: digest, pair: newAgentPair(a.before, a.after), order: len(a.entries)} + if a.opts.ShowLabels && a.opts.AcknowledgeSensitiveLabels { + e.label = r.label + e.project = row.AgentNamespace + } + a.entries[digest] = e + } + if err := e.pair[side].add(row, obs, q); err != nil { + return err + } + case "ambiguous": + c.AmbiguousRows++ + p = a.ambiguous + default: + c.UnknownRows++ + if !r.absent && !r.empty && !r.nonString && r.source == "request-tag" { + c.NoMatchingTagRows++ + } + p = a.unknown + } + return p[side].add(row, obs, q) +} + +func (a *agentAccumulator) result() *AgentReport { + if a == nil { + return nil + } + r := &AgentReport{Schema: "fleetdiff-agent-attribution/v1", Source: a.opts.AgentSource} + for i, w := range []*AgentWindow{&r.Before, &r.After} { + w.Known, w.Unknown, w.Ambiguous = a.known[i].result(), a.unknown[i].result(), a.ambiguous[i].result() + w.Coverage = a.coverage[i] + } + return r +} + +func (a *agentAccumulator) ordered() []*agentEntry { + var entries []*agentEntry + for _, e := range a.entries { + entries = append(entries, e) + } + slices.SortFunc(entries, func(a, b *agentEntry) int { return a.order - b.order }) + return entries +} + +func agentValues(w Window, total *big.Rat) AgentValues { + c := AgentCostCoverage{Missing: w.Quality.SpendMissing, Invalid: w.Quality.SpendInvalid, ZeroUnknown: w.Quality.SpendZeroUnknown} + c.Usable = w.Requests - c.Missing - c.Invalid + v := AgentValues{Requests: w.Requests, Tokens: w.Tokens, CostCoverage: c} + if c.Usable > 0 || w.Requests == 0 { + usd := w.RecordedSpend + v.RecordedUSD = &usd + n, _ := new(big.Rat).SetString(usd) + v.Share = routingShare(n, total) + } + return v +} + +func (a *agentAccumulator) aliases() map[string]string { + labels := map[string]string{} + if a != nil { + for _, e := range a.ordered() { + labels[fmt.Sprintf("%016x", binary.BigEndian.Uint64(e.digest[:8]))] = fmt.Sprintf("agent-%d", e.order+1) + } + } + return labels +} + +func (a *agentAccumulator) finish(r *Report) error { + if a == nil { + return nil + } + entries := a.ordered() + projects := map[string]string{} + for _, e := range entries { + if e.project != "" && projects[e.project] == "" { + projects[e.project] = fmt.Sprintf("project-%d", len(projects)+1) + } + } + slices.SortStableFunc(entries, func(x, y *agentEntry) int { + if a.opts.RankBy == "recorded-spend-change" { + available := func(e *agentEntry) bool { + for _, p := range e.pair { + w := p.window + if w.Requests > 0 && w.Quality.SpendMissing+w.Quality.SpendInvalid == w.Requests { + return false + } + } + return true + } + xok, yok := available(x), available(y) + if xok != yok { + if xok { + return -1 + } + return 1 + } + if !xok { + return 0 + } + xd := new(big.Rat).Sub(&x.pair[1].cost, &x.pair[0].cost) + yd := new(big.Rat).Sub(&y.pair[1].cost, &y.pair[0].cost) + return yd.Cmp(xd) + } + xd, yd := int64(x.pair[1].window.Tokens)-int64(x.pair[0].window.Tokens), int64(y.pair[1].window.Tokens)-int64(y.pair[0].window.Tokens) + if xd > yd { + return -1 + } + if xd < yd { + return 1 + } + return 0 + }) + report := a.result() + report.RankBy = a.opts.RankBy + if report.RankBy == "" { + report.RankBy = "recorded-token-change" + } + report.LabelsIncluded = a.opts.ShowLabels && a.opts.AcknowledgeSensitiveLabels + report.ShareBasis = "usable recorded USD in all analyzed rows of each period, including unknown, ambiguous and undisplayed agents; missing and invalid costs remain unquantified" + totals := [2]*big.Rat{} + for i, w := range []Window{r.Before, r.After} { + totals[i], _ = new(big.Rat).SetString(w.RecordedSpend) + } + remaining := [2]*routingTotals{newRoutingTotals(), newRoutingTotals()} + report.Contributors = []AgentContributor{} + leading := "" + if r.Increase != nil && r.Increase.Count == 1 { + for _, m := range r.Rankings.Movers { + if m.Delta.Lower > 0 { + leading = m.Item + break + } + } + } + for i, e := range entries { + c := AgentContributor{Item: fmt.Sprintf("agent-%d", e.order+1), Label: e.label, ProjectAlias: projects[e.project], Before: agentValues(e.pair[0].result(), totals[0]), After: agentValues(e.pair[1].result(), totals[1])} + if a.opts.ShowHashes { + c.Hash = fmt.Sprintf("%x", e.digest) + } + if c.Before.RecordedUSD != nil && c.After.RecordedUSD != nil { + delta := costText(new(big.Rat).Sub(&e.pair[1].cost, &e.pair[0].cost)) + c.Delta = &delta + } + if c.Item == leading { + report.LeadingContributor = &c + } + if a.opts.RankBy == "recorded-spend-change" && c.Delta == nil { + report.UnavailableCosts = append(report.UnavailableCosts, c) + continue + } + if i >= a.opts.Top { + for side := range remaining { + if err := remaining[side].merge(&routingTotals{a: e.pair[side]}); err != nil { + return err + } + } + continue + } + report.Contributors = append(report.Contributors, c) + } + report.UndisplayedBefore = agentValues(remaining[0].a.result(), totals[0]) + report.UndisplayedAfter = agentValues(remaining[1].a.result(), totals[1]) + r.Agents = report + return nil +} diff --git a/internal/spend/agents_test.go b/internal/spend/agents_test.go new file mode 100644 index 0000000..fdff833 --- /dev/null +++ b/internal/spend/agents_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package spend + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +func agentOptions(t *testing.T) Options { + o := routingOptions(t) + o.RoutingAudit = false + o.GroupBy, o.AgentSource, o.AgentTagPrefix = "agent", "request-tag", "agent:" + return o +} + +func TestAgentTagResolution(t *testing.T) { + for _, tc := range []struct { + raw, state, label string + repeats, empty uint64 + }{ + {`[]`, "unknown", "", 0, 0}, + {`["other:a","Agent:a"]`, "unknown", "", 0, 0}, + {`["agent:a","agent:a","agent:"]`, "known", "a", 1, 1}, + {`["agent:a","agent:b","agent:a"]`, "ambiguous", "", 1, 0}, + {`["agent:","agent: "]`, "unknown", "", 0, 2}, + {`[null]`, "unknown", "", 0, 0}, + {`[1]`, "unknown", "", 0, 0}, + {`["agent:a",true,{"private":"value"}]`, "unknown", "", 0, 0}, + } { + r, err := resolveAgent(Row{Values: map[string]string{"request_tags": tc.raw}}, agentOptions(t)) + if err != nil || r.state != tc.state || r.label != tc.label || r.repeated != tc.repeats || r.emptySuffix != tc.empty { + t.Fatal("tag resolution mismatch", tc, err) + } + } + for _, raw := range []string{`{}`, `"agent:a"`, `["agent:a"`, `["agent:\ud800"]`, `[1,"\ud800"]`, strings.Repeat("x", MaxFieldBytes+1)} { + if _, err := resolveAgent(Row{Values: map[string]string{"request_tags": raw}}, agentOptions(t)); err == nil { + t.Fatal("malformed tags accepted") + } + } +} + +func TestAgentNonStringTagsPreserveAccounting(t *testing.T) { + for _, raw := range []string{`[null]`, `[1]`, `[false]`, `[{"PRIVATE":"value"}]`, `[["agent:a"]]`, `["agent:alpha",1]`} { + rows := agentFixtureRows() + rows[3]["request_tags"] = raw + o := agentOptions(t) + for _, disclose := range []bool{false, true} { + o.ShowLabels, o.AcknowledgeSensitiveLabels = disclose, disclose + r := routingReport(t, rows, o) + w := r.Agents.After + if r.After.Tokens != 620 || r.After.RecordedSpend != "6.2" || w.Known.Tokens != 200 || w.Unknown.Tokens != 350 || w.Unknown.RecordedSpend != "3.5" || w.Ambiguous.Tokens != 70 || w.Coverage.NonStringTagRows != 1 || w.Coverage.UnknownRows != 2 || w.Coverage.NoMatchingTagRows != 1 { + t.Fatal("non-string tags changed accounting or coverage", raw, w) + } + b, _ := json.Marshal(r) + if strings.Contains(string(b), "PRIVATE") || strings.Contains(string(b), `"label":"alpha"`) && disclose == false { + t.Fatal("invalid tags escaped") + } + } + } +} + +func agentFixtureRows() []map[string]string { + var rows []map[string]string + for side, day := range []string{"2026-09-01", "2026-09-02"} { + for i, name := range []string{"alpha", "beta", "gamma"} { + n := 100 + if side == 1 && i == 0 { + n = 300 + } + r := routingSample(fmt.Sprintf("%d-%d", side, i), day, n, 0) + r["request_tags"] = `["agent:` + name + `","unrelated-private-tag"]` + r["agent_id"], r["spend"] = "different-id", fmt.Sprint(n/100) + rows = append(rows, r) + } + } + for i, tags := range []string{`["unrelated-private-tag"]`, `["agent:alpha","agent:beta"]`} { + r := routingSample(fmt.Sprint("extra", i), "2026-09-02", 50+i*20, 0) + r["request_tags"], r["spend"] = tags, []string{"0.50", "0.70"}[i] + rows = append(rows, r) + } + return rows +} + +func TestAgentPartitionReconciles(t *testing.T) { + rows, opts := agentFixtureRows(), agentOptions(t) + r := routingReport(t, rows, opts) + w := r.Agents.After + if r.After.Tokens != 620 || r.After.RecordedSpend != "6.2" || w.Known.Tokens != 500 || w.Known.RecordedSpend != "5" || w.Unknown.Tokens != 50 || w.Unknown.RecordedSpend != "0.5" || w.Ambiguous.Tokens != 70 || w.Ambiguous.RecordedSpend != "0.7" { + t.Fatal("exclusive partition did not reconcile") + } + if w.Coverage.KnownRows != 3 || w.Coverage.UnknownRows != 1 || w.Coverage.AmbiguousRows != 1 { + t.Fatal("coverage mismatch") + } + opts.GroupBy, opts.AgentSource, opts.AgentTagPrefix = "key", "", "" + base := routingReport(t, rows, opts) + r.Before.Quality.MissingIdentity, r.After.Quality.MissingIdentity = 0, 0 + r.Before.AttributedTokens, r.After.AttributedTokens = base.Before.AttributedTokens, base.After.AttributedTokens + r.Before.Distinct, r.After.Distinct = base.Before.Distinct, base.After.Distinct + if !reflect.DeepEqual(r.Before, base.Before) || !reflect.DeepEqual(r.After, base.After) { + t.Fatal("grouping changed base accounting") + } + data, _ := json.Marshal(r) + if strings.Contains(string(data), "unrelated-private-tag") || strings.Contains(string(data), "alpha") { + t.Fatal("default agent labels leaked") + } +} + +func TestAgentColumnAbsenceAndSourceChoice(t *testing.T) { + rows := agentFixtureRows()[:6] + for _, row := range rows { + delete(row, "request_tags") + } + o := agentOptions(t) + r := routingReport(t, rows, o) + if r.Agents.After.Coverage.ColumnAbsentRows != 3 || r.Agents.After.Unknown.Requests != 3 { + t.Fatal("missing column was inferred") + } + o.AgentSource, o.AgentTagPrefix = "agent_id", "" + r = routingReport(t, rows, o) + if r.Agents.After.Known.Requests != 3 { + t.Fatal("explicit agent_id not selected") + } +} diff --git a/internal/spend/investigate.go b/internal/spend/investigate.go index 74fb9d2..d279b7c 100644 --- a/internal/spend/investigate.go +++ b/internal/spend/investigate.go @@ -33,11 +33,13 @@ type Options struct { ProviderCountsPath, ProviderModelMapPath string ProviderContractPath string ScopeRoute, ScopeAgent string + AgentSource, AgentTagPrefix, RankBy string RoutingAudit bool RoutingBy string Top int ShowHashes bool ShowLabels bool + AcknowledgeSensitiveLabels bool // Row adapters may share model aliases without changing group identities. ModelAliases bool Now time.Time @@ -156,6 +158,14 @@ func requestDigest(key []byte, id string) [32]byte { } func groupIdentity(row Row, group string, key []byte) ([32]byte, bool, error) { + if group == "agent" { + if row.agent == nil || row.agent.state != "known" { + return [32]byte{}, false, nil + } + a := row.agent + digest, err := identity(key, "agent", a.source, a.prefix, row.AgentNamespace, a.label) + return digest, true, err + } field := map[string]string{"key": "api_key", "team": "team_id", "user": "user", "end-user": "end_user", "session": "session_id"}[group] value := row.Values[field] if strings.TrimSpace(value) == "" { @@ -181,7 +191,7 @@ func Investigate(path string, opts Options) (Report, error) { // InvestigateRows composes an offline source with the existing investigation. // The reader must replay the same completed snapshot on every pass. func InvestigateRows(read func(func(Row) error) error, opts Options) (Report, error) { - if read == nil || opts.DailySpendPath != "" || opts.RoutingAudit || opts.RoutingBy != "" || opts.ScopeRoute != "" || opts.ScopeAgent != "" || opts.ShowLabels || opts.ProviderCountsPath != "" || opts.ProviderModelMapPath != "" || opts.ProviderContractPath != "" { + if read == nil || opts.DailySpendPath != "" || opts.RoutingAudit || opts.RoutingBy != "" || opts.ScopeRoute != "" || opts.ScopeAgent != "" || opts.ShowLabels && opts.GroupBy != "agent" || opts.ProviderCountsPath != "" || opts.ProviderModelMapPath != "" || opts.ProviderContractPath != "" { return Report{}, errors.New("row sources do not support SQL coverage, provider counts or routing options") } return investigate("", opts, func(_ string, visit func(Row) error) error { return read(visit) }) @@ -194,8 +204,11 @@ func investigate(path string, opts Options, source func(string, func(Row) error) if opts.GroupBy == "" { opts.GroupBy = "key" } - if !slices.Contains([]string{"key", "team", "user", "end-user", "session"}, opts.GroupBy) || opts.Top < 1 || opts.Top > 100 { - return Report{}, errors.New("group-by must be key, team, user, end-user or session; top must be 1-100") + if !slices.Contains([]string{"key", "team", "user", "end-user", "session", "agent"}, opts.GroupBy) || opts.Top < 1 || opts.Top > 100 { + return Report{}, errors.New("group-by must be key, team, user, end-user, session or agent; top must be 1-100") + } + if err := ValidateAgentOptions(opts); err != nil { + return Report{}, err } if err := validateRoutingOptions(&opts); err != nil { return Report{}, err @@ -218,12 +231,25 @@ func investigate(path string, opts Options, source func(string, func(Row) error) opts.ScopeRoute, opts.ScopeAgent = "", "" routing := newRoutingAccumulator(opts, selection) read := Read - if routing != nil { + if routing != nil || opts.GroupBy == "agent" { read = ReadRouting } if source != nil { read = source } + if opts.GroupBy == "agent" { + base := read + read = func(path string, visit func(Row) error) error { + return base(path, func(row Row) error { + var err error + row.agent, err = resolveAgent(row, opts) + if err != nil { + return err + } + return visit(row) + }) + } + } var earliest, latest time.Time firstDigest := sha256.New() automatic := opts.BeforePeriod == "" && opts.AfterPeriod == "" @@ -259,6 +285,7 @@ func investigate(path string, opts Options, source func(string, func(Row) error) return Report{}, err } windows := [2]*aggregate{newAggregate(before, true), newAggregate(after, true)} + agents := newAgentAccumulator(opts, before, after) models := map[[32]byte][2]*aggregate{} modelOrder := map[[32]byte]int{} modelLabels := map[[32]byte]string{} @@ -345,6 +372,9 @@ func investigate(path string, opts Options, source func(string, func(Row) error) if e = w.add(row, a, q); e != nil { return e } + if e = agents.add(row, side, group, a, q); e != nil { + return e + } if present { h := binary.BigEndian.Uint64(group[:8]) w.distinct.AddHash(h) @@ -402,6 +432,7 @@ func investigate(path string, opts Options, source func(string, func(Row) error) return Report{}, errors.New("export changed while reading; use a completed immutable export") } r.Before, r.After = windows[0].result(), windows[1].result() + r.Agents = agents.result() r.RowCoverage = coverage.result() r.ProviderCoverage, err = provider.result(r.RowCoverage) if err != nil { @@ -504,7 +535,7 @@ func investigate(path string, opts Options, source func(string, func(Row) error) r.Cache.Rankings.Movers = r.Cache.Rankings.Movers[:min(opts.Top, len(r.Cache.Rankings.Movers))] groups = append(groups, r.Cache.Rankings.Movers) } - aliases := map[string]string{} + aliases := agents.aliases() for _, movers := range groups { for i := range movers { m := &movers[i] @@ -531,6 +562,9 @@ func investigate(path string, opts Options, source func(string, func(Row) error) "Recorded spend is the exact decimal sum of usable source values in USD, not an invoice. Default-zero costs have unknown origin.", "Rankings describe tracked candidates; concentration uses the net recorded increase and can exceed 100% when other contributors decreased.", } + if err := agents.finish(&r); err != nil { + return Report{}, err + } return r, nil } diff --git a/internal/spend/reader.go b/internal/spend/reader.go index 5f05eb9..45e1ef1 100644 --- a/internal/spend/reader.go +++ b/internal/spend/reader.go @@ -40,9 +40,18 @@ type Row struct { // Set only by observation adapters, never by SQL/CSV status projections. // Severity does not establish provider completion or billing finality. ObservationLevel string + // Set by the Langfuse adapter only, preserving project-scoped agents. + AgentNamespace string + agent *agentResolution } func (r Row) replayProjection() any { + if r.AgentNamespace != "" { + return struct { + Values map[string]string + ObservationLevel, AgentNamespace string + }{r.Values, r.ObservationLevel, r.AgentNamespace} + } if r.ObservationLevel == "" { return r.Values } @@ -80,7 +89,7 @@ func allowedColumn(name string) bool { case "request_id", "call_type", "api_key", "user", "team_id", "organization_id", "end_user", "model", "model_id", "model_group", "custom_llm_provider", "prompt_tokens", "completion_tokens", "total_tokens", "spend", "status", "session_id", "startTime", "endTime", "cache_read_input_tokens", - "cache_write_input_tokens", "reasoning_output_tokens", "agent_id", "rejection_kind": + "cache_write_input_tokens", "reasoning_output_tokens", "agent_id", "rejection_kind", "request_tags": return true } return false @@ -95,7 +104,7 @@ func routingReaderColumn(name string) bool { } func retainSpendEmpty(name string, routing bool) bool { - if name == "rejection_kind" { + if name == "rejection_kind" || name == "request_tags" { return true } return routing && (routingReaderColumn(name) || name == "agent_id" || name == "model" || name == "model_id") diff --git a/internal/spend/report.go b/internal/spend/report.go index ce14f7d..dff11a7 100644 --- a/internal/spend/report.go +++ b/internal/spend/report.go @@ -49,6 +49,7 @@ type IncreaseShare struct { } type Report struct { + Agents *AgentReport `json:"agents,omitempty"` AnalysisGap string `json:"analysis_unavailable,omitempty"` RowCoverage *RowCoverage `json:"row_coverage,omitempty"` Cache compare.CacheView `json:"cache"` diff --git a/internal/spend/routing.go b/internal/spend/routing.go index 03f413f..ca0dca0 100644 --- a/internal/spend/routing.go +++ b/internal/spend/routing.go @@ -459,7 +459,7 @@ func newRoutingAccumulator(opts Options, selection *scopeSelection) *routingAccu a.totals = newRoutingPair() a.routes, a.models = map[routingID]int{}, map[routingID]int{} a.modelLabels = map[routingID]string{} - a.showLabels = opts.ShowLabels + a.showLabels = opts.ShowLabels && opts.GroupBy != "agent" if a.showLabels { a.routeLabels, a.agentLabels = map[routingID]string{}, map[routingID]string{} } diff --git a/internal/spend/routing_labels_test.go b/internal/spend/routing_labels_test.go index a5d327d..9edaaba 100644 --- a/internal/spend/routing_labels_test.go +++ b/internal/spend/routing_labels_test.go @@ -56,6 +56,7 @@ func TestRoutingLabelsOptInAndAccountingUnchanged(t *testing.T) { baseline := routingReport(t, rows, o) assertRoutingPrivate(t, baseline) o.ShowLabels = true + o.AcknowledgeSensitiveLabels = true shown := routingReport(t, rows, o) c := shown.RoutingAudit.Cohorts[0] if !shown.RoutingAudit.ConfigurationLabelsIncluded || c.Route.Label != "PRIVATE_ROUTE" || c.Agent == nil || c.Agent.Label != "PRIVATE_AGENT" { @@ -78,6 +79,7 @@ func TestRoutingLabelsOptInAndAccountingUnchanged(t *testing.T) { func TestRoutingUnknownAndAmbiguousLabelsRemainAliased(t *testing.T) { o := routingOptions(t) o.ShowLabels, o.RoutingBy = true, "route-agent" + o.AcknowledgeSensitiveLabels = true rows := []map[string]string{routingSample("a", "2026-09-01", 90, 10), routingSample("b", "2026-09-02", 90, 10)} rows[0]["model"], rows[1]["model"] = "gpt-6.1-sol", "gpt-6.1-sol" delete(rows[0], "model_id") diff --git a/internal/spend/selection.go b/internal/spend/selection.go index 97d1888..937e055 100644 --- a/internal/spend/selection.go +++ b/internal/spend/selection.go @@ -16,8 +16,11 @@ type scopeSelection struct { } func validateRoutingOptions(opts *Options) error { - if opts.ShowLabels && !opts.RoutingAudit { - return errors.New("show-labels requires routing-audit") + if opts.ShowLabels != opts.AcknowledgeSensitiveLabels { + return errors.New("show-labels and acknowledge-sensitive-labels must be supplied together") + } + if opts.ShowLabels && !opts.RoutingAudit && opts.GroupBy != "agent" { + return errors.New("show-labels requires agent grouping or routing-audit; user, session, end-user and key rankings stay alias-only") } if opts.RoutingBy != "" && !opts.RoutingAudit { return errors.New("routing-by requires routing-audit") diff --git a/scripts/install.sh b/scripts/install.sh index 9797328..c717ff8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,11 +6,11 @@ umask 077 usage() { printf '%s\n' 'Usage: sh scripts/install.sh [VERSION [NEW_DIRECTORY]]' \ - 'Defaults: v0.13.1 ./fleetdiff-install. Requires curl, gh, shasum, and tar.' + 'Defaults: v0.14.0 ./fleetdiff-install. Requires curl, gh, shasum, and tar.' } case "${1:-}" in --help|-h) usage; exit 0 ;; esac if [ "$#" -gt 2 ]; then usage >&2; exit 2; fi -version=${1-v0.13.1} +version=${1-v0.14.0} destination=${2-./fleetdiff-install} case "$version" in ''|*[!a-zA-Z0-9.-]*) printf '%s\n' 'Supply a release version such as v0.3.0.' >&2; exit 2 ;; diff --git a/scripts/install_test.go b/scripts/install_test.go index e70397a..1869d4f 100644 --- a/scripts/install_test.go +++ b/scripts/install_test.go @@ -15,7 +15,7 @@ import ( "testing" ) -const installTestVersion = "v0.13.1" +const installTestVersion = "v0.14.0" const installTestURL = "https://raw.githubusercontent.com/llm-measurement/fleetdiff/0d7db95c5bb3013f8c564c91fbb07b6235a2664e/scripts/install.sh" func TestInstallDocumentationVersionPins(t *testing.T) { diff --git a/scripts/restricted-smoke.sh b/scripts/restricted-smoke.sh index c4a79a4..01ed207 100644 --- a/scripts/restricted-smoke.sh +++ b/scripts/restricted-smoke.sh @@ -143,7 +143,8 @@ check_report routing-audit '.routing_audit.model_rows.before.requests == 3 and check_case routing-labels 0 investigate --litellm-spend /examples/routing/synthetic.csv \ --before-period 2026-10-01 --after-period 2026-10-02 --routing-audit \ - --routing-by route-agent --show-labels --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json + --routing-by route-agent --show-labels --acknowledge-sensitive-labels \ + --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json check_report routing-labels '.routing_audit.configuration_labels_included == true and .routing_audit.cohorts[0].route.label == "coding-default" and .routing_audit.cohorts[0].agent.label == "agent-a" and @@ -162,4 +163,31 @@ check_case what-if-routing 0 what-if --litellm-spend /examples/routing/synthetic check_report what-if-routing '.covered_observations == 2 and .excluded_observations.missing_split == 1 and .excluded_observations.unsupported_call_type == 1 and .moves[0].scoped == true' -printf 'PASS restricted offline binary smoke: 14 cases (%s)\n' "$platform" +check_case agent-id 0 investigate --litellm-spend /examples/litellm-spend/agents.csv \ + --before-period 2026-10-07 --after-period 2026-10-08 --group-by agent \ + --agent-source agent_id --rank-by recorded-spend-change \ + --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json +check_report agent-id '.agents.label_source == "agent_id" and + .agents.contributors[0].recorded_usd_delta == "2" and + .agents.contributors[0].after.recorded_usd == "3" and + (.agents.contributors[0] | has("label") | not)' + +check_case agent-tags 0 investigate --litellm-spend /examples/litellm-spend/agent-tags.csv \ + --before-period 2026-10-07 --after-period 2026-10-08 --group-by agent \ + --agent-source request-tag --agent-tag-prefix 'agent:' --rank-by recorded-spend-change \ + --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json +check_report agent-tags '.after.recorded_tokens == 620 and .after.recorded_spend_usd == "6.2" and + .agents.after.known.recorded_tokens == 500 and + .agents.after.unknown.recorded_tokens == 50 and + .agents.after.ambiguous.recorded_tokens == 70 and + .agents.after.coverage.repeated_agent_tag_rows == 1 and + .agents.after.coverage.empty_suffix_rows == 1' + +check_case agent-labels 0 investigate --litellm-spend /examples/litellm-spend/agent-tags.csv \ + --before-period 2026-10-07 --after-period 2026-10-08 --group-by agent \ + --agent-source request-tag --agent-tag-prefix 'agent:' --rank-by recorded-spend-change \ + --show-labels --acknowledge-sensitive-labels \ + --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json +check_report agent-labels '.agents.sensitive_labels_included == true and + .agents.contributors[0].label == "alpha"' +printf 'PASS restricted offline binary smoke: 17 cases (%s)\n' "$platform"