Skip to content

feat: surface media usage in the task API and chat UI - #1463

Merged
OliverBryant merged 3 commits into
xorbitsai:mainfrom
OliverBryant:feat/media-usage-frontend
Aug 21, 2026
Merged

feat: surface media usage in the task API and chat UI#1463
OliverBryant merged 3 commits into
xorbitsai:mainfrom
OliverBryant:feat/media-usage-frontend

Conversation

@OliverBryant

Copy link
Copy Markdown
Contributor

Last of five changes splitting #997. Once this and the others land, #997
can be closed.

Depends on #1422, #1424, #1425 and #1457. This branch contains their
commits too, so the diff here shows all five. Review those first; once
they merge this reduces to just the API + UI changes below.

The task detail endpoint now returns aggregated media rows, and the chat
token-usage popover renders them alongside LLM tokens.

media_usage carries one row per (model, unit, call_type, resolution)
from aggregate_media_usage_by_model. Deliberately no cross-unit
quantity total
— summing images + seconds + characters produces a
number with no meaning — and no media_calls companion field, see
below. Locales cover all nine MediaCallType values and all five
MediaUnit values in both en and zh; I diffed the key sets against
the enums to confirm.

Review feedback addressed from #997

  • The dead media_calls response field is dropped rather than wired
    up. It had zero references in frontend/src while the component
    recomputed the same total from media_usage; a second server-side
    reduction is a duplicate that can drift from the rows it summarises.

  • Media rows are normalised at the fetch boundary.
    token_usage_details is free-form legacy JSON, and both failure modes
    the review named were live: reducing over a null row throws on
    property access (blanking the component), and a string quantity like
    "4" passes the > 0 check but then fails Number.isFinite in the
    formatter, so it silently rendered the "not yet measured" placeholder —
    reading as if the call had cost nothing. One normaliser now coerces
    every row to finite non-negative numbers and plain strings and drops
    non-objects. Self-review added arrays to that drop list, since
    typeof [] === 'object' and an array would otherwise normalise into a
    bogus all-zero row.

  • The test i18n mock is synced with the real locale files. It carried
    a stale unit.tokens that no longer exists and never had unit.texts,
    which embedding rows use. One correction to the finding: the suite did
    already assert rendered unit labels ("3 images", "12.5 sec"), so it
    was not fully uncatchable — but neither covered texts, which is
    exactly the key that was missing. Added a test asserting the rendered
    texts label so that specific drift is now caught.

Also added: null/non-object row survival and string-quantity rendering
tests. Both fail without the normaliser.

Testing

CI. Frontend tests were not runnable in this worktree — vitest hangs
against a symlinked node_modules — and the Python suite does not run on
this machine at all (importing xagent.core.model.chat hangs, on main
too). The component changes were verified by review; ruff, isort and
mypy (pinned versions) are clean on the changed Python. I am relying on
CI's frontend jobs rather than claiming a local pass I did not get.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive media-usage tracking system for non-LLM modalities (such as image, video, TTS, ASR, embedding, and reranking) across both the frontend and backend. It enhances the TokenUsage model with thread-safe locking, adds aggregation helpers, and integrates usage recording into various model adapters and tools. Additionally, it introduces standalone usage scopes to capture tracking data across thread boundaries and background tasks. The review feedback suggests adding a defensive guard in TokenUsage.merge to prevent a potential self-merge from doubling the usage counts.

Comment thread src/xagent/core/model/chat/token_context.py Outdated
Completes the series: the task detail endpoint returns aggregated media
rows and the chat token-usage popover renders them alongside LLM tokens.

`media_usage` carries one row per (model, unit, call_type, resolution)
from `aggregate_media_usage_by_model`. There is deliberately no
cross-unit quantity total — summing images + seconds + characters
produces a number with no meaning — and no `media_calls` companion
field: the client derives its own count from these rows, so a second
server-side reduction would be a duplicate that can drift. Locales cover
all nine `MediaCallType` values and all five `MediaUnit` values in both
en and zh.

Review feedback from xorbitsai#997:

- The dead `media_calls` response field is dropped rather than wired up.
  It had zero references in `frontend/src` while the component
  recomputed the same total from `media_usage`.

- Media rows are normalised at the fetch boundary.
  `token_usage_details` is free-form legacy JSON, so a row can be null, a
  non-object, or carry a numeric field as a string — and both failure
  modes were live: reducing over a null row throws on property access,
  and a string quantity like `"4"` passes the `> 0` check but fails
  `Number.isFinite` in the formatter, silently rendering the
  "not yet measured" placeholder as if the call had cost nothing. One
  normaliser now coerces every row to finite non-negative numbers and
  plain strings, dropping rows that are not objects (arrays included,
  since they are `typeof 'object'` too), so every consumer below deals in
  real numbers.

