Skip to content

fix(server): record streamed chat completions in Metrics - #1919

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-1911-streaming-chat-metrics
Sep 19, 2026
Merged

inureyes merged 2 commits into
mainfrom
fix/issue-1911-streaming-chat-metrics

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Summary

The streaming branch of POST /v1/chat/completions never called Metrics::record_request, so requests_total and completion_tokens_total (and the prompt-token and generation-time totals) only moved for non-streaming chat. The WebUI chat always streams, so the Activity tiles read 0, and mlxcel_requests_total undercounted every streaming client.

  • stream_chat_completion records the request once, right after slot.finish, using the result's own counts. It does not depend on stream_options.include_usage. Err records nothing, as on the non-streaming path.
  • Streams the client cancelled are not counted. That covers a disconnect without X-Conversation-Id, DELETE /v1/stream, and session replacement. After an abort the worker still returns Ok with the partial count, so the route checks the cancellation flag. The flag is now cloned before it moves into the provider call. A resumable stream whose client only disconnects keeps generating and is counted.
  • The mlxcel_requests_total and mlxcel_completion_tokens_total HELP lines, the two counter_source reasons (and runtime.ready-shared.json, which pins them) and docs/webui/runtime.md now state these semantics. Metric names, values and both response shapes are unchanged.

The scope is chat completions only. Streaming /v1/completions and native /completion still never record, and /v1/messages and /v1/responses streams still record even when cancelled. That is why the HELP line says "chat completion streams". Both are left for follow-ups.

Tests

New tests in server::routes::stream_route_tests::metrics_tests, a child module of stream_route_tests that reuses its helpers. stream_route_tests.rs was already 698 lines. scripted_app_with_state also returns the AppState, and scripted_app delegates to it. The cancelled and resumable cases wait for the generation task to exit before asserting, because its completion-control registration drops after the record point. Without that wait, a zero count would prove nothing.

  • completed_stream_records_one_request_with_its_counts: 3 tokens, [DONE], then +1 request, +3 completion tokens, still 1 after task exit.
  • client_cancelled_plain_stream_is_not_recorded: disconnect, flag flips, finish(), task exits, requests_total == 0.
  • resumable_stream_is_recorded_after_a_client_disconnect: the flag stays clear, and the request counts with all 3 tokens.
  • metrics_endpoint_reports_a_completed_stream: whole-line mlxcel_requests_total 1 and mlxcel_completion_tokens_total 3.
  • activity_projection_counts_a_completed_stream: runtime_snapshot reports completed_requests_total 1.0 and completion_tokens_total 3.0. It is gated on the webui feature, like server::webui itself, so the --no-default-features OpenXLA compile job still builds the file.

Revert check

Arm 1 removes the record block from stream_chat_completion and runs server::routes::stream_route_tests::metrics_tests: 1 passed, 4 failed. client_cancelled_plain_stream_is_not_recorded passes here, as expected, because this arm records nothing at all.

completed_stream_records_one_request_with_its_counts
  panicked at src/server/routes/stream_route_metrics_tests.rs:83:5:
  assertion `left == right` failed
    left: 0
   right: 1
resumable_stream_is_recorded_after_a_client_disconnect
  panicked at src/server/routes/stream_route_metrics_tests.rs:165:5:
  assertion `left == right` failed
    left: 0
   right: 1
metrics_endpoint_reports_a_completed_stream
  panicked at src/server/routes/stream_route_metrics_tests.rs:196:9:
  /metrics must print "mlxcel_requests_total 1":
  (scrape body) mlxcel_requests_total 0 / mlxcel_completion_tokens_total 0
activity_projection_counts_a_completed_stream
  panicked at src/server/routes/stream_route_metrics_tests.rs:233:5:
  assertion `left == right` failed
    left: Some(0.0)
   right: Some(1.0)

Arm 2 keeps the record and drops only the !cancelled condition: 4 passed, 1 failed.

client_cancelled_plain_stream_is_not_recorded
  panicked at src/server/routes/stream_route_metrics_tests.rs:126:5:
  assertion `left == right` failed: a client-cancelled stream must not count as a completed request
    left: 1
   right: 0

With the fix restored, all 14 pass. Neither reverted state was committed. The review and security passes re-ran the arms independently and got the same split (arm 1: 1 passed, 4 failed; arm 2: 4 passed, 1 failed).

Validation

All commands use cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib <path>.

  • server::routes::stream_route_tests: 14 passed, 0 failed (9 existing plus 5 new).
  • server::webui::runtime: 6 passed, 0 failed. This includes ready_projection_matches_whole_canonical_fixture, which pins the new counter_source strings.
  • server::routes::metrics: 6 passed, 0 failed.
  • cargo clippy -p mlxcel --lib --tests --features metal,accelerate -- -D warnings and cargo fmt --all -- --check are both clean.
  • The full workspace suite was not run in this unit.

Real-binary verification

Run by the epic #1910 orchestrator on M1 Ultra (macOS 27.0), mlxcel-server built from this PR head (414d45dc) under --profile test-fast --features metal,accelerate, started as mlxcel-server --webui --models-dir models/mlx --api-key-file <f> --no-models-autoload --settings --props --metrics --slots against the 224-entry local store, with qwen3-0.6b-4bit loaded through POST /ui-api/v1/model-actions.

  • One stream: true (with stream_options.include_usage) and one stream: false /v1/chat/completions request, max_tokens: 20, temperature: 0: both responses reported usage.completion_tokens = 9. GET /ui-api/v1/runtime?model_id=<id> then reported completed_requests_total = 2 and completion_tokens_total = 18, matching decode_tokens_total = 18. On 0ef0a1a4 the same pair gave completed_requests_total = 1 (issue body).
  • /metrics?model=qwen3-0.6b-4bit printed mlxcel_requests_total 2 and mlxcel_completion_tokens_total 18. In router mode the scrape needs the model query parameter; without it the server answers 400 model name is missing from the request, which is existing behavior.
  • Fresh server, one WebUI streaming chat driven with Playwright (Chromium, 1440x900) through the bundled UI: the Activity summary for the model read Total completed requests 1 requests and Total completion tokens 5 tokens.
  • cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server:: on this head: 3224 passed, 0 failed, 13 ignored.

