* better development against audit db
* Bump :discouraged-namespace kondo ratchet to 81
connection.clj now requires metabase.audit-app.core with a
:discouraged-namespace ignore (audit-db dev support), so the budget
needs +1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add hierarchy visualization utilities
* Normalize hierarchy visualization label newlines
* Preserve ordered hierarchy traversal in visualizations
* Format hierarchy utility docstrings
* Move hierarchy visualization utilities to dev
* Move hierarchy visualization tests alongside the dev namespace
Also sort nodes unreachable from a root, so malformed hierarchies still
render in a stable order, and mark the hierarchy tests parallel.
* Keep hierarchy visualization tests discoverable by the test runner
The runner skips `dev` directories and non-`metabase.*` namespaces, so a
`dev.*` test never runs. Follow `metabase.core.modules-test` and
`metabase.core.kondo-ratchet-test`, which test dev namespaces from a
`metabase.*` test namespace, and say so in the namespace docstring.
* List hierarchy nodes without drawing a tree
`nodes` returns every tag as a flat vector, leaves-to-roots by default so a node
precedes its parents, or alphabetically on request. Ordered hierarchies break ties
by derivation order and the rest fall back to `pr-str`, both of which `hierarchy->graph`
already worked out, so there is no separate code path for the two kinds.
A test pins the topological order to `ordered-hierarchy/sorted-tags` so the dev output
cannot drift from the order the runtime uses.
* Fix ordered hierarchy ordering and validation
* Format hierarchy utility docstrings
* Mark ordered hierarchy tests parallel
* Return nil from first-common-ancestor when tags share no ancestor
Passing a tag with no ancestors as the second argument made the lookup call nil as a
predicate, so disconnected roots and tags outside the hierarchy threw an NPE instead of
returning nil.
* Reject children declared after a tag's first occurrence in any basis
The check only ran for nested vectors, so a tag introduced in one basis could pick up
children in a later one. Hoisting it into derive-basis applies the same rule to every
top-level basis.
* Ratchet down kondo ignore budgets
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
The notification create/update endpoints let a handler attach an existing
ChannelTemplate by id, or carry an inline template that creates, overwrites,
or deletes one, but didn't apply the permission check used when writing a
ChannelTemplate directly. Gate any such template write on
mi/can-write? :model/ChannelTemplate, mirroring the existing inline-channel
check. Seeding and the pulse->notification migration go through the model
layer directly, so they're unaffected.
POST /api/action and PUT /api/action/:id now require the current user to
have ad-hoc native query permission on the target database when the
submitted dataset_query is native.
* Load the enterprise admin pages through plugin route slots
* Load the tenant collection pages through plugin route slots
* Resolve every plugin route slot in a test
* Name the chunks the plugin route slots load
Check revert perms against the table of the restored definition
For Segment and Measure reverts, table_id is re-derived from the
definition on update, so check write perms against the source table of
the restored revision's definition when it has one, falling back to the
revision's stored table_id otherwise.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Default undeclared endpoint params to an empty map schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Ignore unparsed request bodies instead of declaring them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Pass multipart params as the request body of multipart endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Declare query and route params read by dashboard and database endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Ignore the kebab-case linter for newly declared snake_case query params
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Anchor remote-sync conflict-modal test on the async conflict lifecycle
* Reword test comments per writing conventions
* Trim comments and move timeouts to request/test-level options
* Use navigation helper when navigating to the transforms page
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fail fast on terminal sync task statuses
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix detached-DOM flake in upload table deletion e2e test
Re-query the upload-tables-table container on each assertion instead of
caching it via within(). The table re-renders when getUploadTables
resolves, detaching the node within() captured, so a retrying query
against the stale subtree got stuck on an intermediate render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Appease the linter
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
* Key cached session tokens on the app db
with-empty-h2-app-db! swaps in a different application database, and this cache was keyed on the username
alone. A token minted against the previous database names a session that does not exist in the new one, so
the request 401s, and the token minted inside leaks back out when the original is restored. The retry in
client-fn cleared the whole cache and tried again, which papered over it often enough to look like flake:
"Failed to authenticate X after three tries".
Key it on the app db as well, like every neighbouring cache does via memoize-for-application-db.
databases-list-can-upload-test and dismiss-spinner-test go from failing to passing; both were in the CI
failure list, the former across 12 jobs.
* Track globally-created test users per application database
with-empty-h2-app-db! swaps in an empty app db, where the users another database created do not exist.
`created-globally?` was a boolean, so it stayed true across the swap and user->id kept handing back ids from
the previous database. Authenticating then inserted a Session for a user that was not there:
Failed to authenticate :crowberto after three tries: Referential integrity constraint violation:
FK_SESSION_REF_USER_ID: PUBLIC.CORE_SESSION FOREIGN KEY(USER_ID) REFERENCES PUBLIC.CORE_USER(ID) (1)
Track the set of app dbs that have them instead. The memo beside it was already keyed this way via
memoize-for-application-db; only the flag was not.
databases-list-can-upload-test failed 8/8 before this and 0/8 after; the whole namespace now passes.
* Say what the token cache holds
Update E2E test timings with averaged data
Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).
🤖 Generated automatically by update-e2e-timings workflow
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iethree <30528226+iethree@users.noreply.github.com>
Reject impersonation role attribute equal to the driver default-role sentinel
connection-impersonation-role now rejects a role attribute that (case-insensitively)
equals the driver's default-database-role sentinel (e.g. NONE / DEFAULT), which would
otherwise emit SET ROLE NONE and drop the connection to the base warehouse credential.
* Resolve card UI parameter fields from param_fields for saved cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Resolve parameters against the query for questions opened from a dashboard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Remove unused data from embed and public endpoints
Public and static-embed card/dashboard/document endpoints now return a
minimal placeholder dataset_query (a blank native query against the same
database) instead of selecting individual query keys. The frontend only
needs the database id here. Parameter combining and :param_fields
hydration in public-card now run before the card is trimmed since both
read template tags from the full query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add tests for embed card and dashboard dataset_query
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Assert strictly on dataset_query in public and embed tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Handle native queries in Table viz
* Fix column shadowing in HeaderCellWithColumnInfo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for public question parameters
Public and static-embed card payloads carry param_fields instead of the
real query, so PublicOrEmbeddedQuestion now builds UI parameters from
them via getCardUiParametersFromParamFields, mirroring the saved-mode
dashboard path. The query-based helpers are renamed to the FromQuery /
FromParamFields scheme on both the card and dashboard sides, and the
ParameterWithTarget casts are dropped now that getParameterTargetField
accepts an optional target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for embed setup card parameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for guest embed SDK question parameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Route card UI parameters by param_fields presence
Restores getCardUiParameters as the entry point: it delegates to
getCardUiParametersFromParamFields when the card carries param_fields
(public and static-embed payloads) and to the query-based variant
otherwise. The SDK SQL parameter surfaces use it since they serve both
guest and authenticated contexts. Also reverts the dashboard helper
renames back to getSaved/getUnsavedDashboardUiParameters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix preview embed card param values
The preview-embed card values and remapping endpoints get their card via
card-for-unsigned-token -> public-card, which now replaces the query with
a blank native placeholder, so field-filter parameters could no longer be
resolved and the response schema rejected the nil result. Bind
qp.perms/*param-values-query* around the card fetch, the escape hatch
remove-card-non-public-columns already honors, matching the public
sharing values endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add preview embed test for remapped parameter fields
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix
* vscode fix
* Backfill template tag parameters in param_fields UI parameters
GET /api/card/:id returns parameters as stored, unlike the public and
embed endpoints which combine them with template tags, so
getCardUiParametersFromParamFields now derives parameters via
getParametersFromCard like the query-based variant always did. Guest
embed payloads are unaffected: their parameters arrive already combined
and the blank query derives nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use getCardUiParameters dispatcher in SDK load-question
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Guard applyTemplateTagParameters by param_fields presence
Cards whose payloads carry param_fields have parameters already combined
with template tags by the backend, so re-applying from the query would
wipe them. Keying the guard on the card payload keeps it in sync with
how the card was loaded, replacing the isGuestEmbed flag checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use getCardUiParameters everywhere and build test queries via Lib
Question.parameters and the query builder call sites go back to the
getCardUiParameters entry point, which now routes by param_fields
presence, leaving those files unchanged relative to master. The
template tag fallback test builds its query with
Lib.createTestJsNativeQuery instead of an inline legacy map.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Make getCardUiParameters the only entry point
The FromQuery and FromParamFields variants are private to cards.ts now;
all consumers call getCardUiParameters, which routes by param_fields
presence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Build preview embed remapped-fields test query via lib
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Restore null fallback in useAvailableParameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix
* Preserve query type in stripped public and embed card queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add placeholder aggregations to stripped MBQL queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep parameter template tags in stripped native queries and revert FE workarounds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Export parameter-template-tag? via import-vars
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add test for :param_fields hydration on GET /api/card/:id
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Clear All tenant users metabot permissions when switching to group-level mode
Switching metabot permissions to group-level only wiped the All Users magic group's rows.
Under tenants the simple mode also writes to All tenant users, and since resolution takes the
most permissive value across a user's groups, a leftover :yes there overrode every group-level :no.
POST /advanced now clears both magic groups; DELETE /advanced keeps both instead of dropping the
tenant toggle. The confirmation dialog names both groups when tenants are on.
Fixes#80394
* Backfill the tenant permission cleanup and make both mode-switch dialogs tenant-aware
Instances that switched to group-level mode before the fix still carry the seeded all-yes rows
for All tenant users, so a migration clears them wherever metabot-advanced-permissions is true.
The disable dialog now says both magic groups keep their access under tenants, and both dialogs
use the "All internal users" name the permissions UI shows when tenants are on.
* Explain why the tenant-permission migration only reads the setting row
* Move the tenant-permission migration to 063 for the single backport
* Move the tenant-permission migration to 061 so it backports to every affected release
* Scope metabot permission resolution to the groups the current mode shows
Resolution took the most permissive value across every group a user belongs to regardless of
mode, so any row on a group the admin UI hides -- the All Users and All tenant users rows in
group-level mode, the seeded data-analyst rows and any API-written group in simple mode --
silently overrode every visible :no. The write-site fixes (deleting rows on POST /advanced, the
backfill migration gated on the setting row) only covered the paths that went through the
endpoint; PUT /api/setting, serdes import and PUT / on a hidden group all bypassed them.
Filter in the resolver instead: simple mode only counts Administrators, All Users and All tenant
users; group-level mode counts everything but those two default groups. Hidden rows stay in the
table and come back into force on switching modes, so POST /advanced no longer deletes them and
DELETE /advanced genuinely keeps them, which also restores the simple-mode values after a round
trip instead of leaving the default groups at all-no. With leftover rows inert the migration is
no longer needed.
* Leave the mode-switch dialog copy as it was
* Refuse the metabot permission mode switch while an env var forces the setting
The setter writes the row regardless, but the env var keeps winning on read, so DELETE /advanced would
delete every group's rows and leave the instance in group-level mode with nobody but admins allowed in.
* Flip the metabot permission mode before dropping the group-level rows
Deleting first meant a failed setting write left group-level mode in place with its permissions already gone. In the new order a failed delete only leaves rows that simple mode ignores.
* Say in the docstrings that resolution only sees the active mode's groups
* Clear the default-group rows again when switching to group-level mode
Preserving them so they returned on a switch back was a silent widening: the 061 migration seeds every default group all-yes, so a round trip through group-level mode handed everyone AI access again, including the tenant rows that caused this bug.
Both mode-switch dialogs promise removal, and nothing in the docs describes a mode remembering its old configuration, so the modes now delete the rows the other one owns, as before. Resolution stays scoped to the active mode's groups: that is what makes rows created by other write paths, and the rows already sitting on instances in the field, harmless without a migration.
* Switch the metabot permission mode in one transaction
Ordering the two writes only chose which half-applied switch an admin got: the mode changed while the rows did not, or the rows went while the mode stayed. The setting write goes last inside the transaction, since it updates the settings cache in place and a rollback would leave the new value there.
* Reload the settings cache when a mode switch rolls back
The setting write updates the cache in memory, which the rollback does not undo, so a commit failure would leave this node serving a mode the database does not have.
* Cover the settings cache in the mode-switch rollback test
The redefined setter now writes the setting for real before throwing, so the test fails if the rollback leaves the new mode in the cache.
* Extract the mode's group list to one definition
Both the resolution query and the mode-switch endpoints had their own copy of the
Administrators / All Users / All tenant users list, and read as opposites: one
selected with :in, the other deleted with :not-in. They now share
visible-groups-clause, with hidden-groups-clause defined as its complement.
* Improve Metabot permissions documentation
* Address Metabot permissions review feedback
* initial deepseek support
* [BOT-1899] Port DeepSeek onto the LLM provider connection registry (#80100)
Wire DeepSeek into the LLM provider connection registry
DeepSeek arrived as an adapter reading its own credential settings, which is how
providers worked before the connection list. Make it a connection type like the
rest:
- `deepseek` joins `metabase.llm.provider`'s registry with the api-key and
base-url fields the admin connection form renders itself. No key prefix:
DeepSeek keys carry the same `sk-` an OpenAI key does, so matching on it would
claim OpenAI keys and reject nothing.
- `llm-deepseek-api-key` / `-api-base-url` become the type's single-provider
environment variables, reading and writing through the connection they
configure like every other provider's.
- The adapter takes `:credentials` off the request instead of reading settings,
so two DeepSeek connections can hold different keys.
- The frontend mirrors the new type name and gets a DeepSeek mark.
The app-db migration that folds the old per-provider settings into the
connection list is a frozen snapshot of the registry as it stood then, and stays
untouched: no released version wrote a DeepSeek setting, so there is nothing to
adopt, and a rollback drops the connection because the versions it rolls back to
cannot serve DeepSeek either. A test pins that.
Regenerated the settings docs, and listed DeepSeek among the providers you can
bring a key for.
* revert docs changes
* review feedback
---------
Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* Fix flaky native database source selectDatabase detach
The database picker popover opens automatically and re-mounts its rows as the database list finishes loading, which could detach the target row while Cypress waited for it to become actionable ("elements ... disappeared from the page"). Anchor the click in selectDatabase behind a visibility assertion so Cypress re-queries the popover chain on detach and clicks a freshly attached, settled row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Force-click the native database picker row to avoid detach
The database picker popover opens automatically and, as GET /api/database
resolves, its onQueryStarted hook re-writes shared metadata. That hands the
selector a fresh databases array reference and re-mounts the AccordionList rows.
The previous visibility-anchored click still entered Cypress's actionability
wait, during which the target row detached ("elements ... disappeared from the
page"). Click the row with { force: true } so Cypress fires the moment the row
is found and visible, skipping the actionability wait window in which the
re-mount can detach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>
* BOT-1606: Truncate over-long Metabot answers instead of going silent
`channel/final-text-blocks` put the whole answer into a single Slack `section`
block. Slack caps a section's `text.text` at 3000 characters, so any longer
answer came back as `invalid_blocks` ("must be less than 3001 characters") and
the thread simply went quiet -- no answer, no error, nothing to retry.
Elide the answer at the limit, and when that actually cuts it, follow it with a
`context` block saying so and linking to the full answer in Metabase. The notice
is deliberately kept out of the message's `:text`, which `thread->history`
replays back to the model as the assistant's own words.
The answer stays a single mrkdwn `section` block, as before. Answers that already
fit are byte-for-byte unchanged: `u.str/elide` returns its input untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* BOT-1606: Address review comments on the truncation notice
Works through appleby's review nits on #80105:
- Docstring summary for `truncation-notice` is one sentence (continuation
aligned to 3 spaces, matching the rest of the file).
- The notice now names the limit, so a reader can judge how much to trim:
"This answer was longer than 3000 characters."
- Adopted the suggested "...and try again." ending.
- Added a debug log on the truncation path, recording the answer length
against the limit.
- Moved the shared `oversized-answer` fixture into `slackbot.test-util`;
`channel_test` and `api_test` had identical copies.
- `slack-section-text-limit` now derives from the production constant
rather than restating it, which makes the drift test redundant.
`log/debugf` evaluates its args only once the level is enabled, so the
debug log was verified through the REPL with log capture on rather than
through the suite, which cannot reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Cap the visualization title so Slack accepts the message
The truncation shipped so far only guarded the answer block. The error in
BOT-1606 names `/blocks/1`, which is the visualization title block, not the
answer: `format-viz-title` embeds a `/question#<base64-query>` link, and for
a large query that base64 alone runs past Slack's 3000-character section
limit, so `chat.postMessage` rejects the whole message.
Reproduced with a native query of the size Metabot routinely writes: a
7,119-character SQL string yields a 9,770-character link and a 9,820-character
title block, and Slack returns the reported error verbatim.
A cut URL is a broken URL, so drop the link rather than truncate it and keep
the title; with no title left, `viz-output->blocks` already falls back to
"Query results". `section-text-limit` becomes public for this -- streaming
already required the channel namespace, so no new dependency.
Both this and the answer truncation log at `info` rather than `debug`:
`metabase.slackbot` inherits INFO from log4j2.xml, so a debug line there is
invisible on a default instance. Link drops get their own counter rather than
reusing `responses-truncated`, which counts responses; a long answer and a
long query link are different problems and worth telling apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Fall back to plain text when Slack rejects the blocks
`send-channel-response` logged a failed `chat.postMessage` and stopped
there, so a user whose message Slack refused saw the "is doing science..."
status and then nothing. `send-dm-response` already retries with a
plain-text reply; do the same here, with the same wording.
A plain-text reply carries no blocks for Slack to reject, so it survives
whatever made the block message fail -- including causes we have not
anticipated and so cannot cap the way the answer and viz title are capped.
Add `responses-undeliverable`, incremented from both paths when the
fallback fails too. It counts only total delivery failure: a response the
fallback rescues is deliberately not counted, so the metric answers "how
often did a user get nothing at all", which is the alertable question.
Left the error-cleanup fallback in `send-dm-response` uncounted -- that
path is already reporting a thrown exception, and its fallback carries an
error notice rather than an answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Cap alt_text, and stop miscounting title-only elisions
Follow-ups from a Clojure review of the branch.
Slack caps an image block's `alt_text` at 2000 characters, not the 3000 it
allows a section block, and `viz-output->blocks` passed the raw title
straight through. A title of 2001-3000 characters therefore cleared
`format-viz-title` untouched -- comfortably inside the section limit -- and
then failed `chat.postMessage` with `invalid_blocks`: the same silence this
branch exists to fix, reachable through the function it hardened. Cut the
title with `limit-chars` rather than `elide`, since alt text is plain and an
ellipsis would only eat into the budget.
`format-viz-title` also reported a dropped link whenever the rendered title
was over the limit, including when there was no link at all -- logging
`link_length=0` while claiming a drop, and incrementing `viz-links-dropped`
for something that never happened. Both cases still elide the title; only the
reporting now branches.
Docstrings: several first lines ran on into the next line, which
https://guide.clojure.style/#docstring-summary asks not to happen. Also
`format-viz-title` claimed to return nil only when given neither a title nor
a link, though it also returns nil for a link too long to keep -- as the
branch's own test asserts -- and `slack-section-text-limit` restated the
sentence from the var it aliases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
---------
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix flaky alerts toggle e2e: re-query preview iframe for Alerts click
The 'toggles alerts for question when email is set up' spec cached the
embed preview iframe body via within() and chained the Alerts button
visibility assertion straight into the click. Toggling the sidebar
options re-renders the embedded question, so the cached body and the
found ActionIcon detach mid-click ('the page updated while this command
was executing'). Re-query the iframe body fresh and split the visibility
assertion from the click so Cypress retries against a stable element.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Give re-render-dependent alerts lookups a longer timeout
Iteration 1 removed the mid-click detach by re-querying the preview
iframe body, and 49/50 burn-in runs passed. The residual failure is a
different flake: toggling a sidebar option re-renders (and re-runs the
query of) the embedded question inside the already-loaded iframe, and
that round-trip occasionally exceeds Cypress's default 4s command
timeout, so findByRole for the "Alerts" button times out with only a
"group" role present in the iframe body. Give the Alerts button and
alert-modal lookups that depend on a re-render a 15s budget so Cypress
waits the slow re-fetch+render out instead of asserting mid-render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
The "should update existing sandboxes when changing column selection"
test raced in two ways:
- It waited on @updatePermissionsGraph with the default 5s request
timeout, but the "Next" click first runs several sequential metadata
fetches (list tables, list databases, permissions-graph revision)
before issuing that PUT, so under load the request can appear later
than 5s and the wait times out. Give it a 15s timeout.
- It asserted H.undoToast().should("not.exist") with no positive anchor,
which can pass against an unrendered page. Anchor it on the positive
success signal ("Select data" step marked complete) first.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
* Add connection and read timeouts to python-runner requests
* Tighten timeout docstrings
* Cancel the runner on a client timeout, retry transient log-poll timeouts
Review follow-ups on the python-runner timeouts:
- a client-side timeout on /execute now fires /cancel, so the runner
stops working on a run nobody is waiting for
- a read timeout while polling /logs is retried instead of ending the
poll loop for the rest of the run
- use u/seconds->ms instead of inline math
PUT /api/notification/:id accepted a different id from what was in the URL which diverges from normal PUT behaviour. This fix aligns it with how we've handled PUT updates in this codebase by only handling allowlisted params for the update.
* Deflake SDK question data-picker target-collection test
The "change target collection to a different entity id" test blindly
pressed Escape then clicked "Pick your starting data" to open the data
picker. In the embedding SDK the DataStep always renders the data picker
and auto-opens it when no source table is selected, and that open state
settles asynchronously after the collection re-render. When the popover
auto-opened after the blind Escape, the follow-up click toggled it back
closed, so H.popover() never appeared and the Orders lookup timed out.
Wait until the popover is actually gone after Escape before reopening it
from a known-closed state, and use visibility anchors instead of exist,
removing the toggle race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Deflake SDK question data-picker target-collection test
The embedding data picker uses isInitiallyOpen={!table}; with questionId="new"
no source table is ever selected, so the picker auto-opens on every re-render and
stays open. The prior approach toggled it (esc to close, click to reopen), which
raced the async auto-open: sometimes the click toggled the just-opened popover
shut, sometimes esc failed to close it at all. Interact with the already-open
popover directly to remove the toggle race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update E2E test timings with averaged data
Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).
🤖 Generated automatically by update-e2e-timings workflow
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
Reaction types registered by defreaction derived from :metabot/registered-action in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move them into a hierarchy var private to this namespace, mutated through
derive! -- the same shape as metabase.events.hierarchy, kept in-file because defreaction is
the only writer and known-reaction-types the only reader.
This also narrows the schema-rebuild watch. It was attached to #'clojure.core/global-hierarchy,
so it woke on every derive anywhere in the application and compared descendant sets to decide
whether anything relevant had changed; now it only fires for reaction registrations.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy-cookie alias :search.engine/fulltext -> :search.engine/appdb lived in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move it into metabase.search.hierarchy, mirroring metabase.events.hierarchy:
a defonce var holding a hierarchy map, mutated through derive!.
All thirteen engine multimethods now dispatch on that hierarchy, and known-engine? walks it
rather than the global one.
The hierarchy has a single writer inside the search module, so it exposes only derive! and
ancestors -- the operations that are actually used.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The model persistence test clicked the global and per-database persistence
toggles without waiting for their requests to complete, then immediately
POSTed to persist a card. The card-persist endpoint returned 400
("Persisting models not enabled for database") when it raced ahead of the
per-database setting, and the per-database toggle could fail to render when
the database page booted from a stale global setting.
Intercept and wait on both persist-enable requests before proceeding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "unsubscribe from an alert when the user has not created it" test
clicked the Unsubscribe button immediately after a bare findByText for
the confirm modal's title. That query resolves the instant the title
mounts, while the Mantine modal is still animating in and its title
transiently covers the button's click point, so cy.click() intermittently
failed with "covered by another element".
Anchor on the title being visible first so the click is deferred until
the modal's entrance transition has settled.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-running mq init in a REPL (dev server startup plus test init, or a
retried :mq test-init step) threw for the exploration queues:
```
Queue :queue/exploration-plan is already registered with different config.
```
This is because of an attempt to catch cases where we were declaring
the same queue multiple times with different config. However, this was
subtly broken in two ways:
- first, there are two phases to queue registration: declaration, and
registration. Declaration just means "write down what queue X's config
is." Registration means actually executing that. Our check ran on
*registration*, not on declaration. So if there were two different
calls to `def-queue!` for the same queue, the first one would declare
its `def-queue*`, then the second would overwrite it, and there would
be no meaningful check left.
- second, doing registration twice would always throw, because some
queue configs contained functions, and declaring the function twice
resulted in non-equal results.
Instead, we want to just check that we're not *declaring* the same queue
multiple times in different namespaces. We allow declaring the same
queue twice in the same namespace for REPL convenience - technically a
loophole, but this is similar to a check we have for `defsetting`s and
it doesn't seem to have been a problem.
* Pin exploration auto-binning to an explicit bin width (UXW-4391)
Exploration charts fan one metric/dim query out into several
segment-filtered variants that share an x-axis. With {:strategy :default}
binning, the QP re-derives the bin width per variant from that variant's
own filters, so a segment that range-filters the binned column (e.g.
"Highly rated products", rating >= 4) got 0.125-wide bins while its
siblings got 0.75-wide bins.
Fix: resolve the width the QP would use for the unsegmented query
(card-level filters included, per-segment filter excluded) and pin it on
the breakout as {:strategy :bin-width}. The binning middleware never
renegotiates an explicit bin width, and bin edges snap to multiples of
the width, so every variant lands on the same bin grid. Card-level range
filters on the dim still narrow the bins, since they're shared by all
variants.
- move filters->field-map + extract-bounds from the binning middleware
into metabase.lib.binning.util (middleware delegates; extract-bounds
now returns nil and the middleware keeps the qp.error-type throw)
- add metabase.lib.binning/default-bin-width (exported via lib.core):
the width :default binning resolves for a column given the query's
own filters, nicer-breakout fixed point included
- pin it in metabase.explorations.query-plan.mbql/apply-default-bucket
* Address CR
- default-bin-width now keys its filter lookup by the column ref's own
id-or-name (like the QP binning middleware) instead of (or (:id column)
(:name column)) with a regression test for the bug this fixed.
- Build the segment :definition test fixtures through lib instead of the
mt/mbql-query macro
* Hide the empty dashcard menu on visualizer cards when downloads are not permitted
* Address review comments
* Type the dashcard menu's underlying-question items as an array and simplify code
* add copy value click action
* move details above filter
* move copy into it's own section
* fix import
* e2e adjustments
* Adding unit test for column settings
* Don't show copy action on PK columns
h2x/cast already guards cast targets: a plain type name is emitted raw,
anything else is quoted as an identifier. The MySQL and Postgres
json-query implementations bypassed that route and spliced the stored
field's database-type into SQL unvalidated (CONVERT(.., <raw>) and
::<raw> respectively).
Expose the plain-type-name predicate as h2x/raw-type-name? and use it:
Postgres quotes a non-plain type as an identifier (same rule as
h2x/cast); MySQL rejects it with an invalid-query error, since
CONVERT's target type cannot be a quoted identifier.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Tile endpoints resolve latField/lonField legacy refs via resolve-field;
a ref with :source-field (implicit join) now fails fast with a 400
instead of producing a broken query. The check lives in resolve-field,
which every tile endpoint (ad-hoc, card, dashcard, public, embed,
preview-embed) reaches through tiles-query.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Internal query-processor and permission keys (`:qp/...`, `:query-permissions/...`,
`:impersonation/...`, `:persisted-info/...`, `:source-query/...`) are added to a
query during preprocessing and drive permission enforcement, so a client must
not be able to supply them. Previously five separate stripper middlewares
removed them one key at a time.
Replace those with a single declarative rule: a qualified map key outside the
`:lib` namespace is an internal key and gets stripped when a query is
deserialized from JSON. The rule is a Malli `:decode/api` (and
`:encode/serialize`) transformer on the query/stage/join/clause-options schemas,
run via `lib.serialize/prepare-after-deserialization` at the JSON boundaries
(REST API request decode and app-DB read) and once as a preprocessing
middleware, `qp.perms/remove-internal-keys`, gated by `qp.pipeline/*pivot?*` so
pivot sub-query keys survive their re-run.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Improve API schemas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keywordize nested parameter option map keys
`::parameter-with-value` only keywordizes its top-level keys (via
`normalize-map-no-kebab-case`), so on the query-string parameter path -- where
the whole value arrives as a JSON string with string keys at every level -- the
nested `parameter.options` and `dimension.options` maps kept string keys. The
api decode's `strip-extra-keys` pass then dropped those undeclared string keys,
silently emptying `:options` (a case-insensitive filter would run case
sensitively, a relative date filter would lose `:include-current`, a
stage-targeted dimension would lose `:stage-number`).
Give both option schemas a `:decode/normalize` of `lib.schema.common/normalize-map`
so their keys are keywordized before the strip pass, matching how the rest of
the lib schemas decode map keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address agent-api / xrays review feedback
- /v2/query continuation-token branch now normalizes-and-validates the embedded
query like the query_handle branch does, instead of trusting the unsigned
token's shape; the total-row budget is read off that validated query.
- reject-native-query! normalizes the payload to MBQL 5 (best-effort) and checks
for a native stage with lib/any-native-stage?, so native detection no longer
depends on how the JSON was decoded (keyword vs string keys); /v1/execute and
the query_handle path decode with plain json/decode and let normalization do
the keywordizing. Malformed payloads still fall through to the existing shape
checks for their clean 400s.
- Renamed validate-query -> normalize-and-validate-query (it normalizes and
strips, not just validates) and led its docstring with what it returns.
- xrays cell-query route params decode declaratively via a ::cell-query schema
(:decode/api base64-decodes + validates ::root.cell-query) instead of
base64-decoding once in the schema and again in the handler.
- Tiles reject test asserts a clean 400 (status-code) rather than any Exception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update sandbox dimension-target test for keywordized option keys
The dimension-target `:options` map now keywordizes its keys (e.g. `"stage-number"`
-> `:stage-number`), so the attribute-remappings round-trip test expects the
normalized keyword form on the way back out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* add permalink to quarantine entry, update compare function to return QuarantineEntry type
* print search url to make tests easy to find
* update tests
* remove a few comments
* simplify search url
Disable "Send now" when the alert has no delivery target (#79881)
Extract alertHasValidTarget from alertIsValid: the handler and channel
checks without the subscriptions one. Send now is a one-off send that
ignores the schedule, so it gates on the target only, while Done keeps
the full check.
Fix disabled Done button in question alert modal (#79914)
bg is a style prop, lands inline on the root and beats the :disabled
rule in Button.module.css, so the button kept its brand background while
the label switched to the dark text-disabled token. color feeds
--button-bg instead, which the disabled rule overrides.
* Run full query-permission checks when compiling a native form (SEC-822)
POST /api/dataset/native compiled and returned the query after checking only
database-level native perms, so a referenced source card or card template tag
was resolved without a read check. Preprocess the query once (with inline
parameter compilation enabled), run the same check-query-permissions* checks
the run path uses (surfacing a permission failure as a 403), then compile the
preprocessed query. Also trim the response to :query and :params.
* Validate link-card entity ids on write for dashboard cards (SEC-836, SEC-832)
DashboardCard validated the link-card entity id only when building the read
query; nothing checked it on write, so a non-integer id could be stored and
would then break every hydration of the dashboard. Add a write-side check in
define-before-insert and a new define-before-update, and wrap the notification
link-card renderer in try/catch so one bad card degrades to a missing part
instead of failing the whole subscription. Adds regression tests.
* Require positive-integer entity ids in dependency backfill (SEC-830)
replace-dependencies! placed to_entity_id directly in the insert, and the
dashboard click_behavior extraction forwarded targetId with no type check. Add
an explicit pos-int? check at the insert where to-add is built (a runtime
check, not an mu/defn schema, which is stripped from prod builds), and filter
targetId to positive integers during extraction so bad ids drop rather than
making the backfill job throw. Adds regression tests.
* Validate click-behavior target ids on write for dashboard cards (SEC-830)
Review follow-up: the entity ids the dependency backfill consumes come from
dashcard click_behavior targetIds, so validate them at the write boundary as
well, keeping the deep guard in replace-dependencies! as defense in depth.
Covers both the top-level click_behavior and per-column column_settings ones,
on insert and update. Adds regression tests.
The 'converting question to a native query' e2e test asserted on
[data-testid=cell-data] immediately after clicking 'Convert this
question to a native query' (and after 'Explore results'), without
waiting for the POST /api/dataset run to settle. Under load the
assertion raced a stale/mid-flight results table and timed out.
Mirror the working convertToSql() helper: intercept POST /api/dataset
and cy.wait for it before each cell-data assertion.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move plan util from common into lib utils
* Move waitUntilNextFramePainted from common into lib utils
* Move export format types from common into metabase-types
* Move plan helpers into the settings module
Add a copy button to the query builder's view footer, next to download.
It copies whatever is on screen: tabular results (tables, pivot grids,
or a chart toggled to the raw data view) as tab-separated text plus a
text/html table flavor so spreadsheets paste a grid, and any other
chart as a png rendered through the same pipeline as the png download.
Copies are always formatted, matching the rendered table: remapped
columns, column visibility and order, renamed titles, click-behavior
link text, and pivot grids with their headers and totals. Raw data
stays a download concern. Copying is capped at the rows loaded in the
browser, with a toast saying when only the first slice was copied.
The clipboard write starts inside the click's user activation so Safari
accepts it, cell click metadata is built only for columns whose text
needs it so wide tables serialize in linear time, and the selected-cell
copy now shares the quoting TSV serializer.
Cover Database connection details on serialization import
Pins the two checks in load-one! for Database: an H2 engine is refused, and
low-level JDBC keys are dropped from details, write_data_details and
admin_details.
* Harden document card-creation permission checks
Route document create/update/copy card creation through the same checks
POST /api/card applies: create-check the target collection, read-check
each card copied by document_id FK, and query-permission-check the fields
that parameter targets name.
* Guard document AST entity ids before app-DB lookups
smartLink/cardEmbed reference ids come from free-form document JSON and
may be any value. Add a shared node-entity-id accessor that yields the id
only when it is a positive integer, and use it in the raw-entity readers
(serdes export transform, export-time dependencies, and descendants) so a
malformed reference is skipped rather than passed to a Toucan query.
* Add access and type checks to comment notifications
Mentioned users are now resolved to active users whose permissions cover
reading the commented entity before being added to the notification
recipients, matching how other delivery surfaces scope their audiences.
The TipTap text-node renderer now coerces non-string :text values to
strings so they always compile as escaped text rather than as hiccup
elements.
* Scope the comment mentions listing to visible users
GET /api/comment/mentions now applies the same audience rules as
GET /api/user/recipients: non-superusers only see users in their own
tenant, further narrowed by the user-visibility setting (:none -> self,
:group -> shared-group members, :all -> everyone in the tenant). This
also resolves the endpoint's long-standing TODO about tenant handling.
same-groups-user-ids moves from the user REST namespace into the user
model so both endpoints share one implementation.
* Move JoinConditionOperator from metabase-lib into metabase-types
* Move font family and DeepPartial types into metabase-types
* Generate mock document ids without the lib utils uuid helper
* Import JoinConditionOperator from the api barrel in metabase-lib
* Drop lib-utils re-export shims and inline docs at the moved declarations
* init vllm support
* tmp improvements
* cleanups; bump max tokens floor for vllm; env shadowed cred field guards
* fix openrouter temperature
* vllm reasoning
* fixes
* cleanups
* make vllm transport errors non-retryable
* fix vllm connect diagnostics; add recorded stream fixtures
* [BOT-1930] Rebuild vLLM support on the provider connection registry (#80103)
* Rebuild vLLM support on the provider connection registry
vLLM becomes a provider type in `metabase.llm.provider` rather than a set of
`llm-vllm-*` settings read by the adapter: a required base URL and an optional
API key, no default model, and an `MB_LLM_VLLM_API_BASE_URL` group so the
environment can configure one connection. The base URL is the credential — a
server started without `--api-key` takes no key — so it alone brings a usable
connection into existence, and the admin form renders from the descriptor
instead of a bespoke component.
Two things the adapter used to reach out for now travel with the connection:
- The connect-time preflight, which exercises tool calling and structured
output against the model that will be used, runs from `verify-credentials!`
under a new `probe?` flag. Only writes probe; a model listing must not spend
a generation on the operator's server.
- Whether the served model streams reasoning, which only the probe can answer,
is reported back as `:learned-config` and stored on the connection instead of
in a global setting, so two vLLM connections no longer overwrite each other's
verdict. The request path reads it from the credentials it is handed.
A vLLM connect has no default model to select, so it adopts the model its probe
exercised, and an edit re-probes against the model the connection is actually
serving Metabot.
* Document connecting a vLLM server
The provider list in the AI settings docs is hand-maintained. vLLM is the one
provider an admin can misconfigure outside Metabase, so it gets the server flags
the connect-time check looks for alongside its entry.
* fix kondo
* revert docs
---------
Co-authored-by: Tyler Plude <tsplude@gmail.com>
* review comments
---------
Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* upload all pass/failures for backend tests on rerun
* also for frontend tests
* add some tests
* cleanup whitespace
* copilot review
* a few more fixes from local review
* determine rerun from github context instead of input
* Use content-based cache keys for m2, bun, cypress, eslint.
Eagerly populate cache on push to master/release for m2, bun, cypress.
Read-write eslint cache from every branch.
* Handle multiline outputs
* Remove unneeded M2 restore on prepare-frontend
* Handle potentially multiple cyprss version matches. Update commentary.
* Validate list of alias combinations in use across the repo
* Check preresolve aliases in parallel
* Enable Qwen 3.8 Max via OpenRouter
* Downgrade tool_choice "required" to "auto" for qwen3.8-max
Alibaba returns a 400 request error if you pass tool_choice "required" and unlike moonshot / Kimi K3, does not allow
you to disable thinking mode for Qwen 3.8.
* Add coerce-stringified-scalars to transform tool call args
Qwen 3.8 sometimes sends integer args as strings, like `{:limit "15"}`. Use the tool's declared schema to
automatically transform any simple numeric or boolean scalars.
* Test: transform revision diffs should not include past source bodies
A transform's revision history diffs two full snapshots, so every past
:source (native SQL / Python body) and :source_database_id shows up in
the diff - even for versions that read from a database the current
viewer has no query access to. Add a test asserting the diff only
describes that the source changed, without reproducing the old source.
* Guard transform revision history per snapshot, not per current row
* extra guards in metabot tool
* GDGT-2932: Refactor MCP/CLI analytics to shared-layout + nested routes
Replaces the tab-query-param pages with a shared layout (header, tab
navigation, filter bar, and a single "has any data" gate) rendering
usage/events sub-routes through an Outlet, mirroring the
dependency-diagnostics pattern. New URLs:
- /monitor/ai-auditing/mcp/{usage,events}
- /monitor/ai-auditing/cli/{usage,calls}
with an index redirect from the bare section path to /usage.
Shared filter/pagination state is passed to the active leaf route via a
typed React context (following MonitorContent's existing
useMonitorSidebarContext precedent) rather than react-router's untyped
useOutletContext.
Also fixes two bugs found in cross-review: tab links were dropping the
current query string (clearing filters on every tab switch, since
LinkTab doesn't inherit location.search), and the index redirect was
gated behind the "has data" query so a bare /mcp or /cli with zero rows
never canonicalized to /usage. Bundles a parity fix for useCliHasData
(now surfaces query errors, matching useMcpHasData) so the CLI usage
view doesn't spin forever on a failed count query.
* Fix MonitorHeaderTabs to use aria-current instead of the ARIA tabs pattern
MonitorHeaderTabs rendered its options with role="tab" (via Mantine
Tabs.Tab + a Link component), but each "tab" is a real, bookmarkable
route rather than in-place content switching — the ARIA tabs pattern
implies activating a tab doesn't navigate, which isn't true here. It
also never rendered a matching tabpanel/aria-controls pairing, since
the actual content lives in a react-router <Outlet/> outside the Tabs
tree entirely.
Rewritten as plain nav links with aria-current="page" on the active
one, matching the existing AreaTab component's approach for the same
kind of route-driven navigation. Visual pill styling is preserved via
a small CSS module. Updates the two dependency-diagnostics/tasks/mcp/
cli consumers' tests and e2e specs that asserted the old role="tab".
* GDGT-2932: Review
* GDGT-2932: Keep error count queries mounted across analytics routes
* GDGT-2932: Cleanup
* Fix task tabs E2E selector
* GDGT-2932: Address PR comments
* GDGT-2932: Cleanup
* GDGT-2932: Remove LinkTab. Introduce shared AiAnalyticsSectionLayout. Fix comments
* Move the xyflow stylesheet into the graph components
* Give the schema viewer one loader for its route and its prefetch
* Load the dependency graph from the plugin slot lazily
* Deflake metabot error boundary test by loading the chat module at suite setup
* Preload the chat module in the shared metabot test setup
* Use the shared metabot test setup in the error boundary spec
* Clean up leftover JS-era config, deps, and docs after the TypeScript migration
* Address review: keep .jsx in resolve.extensions, drop .js from loader tests, includes-based e2e validator filter
* Keep @babel/node for a separate deps cleanup round
* Keep js/jsx in rspack loader tests
* Fix flaky dashboard parameter-removal e2e test
The "should be able to remove parameter (metabase#17933)" test asserted on
the full dashboard URL query string with cy.location("search").should("eq",
...). The order in which parameter slugs are serialized into the URL is not
deterministic, so a correct-values-but-different-order string
(?text_starts_with=G&text_ends_with= vs ?text_ends_with=&text_starts_with=G)
could never match and the retry just spun until timeout. Empty/stale-URL
timing races produced the other observed failures.
Replace the four exact-string assertions with an order-independent helper that
parses the query string via URLSearchParams and deep-equals the expected
param map. It still retries through cy .should(callback), so async URL updates
settle before the assertion resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wait for parameter-remove button to enable before clicking
The parameter-removal step in the dashboard parameters spec clicked the
sidebar's Remove control via cy.findByText("Remove"). That query resolves to
the button's inner label <span>, not the <button> element. A span is never
"disabled", so Cypress's actionability check did not wait: it clicked the span
while the underlying button was still disabled.
The Remove button stays disabled until the dashboard's subscriptions have
loaded (the sidebar warns before archiving any subscription bound to the
filter). When that GET /api/pulse request was slow, the click landed on the
disabled button as a silent no-op, the parameter was never removed, and the
subsequent URL assertion timed out waiting for the slug to disappear.
Target the button element by role and assert it is enabled before clicking so
the click waits for the subscriptions request to settle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix linting error
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
The test opened the info sidebar right after an ad-hoc edit-mode save that
only awaited the PUT /api/dashboard request. The trailing query_metadata
reload re-rendered the header, dropping the 'More info' click so the
sidesheet never opened. Use the H.saveDashboard() helper, which waits for the
full save-and-settle sequence before proceeding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix getTable intercept in segment_created tracking e2e test
The segment editor's DataStep loads a table via fetchTableMetadataAndForeignKeys, which calls GET /api/table/:id/query_metadata. The test intercepted the bare GET /api/table/:id endpoint instead, so cy.wait("@getTable") always timed out because that request never fires in this flow. Point the intercept at the query_metadata endpoint the editor actually calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Gate segment filter click on enabled Add-filters button
The segment editor renders a disabled "Add filters to narrow your answer"
button until onChange builds the query from fully-loaded table + FK metadata.
Waiting on the getTable response alone isn't enough — the trailing foreign-key
metadata loads race the click, so the click lands on the still-disabled button
and the filter popover never opens. Assert the button is enabled first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* add sample db
* delete cross version 57 since we don't support it anymore
* update cross version tests to use postgres
* fix modal selector to work on v1.60
* fix modal selector for v60 and v63 compatibility
* add better logging
* use qa image
* use herestring to handle large output properly
* use data from WA image
* fix flake in nested model assertion
* cleanup the README
* code review fixes
* Validate imported queries against this instance's MBQL schema (GHY-4241)
An export decides the shape of the queries it carries, so an import can hand us
a query shape this version has no representation for. `lib/normalize` does not
always reject one: it can quietly produce something the schema rejects, which
is then stored and breaks the card on read.
After `import-mbql` normalizes an ingested query, validate the result against
`::lib.schema/query` and refuse the import if it fails. Only full queries are
checked - bare refs and the MBQL fragments inside visualization settings have no
standalone schema to validate against.
Normalization failures stay non-fatal. They fire today on content this instance
produced itself (pivot column refs in visualization settings raise
`:malli.core/invalid-schema`), so promoting them would reject legitimate
imports. Validating the successfully-normalized result catches the real problem
without that fallout.
`MB_SERIALIZATION_SKIP_SCHEMA_VALIDATION` opts out. `metabase.models.serialization`
cannot read an EE setting, so the flag reaches it through a dynamic var bound by
`load-metabase!` - the one choke point every import path funnels through.
Also validate the bundled instance analytics content against the same schema in
CI. That content re-imports on every EE boot and a failure there propagates out
of `init!` to `System/exit`, so a bundle regeneration that broke it would
crash-loop rather than degrade.
* Fix import validation documentation
* Refactor documentation for import validation settings
* update docs
* Report the schema refusal as a 400 and stop duplicating its errors (GHY-4241)
The serdes import API reads `:status` off ex-data itself rather than going
through the API layer's exception handling, so `:status-code` left it nil and
the refusal surfaced as a 500 for what is a client input error.
`u/strip-error` already renders ex-data into the message, so the truncated
`Schema errors:` tail was a second, shorter copy of `:schema-errors`.
* Drop the unreachable status code from the schema refusal (GHY-4241)
`load-one!` rewraps every exception from the load block in a fresh ex-info
carrying `path-error-data`, and the import API reads `:status` off the
outermost exception only. Neither `:status` nor `:status-code` attached here
was ever visible, so claiming one in a comment was worse than saying nothing.
* Name both causes in the refusal, and pin bundle ingest errors (GHY-4241)
`mu/defn` schemas are not instrumented in prod, so an app DB can hold MBQL the
QP tolerates but `::lib.schema/query` rejects - a stale `:join-alias`, a field
filter with no `:widget-type`. Such content exports intact and is then refused
on import, so "most likely exported by a newer Metabase" was the wrong
diagnosis for a whole class of refusals. Name both causes, and point at
continue_on_error as the per-entity escape rather than only the global one.
The bundle test proved every query imports but not that every file parses.
`ingest-list` collects unreadable files in an errors atom rather than failing,
so a malformed bundled YAML would drop out of the queries under test while
still crashing EE boot. Assert the atom is empty too.
* Move refusal rationale out of docstrings, harden a test helper (GHY-4241)
Docstrings state the contract; the reasoning behind the check, the message
wording and the sentinel moves to comments beside the code it explains.
`*skip-schema-validation?*` no longer names who binds it, which would go stale
silently. `validate-imported-query!` now says legacy MBQL 4 queries are left
alone as well, which was true but unstated.
`cause-chain-messages` used `map ex-message`, so an exception with no message
would put a nil in the vector and NPE the callers' `re-find` - failing the
test for a reason unrelated to what it asserts.
* kick ci
* Simplify the import schema check's control flow (GHY-4241)
`validate-imported-query!` returns its argument and no longer consults the
opt-out, so it is a plain step in `import-mbql`'s pipeline and the caller
decides whether to run it. That removes the sentinel `normalize-imported`
needed to keep the refusal outside its `catch`, along with the comment
explaining the sentinel.
`some->` replaces `->`, which makes `normalize-imported`'s nil guard
redundant.
* update comment
* Route semantic embeddings through the provider SPI
Summary
- Register the existing ai-service, OpenAI, and Ollama implementations through the shared provider registry.
- Route semantic embedding, readiness, resolution, and preparation through the SPI while preserving the existing public adapter.
- Preserve the compatibility client model-dimensions contract by normalizing it to vector-dimensions at the provider boundary.
- Keep semantic-specific batching, metrics, and settings policy in semantic search.
Scope
This migrates consumers to the SPI. Persisted embedding-space compatibility and in-process provider selection are intentionally handled by later stack layers.
Testing
- Built-in provider behavior and existing semantic embedding adapter tests.
- Compatibility-client dimension normalization and conflict handling.
* Describe the embeddings client contract in SPI terms
The batch entry point delegates to the provider registry rather than a multimethod.
* Port complexity-test fake embedder to the provider SPI
get-embeddings-batch is a plain fn now, so the old defmethod no longer compiles
and broke every job that loads this namespace. Register a capturing provider
instead and assert on the descriptor it receives.
* Induce the dimension mismatch from the model, not the mock vector
The SPI rejects a vector whose length doesn't match the resolved model, so a dim-1 mock embedding
no longer reaches pgvector. Shrink the configured model instead, which is what an upgrade does.
* Persist immutable embedding-space identity
Summary
- Persist embedding-space IDs in semantic and library-entity index metadata.
- Backfill semantic metadata through a preserving schema migration and rebuild legacy entity vectors once when identity is unknown.
- Include immutable space identity in compatibility checks and physical table names.
- Enforce the PostgreSQL 63-byte identifier limit using UTF-8 byte length.
Scope
This establishes durable compatibility. Synchronization between entity compatibility checks and rebuilds follows in the next stack layer.
Testing
- Semantic migration preservation and backfill.
- Entity-index rebuild and compatibility behavior.
- Embedding-space table naming and non-ASCII identifier limits.
* Resolve the model in the index-cleanup test helper
record-new-index-table! now persists embedding_space_id into a NOT NULL column,
so create-test-index must build a resolved model that carries one.
* Track the index-status rename after rebase onto master
* Resolve the model-v2 variant in the reconcile tests
A bare assoc keeps the v1 embedding-space-id on a renamed model, so the rebuild wrote a meta row
whose space no longer matched what the provider resolves and nothing repopulated.
* Serialize entity index queries with rebuilds
Summary
- Coordinate library searches and reconciles with shared and exclusive PostgreSQL advisory locks.
- Keep compatibility rechecks and vector queries on the same locked connection so they cannot straddle a rebuild.
- Use non-blocking search lock acquisition so waiting searches cannot exhaust the pgvector pool.
- Preflight compatibility before embedding and return no results while a reconcile owns the index.
Scope
This PR only addresses library entity-index concurrency and degradation behavior.
Testing
- Search call ordering and locked connection reuse.
- Incompatible-index preflight and reconcile-contention degradation.
* cljfmt entity-retrieval core-test
* Follow the search-unfiltered rename in the entity-retrieval tests
Rebase fallout: the parent branch renamed `search` to `search-unfiltered`, and these call sites came across from
before the rename.
* Enable the in-process embedding provider
Summary
- Allow plugin-defined providers in semantic settings and safely encode provider names in physical index identifiers.
- Enable the bundled in-process Arctic provider with dimension readiness, query prefixing, and token metrics.
- Document the optional plugin and its glibc 2.34+ Linux or Apple Silicon macOS runtime requirement.
- Build the embedder jar in CI and run one artifact-only smoke path through the provider SPI on the OSS classpath.
Scope
This intentionally supports one pinned Arctic model. Custom model sources, MiniLM, and library overrides remain out of scope.
Testing
- Settings, prefix, token-accounting, readiness, and derived table-name regressions.
- Jar-manifest discovery, platform failure modes, batching, no-network behavior, and real ONNX inference.
* Re-hash qualified index table names over the 63-byte limit
qualify-index wraps the model table name with the metadata qualifier but did not
re-check Postgres's 63-byte identifier limit. A longer provider-encoded name plus
the qualifier could exceed it, so Postgres truncated the name on CREATE while
table-exists? still looked it up by the full name -- reporting the index table as
missing. Re-apply hash-identifier-if-exceeds-pg-limit to the qualified name.
* Hash only the table component of a qualified index name
Postgres's 63-byte limit is per identifier. Hashing the whole schema-qualified
name dropped the schema, so in app-db mode a long semantic_search.<table> became
a bare index_<sha1> created outside the isolated schema. Split off the schema and
hash only the table component.
* Cover in-process MiniLM complexity scoring
* Activate the in-process embedding provider on demand
* Stub lazy provider activation in token usage test
* Update Kondo ratchets for embedder changes
* Rely on bootstrap for in-process provider registration
* Cover the bundled complexity-score model in the artifact smoke test
The artifact job only exercised the retrieval model, so a broken or missing MiniLM
bundle -- which uses a different pooling and the three-input INT8 graph -- would have
shipped unnoticed.
* Document both bundled embedder models
* Register the in-process provider in standalone CLI mode
Provider registration happens in metabase.core.core/init!, which `--mode complexity-score`
skips entirely. A synonym axis configured for the in-process provider therefore failed with
:provider-not-registered on the CLI while working on the server.
The activation moves into metabase.embeddings.startup so both entry points run the same
routine instead of drifting apart.
* Assert the descriptor the in-process synonym embedder sends
The test claimed the embedder passes the MiniLM descriptor to the provider SPI but only
checked that it was a function, so an Arctic descriptor would have passed too.
* Pin the CLI's provider registration with a test
Nothing caught the standalone CLI skipping provider registration the first time, so assert
appdb mode registers before it scores.
* Honour :snowplow? false in the ai-service adapter
Routing single embeddings through the batch SPI dropped the option, so the health and
circuit-recovery probes started reporting their synthetic embeddings as organic token usage.
* Make the dimension-mismatch test reach pgvector
Shrinking the model with assoc kept the old embedding-space id, so resolution failed and the
query never ran -- the same path search-degrades-when-configured-model-cannot-be-resolved-test
already covers. Use a self-consistent model, stub the compatibility preflight aside, and assert
the warning that only the 22000 branch emits.
* Use dynamic redefs for the mock index's table suffix
Pre-existing on master: a plain defn redefined on one thread, so nothing here needs the root
swap. Same mock-index value either way.
* Document the tiny bundled models as a test tier
* Fix index-health-check false degraded report
row->index's :embedding-model now carries :embedding-space-id and
:embedding-spi-version, which get-configured-embedding-model doesn't
return, so the equality check in index-health-check always failed and
reported every healthy index as degraded. Compare only the keys
settings actually configure.
* Give v1 Arctic-embed models their own query prefix
model-family-query-prefixes matched every snowflake-arctic-embed
model name and prefixed queries with "query: ", which is only correct
from v1.5 onward. v1 models (including the bundled xs test-tier
model) expect the longer CQE-style instruction instead.
* Carry the plugin-name pin into embeddings.startup
The provider registration this branch moved into
embeddings.startup/ensure-in-process-provider! re-duplicated the
manifest name as its own private literal. Make it the one public
constant both the startup logic and the tests reference, instead of
reintroducing a third copy.
* Correct the Arctic Embed query-prefix cutover to v2.0
The generation split is at v2.0, not v1.5. m-v1.5 was getting "query: "
when it wants the longer instruction, and m-long matched neither pattern
so it got no prefix at all.
Replace the two mutually-exclusive patterns with an ordered first-match-wins
table. That drops the exclusivity constraint the previous shape needed and
covers every model in the family.
* Stop the query-prefix setting claiming `query: ` family-wide
The description predates the generation split and tells admins the whole
snowflake-arctic-embed family expects `query: `, which is wrong for the
bundled model.
* Anchor the Arctic Embed patterns to the model-name segment
Unanchored, they matched any name merely containing the family name, and
a false positive there is unsuppressable: `ee-embedding-query-prefix` can
replace a matched prefix but never remove one.
* Compare embedding spaces in the semantic health check
Settings can stay identical while a provider upgrade resolves the same
model into a new space, stranding the index. The space id is what
actually decides whether the stored vectors are still usable, so compare
that rather than provider/model/dimensions.
Resolution reaches the provider, so it can fail outright when the plugin
is absent or the platform is unsupported. That is its own incident, not
a model mismatch.
* Document the in-process semantic search model
* Add in-process semantic search integration test
* Drop incompatible legacy entity indexes
* Address embedder review findings
* Quote legacy index identifiers separately
* Internal load-namespaces tool to check for compilation errors
* Ignore load-namespaces in module checks
* lint load-namespaces
* Reject positional args
* Test the new utility
* Guard against nonexistent root.
Make the code more testable.
* Load CodeMirror editors lazily so they leave the initial bundle
* Inline the single-use dynamic imports into the lazy calls
* Share the codemirror split chunk with the embedding SDK build
* Split the snippet, Python and public document pages by route instead of wrappers
* Dedupe modules shared by async chunks instead of naming CodeMirror
* Drop the duplicate metrics viewer route that master already added
* Keep barrel imports and mark the expression language side-effect free
* Move the action editor boundary to the new action-creator module
* Drop the codemirror cache group the SDK config no longer needs
* Drop props types exported for wrappers that route splitting replaced
* Delay the code fallback so a fast chunk load never flashes uncoloured code
* Render nothing while the code editor chunk loads
* Delay the whole expression widget instead of showing a spinner inside it
* Open the editors from routes and modals instead of loading states
* Fold the Metabot preview chunk into the message's own loading state
* Fold the query editor chunk into the transform pages' own loading state
* Prefetch the expression widget from the notebook so its popovers open ready
* Await the run error message now that the code editor loads lazily
* Hold the custom column popover shut until the expression widget arrives
* Wait for the expression editor to hold focus before typing into it
* Type into the page directly when asserting the read-only native editor
* Read the editor value only once its document stops changing
* Fetch the routes-only plugin pages when their route is matched
* Fetch the remaining plugin route factories when their route is matched
* Load the AI settings pages as one chunk
* Say why these route files register no prefetch
* Derive the AI settings prefetch paths from the route paths
* Prefetch the model migration page like its siblings
* Count the python transform pages master already made lazy
* Give each AI settings page its own chunk
* Move useEntityData out of the tiptap SmartLink node module
* Load the Metabot chat panel lazily
* Load the public document page lazily
* Recover from a failed fetch of the Metabot chat panel
* Fetch the public document chunk at startup
* Fetch the data studio, model and snippet pages when their route is matched
* Fetch the monitor and timeline pages when their route is matched
* Name the chunks the route loaders build
* Fetch the AI auditing and library pages when their route is matched
* GDGT-2977 Style full markdown output in shared Markdown component
- Markdown.module.css covers every CommonMark+GFM element as :where() base layer
- compact prop for line-clamped previews
- disallowHeading renders headings as paragraphs instead of unwrapping to text
- drop duplicated rules from AIMarkdown, delete AdvisoryCard.module.css
- kitchen sink fixture + stories + spec failing on unstyled elements
* GDGT-2977 Trim markdown specs to our own behavior
- drop tests that only exercise react-markdown/remark-gfm
- cover urlTransform (metabase://, data:image, unsafe protocols)
- assert headings render as p when disallowed
- merge AdvisoryCard markdown cases
* GDGT-2977 Fix markdown spec asserting against mantine style tag
- container.firstElementChild was the injected <style>, so every
querySelector check passed vacuously
- render into a host element and select the markdown root from it
- javascript: urls collapse href to empty, which drops the link role
* addressing the PR's comments
* GDGT-2977 Match skeleton caption tooltip to legend caption
compact + lineClamp, per PR review
* fix small regression
* Let conversation titles run on their own model
Every LLM call read the one `llm-metabot-provider` setting, so naming a
conversation — a short, high-volume call — burned the same model the chat runs
on. Add an `llm-title-model` setting whose getter resolves rather than defaults:
the stored value, else the fastest model of the connection Metabot uses, else
that model itself. Provider types now carry a `:mini-model` alongside
`:default-model`, taken from each adapter's own supported-models list so the
fallback names a model the picker actually offers; Azure and the managed
provider have none and fall through.
Move model selection out of the AI providers card into its own Models section,
so per-use-case choices have somewhere to live. Deleting a connection clears a
title model pinned to it, reading the raw stored value so a merely-derived one
is left alone.
* Name a mini model for every provider type that has one
Mistral and Z.AI were the only types with no `:mini-model`, so conversation
titles on those connections fell through to the model Metabot chats on. Both
adapters whitelist exactly one model today, so their mini model is that same
model — the fall-through by another name, but spelled out where a real small
model goes when one is added.
Two tests hold the line. The registry map fails when a type gains or loses a
`:mini-model`, since a missing one reads as nil and quietly sends titles to
the full-size model. The listability test fails when a type names a model its
adapter will not offer: `list-models` returns the whitelist intersected with
the provider's catalog, so an id the whitelist omits never reaches the admin
picker, which renders it as a blank selection.
That second test caught Anthropic naming `claude-haiku-4-5` while the Claude
whitelist carried only `claude-haiku-4-5-20251001`, leaving the new title
picker blank on the commonest setup there is. The whitelist was the odd one
out — the adapter already defaults to the undated alias — so it loses the date.
* Sell the title model as the mini model it already was
Review asked whether each feature should get its own picker or whether one
cheap model should serve every quick task; the answer was one model. The
setting already resolved through the registry's `:mini-model` and nothing but
titles read it, so this is a rename — `llm-title-model` becomes
`llm-mini-model` — plus copy that presents naming conversations as the first
of the quick tasks rather than the point.
The derivation tests now pin the stored value to nil instead of assuming a
clean database; a crashed run had left a stored value behind, which the old
tests read as the derived one.
* Return Haiku's dated id, which is what the Anthropic catalog serves
Dropping the date assumed /v1/models would list the bare alias. Review
testing with a live key showed it does not: the catalog carries only
claude-haiku-4-5-20251001, so the undated whitelist key filtered Haiku out
of the picker, and the derived mini-model reference matched no option —
titles kept running on Haiku while the picker sat blank.
The alias stays where it always worked: inference-call defaults. The
whitelist and the registry go back to the catalog's id, which is also the
whitelist's convention — the 2025 models are dated, the 2026 models are not,
tracking what the catalog returns for each.
* Move a pinned mini model with the deployment its connection now serves
Editing an Azure connection's deployment already moves the Metabot
selection, because the reference bakes the deployment name in. A mini model
pinned to that connection went stale the same way, so it moves the same
way. A derived mini model is left unstored — it follows the Metabot
selection on its own.
* use json_query as the cache key to prevent cardinality cache collisions
* fix e2e, persist default settings that depend on cardinality
* review comments
* Display percentages instead of values on stacked bar charts
Adds a Stack value format setting that lets stacked bar chart segments display their percentage of the stack total.
Addresses #67702
* Handle percetanges for negative values
* Show decimals for fractional stack percentages
* Show percentage totals for stacked bars
* Improve condition readability
---------
Co-authored-by: Abhijnya K G <53806883+Abhijnya002@users.noreply.github.com>
* Invalidate the cached models list when credentials change
A credential write can change which models the provider serves, e.g. a different Bedrock region, Google location, or
Azure resource.
* Support multiple LLM provider connections at once
Metabot could only ever be connected to one AI provider, because each
provider's credentials lived in its own hardcoded settings
(llm-anthropic-api-key, llm-azure-api-key/-base-url, llm-bedrock-*, ...)
and llm-metabot-provider named the single provider in use. Adding a
provider meant editing a `case` in six namespaces plus three constant
maps on the frontend, and two keys for the same provider were impossible.
Replace that with a list of provider *connections* stored in one
`llm-providers` JSON setting, fronted by a CRUD API rather than edited as
a raw blob -- the same shape the `oidc-providers` setting already uses.
Provider types are now a pure-data registry describing each type's
credential fields, so the admin UI renders a connection form from the
server descriptor instead of a bespoke component per provider, and the
model picker lists models from every connection, grouped by connection.
Connection keys are slugs defaulting to the provider type, so existing
`llm-metabot-provider` values keep resolving unchanged. `metabase`
becomes an ordinary connection, which collapses the three-segment
`metabase/provider/model` special case into the generic rule. The
per-provider env vars keep working and each synthesizes one read-only
connection; MB_LLM_PROVIDERS puts the whole list under env control.
* Fix managed provider handling in the connection list
Five issues from reviewing the new admin UI, all around the Metabase-managed
provider:
- The managed provider's models were fetched from the proxy, so instances
that cannot reach it showed "Couldn't load models". The proxy serves one
benchmarked model rather than a listable catalog, so the registry now
carries that catalog directly and nothing is fetched. It doubles as the
allow-list `llm-metabot-provider` is validated against, replacing the
separate copy in metabot.settings.
- The managed provider had an Edit action that opened an empty modal (or one
offering to re-Connect something already connected). There is nothing to
edit, so managed connections now only offer Remove.
- It could be added twice. Provider types can declare themselves singletons;
the API rejects a second connection and the FE drops already-connected
singletons from the Add dropdown.
- The empty state repeated the section description verbatim.
- Cards showed the raw type slug under the name ("Metabase" / "metabase").
The provider type's label is now shown only when it differs from the
connection's name, i.e. when the admin renamed it.
Also drops the startup step that materialized a `metabase` connection on any
proxy-configured instance, which made the managed provider look connected when
nobody had connected it. It is now materialized only where a `metabase/...`
model reference is actually written: the legacy metabot-v3 sync, and the
migration of an instance already pointed at it.
* Address review comments on the provider e2e helpers and managed setup spec
- Drop the doc comments added to e2e-metabot-helpers.ts.
- Move the mock LLM server's base URL into the connection's own config rather
than the legacy `llm-anthropic-api-base-url` setting. Both worked, because
the adapters fall back to that setting when the connection carries no base
URL, but relying on the fallback left the connection describing one endpoint
while requests went to another.
- Assert that the add-on purchase really does precede the connection POST,
which the test claimed in its name but never checked, and add the missing
case: a failed purchase must not create the connection.
* Stop repeating the model family in the model picker
Providers already carry the family in the model's display name, so prefixing
it with the group produced labels like "Opus · Claude Opus 4.8" and
"Anthropic · Anthropic: Claude Haiku 4.5". Show the display name on its own.
The group still comes back from the API: the backend orders each provider's
models by it, so dropping it here keeps the grouping-driven order without
showing the label twice.
* Select a default model on connect, and simplify the ad-hoc connect view
- Connecting a provider when Metabot had nothing usable to run on now points
`llm-metabot-provider` at that connection's default model, so the first
provider you add leaves the instance working instead of connected with no
model selected. An existing selection that still resolves is left alone, so
adding a second provider does not silently switch Metabot over to it. The
"did it have a usable model" check has to run before the new connection is
saved: the setting's default names the `anthropic` connection, which a
freshly saved Anthropic connection would otherwise satisfy retroactively.
- The ad-hoc "connect to a model" modal no longer renders the admin list with
an "Add a provider" button that opens a second modal. It is one connect form,
and once a provider exists it is the model picker. Extracted
ProviderConnectionForm so the admin modal and this view share it.
- Show a skeleton while the connection and provider-type lists load.
- Replace the add-on ordering assertion, which compared indexes into the call
history, with a purchase that only resolves once the test releases it: the
connection must not be created while it is in flight.
* Fix the managed-provider-limit spec for the single-form connect view
The ad-hoc connect modal no longer offers an "Add a provider" button that
opens a nested modal, so assert on the provider picker the form now renders
directly.
* Show the model picker after connecting from the ad-hoc modal
Connecting a provider in the ad-hoc view called onClose straight away, so the
modal shut before the picker it was supposed to hand over to could render.
Stay open and show the picker instead, except for a provider that serves a
single fixed model (the managed one), where there is nothing to pick.
* Split the AI provider list and setup views apart
The one component branched on an `isModal` prop to be either the admin list
(cards, add/edit/remove, model picker) or the ad-hoc connect flow, which meant
neither was composable and the modal branch read as an afterthought. Split it
into AIProviderList and AIProviderSetup, each owning its own data and state,
sharing ProviderConnectionForm, LlmModelPicker and ProviderListSkeleton.
Call sites pick the one they mean instead of passing a flag.
Also use `useDisclosure` for the add-provider modal. `editing` and `deleting`
stay on `useState` because they carry the row being acted on rather than a flag.
* Cancel the managed AI add-on from the backend, not a plugin hook
`PLUGIN_METABOT.useOnProviderRemoved` only ran when the managed connection was
removed through that one component, so removing it any other way — the API
directly, or the fallback path in the delete handler — left the customer paying
for an add-on they no longer used.
Move the cancellation into `DELETE /api/llm/providers/:key` behind a
`defenterprise`, which is where the invariant actually holds: removing the
managed connection cancels its add-on for every caller. The EE side decides
which product type to cancel from the same premium features the frontend was
reading, and keeps the rule that an instance on legacy tiered pricing with no
metered offer to move to is left alone. Extracted the Store call the
cloud-add-ons endpoints already make into `cloud-add-ons.core` so both share it.
The plugin hook and its EE implementation are gone.
Also clear fetch-mock state between AI provider spec setups. The file grew a
second `describe`, and its routes and call history leaked into whichever suite
jest packed next into the same worker.
* Address review comments on the model picker and provider API shape
- Snake_case the two kebab-case keys the provider-type endpoint put on the
wire (docs-url, default-model), matching display_name alongside them. The
internal registry keeps its idiomatic kebab-case keys.
- Drop the model picker's own success toast: useAdminSetting's updateSetting
already toasts both outcomes, so picking a model fired two toasts and the
error path only looked unhandled.
- Show SetByEnvVar under the picker when llm-metabot-provider comes from an
env var, so the disabled Select says why.
* Remove the unused GET /api/llm/list-models endpoint
Nothing called it: no frontend, SDK, or e2e reference, only its own tests. It
listed models for whichever connection Metabot was pointed at, which
GET /api/llm/models now covers for every connection.
It is in the generated OpenAPI spec, so this is a breaking change for anyone
calling it directly. The spec is regenerated by the openapi-self-healing CI job
rather than by hand — a local run rewrites far more of the file than this change
touches.
* Pick a provider type from an icon grid instead of a dropdown
Replace ProviderTypeSelect with a ProviderTypePicker card grid, giving each
provider type an icon the backend names in the provider-type registry.
Connections now also report the env vars that configured them, so the UI can
name them rather than just saying the connection is env-managed.
* Use real provider logos, drop the stepper, lead with Metabase
- Drop the two-step "Provider / Configure" indicator. The modal swaps content
in place rather than paging, so it rendered as a lone empty circle next to a
floating label and conveyed no progress.
- Ship brand logos for the five bring-your-own-key providers, mirroring how
driver logos already work: a file per provider under
app/assets/img/llm-providers, looked up by provider type, falling back to the
registry's icon when there is no logo (the managed provider keeps `metabot`).
Sourced from svgl. The three monochrome marks are rewritten to currentColor so
they survive dark mode; Azure and AWS keep their brand colour. Bedrock uses the
AWS mark for want of a Bedrock-specific one.
- Offer the Metabase-managed provider first when the instance can actually use
it, since it is the option to reach for before bringing a key.
- Fix the blank managed connect screen. MetabaseAIProviderSetup decided between
"connect" and "already connected" from `llm-metabot-configured?`, which under
multi-provider is true as soon as *any* connection works — so adding Metabase
alongside an existing Anthropic connection rendered the connected-state card
for a subscription that was never bought, which draws nothing. The caller now
passes whether the managed provider itself is connected.
The provider-types order assertions became environment-dependent once ordering
keys off proxy availability (this worktree's .env sets MB_LLM_PROXY_BASE_URL), so
they now assert the set of types plus the lead position explicitly.
* Drop the provider picker heading that repeated the modal title
* Right-align the managed provider footer to match the other setup screens
* Focus the confirm button so Enter confirms instead of dismissing the modal
* Tuck the optional connection fields behind Advanced settings
The connect form led with Display name and API base URL, so the one field
that actually matters — the API key — was buried among inputs that already
have working defaults.
Show only the fields a provider type marks required, and collapse the rest
plus Display name into an Advanced settings disclosure at the bottom. The
split comes from the field descriptors rather than a hardcoded list, so
Azure (whose base URL is required) keeps both inputs up front.
Editing a connection that already customized an optional field opens the
section, so a non-default value is never hidden.
* Let the registry say which connection fields are advanced
Inferring it from `required` was wrong for Bedrock. `required` means
validation fails without a value, which is a different question from whether
an admin has to think about the field. Region has a default, so it is not
required — but it decides which models are reachable and where inference
runs, so burying it hands EU customers a silent us-east-1 default.
Add an explicit `advanced?` to the field descriptors and split on that. Only
the base URLs and Bedrock's session token carry it; region now stays up
front where it belongs.
* Cover submitting the provider connection form with the enter key
* Treat the single-provider env vars as a supported way to configure a provider
They were named and documented as a legacy compatibility shim — `legacy-env-connections`,
`migrate-legacy-provider-connections!`, and a doc line promising they "keep working".
That undersells them: configuring one provider by environment variable is the simpler
option when you do not want to hand-write the `llm-providers` JSON, and it is resolved
from the environment on every read, so editing a variable is picked up rather than being
frozen into a copy at first startup.
Rename to say what they are, and reword the docs to position the two mechanisms as
alternatives rather than old and new. The one-shot at startup only ever adopted
credentials stored in the app DB, so name it for that instead of for migrating away from
something we are keeping.
* Type the LLM provider logo lookup and move it out of the icon component
The logo map lived inline in ProviderTypeIcon as a Record<string, string>, so a new
provider type silently fell back to the generic icon instead of failing the build.
Give provider types a LlmProviderTypeName union and key the map on it, so adding a type
without deciding on a logo is a compile error. The map moves to metabot/constants.ts and
the lookup to metabot/utils/llm-provider.ts, matching how databases/ splits ENGINE_LOGO
from getEngineLogo.
* Focus the first credential field once a provider is picked
* Show the Metabase logo for the managed provider
* Only badge a provider connection when something is actually wrong
The green check was driven by `usable`, which only asks whether the required fields are
non-blank. The connect form already refuses to submit until they are, so every connection
made through the UI got a check — including one whose API key the provider rejects, right
above a "Couldn't load models" error saying so.
Drop the check and keep the warning, which still carries a real signal for connections
that arrive incomplete from MB_LLM_* or MB_LLM_PROVIDERS, and label it so it says what is
wrong rather than just showing a red triangle.
* Lighten the provider list to divider-separated rows
Each connection was a bordered Card, but the list already sits inside SettingsSection,
which is itself a bordered, padded card — so every row read as a card inside a card.
Follow DatabaseInfoSection instead: one container, rows separated by a divider. Also drop
the icon from 40 to 32, since the tile no longer has to anchor a card of its own.
* Tighten the provider list spacing and align the add button with the rows
The list and its add button sat in the same lg-gap stack as the model picker, so a control
that belongs to the list was pushed 24px away from it, as far as the next setting. Group
them in their own sm stack and keep lg for the gap to the picker.
The subtle button also carries horizontal padding, so its + sat indented from the provider
logos below it. Drop the padding, matching the Advanced settings toggle on the connect
form, so both start on the same edge.
Tighten the section header gap from lg to md via the stackProps SettingsSection already
exposes for this.
* Fetch provider models once and report failures on the provider that failed
A model-loading failure was only visible under the model picker, which reads as a problem
with the picker rather than with Anthropic. It also meant the failure was invisible
anywhere a picker was not rendered.
/api/llm/models already returns every connection in one response with a per-connection
error, and RTK Query already caches it under the llm-models tag, so the fetch is shared by
construction. Wrap it in useLlmConnectionModels, which hands back the grouped select
options and the errors keyed by connection. Any future picker goes through that hook and
gets the same cache entry and the same keying, rather than re-deriving either.
The provider row now renders its own error and the picker no longer lists them; the picker
keeps the request-level error, which is a different failure.
* Pick the provider from a pasted API key
Paste a key anywhere on the provider grid and it jumps straight to that
provider's form with the key filled in, ready to connect.
The registry already declared a `:prefix` per credential field — the backend
validates against it — so this just exposes it and reuses it for recognition.
Anthropic, OpenAI and OpenRouter carry one; nothing else is guessable.
Longest prefix wins, since OpenAI's `sk-` also matches an Anthropic
`sk-ant-` key. Text that matches nothing is left alone.
* Leave the paste-to-detect shortcut unannounced
Drop the hint under the provider grid. The shortcut stays; it is just for
people who find it. `canRecognizeApiKeys` existed only to decide whether to
render that line, so it goes too rather than sitting unused.
* Gate the managed provider usage panel on the features that fill it
The panel was shown whenever the metabot plugin was enabled, which is a
weaker condition than having usage and pricing to report. Move the check
behind a hasMetabaseManagedProviderDetails plugin hook so the enterprise
side decides from the premium features, and OSS answers false.
* Register the Mistral and Z.AI provider types in the connection registry
master added both providers to the single-provider settings architecture this branch
replaces, so rebasing dropped them from the admin UI: the adapters, the model listers and
the MB_LLM_MISTRAL_*/MB_LLM_ZAI_* settings all survived, but nothing offered either type
as something an admin could connect.
Registry entries restore that, and adding them to `single-provider-settings` is what keeps
an instance already configured through those env vars working — the same entry drives both
the env-resolved connection and the app-DB adoption at startup.
* Resolve provider connections in the LLM call tests
`parse-provider-model` stopped deriving a provider from the model string's first segment
and started resolving it against a configured connection, but this file still called it
with nothing configured, so every test that reached an adapter failed on "No LLM provider
connection named". Configuring the default connections is the fixture those tests were
always implying.
The invalid-format cases now assert the error the new resolution actually raises: an
unparseable string and an unknown connection are the same failure, because a name that is
not a connection is all "unknown provider" can mean once the registry is the source.
* Move the onboarding AI step onto the provider connection list
The setup wizard's AI step was built against the single-provider form this branch removed,
so it still imported `AIProviderConfigurationForm` and the provider enum and no longer
compiled. It now renders `AIProviderSetup`, the same connect-then-pick-a-model view the
ad-hoc modal uses, so onboarding and the admin page stay one flow rather than two.
Preselecting `metabase` goes away with the dropdown it was written for. The backend already
sorts the managed provider first in the type picker, which is what the preselection was for,
and picking it in a grid of logos is not a step the wizard needs to skip on the admin's
behalf.
* Add the Mistral logo and make a missing provider logo fail the build
LlmProviderTypeName is written by hand and had drifted: the registry gained mistral and
zai, the union did not, so the exhaustive LLM_PROVIDER_LOGOS record was exhaustive over a
stale set and both types fell through to the generic icon without a type error.
Add them to the union, which does make the record fail to compile until each is decided.
Mistral gets its mark from svgl; svgl has no Z.AI logo, so zai is an explicit null and
keeps the fallback icon until we have a real asset.
Nothing links the registry to the union, so add a backend test over the registry's type
names that names both frontend files to update.
* Thread per-connection credentials through the Mistral and Z.AI call paths
Both adapters took :credentials in list-models but ignored them in mistral-raw/zai-raw,
which read llm-mistral-api-key / llm-zai-api-key directly. So a connection's models listed
under its own key while every actual chat call went out under the global setting: a second
Mistral connection would silently answer with the first one's key, and a connection with no
global setting configured would fail outright.
Match the openrouter/claude shape — credentials first, configured settings for whatever
they do not carry — for both the key and the base URL, and cover the call path, which no
adapter had a test for.
* Give Z.AI its logo
Taken from z.ai's own logo.svg, since svgl carries no Z.AI mark. Stripped the Illustrator
export's ~10.5KB of unused .st0-.st194 declarations — only two of them were referenced —
and kept the geometry and colours exactly as shipped.
Every registered type now has a mark, so the fallback is only reachable when the backend
serves a type this union has not caught up with. Test it that way instead.
* Let instances that are only offered managed AI reach the purchase flow
The managed provider tile is enabled by `type-available?`, which asks whether
the LLM proxy URL is readable. That setting was gated on `metabase-ai-managed` or
`metabot-v3` — features granted only by the AI add-on products — while
`offer-metabase-ai-managed` comes from the starter and pro-cloud plans. Every
cloud instance eligible to buy the add-on therefore saw the tile as unavailable
and had no way to reach the purchase flow that grants the feature.
* Cover per-connection credentials on the remaining providers' call paths
Every provider tested credentials through list-models and none through the call path,
which is why Mistral and Z.AI could list models under a connection's own key while sending
every chat request under the global setting.
Claude, OpenAI and OpenRouter resolve credentials inline like Mistral did, so they get the
same explicit/blank pair. Azure and Bedrock instead forward :credentials into a shared
request helper the list-models tests already exercise, so what is worth pinning there is
the forwarding: with no settings saved, the request only goes out if the passed-in
credentials arrive.
* Pin every provider type's default model
The removed settings-put-connect-*-defaults-model tests asserted this per provider; the
connection API replaced them with one test that a first connection gets a model selected,
and provider-types-test only spot-checked anthropic and azure. A typo in mistral's or
zai's :default-model would have shipped silently.
* Alias metabase.llm.test-util as llm.tu, not lct
The letters do not spell anything in the namespace, so at a use site like
lct/default-connections there is no way to tell which namespace it came from. The repo
convention for a module's test utilities is <module>.tu — lib.tu, notification.tu.
Add it to kondo's :consistent-alias map too; it was absent, which is why the alias could
be picked freely in the first place.
* Make the whole provider header toggle the usage details
Only the chevron was clickable, so the icon and name next to it did nothing — unlike
Accordion.Control, which is what makes the entire header hit the toggle everywhere else.
Promote the header itself to the control and leave the chevron as the affordance, with
the options menu as a sibling rather than nested inside it. SetByEnvVar moves below the
header for the same reason: it carries a docs link, and a link inside a button is invalid.
Not Mantine's Accordion, because expandability is per-row here — only the managed provider
has a panel, and wrapping the rest would give them a control that opens nothing.
* Let the provider modal play its closing animation
The modal hardcoded opened and the parent mounted it conditionally, so closing unmounted
it outright and Mantine never got to run the exit transition. Opening animated because
mounting still runs the enter one.
Drive it with opened instead and keep it mounted, the way ConfirmModal a few lines below
already does. Clearing the edited connection moves to onExitTransitionEnd so the title
does not flip to "Add a provider" on the way out.
* Fix the edit modal never closing
Openness was derived from isAdding || editing != null, but closing only cleared isAdding
and left editing to be cleared after the exit transition — which could not start, because
editing still held the modal open. Editing a provider trapped it.
Track openness in its own disclosure so closing is one flag, and let editing carry only
which connection to show, cleared after the transition. Adding worked before, which is why
the existing test missed this; cover closing from edit too.
* Revert the provider modal close-animation work
Reverts 7e78358857d and 1a7c37d6412. The missing close animation is not this component's:
the Modal theme attaches enter-only CSS keyframes (popInFromBottom, fadeIn) to every modal
in the app and animation.module.css defines no exit counterpart, so driving this one with
opened bought nothing but state to keep in sync — and the first attempt at it trapped the
edit modal open.
Back to mounting it conditionally. Keeps the fetch-mock teardown from the reverted commit,
which is unrelated hygiene: without it this spec's routes leak into
MetabotAgentDataSourcePills and fail it in a combined run.
* Serve a request from its own connection's credentials, nothing else
Every adapter fell back to the single-provider `llm-*-api-key` setting when
the credentials it was handed came up blank. That made sense while the two
systems coexisted, but the env vars are themselves connections now, so the
fallback could only ever fire by lending one connection another's key — a
connection whose key an admin cleared would silently keep working on someone
else's, while `connection-usable?` reported it as broken.
Resolution now fills each field's registry default into the config, so
`:credentials` carries everything the adapter needs, including the base URL
it used to read from the settings. The adapters take credentials and nothing
else, and throw when they are missing.
Tests drive the adapters the way production does — by passing credentials —
rather than by setting a global and calling with none.
* Finish removing the settings fallback: Mistral, Z.AI, and the docs
Mistral and Z.AI landed alongside the previous commit and were copied from
the old shape, so both still reached for their `llm-*-api-key` setting when
the credentials came up blank. A half-applied invariant is worse than either
state — the next adapter gets copied from whichever one you happen to open.
The docstrings across all seven still advertised the fallback I had already
deleted, including the canonical `LLMRequestOpts` contract. Code that lies
about itself in the one place people go to read the rule is how the rule
comes back.
* Drop the editorializing line from the credentials doc
The entry should say what the field is, not argue for it.
* Reject runtime writes to the per-provider credential settings
These configure a connection only when set by an environment variable, which
llm.provider/connections resolves on every read. Writing one to the app DB landed somewhere
nothing reads: the settings API returned 200 and the request kept using whatever the
connection held. The e2e specs are the proof — one pointed llm-anthropic-api-base-url at a
mock server and would have talked to the real Anthropic instead.
Mark all sixteen :setter :none, so a write raises "you cannot set X; it is a read-only
setting" instead. Tests keep working because with-temporary-setting-values passes
:bypass-read-only?; connections are managed through /api/llm/providers.
Give each a :doc, since env-var-dox drops a read-only setting from the generated docs unless
it has one, and format-config-name then labels them environment-variable-only.
The custom setters and normalize-llm-base-url are unreachable now, so they go; base URLs on
connections were never normalized, only the setting path was.
* Disable the managed-AI Connect button instead of hiding it
An admin who had not ticked the Terms box saw no Connect button at all, so
the modal gave no sign that connecting was possible or what unlocked it.
Show it disabled: the checkbox is now visibly the thing standing in the way.
The gate moves out of `connectAction`, which keeps meaning "there is nothing
to do here" — already connected, or not an admin — and stays null in those
cases so the button remains hidden.
* Let a connection name the model it serves, so Azure is selectable
Azure's listing endpoint returns the regional catalog rather than the customer's
deployments, so list-models always came back empty and the picker had nothing to offer: you
could connect Azure and never pick a model. Master collected the deployment alongside the
credentials; the connection API dropped that and nothing replaced it.
Give the registry a :model-field naming the config key holding the model, and Azure a
required Deployment field. /api/llm/models still makes the call — it is what verifies the
credentials — but reports the configured deployment instead of the empty list. The connect
form needs no frontend change: it is descriptor-driven, so the field renders itself.
Two things this pulled in. config-complete? now demands the deployment, which is right for
"can this connection serve a request" but wrong for the adapter's auth check, so that
gets credentials-complete?, which ignores the model field. And the environment path needs
to express the deployment too, or an env-configured Azure connection reports unusable, so
llm-azure-model joins the single-provider settings.
Also give the unsupported-model error a 400: the deployment is typed by hand, so a bad
prefix is a bad request, not an outage.
* Import the provider logos instead of pointing at asset paths
The filename map plus a path-building helper came from ENGINE_LOGO, but that is the older
pattern: 57 places import an SVG as a module, and DatabaseHostnameWithProviderField's
ProviderIcon does exactly this — vendor logos colocated in the component directory, imported
and mapped by provider name.
So colocate them and import them. The bundler resolves the reference, which means a renamed
or missing file fails the build rather than 404ing at runtime, and the path string stops
being something to keep in sync by hand. The map moves next to the imports, which drops
metabot/utils/llm-provider.ts and the constants entry entirely.
The per-file src assertions go: jest maps every SVG to one stub, so what is left to check
is which of the three marks renders — the Metabase logo, a vendor image, or the fallback.
* Judge the onboarding AI step on a usable connection, not the first listed
connections[0] answered three questions it could not answer well. A listed connection is
not necessarily one that can serve a request — one configured by env var with a field
missing reports usable false — so onboarding would call itself connected, hide the skip
button, and report that provider to analytics.
Ask for the first usable connection instead. The skip button also waited on the list being
empty, which is equally true while the request is in flight, so it flashed on every load;
gate it on isLoading as well.
The completed-step title inferred skipping from a missing label, but the label comes from
the provider-types request, so a connected provider with that request still in flight read
as skipped. Pass whether a provider connected separately from what it is called.
* Match the loading skeleton to the list it stands in for, and colocate it
It still described the card-per-connection layout: two 4rem blocks with rounded corners and
a gap between them. The list has been divider-separated rows for a while now, so the
skeleton was a size and a shape that no longer appear anywhere.
Rebuild it from the row it mimics — PROVIDER_ICON_SIZE plus the row's py="sm" — with a
divider between and the button that follows, and move it into AIProviderList.tsx beside
those constants, so the next layout change has it in view rather than a file away.
Cover it too: nothing asserted the skeleton rendered at all, which is how it drifted.
* Keep the Metabase mark on the managed provider when whitelabelled
LogoIcon resolves through PLUGIN_LOGO_ICON_COMPONENTS, which the whitelabel plugin fills
with the customer's logo. That is right nearly everywhere, and wrong here: this row is
Metabase's own hosted AI service, so a whitelabelled instance was labelling it "Metabase"
— the registry string is not rebranded — beside someone else's logo.
Use DefaultLogoIcon, as WelcomePage and SdkUsageProblemBanner already do where the mark has
to be Metabase's regardless of branding.
* Stop standing in for an unknown provider with a real one
Gemini is a provider we could plausibly add, and the day we do, every test asserting it is
unknown starts asserting the opposite of what it means. Use a name nobody will ship.
Same for the Azure family placeholder, which picked gemini for a family Azure does not
serve. The genuine google/gemini-2.5-flash model references stay.
* Match on the secondary button instead of nesting a && inside a ternary
isEditing ? (onCancel && <Cancel/>) : <Back/> reads as two branches but is three, and the
one that renders nothing is the one you cannot see. The rest of this component already
picks its shape with match, so this now does too — and onCancel is narrowed by the pattern
rather than guarded separately.
* Pick the Azure model provider from a dropdown again
The rewrite collapsed master's Model provider select and Deployment name
input into one text field where the admin types `openai/` as a prefix. Split
them back apart and compose the model from the two, so the wire family is
picked rather than spelled.
`:model-field` becomes `:model-fields`, a vector composed with `/`, which is
the shape the Azure adapter already parses back out. A required field the
registry gives a `:default` now counts as filled — the form shows it
pre-selected, and resolution supplies it — so the family select does not
block Connect on a connection that simply accepted it.
* Drop two explanatory comments
Both restated what the line below already says.
* Shrink the loading skeleton to the row's content height
The skeleton painted the full 3rem row, padding included, so it read as two
heavy slabs butted against the divider. Wrap it in the row's own py="sm" and
size the bar to the provider icon instead: the row still occupies the same
height, so nothing shifts when the list arrives.
* Name the provider on the closed model picker
A model name alone does not say who serves it, so the picker gave no way to tell which
provider was selected without opening it. With several connections — two Anthropic keys,
say — that is the part you most need to see.
The option label carries the provider, since that is what a closed Select renders. The
dropdown keeps rendering the model alone through renderOption, because the group heading
above it already names the provider.
* Only treat a usable connection as one worth moving on from
Setup moved past the provider picker as soon as any connection was listed,
while the step around it counted only usable ones. A connection that exists
but cannot serve a request landed between them: the picker was replaced by a
model dropdown with nothing in it, the step still offered to skip, and there
was no way to connect anything.
That is reachable, not theoretical — the single-provider env vars synthesize
a connection as soon as one credential field is set, so MB_LLM_AZURE_API_KEY
on its own produces one Azure needs three fields to use.
* Serve the disconnect test's stream from the connection under test
The fake OpenRouter server was reached through the per-provider base-URL
setting, which no longer feeds a request now that credentials come from the
connection: the stream went to openrouter.ai and the turn finalized as an
error rather than an abort.
Disabling clj-http decompression on the LLM request also had to move from
http/post to the fn the adapters actually call, so the aborted stream no
longer races a ZLIB EOF against the cancel.
* Stop shipping an icon name the frontend never reads
The frontend keys its logos on the provider type and fails to compile for a
type it has no logo for, so the registry icon was only ever reachable for a
type the server knows and the client does not — which cannot happen when the
two ship as one artifact. Seven of the eight entries said "ai" anyway, and
the eighth said "metabot" for the managed provider, which is drawn from the
Metabase logo component before the icon is consulted.
Drop it from the registry, the response, and the type, and let
ProviderTypeIcon own the generic fallback it was already defaulting to.
* Point the setup wizard's AI e2e tests at the connection API
All three drove the single-provider flow this branch replaced: a Provider Select that is
now a grid of buttons, and PUT /api/metabot/settings, which no longer exists. The Anthropic
one also aimed the mock server via llm-anthropic-api-base-url, which stopped being writable
when those settings became read-only — it now types the base URL into the connection's own
advanced settings, which is where a connection reads it from.
The managed test additionally stubs GET /api/llm/provider-types and /api/llm/providers: the
managed type is only connectable once the LLM proxy is configured, and that has no backing
in e2e, so it is patched the same way the token features already are.
* Match the required API key label the way the unit specs do
Mantine appends a required marker to a required field's label, so the setup
spec's exact-string lookup found nothing once the API key field came from the
provider registry with :required? true. The unit specs already match on a
regex; the e2e spec was the last one on the bare string.
Also name do-with-entitlements! for the thread-unsafe settings it binds, which
Kondo flags.
* Edit the stored connection list rather than the environment-resolved one
The environment winning on read is right, but writes rebuilt the list from
connections, where an env-configured connection replaces the stored one with
the same key. Saving anything else then dropped the shadowed credentials from
llm-providers for good, so taking the env var back off did not restore them.
Editing the stored list also lets PUT report a shadowed key as env-configured
instead of 404ing, and stops the startup managed-connection write from landing
in the app DB when the whole list comes from MB_LLM_PROVIDERS -- which would
have pointed Metabot at a connection that never resolves.
* Configure an Azure deployment from the environment the way the form does
MB_LLM_AZURE_MODEL landed under a :model config key that nothing reads --
azure declares :model-fields [:model-family :deployment-name] -- so an
environment-configured Azure connection composed no model and counted as
incomplete, which hid the Metabot UI instance-wide behind
llm-metabot-configured?. Replace it with one variable per config key.
The same completeness rule dropped Azure from the startup adoption entirely,
because the credentials that existed before this branch never named a
deployment: they carried it in llm-metabot-provider. An upgraded instance was
left pointing at a connection that did not exist, with Metabot failing on every
request. A deployment names what to call rather than what authenticates the
call, so stop requiring it, and recover the pair from the model reference
during adoption so the connection can still offer it in the picker.
* Add a provider alongside the managed one instead of cancelling it
Running out of included tokens is a reason to bring your own key, not to end the
subscription. "Use a different AI provider" was wired straight to the add-on
cancellation: it charged ahead with no confirmation, offered no provider to
switch to, and left the metabase connection and llm-metabot-provider pointing at
each other afterwards, so nothing on screen changed.
Let the button open its own modal again. AIProviderSetup short-circuits to the
model picker once any connection exists, which is exactly wrong here, so let a
caller start on the connection form; the managed connection is left alone and
the model picker follows the save. Removing the connection outright is already
the list's Remove action, which cancels the add-on server-side.
* Let the single-colour provider marks follow the theme
The marks were loaded through <img>, and an SVG in an <img> is its own document:
currentColor resolves against that document's initial color, black, with no way
to reach the color the tile sets. Anthropic, OpenAI and OpenRouter therefore
painted black on a near-black tile in dark mode, and Bedrock's navy "aws"
wordmark left only the orange smile visible.
Inline those four instead, so currentColor picks up the tile's text colour --
dark on light, light on dark, which is how each brand publishes its monochrome
mark. Mistral, Z.AI and Azure keep their own colours and stay images. Bedrock's
wordmark becomes currentColor, reproducing AWS's own light and dark variants.
OpenRouter pointed at a clipPath the file never defined; harmless while it was
sandboxed in an <img>, but an id to collide with once inlined.
* Optimize the provider logos and normalize them to one size
The files came from seven different sources and showed it: viewBoxes ranged from
24x24 to 512x512, some carried fixed width/height, and each baked in its own
arbitrary padding. Rendered into the same 20px tile the marks came out anywhere
from 12px to 20px tall, so the row read as a jumble.
Run everything through SVGO (viewBox kept, dimensions and editor cruft dropped),
then re-cut each viewBox to a square centred on the mark's real ink extent. Every
mark now fills the tile's width, with padding controlled by the tile alone rather
than by whoever exported the file. Z.AI is inset 12%: its mark is itself a filled
rounded square, so at an equal bounding box it reads far heavier than the open
marks.
* Say when the provider lists fail to load
Both components defaulted the two queries to empty arrays and never read
their error, so a failed request was indistinguishable from having nothing
configured. The provider-types failure was the worse of the two: it rendered
an empty picker grid — no providers to choose, no explanation, no way on.
The connections failure is quieter but wronger, since an admin with several
providers would be told to connect their first one.
Report the error where the list or the picker would have gone, using the
same getErrorMessage the rest of this feature already uses.
* Tighten the provider section's spacing and hang the icon off the name
Sloan flagged three gaps as feeling high even where they match other sections: drop the
section's header gap from md to sm, and the gap out to the model picker from lg to md. The
row-to-button gap was already the tightest of the three and stays.
The icon was centred against the whole text block, so it sat slightly off the name and
drifted further as an error or env-var line appeared beneath it. Give the name the icon's
height and centre it there, so the two line up on one row however many follow.
* Collapse the add button's box, which was most of the spacing
Trimming gaps barely moved this because the space around "Add another provider" was not a
gap. Metabase's Button defaults to size md, so it is 42px tall; p={0} removes the padding
but not the height, leaving its ~20px label centred with about 11px of dead space above and
below it. That is the two gaps flagged around the button, and no gap value could reach them.
Let it take its label's height once it is an inline link. Rows go from py="sm" to "xs" as
well, since a 32px icon does not need 8px above and below it.
* Give the add button its height back
Collapsing it to its label bought vertical space but cost the button its shape. Keep the
tighter rows from the same change.
* Draw every provider logo on one 24x24 grid
Making each viewBox square left seven different squares -- 24, 88, 300.23, 256,
259.53, 511, 30.72 -- so the files only matched once a consumer sized them. Bake
each mark's scale and centring into its path data instead, so every file is
literally width=24 height=24 viewBox="0 0 24 24" with the mark centred on that
grid and its longest side spanning it.
Z.AI keeps its 12% inset, for the same reason as before: its mark is a filled
rounded square and reads heavier than the open marks at an equal box.
* Sit the row actions on the icon band and cut the gap to the add button
* Fit a provider's name and type inside the icon's height
* Give each provider row a little more room
* Inline the remaining provider logos instead of loading them by URL
Mistral, Z.AI and Azure rendered as broken images. Their files are valid; the
copies the bundler emitted were not. BannerPlugin is configured with no test, so
it prepends the licence header to every emitted asset -- and re-applies it on
each watch rebuild, so the served .svg started with six stacked comment blocks
before its <svg>, which no browser will parse as an image. Editing these files
repeatedly is what re-emitted them and made it visible.
Inline all seven the way the other four already were: SVGR turns them into
modules, so nothing reaches the asset pipeline and one import style covers the
set. Azure's gradients were on ids a/b/c, which are too generic to share a
document with, so they are namespaced now.
The banner covers every other emitted asset too, so any .svg re-emitted during a
dev session hits this; that is worth fixing in the build config separately.
* Call the managed provider Metabase AI service and tighten the add-provider modal
Drops the redundant 'Selected provider' subtitle and 'About Metabase AI service'
heading, puts the free-token and pricing lines in their own callout, and swaps the
row's ellipsis button for a neutral ActionIcon.
* Pin the base URL the credential-adoption tests read
* Mock the provider endpoints the onboarding AI modal calls
* Give the explorations e2e helper a provider connection instead of read-only settings
* Make Moonshot AI a provider type you connect to
* Regenerate the settings docs
* Answer the review on the model listing endpoint
- Drop the per-provider :group heuristics. The picker groups by connection
name and never reads the field, so it was computed on every admin page load
for nothing.
- Key the model cache on a hash of the connection's config rather than on the
config itself, so a credential is not held in a process-wide cache, and a
rotated one gets its own entry instead of the old list.
- Take the model a connection composes from its own config into account when
Metabot's selection has to move: Azure has no default model, so it was never
a fallback, and editing its deployment left the selection naming one that no
longer exists.
* Migrate the per-provider credential settings instead of adopting them at startup
The startup adoption left the settings it copied from behind, frozen: nothing
writes to them any more and they are read-only, so an instance rolled back to
code that still reads them would run on whatever was last stored there rather
than on what is configured. A reversible migration fixes both ends — it clears
the settings on the way up, and writes the connection list back into them (and
rewrites a model reference older code cannot resolve) on the way down.
Also pulls the copy of `trimmed-string` the two LLM namespaces shared into
`metabase.util`.
* Wait for the model list before opening the picker in its spec
* Make Google Gemini a provider type you connect to
Carries the Gemini Enterprise Agent Platform provider over to the connection
list: a registry entry with the same form every other provider gets, credentials
resolved from the connection rather than from the `llm-google-*` settings (which
become environment-only like the rest), and the connection naming the model it
serves — the platform has no listing endpoint worth trusting, so connecting
validates the model with a free countTokens probe, the way Azure validates its
deployment.
* Cover the Google credentials in the settings migration
* Name the Gemini models Metabot works best with
* Use Moonshot AI's own mark, not Kimi's
The file was Kimi's rounded-square K — Kimi is Moonshot's chatbot, not the
company we connect to. Icon sets ship the two separately and it is an easy
one to pick wrong.
Replaced with the MoonshotAI mark, kept in the same shape as the other
single-colour logos here so it takes the theme's text colour.
* Give Google the form the upstream PR built for it
Three field kinds the registry could not express before, so the connection form
can offer what the settings page did:
- a fixed model list, replacing the free-text field and the paragraph of advice
about which IDs work;
- an authentication method to pick, rendered as a segmented control;
- a file to upload for the service account key, read into the config the way
every other credential is typed into it.
A field can now hang off another one's value (`show_when`), which is what makes
the credential follow the method — and a field the form hides is cleared on
save, so switching methods drops the credential it replaced.
* Let Gemini's models come from the shared picker
A connection only names its own model when nothing else can name it — Azure,
whose deployments its listing endpoint does not return, and which serves one of
them. Gemini's models are a known list, so pinning one per connection put a
second model picker inside the form and hid the other model from the one below
it. The list becomes the type's fixed catalog instead, the way the managed
provider's is, and both models show up per connection in the model picker.
A fixed catalog no longer skips the provider call: that is what verifies the
credentials, so it is made against the catalog's first model and the catalog is
what comes back. The managed provider still answers from the registry alone —
there is nothing to call, and instances that cannot reach the proxy would fail.
* Answer the migration review notes
Rename the frozen table's :credentials to :required-keys — they are the keys a
provider needs to count as configured, and "credentials" made the optional ones
sound like something else.
Stop the downgrade encrypting every value it writes. write-setting! ran
maybe-encrypt unconditionally, so base URLs, regions and the model reference —
settings declared :encryption :no — came out of a rollback holding ciphertext.
Reads decrypt opportunistically so nothing broke, but a setting should hold what
its declaration says it holds. Only the :sensitive? settings are encrypted now,
and a test pins the raw storage both ways.
The Google row is also renamed to Google Gemini Enterprise alongside the
registry, to keep the adopted connection's display name in step.
* Close the review's holes in the provider registry and its API
Redact the Google service account key. secret-field-keys covered only
:password fields, and the key is a :file upload, so GET /api/llm/providers
returned the whole credential — private key included — in plaintext. Fields can
now be marked :secret? regardless of input type, which also makes the masked
echo-back flow cover the key on edit.
Require what Google actually requires. Every field was individually optional
because either credential will do, so an empty config counted as complete —
usable: true on a connection that can serve nothing. Provider types now declare
alternative credential groups (:required-any): a service account key on its own,
or an OAuth token together with the project ID it bills to. Completeness and
validation both honor the groups.
Treat blank credentials as absent. The form clears the field it hides when the
authentication method changes, but the empty string it sent was stored and
passed to the adapter, which fed "" to the service-account parser — the
"no JSON input found" from the review. Blank values are dropped on save, and
the adapter trims what reaches it so an env-supplied blank cannot shadow the
other credential or the project ID a key carries.
Reserve the metabase connection key. Any type could claim it, splitting
managed-model-ref? from managed-type? — a bring-your-own-key connection whose
requests would be tagged as proxied in metabot_message and confuse the managed
free-tier limit. Only the managed type may hold the key now, a squatter blocks
managed connection with a clear message instead of colliding, and the unused
proxied-model-ref? is gone.
Also: per-field :validate hooks in the registry (Google's project ID and
location get the setting-specific validation the old defsettings had), select
values are checked against their options, with-entitlements takes a kwargs map,
and the cond-> in self.clj is realigned.
* Pick the Google model in the connection form
Connecting a Google provider validates the credentials with a countTokens probe
that needs a model, but the form collected none, so the probe ran against a
hardcoded default the chosen location may not serve — a confusing rejection the
admin had no control over, and no guarantee the model they wanted works at all.
Provider types now ship their fixed catalog and alternative credential groups to
the frontend. A type with a catalog gets a model picker in its connection form,
the probe runs against the picked model, and Metabot starts on it. The Connect
button also honors the credential groups, so it no longer offers to connect a
Google provider with nothing filled in.
* Quiet the kondo warnings CI caught
u/lower-case-en over str/lower-case in the reserved-key check, and the
validate-config! additions split into their own deftests instead of growing the
original past the one-concern-per-test lint.
* Report the provider Metabot landed on, not the first usable connection
The setup step resolved its connected provider with find(usable) over the
connection list, which is insertion order. Connect two providers during setup
and pick the second one's model, and the step still reported the first — in its
title and in what submitAiConfig records. Resolve the connection from the
llm-metabot-provider reference instead, the way the pre-connection-list code
compared against the saved value.
* Spell the field-level secret flag :sensitive?
Same word as the defsetting option it mirrors, so a grep for :sensitive?
surfaces the registry's secret fields alongside the settings.
* Give the LLM settings migration its own test file, and a managed round trip
Twice now a reviewer could not find the migration's coverage, because the tests
sat in the shared custom_migrations_test and referenced the migration only by
its changeset id. They live in a file mirroring the migration's path now, with a
docstring naming the change class that connects the two.
The round trip crisptrutski asked about also surfaced what he predicted: the
managed connection is not in the migration's provider table, so every rollback
of a managed-AI instance logged a false 'no equivalent setting: metabase'
warning. It holds no credentials and its model reference survives the rollback,
so it is dropped silently now, and a new test walks the managed round trip.
* Let the environment shadow connections field by field, and settings write through
Answers crisptrutski's review of the environment and settings story.
The MB_LLM_* variables now shadow a stored connection's config per field, the
way an env var shadows any other setting, instead of swapping the whole
connection out. A lone MB_LLM_ANTHROPIC_API_BASE_URL reaches the stored
connection's base URL rather than doing nothing; everything the environment
does not supply stays editable, and env_fields tells the form exactly which
inputs to disable. A standalone env connection still only comes into existence
when a credential variable is set — which is why Azure's base URL is no longer
marked as one: alone it used to synthesize an unusable connection that shadowed
the stored credentials wholesale. A metabase/ reference pinned by
MB_LLM_METABOT_PROVIDER synthesizes the managed connection it names, so that
reference resolves without an app-DB row.
The per-provider settings are a view over the connection list rather than
read-only. :setter :none broke config.yml provisioning outright — the
provisioner calls setting/set! on every entry and nothing catches the throw, so
the instance failed to start — and orphaned generate-sql and semantic search,
which still read llm-anthropic-api-key and llm-openai-api-key directly after
the migration deleted the rows underneath them. Getters resolve the connection
field with the environment and registry defaults layered in; setters write
through to the connection, creating it on first write and keeping the old
setters' field validation. Both docs files are regenerated: the config
template carries the settings again.
Also from the review: deleting the managed connection requires superuser — it
cancels the Store subscription, and everything else that can cancel add-ons is
superuser-only — and the confirmation dialog says so; the automatic
llm-metabot-provider writes (first connection, edit follow-up, delete
fallback) are skipped when the env var pins the selection, restoring what
check-not-env-shadowed! used to guarantee; the edit form starts the model
picker on the model the connection is actually serving and a changed pick
follows through to the selection for fixed-catalog types; and redact masks
every field of a provider type the registry does not recognize, instead of
none of them.
* Reconcile the restack onto bot-1875-gemini-models-fe
Mechanical conflict resolutions during the rebase left a few files at the wrong
side of the divergence; this restores the reviewed merge result: the shared
tool-function conversion in the Claude and OpenAI adapters, the settings
endpoints stripped from the relocated metabot API slice, and
gemini-3.7-flash — new on the Gemini branch — joining the fixed catalog.
* Restore the stop-reason propagation the restack dropped from the adapters
* Strip trailing slashes off base URLs when a connection resolves
* Slug-proof the reserved key, follow the effective config, and seed the models cache
* Count a blank-config connection as dropped on rollback
* Warn that deleting the anthropic or openai connection turns off the feature reading it
* Regenerate the environment variable docs against current master
* Drop the env-var doc section master removed in the backend hardening
* Update src/metabase/llm/settings.clj
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Mark the dev rollback subqueries so the app-DB honeysql guard allows them
* Gate the settings-access delete test on EE, where advanced permissions exist
* Wait for the AI setup buttons to render before clicking them
---------
Co-authored-by: Mike Appleby <86076+appleby@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Add a versioned embedding provider SPI
Summary
- Add a runtime registry for embedding providers with readiness, model resolution, preparation, and batch embedding.
- Define immutable embedding-space descriptors and legacy deterministic identities.
- Validate provider results, dimensions, counts, and finite numeric vector components.
- Make SPI version 1 a compile-time constant; provider artifacts must embed the literal version they were built against.
Scope
This defines the neutral provider contract only. It does not migrate semantic-search consumers or ship a provider plugin.
Testing
- Registration and version compatibility.
- Identity stability and revision round trips.
- Registry snapshot consistency and malformed provider output.
* Rename shadowing local in provider/prepare!
The local bound from :prepare! shadowed the prepare! var (eastwood local-shadows-var).
* Add the Arctic embedder plugin
Summary
- Build a separate runtime plugin containing the pinned Snowflake Arctic embedding model and DJL/ONNX dependencies.
- Bundle arm64 and AVX2 exports, lazy-load native inference, and cap local batches.
- Register literal embedding SPI version 1 and derive immutable space identity from the exact model/runtime bundle.
- Fail readiness closed for unsupported platforms, musl, and glibc older than 2.34.
- Disable DJL network telemetry and revision-address bundle URLs so model upgrades cannot reuse stale cached content.
Scope
This PR ships and registers the provider but does not yet select it from semantic-search settings. Consumer integration follows upstack.
Testing
- Model bundle and plugin uberjar build tests.
- Catalog, classloader, platform-readiness, identity, cache-key, and batching coverage.
- Artifact-only real inference with zero outbound network attempts.
* Follow prune-provided-libs! rename in the embedder plugin build
* Merge nested let in embedder-plugin artifact test
Kondo flagged the inner let as redundant; fold both binding vectors into one.
* Deref the now-delayed core provided-libs in the embedder build
* Bundle MiniLM for in-process complexity scoring
* Activate the embedder plugin in its artifact smoke test
* Activate lazy embedding providers on demand
* Use plugin initializer in artifact smoke test
* Make lazy provider activation best effort
* Log lazy embedding provider activation failures
* Declare the embeddings plugin dependency
* Keep embeddings provider internal
* Activate the in-process embedder during bootstrap
* Fix embedder validation and optional plugin startup
* Declare the embedder plugin as a module
The deps graph only scanned src, enterprise/backend/src, and modules/drivers, so the plugin's
namespaces belonged to no module and anyone linting them got six unresolved-dependency
warnings. Scanning modules/embedder/src too lets the generator record what the plugin actually
takes from the host.
* Move embedder model caching into the weekly cache generator
Review feedback on #78310:
- Drop the redundant persist-credentials: false checkout options.
- Stop writing the model cache from the CI job. The weekly cache-generator
workflow is the single cache writer; the build job now restores only.
The downloader is content-addressed (SHA-256 verified, refetches deltas),
so a stale weekly cache still protects against HuggingFace rate limits
without affecting correctness.
* Bundle snowflake-arctic-embed-xs instead of Arctic-L
The bundled models are a test tier: they exercise the packaging, DJL
runtime, tokenizer, and pooling machinery without shipping the 1.1 GB
Arctic-L artifacts. xs keeps the same family, query-prefix scheme, and
cls pooling as Arctic-L at 23 MB per architecture. A release-tier build
with the full model comes later, when the plugin is published for
customers or offline use.
At ~90 MB of pinned downloads there is nothing worth caching, so the
cache-generator wiring is reverted and the CI job downloads directly.
* Fail loudly when a catalog entry lacks this architecture's export
resolved-model computed the embedding-space-id from
get-in spec [:architectures arch :sha256] without checking it was
present. A model missing this architecture's export would silently
bake a nil into the identity hash instead of failing, producing a
syntactically valid but bogus embedding-space-id.
* Pin the embedder plugin manifest name to one constant
"Metabase In-Process Embedder" was duplicated in core.clj (the
registered?/load-plugin! lookup key) and the artifact test. A rename
in modules/embedder/resources/metabase/embedder/metabase-plugin.yaml
without updating both call sites would silently disable the plugin,
since registered? just returns false for a name it doesn't recognize.
* Downgrade the hashed-table-name log line to debug
Hashing fires deterministically for every identifier derived from a
long provider/model pairing (e.g. "in-process" + the bundled xs
model), so it was logging a warning on every lookup rather than once
at creation.
* Cover the missing-architecture catalog guard
The error path added alongside the guard had no test, so the earlier
silent nil-hashing behaviour could come back unnoticed.
* Assert the reason on the missing-architecture guard
The message alone leaves `:reason :architecture-not-bundled` untested,
which is the part callers actually branch on.
* Drop the hashed-table-name log line
It fires from a pure name-derivation helper on every lookup. The table
creation it hints at is already logged at info by the caller, and the
parameters behind the hash are in index_metadata.
* Extract the missing-architecture check out of the smoke test
The guard's assertions pushed the smoke test to 155 lines, past the
150-line deftest limit, so Clj-Kondo failed. Pull it into a helper the
way the platform-support checks already are.
* Update README with new image URL and bug reporting info
Updated the image URL for the Metabase product screenshot and added a bug reporting guide.
* put metabase-product-screenshot-updated.png to the docs
* fix link in the readme
---------
Co-authored-by: Timur Mashara <timur@metabase.com>
* Use Breadcrumb component in Detail View (GDGT-2596)
Detail View had its own Breadcrumb, duplicating the shared one introduced
in #75114. Point TableNav, ModelNav and CollectionBreadcrumb at
metabase/common/components/Breadcrumb and delete the local copy.
The shared component hardcoded showTooltip={false}, which would have lost
the full-text tooltip Detail View shows on truncated crumbs, so make it a
prop. It defaults to false, leaving every other consumer unchanged.
Crumbs now take the shared component's sizing (0.875em, bold) instead of
inheriting 14px regular; the separator is sized to match so the row stays
consistent. Colour and icons are unchanged.
* Use URL helpers for the Detail View breadcrumb links
TableNav built the database and schema URLs by hand. Extract databaseSlug
from browseDatabase so browseSchemaBySlug can reuse it, then build both
links from the helpers. Both now carry the database slug rather than only
the id.
* Style official collections in the Detail View breadcrumbs
Collection crumbs hardcoded icon="folder", so an official collection
rendered like a regular one. Use CollectionBadge, which resolves the icon
from the collection's authority level, the same way the model page does.
Drop the text-secondary override too: it made Detail View darker than
every other breadcrumb in the app bar.
* Align Detail View breadcrumbs with the rest of the app bar
The detail view nav carried a -13px left margin, putting its first crumb
13px closer to the logo than the collection breadcrumbs shown on every
other page. Drop it so both start at the same offset.
* Match the Detail View breadcrumb separator to the app bar
Its color, font size and line height all differed from the separator the
collection breadcrumbs render. Use the same values, and Box rather than
Text so it inherits the line height instead of setting its own.
* Update the database breadcrumb href expectation
The database crumb now goes through Urls.browseDatabase, which appends
the slug, so the link is /browse/databases/1-sample-database rather than
/browse/databases/1.
2026-08-18 15:39:42 +02:00
1901 changed files with 65493 additions and 26409 deletions
description: Deciding where frontend code lives — tier model, move mechanics, extension points, side effects, naming, and the traps. Use when moving code between modules, carving new modules, fixing boundary violations, adding a barrel or an endpoint, or reviewing module-shape decisions.
---
# Frontend modularization
The module is the unit of blast radius: dependency direction, test selection,
bundle cones, and review scope all follow module boundaries. Every decision
here serves one goal: **make the file locations tell the truth about who owns
what and who may depend on whom.**
## Hard rules (check these before anything else)
- NEVER add an `export … from` re-export at an old path, and NEVER `export *`
from a barrel. Move the code, codemod every call site, delete the old path,
all in the same PR.
- NEVER import past a module's `index.ts` when that module has
`enforcePublicApi: true`, and never past `metabase/ui`'s index at all. If
the barrel is heavy, the fix is making the module side-effect free (below),
not a deep import.
- NEVER rewrite a consumer to a lower-level idiom to make the linter pass. If
the fix makes the consumer worse, the plan is wrong.
- NEVER give a new module `enforceSharedTiers: false`; new modules ship
enforced with `enforcePublicApi: true` and an explicit `index.ts`.
- NEVER move code without the consumer-tier check (procedure step 2).
- ALWAYS delete a module's `enforceSharedTiers: false` line in the PR that
takes it to zero violations.
- ALWAYS measure `bun run module-boundaries` before and after, and report the
numbers.
- ALWAYS import `dayjs` from `metabase/dayjs`, routing from `metabase/router`,
Mantine from `metabase/ui`, react-redux hooks from `metabase/redux`; the
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
## The model
Tiers: `lib < basic < shared < feature < app`. Imports point downward, never
sideways at feature tier (features may not import features; app and EE may
import anything). The shared tier is subdivided in `shared-tiers.mjs` into
shared-utils (U0…), shared-platform (P0…), and shared-domain, each ordered
into levels. A module imports only *strictly lower* levels of its sub-tier
plus the sub-tiers below; same-level peers are deliberately forbidden, which
is what makes cycles structurally impossible. A domain may hold two seats
when consumers demand it: a low core and a high surface (metabase-lib below
@@ -75,10 +75,6 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **No comments by default**. Well-named identifiers carry the `what`.
- **Comments should be concise**. Add a short, concise comment only when the `why` is non-obvious: a workaround, a hidden invariant, a subtle ordering constraint, a clever reduction. Never document the actual implementation, focus on the intent and the why.
## TypeScript Migration
**When touching existing JavaScript files, propose to convert them to TypeScript first**. Create a separate PR for the conversion, then implement the changes.
## Verify before done
- **Run the project type-check** when finished (see the shared TypeScript commands above).
(are[task-namespace](=?[{:message"Module task should not be used in the api module. [:metabase/modules api :uses]"
:type:metabase/modules}]
@@ -43,6 +56,16 @@
'{:metabase/modules{api{:uses#{search}}
search{:api#{metabase.search.core}}}}))))
(deftest^:parallelmodule-checker-empty-api-test
(is(=?[{:message"Namespace metabase.embeddings.core is not an allowed external API namespace for the embeddings module. [:metabase/modules embeddings :api]"
description:Run the full suite regardless of the diff (master / release branches).
type:boolean
default:false
# Run one app-DB job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- services, matrix legs, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
app-db:
description:'Which app-DB job to run. Only this one runs; the others are skipped.'
type:choice
required:true
options:
- mariadb
- mysql
- postgres
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.db.custom-migrations-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
description:Run the full suite regardless of the diff (master / release branches).
type:boolean
default:false
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
# whatever the normal CI run uses, because they are the same jobs.
workflow_dispatch:
inputs:
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank runs each leg''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
description:Run the full suite regardless of the diff (master / release branches, ci:run-all label).
type:boolean
default:false
# Run one driver job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- credentials, services, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
driver:
description:'Which driver job to run. Only this one runs; the usual decision rules are bypassed.'
type:choice
required:true
options:
- athena
- bigquery
- clickhouse
- databricks
- druid-jdbc
- h2
- mongo
- mongo-sharded-cluster
- mongo-ssl
- mysql-mariadb
- oracle
- postgres
- presto-jdbc
- redshift
- snowflake
- sparksql
- sqlite
- sqlserver
- vertica
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.driver.bigquery-cloud-sdk-test/sync-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
"text": `Commit <https://github.com/metabase/metabase/commit/${BREAKING_COMMIT}|${BREAKING_COMMIT.slice(0,7)}> by ${AUTHOR} (${mentionUserByGithubLogin(AUTHOR_LOGIN)}) has failing <${FAILED_RUN_URL}|${FAILED_RUN_NAME}> tests on <https://github.com/${owner}/${repo}/commits/${BRANCH}|\`${BRANCH}\`>`
# Run one semantic-search job on demand, against the ref you dispatch on, optionally under a
# profiler. Everything else about the job -- services, matrix legs, test args, quarantine gating --
# is whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
suite:
description:'Which semantic-search job to run. Only this one runs; the others are skipped.'
type:choice
required:true
options:
- h2
- mariadb
- mysql
- postgres
- appdb-mode
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase-enterprise.semantic-search.index-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
@@ -35,7 +35,7 @@ For detailed guidance on writing and reviewing code and documentation, see the s
- **[analytics-events](.claude/skills/analytics-events/SKILL.md)** - Add product analytics events to track user interactions
**Important**: When working with frontend code, read [frontend/CLAUDE.md](frontend/CLAUDE.md) for project-specific guidelines on component preferences, styling, TypeScript migration, testing requirements, and available scripts.
**Important**: When working with frontend code, read [frontend/CLAUDE.md](frontend/CLAUDE.md) for project-specific guidelines on component preferences, styling, testing requirements, and available scripts.
@@ -46,7 +46,8 @@ The [Metabase handbook](https://www.metabase.com/docs/latest/).
## Contributing
To contribute to Metabase, see our [Developer docs](./docs/developers-guide/start.md).
-To contribute to Metabase, see our [Developer docs](./docs/developers-guide/start.md).
- Found an issue? Read our [Reporting a bug guide](https://www.metabase.com/docs/latest/troubleshooting-guide/bugs)
## Extending Metabase
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.