- The test i18n mock is synced with the real locale files: it carried a
  stale `unit.tokens` that no longer exists and lacked `unit.texts`,
  which embedding rows use. Added a test asserting the rendered `texts`
  label, so the mock can no longer drift from the locales undetected —
  the existing assertions only covered `images` and `seconds`, which is
  why this specific gap was invisible.

Also added: null/non-object row survival and string-quantity rendering
tests, both of which fail without the normaliser.

Frontend tests were not runnable locally (vitest hangs against a
symlinked node_modules in this worktree), so the component changes were
verified by review and are relying on CI's frontend jobs.
`data` comes from `response.json()` and is `any`, so
`.map(normalizeMediaUsage)` produced `any` and the type-predicate
callback's parameter was implicitly `any` — `TS7006` under
`noImplicitAny`, failing both the static-export build and `npm
type-check`. The array is now cast to `unknown[]` and the filter callback
annotated explicitly.

Every frontend test file already passed, including the three new media
cases; only the type checker rejected this.

Verified locally this time, with a tsconfig scoped to the changed file:
reverting the annotation reproduces `TS7006` at the same position, and
the fix clears it.
@OliverBryant
OliverBryant force-pushed the feat/media-usage-frontend branch from 2aff110 to bb428d9 Compare August 21, 2026 04:04
@OliverBryant

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (b2ddb5ad). The diff is unchanged: 5 files, +601/-26.

What was dropped. This branch had been cut on top of the whole media-metering stack, so it carried 15 commits that do not belong to it:

Each of those is landing through its own PR, so only the top two commits — this PR's actual increment — were replayed onto main.

No API migration was needed. The increment's only reference to the primitives is aggregate_media_usage_by_model, which already merged with #1527 and is present on main (src/xagent/core/model/chat/token_context.py). Nothing here imports standalone_usage or any other symbol introduced by the sibling PRs, so this rebased directly onto main independently of #1425 and #1457.

One thing worth flagging so it does not read as an oversight: this branch does contain the string MediaUnit, but it is the frontend TypeScript helper formatMediaUnit in TokenUsageDisplay.tsx, not the Python MediaUnit enum. It needed no migration.

Contract re-verified against current main, since the enums moved under this branch:

  • i18n unit map covers all 5 MediaUnit values (images, seconds, characters, texts, requests).
  • i18n mediaType map covers all 9 MediaCallType values (generate_image, edit_image, video, tts, asr, music, sound_effect, embedding, rerank).
  • The frontend MediaUsage interface matches the field set aggregate_media_usage_by_model actually returns.

Drift. main advanced 29 commits since this branch was cut; en.ts, zh.ts and chat.py all changed underneath it. The rebase replayed without conflicts and the resulting diff is additions-only in those three files, so no one else's work was reverted — in particular main's get_task change from get_latest_waiting_question to get_pending_interaction_question is preserved alongside the new media_usage line.

Checks run locally: scoped tsc --noEmit over the four changed frontend files (clean), ruff check / ruff format --check and pinned isort on chat.py (clean), and pinned mypy — 118 pre-existing --follow-imports=skip findings, identical count on unmodified main, so no new ones. The vitest suite does not run in this environment; relying on CI for TokenUsageDisplay.test.tsx.

@OliverBryant

Copy link
Copy Markdown
Contributor Author

One coverage caveat, filed separately as #1577 rather than fixed here to keep this PR a pure rebase.