Closes #1911

The streaming branch of POST /v1/chat/completions never called Metrics::record_request, so requests_total, prompt_tokens_total, completion_tokens_total and generation_time_ms_total only moved for non-streaming chat. The WebUI chat always streams, so the Activity tiles completed_requests_total and completion_tokens_total read 0, and mlxcel_requests_total undercounted every streaming client.

stream_chat_completion now records the request once, right after slot.finish, with the result's own counts and independent of stream_options.include_usage. A stream the client cancelled (disconnect without X-Conversation-Id, DELETE /v1/stream, or session replacement) is skipped: the worker still returns Ok with the partial count after an abort, so the cancellation flag, cloned before it moves into the provider call, is what separates the two. A resumable stream whose client only disconnects keeps generating and is counted. Err records nothing, as on the non-streaming path.

The mlxcel_requests_total and mlxcel_completion_tokens_total HELP lines, the counter_source reasons (and the fixture that pins them) and docs/webui/runtime.md state these semantics. Metric names and both response shapes are unchanged.

New tests under server::routes::stream_route_tests::metrics_tests cover a completed stream, a client-cancelled plain stream, a resumable stream after a disconnect, the /metrics lines and the runtime projection behind the Activity tiles.

Refs #1911
@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) labels Sep 19, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Record every finished streaming /v1/chat/completions request in Metrics once, leaving out streams the client cancelled, so the Activity tiles and mlxcel_requests_total stop undercounting.

Findings Addressed

  • No CRITICAL, HIGH or MEDIUM findings, so nothing on the branch was changed. The flag the route reads is the one the SSE sender sets when a client disconnects from a non-resumable stream, and the one StreamSession::cancel sets for DELETE /v1/stream and for session replacement. The streaming and non-streaming paths share no recording code, so no request is counted twice.

Remaining Items

  • "completed generation requests" is not exact for other routes. Cancelled /v1/messages and /v1/responses streams still count, and streaming /v1/completions and /completion never count. That route behavior predates this PR and is out of scope. No follow-up issue exists yet. (LOW)
  • The HELP lines for mlxcel_prompt_tokens_total and mlxcel_generation_time_seconds_total still read "processed" and "total". (LOW)
  • The DELETE /v1/stream exclusion is tested only indirectly, by the existing delete_cancels_the_generation_and_evicts_the_session together with the new flag test. (LOW)

Verification

  • All stated requirements implemented (real-binary acceptance pending orchestrator verification)
  • No placeholder/mock code remaining
  • Integrated into project code flow (router mode reads the same per-model AppState)
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: stream_route_tests 14/14, webui::runtime 6/6, routes::metrics 6/6, clippy and fmt clean

I reran the revert check on 30358e82. Without the record block: 1 passed, 4 failed. Without the !cancelled condition: 4 passed, and client_cancelled_plain_stream_is_not_recorded failed (left 1, right 0). So the negative test asserts after the route has made its record decision, not before it.

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

No CRITICAL, HIGH or MEDIUM findings, so no fix commits were pushed.

  • Counter abuse: the record runs once per generation task, and stream_chat_completion is only reached from the chat handler, so nothing double counts. Suppression needs a cancellation, and the session paths that set it (DELETE /v1/stream, replacement) are keyed by (API key, conversation id), so one key cannot cancel or uncount another key's stream.
  • Hot path: no per-token cost. The change adds one Arc clone per request and four relaxed atomic adds per completed stream. Nothing counts references on the token.
  • Exposure: only static HELP and reason strings changed. The /metrics and /ui-api/v1/runtime shapes are unchanged, and the canonical fixture test pins the latter.
  • Revert check: dropping only the !cancelled condition fails client_cancelled_plain_stream_is_not_recorded (left 1, right 0). Restored and not committed.

LOW, not fixed:

  • The HELP line says "completed generation requests", but streaming /v1/completions and native /completion still never record. That gap predates this PR and is out of scope here.
  • Aborted streams' prefill and partial decode no longer show in mlxcel_prompt_tokens_total or mlxcel_completion_tokens_total (issue fix(server): record streaming chat completions in Metrics so Activity stops reporting zero #1911 chose this). They still show in the decode counters. Nothing reads these counters for quotas or admission.
  • With authentication disabled, every client shares one stream owner. Anyone who knows a conversation id can cancel that stream, and the cancelled stream is now also left out of the counts. This is b10621 behavior and was already documented.

Gates: stream_route_tests 14/14, webui::runtime 6/6, routes::metrics 6/6, clippy and fmt clean.

…ture

activity_projection_counts_a_completed_stream calls crate::server::webui::runtime::runtime_snapshot, but server::webui is #[cfg(feature = "webui")] in server/mod.rs. The `xla-compile` CI job builds with `--no-default-features --features xla-diagnostics`, which drops webui, so this file failed with `E0433: cannot find webui in server`.

Add the matching #[cfg(feature = "webui")] to the test so it compiles and runs under the default feature set and is skipped, not broken, when webui is off. Re-verified server::routes::stream_route_tests (14 passed), server::webui::runtime (6 passed), server::routes::metrics (6 passed), clippy -D warnings and fmt --check, all clean.

Refs #1911
@inureyes

Copy link
Copy Markdown
Member Author

PR finalization

Checked test coverage, documentation, and lint/format on top of 30358e82; pr-reviewer and pr-security-checker had already found no CRITICAL/HIGH/MEDIUM issues.

Tests

The 5 tests in server::routes::stream_route_tests::metrics_tests cover the issue's acceptance cases (completed stream, client-cancelled stream, resumable stream after disconnect, /metrics lines, the runtime projection). No new test added: the Err(_) branch and a zero-token request both fall out of the existing generic code path, and the scripted-stream harness has no way to script a generation error, so adding that capability here would be disproportionate.

Documentation

docs/webui/runtime.md, the two HELP lines in metrics.rs, and the two counter_source reasons in runtime.rs (plus the fixture pinning them) match the brief; docs/webui/ has no runtime.ko.md, so no translation file was added.

Bug found and fixed

activity_projection_counts_a_completed_stream called crate::server::webui::runtime::runtime_snapshot, but server::webui is #[cfg(feature = "webui")]. The xla-compile CI job builds with --no-default-features --features xla-diagnostics, which drops webui, so the branch failed to compile there (E0433: cannot find webui in server). Fixed in 414d45d by gating that one test the same way, matching the pattern already used throughout server/app.rs.

Validation

  • cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server::routes::stream_route_tests: 14 passed, 0 failed.
  • cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server::webui::runtime: 6 passed, 0 failed.
  • cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server::routes::metrics: 6 passed, 0 failed.
  • cargo clippy -p mlxcel --lib --tests --features metal,accelerate -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.

All four re-ran clean after the fix. The CI WebUI installed artifact job is failing on an unrelated real-GPU Activity performance gate (median_decode_degradation_percent: 3.19, status investigate) on the self-hosted GB10 runner; that is a real-binary performance measurement, not something this metrics-counting change plausibly causes, and is left for orchestrator verification per the brief.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
@inureyes
inureyes merged commit eae7108 into main Sep 19, 2026
18 checks passed
@inureyes
inureyes deleted the fix/issue-1911-streaming-chat-metrics branch September 19, 2026 05:18
inureyes added a commit that referenced this pull request Sep 19, 2026
Brings in #1919, #1921 and #1922 (the i18n catalog consolidation from #1913). Conflicts were resolved so both changes survive: primitives.tsx keeps #1922's ConfirmDialog and drops the local Sheet and Tooltip; Activity runtime tiles keep #1922's t(locale, 'activity.*') keys inside the StatCard adapter; the operations list keeps #1922's keyed copy wrapped in SmoothHeight. The embedded bundle was rebuilt by scripts/webui/build_bundle.py rather than merged by hand.

Merged instead of rebased so the test-first commit hashes cited in the PR evidence stay valid and no published commit is rewritten.

Validated after the merge: typecheck, lint, unit (359 Vitest in 47 files, including src/i18n/drift.test.ts), all seven Playwright spec files on a private preview port, build_bundle.py --verify, check_webui_contract.py and check_binary_assets.py.
inureyes added a commit that referenced this pull request Sep 19, 2026
The bundled WebUI now uses all 17 `@lablup/ui-common@0.1.0-alpha.19` components instead of 7. The ten new ones sit behind the `webui/src/design-system/common-*.tsx` adapter seam and are re-exported from `primitives.tsx`, so feature screens still import only the design system. The local `Tooltip`, `Sheet` and `ErrorBanner` implementations are gone from `primitives.tsx`.

Count, measured the issue's way (`grep -rn '@lablup/ui-common' webui/src/` against the 17 names in `dist/index.d.ts`): 17 of 17. Package imports appear only in `common-adapters.tsx` (Button, StatusTag, ProgressBar, EmptyState, Tabs, DataTable), `common-select.tsx` (Select), `common-overlays.tsx` (Drawer, Tooltip), `common-feedback.tsx` (ErrorState, Skeleton), `common-layout.tsx` (BaseCard, PageHeader, PageLayout, SmoothHeight) and `common-data.tsx` (Badge, StatCard), plus the existing `styles/base.css` import in `main.tsx`.

## What each adapter keeps

- **Drawer** (compact nav below 960px, replaces the native-dialog `Sheet`): stable `onClose` (the package reruns its focus effect on every identity change, so each SSE-driven shell render moved focus); completes the first-open focus hand-off the package drops while the panel is still `visibility:hidden`; `<main>` is `inert` while it is open and an Escape that starts outside the panel still closes it (skipping IME composition and already-handled events), because the package only traps focus from inside its panel; the shortcut guard also matches `[role="dialog"][aria-modal="true"]`; left sheet at the approved `min(320px, 100vw - 32px)` via scoped CSS (two commented `!important`s outrank the package's `<768px` full width); localized title and close label; `data-testid="mobile-nav-sheet"` kept by a ref bridge; widening past 960px while open closes it and focuses the desktop sidebar (the old sheet stayed open with a `display:none` restore target).
- **ErrorState**, through the kept `ErrorBanner` name and props: the 23 call sites are unchanged, and #1913/#1914 rely on the name. A ref bridge removes the package's inner `role="alert" aria-live="polite"` so the banner stays the only live region (`status` for info, `alert` otherwise) and sets `role="none"` on the package h2 so no heading lands in sections that own one. `action` stays a ReactNode because `primaryAction` cannot express the chat anchor or icon Buttons. Tones map error/warning/info to danger/warning/accent.
- **Tooltip** (gallery): props are `{content, children}`. The package describes its wrapper, and only while open, so the control is described by an always-present hidden node; the wrapper adds no tab stop; inside `NativeModalContext` it renders in place instead of portalling out of the top layer. Escape now dismisses it (WCAG 1.4.13).
- **BaseCard** (`Card`, gallery cards; also inside StatCard): keeps `.surface-card`, passes `role` (article, or region for the named focusable table card), turns `hoverable` off, and restores the focus outline that BaseCard's `:focus-within` rule removes.
- **PageLayout** (shell content column, `wide`): drops the package's extra 0.5rem inline padding below 768px.
- **PageHeader** (Settings, gallery, the three sign-in/connection states): the eyebrow line is dropped there; its keys stay for #1914, which owns Models/Chat/Activity headings. A ref bridge restores the title and description test ids, and `focusFallback` puts `tabIndex=-1` plus `data-dialog-focus-fallback` on the Settings h1, so the dialog focus fallback stays a named heading.
- **StatCard** (Activity runtime summary and details), **Skeleton** (`LoadingStatus`: one visible, localized `role="status"` over decorative shapes in the Models table loading slot, since SkeletonRow/SkeletonText mount a status per row), **SmoothHeight** (Activity operations list, active only while an operation is in flight plus a 400ms settle window restarted on every transition, so a settled list is never height-pinned or focus-clipped), **Badge** (Models row: selected, source, quantization; rows at 1440 go from 100.5px to 79.5px).

## Recorded decisions and follow-up

- `usePrefersReducedMotion`: not adopted. It reads only the OS media query and cannot see the in-app `data-reduce-motion` toggle; CSS stays the only mechanism and its global rules also stop the shared components' motion (browser test: with the OS preference unset, the drawer's transitions are 1ms). StatCard `animate` stays off for the same reason.
- `formatCompactNumber`: not adopted. It falls back to a locale-less `toLocaleString()`; `format.ts` stays the single locale-aware formatter.
- Both are recorded at the seam (`common-data.tsx`) and in `docs/webui/ui-common.md`, which also records the follow-up to contribute `Dialog` (native top layer, focus trap, Escape with focus restoration) to `lablup/ui-common`; the same note sits above `ModalDialog`. Nothing was opened upstream.

## Tests first: failing before each swap, and naive reverts

Each test commit precedes its swap commit, and the shared-DOM assertion is last, so the red runs fail there unless noted. All Playwright runs used a private preview port (4902) so no sibling worktree's server was reused.

```
Tooltip    RED  vitest: expected null not to be null  (button.closest('.tooltip__wrapper'))
                pw: > 27 | expect(page.locator('.tooltip__wrapper').filter({ has: trigger })).toHaveCount(1)  0 elements
                pw Escape case: > 36 | expect.poll(() => shownTooltips(page)).toEqual([])  (old CSS tooltip is not dismissible)
           NAIVE no describedby clone: expected '' to be 'Tooltips are descriptive only'; toHaveAccessibleDescription failed
                 no modal fallback: expected <div id="tooltip-…"> to be null
                 default tabIndex 0: expected 0 to be less than 0; after Tab toBeFocused failed
ErrorState RED  vitest (4 cases): expected null not to be null  (.error-state--<tone>)
                pw: > 64 | expect(banner.locator('.error-state')).toHaveCount(1)  0 elements
           NAIVE inner live region kept: expected [<section>, …(1)] to deeply equal [<section>]
                 h2 kept as heading: expected ['Runtime', 'Observations are stale'] to deeply equal ['Runtime']
                 no compact CSS: > 62 | expect(layout.top).toBeLessThan(32)  Received: 100
BaseCard   RED  pw: > 86 | expect(page.locator('.surface-card.base-card')).toHaveCount(1)  0 elements
           NAIVE no role: > 73 | expect(articles).toHaveCount(2); hoverable: > 77 | toHaveCSS('transform', 'none'); no outline restore: > 85 | outlineStyle not 'none'
Drawer     RED  vitest (2): expected false to be true  (panel matches aside.drawer[role=dialog][aria-modal=true])
                pw (4): > 26 | expect(panel).toHaveJSProperty('tagName', 'ASIDE')  resolved to <dialog … class="ds-dialog ds-sheet">
                pw widening case: > 122 | expect(dialog).toBeHidden()  (old sheet stays open)
                pw pointer-press case: > 93 | focus left the drawer (Shift+Tab reached the page behind it)
           NAIVE per-render onClose: expected <button …> to be <a href="#chat" …>
                 guard only dialog[open]: expected true to be false  (command dialog opened)
                 no first-open hand-off: > 45 | expect(close).toBeFocused()
                 no left-sheet CSS: > 52 | expect(box.width).toBeLessThanOrEqual(320 + 1)
                 no closeLabel: > 45 | close button named 닫기 not found
                 no widening handler: > 122 | expect(dialog).toBeHidden()
                 no inert <main>: > 93 | focus left the drawer
                 no outside Escape: >  98 | expect(dialog).toBeHidden()  unexpected value "visible"
                 handled Escape still closes: vitest expected false to be true
PageLayout RED  pw (3): > 163 | expect(page.locator('.app-content.page-layout.page-layout--wide')).toHaveCount(1)  0 elements
           NAIVE package padding kept: > 157 | expect(Math.abs(metrics.left)).toBeLessThanOrEqual(1)  (390 and 390 at 200% text)
PageHeader RED  vitest (6): expected false to be true  (.page-header .page-header__title)
                pw (3): > 196 | expect(page.getByTestId('gallery-title')).toHaveClass(/page-header__title/)  resolved to <h1 data-testid="gallery-title">
           NAIVE no test-id bridge: Error: Missing settings-title
                 no focus fallback: expected <button …> to be <h1 class="page-header__title" …>
                 no title CSS: > 186 | fontWeight toBeGreaterThanOrEqual(600)
StatCard   RED  vitest: expected [null, null] to deeply equal ['Active requests: 2 requests', …]
                pw (390, 1440): > 132 | locator('.activity-metric.stat-card') toHaveCount(4)  0 elements
           NAIVE nowrap/ellipsis: > 125 | clipped toEqual([]); uppercase: > 126; hover lift: > 129 | toHaveCSS('transform', 'none')
Skeleton   RED  vitest (en, ko): expected 0 to be greater than 0  (.skeleton inside the status)
                pw (390, 1440): > 158 | expect(status.locator('.skeleton').first()).toBeVisible()  not found
           NAIVE non-decorative shapes: expected [ …(4) ] to have a length of 1 but got 4
SmoothHeight RED vitest: expected null not to be null
                pw (390, 1440): > 193 | locator('.smooth-height.smooth-height--active') toHaveCount(1)  0 elements
           NAIVE always active: expected true to be false (settled list pinned); no settle window: expected false to be true
                 window not restarted on false-true-false: expected false to be true
Badge      RED  vitest: expected [] to deeply equal ['Selected', 'cache', '4bit']
                pw (390, 1440): > 226 | cell.locator('.badge:not(.status-tag)') toHaveText(['models_dir', '4bit'])  0 elements
           NAIVE no whitespace: expected 'alphaSelectedcache4bit' not to contain 'cache4bit'
```

## Also fixed here

`.ds-button:hover` applied to disabled buttons and swapped a disabled primary fill for the 8% selection tint behind white text. Dropping the eyebrow moved the sign-in Connect button up, so after sign-in the pointer rests on the still-disabled Models "Add" button and the existing 1440 signed-in axe check failed about one run in ten; the rule is broken on main too. The hover rules now use `:hover:where(:not(:disabled))`, which excludes disabled buttons without raising specificity, so the package's primary and danger hover fills still win on enabled buttons (a first version at higher specificity made enabled hover text 1.16:1; the test for that case failed before the correction).

Known and left: at 390px the loading or empty Models table scrolls horizontally with nothing focusable inside (`scrollable-region-focusable`, also on main); the Skeleton test tolerates exactly that node and the table belongs to #1918. The served-CSP spec exercises the gallery only, so SmoothHeight and Skeleton reach the real CSP only through their CSSOM height/width writes, which strict `style-src 'self'` permits.

## Validation

- Branch is current with main at fd33ff1 (#1919, #1921, #1922) through a merge commit rather than a rebase, so the test-first commit hashes cited above stay valid. Conflicts with #1922 were resolved so its catalog keys survive inside the StatCard and SmoothHeight swaps and its `ConfirmDialog` stays next to the removed `Sheet`/`Tooltip`; the bundle was rebuilt, not hand-merged.
- `pnpm --dir webui run typecheck` and `lint`: pass. `unit`: 18 node:test and 359 Vitest tests (47 files) pass, including #1922's `src/i18n/drift.test.ts`.
- Playwright chromium at the merged head: ui-common-shell 12, ui-common-components 14, browser 19, activity 3, chat 2, models 3, settings 3, all pass.
- `python3 scripts/webui/build_bundle.py` then `--verify`: clean, regenerated `src/webui/assets/` committed; initial JS 152.7 kB gzip against the 200 KiB budget. `check_webui_contract.py` (49 fixtures) and `check_binary_assets.py`: pass.
- CI at be87e27: all checks pass on run attempt 2. Attempt 1 failed only `src/features/chat/confirmations.test.tsx` "refocuses the Clear All trigger once clearing settles if focus is still on body" (from #1922): its `setTimeout(0)` focus claim can run before React commits the re-enabled button. It passed 15 of 15 isolated and 4 of 4 full-suite runs locally, and this PR does not touch that flow; recorded as a flaky #1922 test, not relaxed here.
- Review: implementation review fixed the hover specificity and the drawer's non-inert background; the security and performance review found nothing at CRITICAL, HIGH or MEDIUM; three LOW hardening items (settle window restart, handled-Escape guard, adapter-scoped layout whitelist) were applied test-first.

## Live acceptance against the embedded bundle

Run against `mlxcel-server --webui --models-dir models/mlx --no-models-autoload --settings --props --metrics --slots` built from be87e27 (test-fast profile, embedded bundle), signed in with a session key, no model loaded, nothing loaded, unloaded or downloaded. Headless Chromium, driven by a local uncommitted spec.

- [x] PASS, 390 px nav: the menu button opens the drawer with focus on its close button and `<main>` inert; Tab cycles home, Models, Chat, Activity, Settings, Close and wraps back to home without leaving the drawer; Shift+Tab from Close reaches Settings then Activity; Escape closes it and focus returns to the menu button; choosing Activity closes it with focus on the menu button; after a pointer press on the drawer heading, Shift+Tab goes to the document and then to the drawer's last link, never to the page behind. No horizontal overflow with the drawer closed or open.
- [x] PASS, 200 percent zoom, emulated (headless Chromium cannot drive native browser zoom): a 780x844 window at 200 percent, emulated as a 390x422 CSS px viewport at deviceScaleFactor 2. Models, Chat, Activity, Settings and `#gallery` have 0 px horizontal overflow with the nav closed and open; the drawer is 320 px and every nav link stays inside the viewport. Not a native-zoom observation.
- [ ] Not met, pre-existing and routed to #1914: a 390x844 window at 200 percent, emulated as 195x422 CSS px at deviceScaleFactor 2, overflows by exactly 125 px on every route with the nav closed or open. The cause is `body { min-width: 320px }` on main (320 - 195), not this PR; the drawer itself fits (163 px, links inside the viewport).
- [x] PASS, real-server `webui/tests/csp.spec.ts` via `MLXCEL_WEBUI_CSP_URL=http://127.0.0.1:<port>/webui/` and `playwright.csp.config.ts`: both cases (light 1440, dark 390) pass with zero CSP violations and no external requests, including the new steps that open the drawer and show the tooltip.
- [x] PASS, Settings and banners: on Settings, with focus on the document, Cmd/Ctrl+K then Escape lands focus on the Settings title (`h1.page-header__title`, `settings-title`). Both Settings info banners are `role="status"` and the gallery warning banner is `role="alert"`, with no nested live region and the package title set to `role="none"` (no extra heading).
- [x] Extra: axe (WCAG 2.2 AA tags) on signed-in Models, Activity and Settings at 390 px with the nav open reports no violations.

Part of #1910

Closes #1902
inureyes added a commit that referenced this pull request Sep 19, 2026
Part of epic #1910.

## Summary

- `identity.display_name` is now the model's inference id with no character rewriting. The catalog no longer turns `-` and `_` into spaces. Cache and preset entries keep their full name (`mlx-community/Qwen3-4B-4bit`), models-dir entries keep the directory name, and single-model entries keep the last `/` segment of the served id, as the issue decided. `qwen3-0.6b-4bit` and `qwen3_0.6b_4bit` are two distinct rows again. Every surface that prints `display_name` (library row, inspector heading, toolbar pill, Chat and Activity selectors, operation titles, settings selectors, dialogs) now shows the name passed to `-m` and the API. The Rust change is the `display_name` helper in `src/server/webui/catalog_metadata.rs` and its two call sites.
- `ModelIdentity.display_name.maxLength` in `docs/webui/api.yaml` goes from 128 to 256 to match `inference_id`, because a cache `owner/name` can be 193 characters. The field's new description gives the rule for each source. `schema_version` stays `webui.ui-api.v1`, and the generated `ui-api.d.ts` is unchanged.
- The delete dialog still accepts only `identity.id` as its token, and the fence test is byte-identical to main. The body now says to type the model ID shown below, not the model name. The field label reads "Opaque model ID shown above (starts with mdl_)" in English and "위에 표시된 모델 ID (mdl_로 시작)" in Korean. The `tests/fixtures/webui/strings.json` copies of both strings, which had drifted, now match `strings.ts`.
- Long ids wrap. The dialog body paragraph and the model names listed in the capacity-recovery dialog now carry `models-wrap`, like the id already did. Without it, a verbatim underscore name has no break point: at 390px the capacity dialog measured scrollWidth 523 against clientWidth 356.
- Fixtures and tests that used space-separated names now use hyphenated ids. That includes `identity-collision.json`, which still collides on `inference_id`, and the performance fixtures and specs. The `models.long_name` gallery string is now a long hyphenated id, the same in en and ko. `docs/webui/catalog.md` and `docs/webui/catalog.ko.md` describe the new meaning. The committed bundle is regenerated.

## Premises checked

- Search already found the real name before this change: `inventory()` and the server `q` filter both match `inference_id`, which already held `qwen3-0.6b-4bit`. What changes is that the matched row now prints that name.
- The issue expected a trailing `/` to fall back to the whole name through the existing `unwrap_or(name)`, but it never did. `"x/".rsplit('/').next()` is `Some("")`, so the old helper returned an empty string. The new helper skips the empty segment, and the test covers that case.
- The issue did not say how to treat presets; this PR keeps their names whole. A preset's inference id is its section name (`config.model_alias = name`), and an overlay-only preset takes over a cache `owner/name`, which the last-segment rule would shorten.
- Single-model mode keeps the last-segment rule, as the issue specified. The id there is either `--alias` or the checkpoint directory's `file_name()`, never a path, so the rule only changes an alias that contains `/` (for example `--alias Qwen/Qwen3-8B` displays as `Qwen3-8B`). The docs and the api.yaml description say so. Showing the alias whole would be a one-line change if that is preferred.

## Changes during review

- Implementation review: the capacity-dialog list items got the wrap class. The capacity-recovery unit test now checks the listed names and the class, and fails without them.
- Implementation review: the api.yaml description and both catalog docs now give the rule for each source. The earlier wording said "verbatim" everywhere, which is not true for single-model aliases. The Korean confirm label dropped "불투명", a word Settings already uses for the visual Opaque material.
- Security and performance review: `performance.spec.ts` searched the 1000-entry catalog for `'0999'`, which only matched the old padded label, so the `WebUI bundle` engine smoke timed out on every engine. It now searches for `perf-model-999`. The review found no injection or path exposure: names render as React text or escaped attributes, and `display_name` is always equal to `inference_id` or a substring of it. The delete fence still requires the exact id and revision.

## Revert-and-fail

With the `catalog_metadata.rs` change alone reverse-applied, the new contract test fails:

```
test server::webui::catalog::tests::catalog_contract_tests::display_name_is_the_inference_id_verbatim ... FAILED
panicked at src/server/webui/catalog_contract_tests.rs:543:5:
assertion `left == right` failed
  left: [("Meta-Llama-3.1-8B-Instruct_4bit", "Meta Llama 3.1 8B Instruct 4bit"), ("mlx-community/Qwen3-4B-4bit", "Qwen3 4B 4bit"), ("qwen3-0.6b-4bit", "qwen3 0.6b 4bit"), ("qwen3_0.6b_4bit", "qwen3 0.6b 4bit"), ("team/qwen3-preset", "qwen3 preset")]
 right: [("Meta-Llama-3.1-8B-Instruct_4bit", "Meta-Llama-3.1-8B-Instruct_4bit"), ("mlx-community/Qwen3-4B-4bit", "mlx-community/Qwen3-4B-4bit"), ("qwen3-0.6b-4bit", "qwen3-0.6b-4bit"), ("qwen3_0.6b_4bit", "qwen3_0.6b_4bit"), ("team/qwen3-preset", "team/qwen3-preset")]
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 8596 filtered out
```

With the patch re-applied, all 34 catalog tests pass. The check was run twice, once by the implementer and once independently. The frontend check was done the same way. Reverting `api.yaml`, `strings.ts` and `dialogs.tsx` makes the new 193-character schema-bound test and the delete-dialog wording test fail. Removing the capacity-list wrap class makes the capacity-recovery test fail.

## Test plan

This branch is rebased onto origin/main at fd33ff1, which includes #1921 (catalog backend column) and #1922 (i18n catalog consolidation). The committed bundle was rebuilt by `build_bundle.py` at each rebased commit rather than merged by hand. One rebase conflict needed both sides: the performance spec's Chat option now reads `perf-model-0 · Ready`, combining this PR's name with #1922's localized state label. No test or fixture added by #1922 uses a space-separated model name. Results on the rebased head:

- [x] `cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib server::webui`: 78 passed, 0 failed, including `display_name_is_the_inference_id_verbatim`
- [x] `cargo clippy -p mlxcel --lib --tests --features metal,accelerate -- -D warnings` and `cargo fmt --all -- --check`: clean
- [x] `pnpm --dir webui run typecheck` and eslint: clean
- [x] `pnpm --dir webui run unit`: node 18/18, vitest 340/340 in 39 files
- [x] Playwright chromium default suite: 30/30. Engines config (`engines.spec.ts` and `performance.spec.ts`), chromium: 7/7. All runs used a private port with `--strictPort`, never the preview server shared with other worktrees
- [x] `check_webui_contract.py` (49 fixtures), `build_bundle.py --verify`, `check_binary_assets.py`, `check_cross_repo_refs.py`: pass

Before the rebase, the same gates also passed `server::router_server` (63 passed, 1 ignored) and the engines config in firefox (7/7).

## Real-binary acceptance

The orchestrator built `mlxcel-server` from 3082e1d, the pre-rebase head carrying the same change (`--profile test-fast --features metal,accelerate`), and ran it with `--webui --models-dir models/mlx --no-models-autoload` against the real checkpoint store and the default managed cache. I drove the bundled WebUI in Chromium at 1440px with a read-only Playwright script that aborts any load, unload, download, delete or refresh request. It recorded no mutating request.

- [x] Hyphenated models-dir entry: `qwen3-0.6b-4bit` prints as `qwen3-0.6b-4bit` in the library row, the inspector heading and the toolbar pill (`qwen3-0.6b-4bit · Unloaded`).
- [x] Underscored models-dir entry: `stablelm-2-1_6b-chat-4bit` prints unchanged in the same three places.
- [x] Cache entry: `mlx-community/Qwen3-0.6B-4bit` prints as the full `owner/name` in the row, the inspector heading and the pill. It also appears as `mlx-community/Qwen3-0.6B-4bit · unloaded` in the Chat "Model for next turn" picker and as `mlx-community/Qwen3-0.6B-4bit` in the Activity selector (225 options).
- [x] Search: `qwen3-0.6b-4bit` returns the `qwen3-0.6b-4bit` row, plus `mlx-community/Qwen3-0.6B-4bit`, which contains the same text in a case-insensitive match. `qwen3_0.6b_4bit` returns no rows ("No matching local models").
- [x] Delete dialog, opened on the cache entry and cancelled without typing: the body ends "To confirm, type the model ID shown below, not the model name.", the `mdl_` id is printed below it, the field label reads "Opaque model ID shown above (starts with mdl_)", and Confirm stays disabled while the field is empty. The long cache name wraps inside the dialog.

## Merge with main and CI

- `origin/main` 5616f86 (#1924, ui-common components) was merged into this branch as 7e40c01 by the epic #1910 orchestrator. Only the generated bundle conflicted; it was regenerated with `scripts/webui/build_bundle.py` (`--verify` digest 711cbf2a) and not hand-merged. On the merged tree: `typecheck`, `eslint`, `unit` (Vitest 362 in 47 files), and Chromium Playwright on a private port (`models`, `ui-common-shell`, `ui-common-components`, `browser`, `chat`, `activity`, `settings`: 56 passed) all pass. The merged Rust tree was checked earlier: `server::webui` 78 passed, `models::registry` 10 passed.
- The GB10 `WebUI installed artifact` job's Activity performance gate reported `investigate` on four consecutive attempts at the pre-merge heads while every other step passed: `two-visible` median decode degradation -0.35 (baseline CV 5.63), +0.20 (CV 5.45), +2.74 (CV 3.38), and `one-visible` +3.38 (CV 4.22). The gate flags a median above 2 percent or a baseline CV above 5 percent over five pairs. On the same runner, `main` after #1922 failed the same gate (`two-visible` -6.76, CV 7.45) and passed on rerun, and #1919 failed it once (+3.19) and passed on its next head. This change only alters the catalog label string and a few UI strings, which do not touch decode or the Activity polling path, so these are read as runner noise. Per the maintainer's decision for this epic, the PR merges with this evidence recorded rather than with the gate relaxed; stabilizing the gate is tracked in #1925.

Closes #1912
inureyes added a commit that referenced this pull request Sep 19, 2026
…1923)

Closes #1903. Part of #1910.

## What changes

- **Theme ids.** `data-theme` always holds `<family>-<scheme>`: `mlxcel-light`, `mlxcel-dark` (the approved baseline, still the default), `glass-light`, `glass-dark`. `webui/src/design-system/theme.ts` resolves it; the stored `themeFamily` and `colorScheme` (`system`/`light`/`dark`) land in `data-theme-family` and `data-color-scheme`. The old flat `theme` field is migrated, and `system` follows host changes live. Settings gets Theme (Standard, Glass) and Color scheme selects.
- **ui-common mechanism.** `main.tsx` imports one theme entry (`themes/index.css`) after `base.css`, and no ui-common theme file. The color, shadow and button `--token-*` values moved from `:root` into `[data-theme]` blocks unchanged; `common-tokens.css` keeps the theme-independent half.
- **Glass theme.** Translucent chrome with a light reflection, tinted buttons, highlighted primary and danger fills, near-opaque content surfaces, no per-button blur. It reads the existing intensity, material, transparency, backdrop support, contrast and motion switches plus the `prefers-*` queries; any transparency or contrast preference makes it opaque at any intensity. Values derive from lablup/backend.ai-go (derivation line, `NOTICE`); the tokens stay here because they bind to this product's semantic token layer.
- **No flash.** `webui/public/theme-bootstrap.js` is a classic render-blocking script in `<head>`, external because the CSP is `script-src 'self'`. `build_bundle.py` rejects a bundle where it is missing, duplicated, deferred in any way, or after the app module, and any inline script.
- **Gate.** `check:theme-selectors` (in the `webui-bundle` job and `make verify-webui-frontend`) evaluates every `data-theme`/`data-theme-family` selector against the shipped ids and rejects `data-color-scheme` selectors, `prefers-color-scheme` outside the resolver, stray theme imports and ids with no theme block; exemptions need a written reason. Against `main` it reports `tokens.css:47`, `:48`, `:50`, `:51`, `:75`.

## Found along the way

- Chrome blur never rendered in Chromium or Firefox (Lightning CSS drops a standard `backdrop-filter` that precedes its `-webkit-` twin), and the primary and danger fills were hard-coded over ui-common's rules. Both are one-line fixes in `components.css`, and both survive the merge with #1902's rewrite of that file, alongside its `:where(:not(:disabled))` hover rules.
- `features/chat/style.test.ts` passed vacuously (Vitest blanks CSS imports); it now reads from disk. `models.css` read two undefined tokens; inspector labels are now muted and the selected row highlighted.
- Review fixed rendered glass fills and a bootstrap check that let `type="module"` through; the security pass fixed the gate skipping its check through a symlink and two super-linear patterns.

## Validation

Rebased onto `fd33ff15` (#1919, #1921, #1922) with an unchanged source diff, then merged with main at `5616f86b` (#1924, #1902's component adoption); `app.tsx` keeps #1902's PageHeader Settings screen with the theme selects, and the bundle was regenerated. On `cdb80fe7`: typecheck, lint, the gate, unit (35 node:test, 470 Vitest), Chromium suite (88, including the glass button fill case and #1902's button hover cases; axe on all four ids), `build_bundle.py --verify`, contract, binary-asset and cross-repo checks. The engines smoke on Chromium, Firefox and WebKit passed before the rebase. Each new check was reverted to confirm it fails.

## Bundled-binary checks

Playwright Chromium against `mlxcel-server` built from `c26e3247` (before the rebase and the #1924 merge) with the embedded bundle and no model loaded.

1. **No flash: pass.** `theme-bootstrap.js` is served 200, `text/javascript`, `no-cache`, under the strict CSP, ahead of the app module. With the app module held, all nine cases (cold profile under light and dark hosts, each id stored under the opposite host, glass `system` under both hosts, legacy `{theme: dark}`) had the right `data-theme` and canvas with nothing mounted, and no CSP violation.
2. **Settings: pass.** Glass then Dark gives `glass-dark`, persists without the legacy field, is restored before mount on reload; `system` follows a host flip live.
3. **390x844: pass** in both families: 0 px overflow on all four routes; the sheet opens, navigates, closes on Escape and returns focus.
4. **200 percent zoom: not native** (headless Chromium cannot zoom; a half-width CSS viewport at deviceScaleFactor 2 stood in). 320 and 640 CSS px: 0 px overflow everywhere. 195 CSS px (390 px window): 125 px on every route from `body { min-width: 320px }`; main's bundle measures the same, so it predates this PR.
5. **Keyboard: pass** for `mlxcel-light` and `glass-dark`: 18 Tab stops in DOM order, each with a visible indicator; Escape restores focus from the command palette (button and Cmd+K), a Select popup and the sheet.
6. **Contrast: pass.** axe WCAG 2.2 AA on all four routes for all four ids at intensity 100: 0 violations normally, under `prefers-contrast: more` and with High contrast on; the chrome drops to `blur(0px)` with no gradient.
7. **Served-CSP spec: pass** (both variants).




## Merge with main

`origin/main` 31c0459 (#1920, verbatim display names) was merged into this branch as 83fc058 by the epic #1910 orchestrator. Only the generated bundle conflicted; it was regenerated with `scripts/webui/build_bundle.py` (`--verify` digest 986294f2) and not hand-merged. On the merged tree: `typecheck`, `lint`, `check:theme-selectors` (152 files, 4 theme ids), `unit` (Vitest 473 in 51 files) and the Chromium Playwright suite on a private port (88 passed) all pass. The live-server script above was also re-run against a server built from cdb80fe (after #1924 was merged in): 23 of 25 cases pass, and the two failures are the pre-existing 195 CSS px overflow from `body { min-width: 320px }` described above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:high High priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): record streaming chat completions in Metrics so Activity stops reporting zero

1 participant