Repository navigation
fix(server): record streamed chat completions in Metrics - #1919
Conversation
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
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Verification
I reran the revert check on |
Security and performance reviewNo CRITICAL, HIGH or MEDIUM findings, so no fix commits were pushed.
LOW, not fixed:
Gates: |
…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
PR finalizationChecked test coverage, documentation, and lint/format on top of TestsThe 5 tests in Documentation
Bug found and fixed
Validation
All four re-ran clean after the fix. The CI |
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.
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
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
…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.
Summary
The streaming branch of
POST /v1/chat/completionsnever calledMetrics::record_request, sorequests_totalandcompletion_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, andmlxcel_requests_totalundercounted every streaming client.stream_chat_completionrecords the request once, right afterslot.finish, using the result's own counts. It does not depend onstream_options.include_usage.Errrecords nothing, as on the non-streaming path.X-Conversation-Id,DELETE /v1/stream, and session replacement. After an abort the worker still returnsOkwith 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.mlxcel_requests_totalandmlxcel_completion_tokens_totalHELP lines, the twocounter_sourcereasons (andruntime.ready-shared.json, which pins them) anddocs/webui/runtime.mdnow state these semantics. Metric names, values and both response shapes are unchanged.The scope is chat completions only. Streaming
/v1/completionsand native/completionstill never record, and/v1/messagesand/v1/responsesstreams 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 ofstream_route_teststhat reuses its helpers.stream_route_tests.rswas already 698 lines.scripted_app_with_statealso returns theAppState, andscripted_appdelegates 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-linemlxcel_requests_total 1andmlxcel_completion_tokens_total 3.activity_projection_counts_a_completed_stream:runtime_snapshotreportscompleted_requests_total1.0 andcompletion_tokens_total3.0. It is gated on thewebuifeature, likeserver::webuiitself, so the--no-default-featuresOpenXLA compile job still builds the file.Revert check
Arm 1 removes the record block from
stream_chat_completionand runsserver::routes::stream_route_tests::metrics_tests: 1 passed, 4 failed.client_cancelled_plain_stream_is_not_recordedpasses here, as expected, because this arm records nothing at all.Arm 2 keeps the record and drops only the
!cancelledcondition: 4 passed, 1 failed.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 includesready_projection_matches_whole_canonical_fixture, which pins the newcounter_sourcestrings.server::routes::metrics: 6 passed, 0 failed.cargo clippy -p mlxcel --lib --tests --features metal,accelerate -- -D warningsandcargo fmt --all -- --checkare both clean.Real-binary verification
Run by the epic #1910 orchestrator on M1 Ultra (macOS 27.0),
mlxcel-serverbuilt from this PR head (414d45dc) under--profile test-fast --features metal,accelerate, started asmlxcel-server --webui --models-dir models/mlx --api-key-file <f> --no-models-autoload --settings --props --metrics --slotsagainst the 224-entry local store, withqwen3-0.6b-4bitloaded throughPOST /ui-api/v1/model-actions.stream: true(withstream_options.include_usage) and onestream: false/v1/chat/completionsrequest,max_tokens: 20,temperature: 0: both responses reportedusage.completion_tokens = 9.GET /ui-api/v1/runtime?model_id=<id>then reportedcompleted_requests_total = 2andcompletion_tokens_total = 18, matchingdecode_tokens_total = 18. On0ef0a1a4the same pair gavecompleted_requests_total = 1(issue body)./metrics?model=qwen3-0.6b-4bitprintedmlxcel_requests_total 2andmlxcel_completion_tokens_total 18. In router mode the scrape needs themodelquery parameter; without it the server answers 400model name is missing from the request, which is existing behavior.Total completed requests 1 requestsandTotal 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