TokenUsageDisplay.test.tsx is not executed by CI. The frontend-build job runs vitest through an explicit allowlist of launchers (src/components/pages, src/components/kb, src/app, five named src/lib/src/i18n files, plus the widget lane's per-file include list); src/components/chat/ is in none of them. Confirmed on this PR's own frontend-build run: the string TokenUsageDisplay does not appear anywhere in the job log.

This is a pre-existing gap on main, not something this PR introduces — the file already carried 368 unexecuted lines before it. This PR takes it to 721. So the 401 added lines are typechecked (scoped tsc --noEmit, clean) and reviewable, but a green CI here does not mean they passed; it means they did not run. Worth weighing when reviewing the test file, since #1577 will run these for the first time.

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

PR #1463 completes the read-side media-usage slice by projecting persisted media detail rows through GET /api/chat/task/{id} and rendering them in the chat usage popover. It reuses the existing aggregation, task-detail cache, polling lifecycle, and localization machinery while keeping heterogeneous units separate and preserving additive API compatibility.
Blocking: no — recommended event: APPROVE

Update summary

The update consists of commits 026a3a6f and bb428d95. 026a3a6f adds the task-detail API aggregation/serialization, media popover, English and Chinese labels, and frontend coverage; bb428d95 fixes the TypeScript annotation/noImplicitAny issue. The rebase context in conversation comment 5365048088 explains that the superseded producer stack was dropped/replayed, so this review evaluates the current five-file API/UI slice.

Round 0 design verdict

Verdict: acceptable-with-reservations. The direction is sound: the PR uses the existing persisted TokenUsage.details stream and media aggregator, adds one additive task-detail field, and uses the established popover, polling, and dynamic-i18n patterns. The mixed measured/unmeasured aggregate-state concern is the exact tracked root in #1495, and the cumulative JSON persistence/read scaling boundary is the exact tracked root in #1466; both are dropped context rather than new findings for this PR. The remaining design reservation is the missing real task-detail serialization/cache contract test, reported below as NEW-API-SEAM-TEST; the independently raised API-shape ownership concern was dropped because the backend already follows the repository's dynamic Dict[str, Any] convention and the frontend normalizer is an appropriate network/legacy-data boundary.

Prior-findings checklist (body-only and inline roots)

Canonical root Status Evidence and disposition
TokenUsage.merge self-merge doubling DROPPED — pre-existing/out of scope The body-only review finding 4959106301 and its duplicate inline root 3802478157 both concern src/xagent/core/model/chat/token_context.py, which is byte-identical between the supplied base and head and is not in this PR's diff. The current in-tree caller constructs a fresh target before merging, and conversation comment 5365048088 explains the superseded stack/rebase context. This is not a FIXED claim and is not re-reported.
Frontend chat test CI execution gap DROPPED — tracked pre-existing Conversation comment 5365089129 identifies the existing explicit-launcher gap for frontend/src/components/chat/TokenUsageDisplay.test.tsx; the workflow, package scripts, and manifest are unchanged by this PR. The concern is tracked in #1577, so it is not re-reported as a PR finding.

The mixed measured/unmeasured aggregate concern is likewise dropped as tracked context only under #1495, and the cumulative details persistence/scaling concern is dropped as tracked context only under #1466. Neither is an active finding below.

Review-criteria coverage

  • Code quality and local API design: The implementation reuses existing abstractions without adding a parallel counter, endpoint, or speculative schema framework. The model-identity presentation issue is reported below; the broader contract-ownership candidate was independently dropped as non-actionable.
  • Code correctness and edge cases: The review checked malformed/legacy JSON, numeric coercion, missing fields, unknown values, zero quantities, small positive quantities, unit/call-type identity, cache behavior, polling cleanup, authorization, and escaping. The confirmed display edge cases are listed below; no new resource-lifecycle, concurrency, error-propagation, security, or compatibility blocker was found.
  • Test quality and coverage: Existing aggregation, locale-parity, and lower-level persistence tests were considered. The missing API seam assertion and four distinct frontend test/fixture gaps (arrays, zero/unmeasured, provider-token labels, and TTS units) are listed below.
  • Design and architecture: The read-model/UI direction fits the existing TokenUsage.details pipeline and correctly avoids cross-unit totals. The two pre-existing architectural boundaries are tracked in #1495 and #1466 rather than duplicated here.
  • Documentation and localization: Both shipped locale trees contain the current media unit/type keys and preserve unknown-value fallback behavior. No separate documentation issue was found because this internal legacy endpoint has no existing field-level response documentation convention.

New findings (all minor, non-blocking)

  1. frontend/src/components/chat/TokenUsageDisplay.tsx:357 — minor — media rows hide meaningful model identity. The backend groups by model_id or model_name and preserves both fields, but this renderer shows only model_name when it is present. Two distinct configured IDs sharing a provider-facing name therefore produce visually indistinguishable rows. Suggestion: mirror the existing LLM-row behavior: when model_id and model_name are both non-empty and differ, render model_id as a secondary label/title, and add a same-name/different-ID regression fixture.

  2. frontend/src/components/chat/TokenUsageDisplay.tsx:121 — minor — small positive quantities can display as zero. Intl.NumberFormat with maximumFractionDigits: 1 rounds a valid 0 < quantity < 0.05 to 0, while the renderer treats the raw value as measured. Suggestion: use adaptive precision or an explicit representation such as <0.1 for positive sub-threshold values, while keeping exact zero mapped to the unmeasured state.

  3. frontend/src/components/chat/TokenUsageDisplay.tsx:214 — minor — fixed plural unit labels are grammatically wrong for quantity one. The new English labels render values such as 1 images, 1 chars, 1 requests, and 1 texts; sec is an acceptable invariant abbreviation. Suggestion: add quantity-aware singular/other translations (for example, 1 image, 1 char, 1 request, and 1 text) and assert the singular row labels.

  4. src/xagent/web/api/chat.py:4601 — minor — the new task-detail serialization seam lacks a route/cache contract test. Aggregator tests and mocked frontend tests can pass if the production GET handler omits, renames, or fails to cache media_usage. Suggestion: extend the existing real GET /api/chat/task/{id} test with representative media details and exact media_usage assertions, including zero quantity, calls, resolution, provider tokens, and tokens_estimated; preferably assert the cache-hit response too.

  5. frontend/src/components/chat/TokenUsageDisplay.test.tsx:387 — minor — the malformed-row test does not cover arrays or prove invalid rows are absent. The production normalizer explicitly rejects arrays, but removing that guard would create an extra zero/unknown row while the current count and quantity assertions could still pass. Suggestion: add an array-shaped row such as [] and assert exactly the expected rendered row (or the absence of an Unknown/unmeasured junk row), retaining the null/string/number cases.

  6. frontend/src/components/chat/TokenUsageDisplay.tsx:373 — minor — the zero/unmeasured rendering branch has no frontend fixture. All current media quantities are positive and the test translation mock omits chatPage.tokenUsage.unmeasured, so a regression to 0 sec or a missing translation can pass. Suggestion: add the mock key and a zero-quantity media row with calls > 0, then assert the localized not yet measured text rather than numeric zero.

  7. frontend/src/components/chat/TokenUsageDisplay.test.tsx:615 — minor — provider-token tests assert numbers but not the token label. The checks for 1.12k and 40~ would pass if tokensShort were missing, mistyped, or rendered with the wrong translation key. Suggestion: add chatPage.tokenUsage.tokensShort to the mock and assert the complete strings, such as 1.12k tokens and 40~ tokens.

  8. frontend/src/components/chat/TokenUsageDisplay.test.tsx:325 — minor — the TTS fixture uses an impossible unit pairing. The fixture sets call_type: "tts" with unit: "seconds", but the producer contract maps TTS to characters, so it never tests the actual TTS path. Suggestion: change the fixture to unit: "characters" and assert the chars label; use video or ASR for a seconds-formatting fixture.

Simplification Lens status

The dedicated Simplification Lens scan was unavailable because it returned usage_limit_reached; no simplification findings are asserted.

Validation context

CI preflight was green with no failed or pending checks. No local tests, builds, linters, formatters, or dependency installation were run; local validation was intentionally not run under the review policy.

Blocking status & recommended decision

Blocking: no. There are no confirmed major or critical findings. All eight confirmed findings above are minor, and the prior self-merge and frontend CI-gap roots were dropped as pre-existing/out-of-scope or tracked.

Recommended event: APPROVE

Comment thread frontend/src/components/chat/TokenUsageDisplay.tsx
Comment thread frontend/src/components/chat/TokenUsageDisplay.tsx
Comment thread frontend/src/components/chat/TokenUsageDisplay.tsx Outdated
Comment thread src/xagent/web/api/chat.py
Comment thread frontend/src/components/chat/TokenUsageDisplay.test.tsx
Comment thread frontend/src/components/chat/TokenUsageDisplay.tsx
Comment thread frontend/src/components/chat/TokenUsageDisplay.test.tsx
Comment thread frontend/src/components/chat/TokenUsageDisplay.test.tsx Outdated
Renderer:
- Surface a differing model_id as a secondary label on media rows, matching
  the rule the LLM rows already use. The backend groups media by
  `model_id or model_name`, so two configured IDs sharing one provider-facing
  name are distinct billable rows that previously rendered identically.
- Render a positive quantity below the one-decimal rounding threshold as
  "<0.1" instead of "0". The row already took the measured branch, so a bare
  "0" contradicted it and read as "this cost nothing".
- Choose unit labels by quantity, so a single item reads "1 image" rather
  than "1 images". Locales without a plural distinction carry the same text
  in both keys; `sec` stays invariant as an abbreviation.

Locales: unit labels become one/other pairs in both en and zh, keeping the
locale trees structurally identical.

Tests:
- Add a real GET /api/chat/task/{id} assertion for media_usage, covering
  same-key aggregation, a zero quantity, mixed estimated tokens, and the
  LLM/media split; plus a cache-hit case, since the detail response is cached
  wholesale and the usage popover polls that path.
- Correct the TTS fixture to bill in characters. MediaCallType.TTS derives
  MediaUnit.CHARACTERS, so the previous tts/seconds pair asserted a state the
  producer cannot emit; a separate ASR row now covers seconds formatting.
- Add an array-shaped malformed row and assert no junk row is rendered. The
  existing count and quantity assertions passed with the Array.isArray guard
  removed, so they did not pin it.
- Register unmeasured and tokensShort in the test locale mock and assert the
  full "1.12k tokens" / "40~ tokens" strings, so a missing or misnamed label
  cannot pass by matching the number alone.
- Cover the zero-quantity unmeasured branch, the sub-threshold quantity, the
  singular unit labels, and same-name/different-id media rows.
@OliverBryant
OliverBryant added this pull request to the merge queue Aug 21, 2026
Merged via the queue into xorbitsai:main with commit 06ba62a Aug 21, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants