Compare commits
35 Commits
python-sec
...
test-oidc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f72e1070 | ||
|
|
20b03e72ed | ||
|
|
33a560203f | ||
|
|
d2008d9468 | ||
|
|
081886e36e | ||
|
|
3379bb98fa | ||
|
|
3c7720cdb9 | ||
|
|
9a46737f72 | ||
|
|
4ce7bd5ed6 | ||
|
|
1287d0aec3 | ||
|
|
e7dd964675 | ||
|
|
61a2d35aa6 | ||
|
|
dbe5a90c20 | ||
|
|
0e1d9f6854 | ||
|
|
65634a9197 | ||
|
|
f2c3234298 | ||
|
|
f6b1a2c80e | ||
|
|
70ebef6bd7 | ||
|
|
6df7eb1939 | ||
|
|
42fce08268 | ||
|
|
21abe0d10f | ||
|
|
dee17b76ef | ||
|
|
a491176b68 | ||
|
|
36c92cc1a2 | ||
|
|
1ba4a32d0b | ||
|
|
51b1b45526 | ||
|
|
d68e2a286d | ||
|
|
b964fce112 | ||
|
|
8b601bd84f | ||
|
|
2e27352337 | ||
|
|
fcdcfb2fff | ||
|
|
c9042c3491 | ||
|
|
74d6646379 | ||
|
|
0a28fe6605 | ||
|
|
66611ad720 |
@@ -178,6 +178,7 @@
|
||||
metabase.channel.core/send!
|
||||
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
|
||||
metabase.notification.models/create-notification!
|
||||
metabase.oauth-server.core/reset-provider!
|
||||
metabase.pulse.send/send-pulse!
|
||||
metabase-enterprise.transforms-python.python-runner-test/execute!
|
||||
;; Workspace isolation functions are safe in parallel tests because they operate on
|
||||
@@ -552,6 +553,7 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
|
||||
@@ -129,6 +129,18 @@
|
||||
models
|
||||
util}}
|
||||
|
||||
agent-api
|
||||
{:team "Metabot"
|
||||
:api #{metabase.agent-api.api}
|
||||
:friends #{enterprise/agent-api}
|
||||
:uses #{api
|
||||
auth-identity
|
||||
metabot
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
util}}
|
||||
|
||||
analytics
|
||||
{:team "UX West"
|
||||
:api #{metabase.analytics.api
|
||||
@@ -175,6 +187,7 @@
|
||||
metabase.api.docs
|
||||
metabase.api.init
|
||||
metabase.api.macros
|
||||
metabase.api.macros.defendpoint.tools-manifest
|
||||
metabase.api.macros.scope
|
||||
metabase.api.open-api
|
||||
metabase.api.response
|
||||
@@ -310,7 +323,6 @@
|
||||
channel
|
||||
{:team "Gadget"
|
||||
:api #{metabase.channel.api
|
||||
metabase.channel.api.slack
|
||||
metabase.channel.core
|
||||
metabase.channel.email
|
||||
metabase.channel.email.messages
|
||||
@@ -340,6 +352,7 @@
|
||||
premium-features
|
||||
query-processor
|
||||
settings
|
||||
slackbot
|
||||
system
|
||||
task
|
||||
timeline
|
||||
@@ -751,7 +764,8 @@
|
||||
|
||||
internal-stats
|
||||
{:team "UX West"
|
||||
:api #{metabase.internal-stats.core}
|
||||
:api #{metabase.internal-stats.core
|
||||
metabase.internal-stats.metabot}
|
||||
:uses #{app-db
|
||||
models}}
|
||||
|
||||
@@ -830,13 +844,17 @@
|
||||
llm
|
||||
{:team "Metabot"
|
||||
:api #{metabase.llm.api
|
||||
metabase.llm.init}
|
||||
metabase.llm.init
|
||||
metabase.llm.settings}
|
||||
:uses #{analytics
|
||||
analyze
|
||||
api
|
||||
driver
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
parameters
|
||||
query-processor
|
||||
request
|
||||
settings
|
||||
sql-tools
|
||||
@@ -868,6 +886,18 @@
|
||||
settings
|
||||
util}}
|
||||
|
||||
mcp
|
||||
{:team "Metabot"
|
||||
:api #{metabase.mcp.api}
|
||||
:uses #{api
|
||||
config
|
||||
oauth-server
|
||||
premium-features
|
||||
request
|
||||
server
|
||||
system
|
||||
util}}
|
||||
|
||||
measures
|
||||
{:team "Querying"
|
||||
:api #{metabase.measures.api}
|
||||
@@ -882,6 +912,52 @@
|
||||
search
|
||||
util}}
|
||||
|
||||
metabot
|
||||
{:team "Metabot"
|
||||
:api #{metabase.metabot.api
|
||||
metabase.metabot.core
|
||||
metabase.metabot.init}
|
||||
:friends #{agent-api
|
||||
enterprise/metabot
|
||||
slackbot}
|
||||
:uses #{activity-feed
|
||||
analytics
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
channel
|
||||
collections
|
||||
config
|
||||
documents
|
||||
driver
|
||||
internal-stats
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
llm
|
||||
models
|
||||
native-query-snippets
|
||||
notification
|
||||
parameters
|
||||
permissions
|
||||
premium-features
|
||||
pulse
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
server
|
||||
settings
|
||||
slackbot
|
||||
sql-tools
|
||||
store-api
|
||||
sync
|
||||
system
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
util
|
||||
warehouses}}
|
||||
|
||||
metrics
|
||||
{:team "UX West"
|
||||
:api #{metabase.metrics.api
|
||||
@@ -939,6 +1015,7 @@
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
oauth-server
|
||||
permissions
|
||||
query-processor
|
||||
remote-sync
|
||||
@@ -963,6 +1040,19 @@
|
||||
remote-sync
|
||||
util}}
|
||||
|
||||
oauth-server
|
||||
{:team "UX West"
|
||||
:api #{metabase.oauth-server.api
|
||||
metabase.oauth-server.core
|
||||
metabase.oauth-server.init}
|
||||
:uses #{api
|
||||
appearance
|
||||
models
|
||||
premium-features
|
||||
settings
|
||||
system
|
||||
util}}
|
||||
|
||||
notification
|
||||
{;; TODO: re-enable when :model/NotificationDashboard is no longer a problem
|
||||
:model-imports :skip-test
|
||||
@@ -1419,11 +1509,13 @@
|
||||
embedding
|
||||
initialization-status
|
||||
lib-be
|
||||
oauth-server
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
session
|
||||
settings
|
||||
sso
|
||||
system
|
||||
tracing
|
||||
users
|
||||
@@ -1494,6 +1586,29 @@
|
||||
system
|
||||
util}}
|
||||
|
||||
slackbot
|
||||
{:team "Metabot"
|
||||
:api #{metabase.slackbot.api
|
||||
metabase.slackbot.config
|
||||
metabase.slackbot.init}
|
||||
:friends #{metabot}
|
||||
:uses #{analytics
|
||||
api
|
||||
channel
|
||||
config
|
||||
formatter
|
||||
lib
|
||||
metabot
|
||||
permissions
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
settings
|
||||
sso
|
||||
system
|
||||
upload
|
||||
util}}
|
||||
|
||||
source-swap
|
||||
{:team "Graphy"
|
||||
:api #{metabase.source-swap.core
|
||||
@@ -1522,17 +1637,21 @@
|
||||
|
||||
sso
|
||||
{:team "UX West"
|
||||
:api #{metabase.sso.api
|
||||
metabase.sso.core
|
||||
metabase.sso.init}
|
||||
:uses #{api
|
||||
auth-identity
|
||||
config
|
||||
permissions
|
||||
premium-features
|
||||
settings
|
||||
system
|
||||
util}}
|
||||
:api #{metabase.sso.api
|
||||
metabase.sso.api.slack-connect
|
||||
metabase.sso.core
|
||||
metabase.sso.init
|
||||
metabase.sso.settings}
|
||||
:friends #{enterprise/sso}
|
||||
:uses #{api
|
||||
auth-identity
|
||||
config
|
||||
permissions
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
system
|
||||
util}}
|
||||
|
||||
startup
|
||||
{:team "DevEx"
|
||||
@@ -2026,23 +2145,17 @@
|
||||
|
||||
enterprise/agent-api
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.agent-api.api}
|
||||
:api #{metabase-enterprise.agent-api.workspace}
|
||||
:uses #{api
|
||||
auth-identity
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
util
|
||||
enterprise/api
|
||||
enterprise/metabot-v3
|
||||
enterprise/workspaces}}
|
||||
|
||||
enterprise/ai-entity-analysis
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.ai-entity-analysis.api}
|
||||
:uses #{api
|
||||
premium-features
|
||||
enterprise/metabot-v3}}
|
||||
metabot
|
||||
premium-features}}
|
||||
|
||||
enterprise/ai-sql-fixer
|
||||
{:team "Metabot"
|
||||
@@ -2051,23 +2164,24 @@
|
||||
driver
|
||||
lib
|
||||
lib-be
|
||||
metabot
|
||||
query-processor
|
||||
util
|
||||
enterprise/metabot-v3}}
|
||||
util}}
|
||||
|
||||
enterprise/ai-sql-generation
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.ai-sql-generation.api}
|
||||
:uses #{api
|
||||
driver
|
||||
util
|
||||
enterprise/metabot-v3}}
|
||||
metabot
|
||||
util}}
|
||||
|
||||
enterprise/analytics
|
||||
{:team "UX West"
|
||||
:api #{}
|
||||
:uses #{driver
|
||||
premium-features
|
||||
sso
|
||||
enterprise/advanced-config
|
||||
enterprise/scim
|
||||
enterprise/semantic-search
|
||||
@@ -2075,8 +2189,7 @@
|
||||
|
||||
enterprise/api
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.api.core
|
||||
metabase-enterprise.api.routes.common}
|
||||
:api #{metabase-enterprise.api.core}
|
||||
:uses #{api
|
||||
premium-features
|
||||
util}}
|
||||
@@ -2322,67 +2435,38 @@
|
||||
collections
|
||||
premium-features}}
|
||||
|
||||
enterprise/llm
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.llm.api
|
||||
metabase-enterprise.llm.init
|
||||
metabase-enterprise.llm.settings}
|
||||
:uses #{analytics
|
||||
analyze
|
||||
api
|
||||
parameters
|
||||
query-processor
|
||||
settings
|
||||
util}}
|
||||
|
||||
enterprise/metabot-v3
|
||||
enterprise/mcp
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.metabot-v3.api
|
||||
metabase-enterprise.metabot-v3.core
|
||||
metabase-enterprise.metabot-v3.init
|
||||
metabase-enterprise.metabot-v3.tools.api}
|
||||
:friends #{enterprise/agent-api
|
||||
enterprise/slackbot}
|
||||
:uses #{activity-feed
|
||||
analytics
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
channel
|
||||
collections
|
||||
config
|
||||
documents
|
||||
driver
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
notification
|
||||
parameters
|
||||
permissions
|
||||
:friends #{enterprise/oauth-server}
|
||||
:uses #{api
|
||||
premium-features
|
||||
enterprise/agent-api}}
|
||||
|
||||
enterprise/metabot
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.metabot.tools.api}
|
||||
:friends #{enterprise/agent-api
|
||||
slackbot
|
||||
mcp
|
||||
metabot}
|
||||
:uses #{api
|
||||
lib-be
|
||||
metabot
|
||||
models
|
||||
premium-features
|
||||
pulse
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
server
|
||||
settings
|
||||
sql-tools
|
||||
store-api
|
||||
sync
|
||||
system
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
util
|
||||
warehouses
|
||||
enterprise/api
|
||||
enterprise/dependencies
|
||||
enterprise/llm
|
||||
enterprise/slackbot
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/oauth-server
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.oauth-server.admin-api
|
||||
metabase-enterprise.oauth-server.core}
|
||||
:uses #{api
|
||||
enterprise/mcp
|
||||
premium-features
|
||||
util}}
|
||||
|
||||
enterprise/permission-debug
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.permission-debug.api}
|
||||
@@ -2490,6 +2574,7 @@
|
||||
app-db
|
||||
config
|
||||
connection-pool
|
||||
llm
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
@@ -2497,8 +2582,7 @@
|
||||
settings
|
||||
task
|
||||
tracing
|
||||
util
|
||||
enterprise/llm}}
|
||||
util}}
|
||||
|
||||
enterprise/serialization
|
||||
{:team "Querying"
|
||||
@@ -2521,27 +2605,6 @@
|
||||
setup
|
||||
util}}
|
||||
|
||||
enterprise/slackbot
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.slackbot.init}
|
||||
:friends #{enterprise/metabot-v3}
|
||||
:uses #{analytics
|
||||
api
|
||||
channel
|
||||
config
|
||||
formatter
|
||||
lib
|
||||
permissions
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
settings
|
||||
system
|
||||
upload
|
||||
util
|
||||
enterprise/metabot-v3
|
||||
enterprise/sso}}
|
||||
|
||||
enterprise/snippet-collections
|
||||
{:team "Querying"
|
||||
:api #{}
|
||||
|
||||
@@ -186,5 +186,5 @@
|
||||
{:node node})
|
||||
|
||||
(defn use-fixtures [{:keys [node config]}]
|
||||
(warn-about-disallowed-parallel-forms node config)
|
||||
(warn-about-disallowed-parallel-forms node (get-in config [:linters :metabase/validate-deftest]))
|
||||
{:node node})
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,3 +176,4 @@ CLAUDE.local.md
|
||||
/resources/python-sources/sqlglot-*.dist-info/
|
||||
/resources/python-sources/.lock
|
||||
__pycache__/
|
||||
.mcp.json
|
||||
|
||||
@@ -6,7 +6,7 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
|
||||
## Clojure
|
||||
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` and `/bin/test-agent` shell commands.
|
||||
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
|
||||
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement
|
||||
|
||||
|
||||
4
deps.edn
4
deps.edn
@@ -4,7 +4,8 @@
|
||||
;; !! PLEASE KEEP THESE ORGANIZED ALPHABETICALLY !!
|
||||
;; !! AND ADD A COMMENT EXPLAINING THEIR PURPOSE !!
|
||||
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
{amalloy/ring-gzip-middleware {:mvn/version "0.1.4"} ; Ring middleware to GZIP responses if client can handle it
|
||||
{integrant/integrant {:mvn/version "1.0.1"}
|
||||
amalloy/ring-gzip-middleware {:mvn/version "0.1.4"} ; Ring middleware to GZIP responses if client can handle it
|
||||
babashka/fs {:mvn/version "0.5.27"} ; Portable filesystem operations
|
||||
bigml/histogram {:mvn/version "5.0.0" ; Histogram data structure
|
||||
:exclusions [junit/junit]}
|
||||
@@ -106,6 +107,7 @@
|
||||
methodical/methodical {:mvn/version "1.0.127"} ; drop-in replacements for Clojure multimethods and adds several advanced features
|
||||
metosin/malli {:git/url "https://github.com/metabase/malli" ; Data-driven Schemas for Clojure/Script and babashka
|
||||
:git/sha "b4a2d78647a049870b316753d7aae7fb4ade3ae5"} ; patched to resolve an issue causing OOM
|
||||
net.carcdr/oidc-provider {:mvn/version "0.5.0"} ; OAuth/OIDC server abstract implementation
|
||||
net.cgrand/macrovich {:mvn/version "0.2.2"} ; utils for writing macros for both Clojure & ClojureScript
|
||||
net.clojars.wkok/openai-clojure {:mvn/version "0.23.0"
|
||||
:exclusions [lambdaisland/uri]} ; OpenAI
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
"Workspace endpoints for the Agent API, mounted at `/api/agent/v1/workspace/...`.
|
||||
Provides workspace access with JWT-based agent authentication."
|
||||
(:require
|
||||
[metabase-enterprise.api.core :as ee.api]
|
||||
[metabase-enterprise.workspaces.api.common :as ws.api.common]
|
||||
[metabase-enterprise.workspaces.types :as ws.t]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :as api.routes.common]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.util.i18n :as i18n :refer [deferred-tru]]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -335,7 +333,4 @@
|
||||
"Returns the workspace routes for the agent API, keyed by version prefix.
|
||||
Currently only v1; new versions can be added here without changing api.clj."
|
||||
[+auth]
|
||||
(handlers/route-map-handler
|
||||
{"/v1" {"/workspace" (ee.api/+require-premium-feature
|
||||
:workspaces (deferred-tru "Workspaces")
|
||||
(api.macros/ns-handler 'metabase-enterprise.agent-api.workspace +authorize +auth))}}))
|
||||
(api.macros/ns-handler 'metabase-enterprise.agent-api.workspace +authorize +auth))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
(ns metabase-enterprise.ai-entity-analysis.api
|
||||
"`/api/ee/ai-entity-analysis/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.core :as metabot-v3]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.metabot.core :as metabot]
|
||||
[metabase.premium-features.core :as premium-features]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -31,7 +31,7 @@
|
||||
:chart {:name name
|
||||
:description description}
|
||||
:timeline_events timeline_events}
|
||||
resp (metabot-v3/analyze-chart chart-data)]
|
||||
resp (metabot/analyze-chart chart-data)]
|
||||
{:summary (:analysis resp)}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
(ns metabase-enterprise.ai-sql-fixer.api
|
||||
"`/api/ee/ai-sql-fixer/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.core :as metabot-v3]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.metabot.core :as metabot]
|
||||
[metabase.query-processor.middleware.permissions :as qp.perms]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
|
||||
(let [driver (-> query :database driver.u/database->driver)
|
||||
normalized-query (lib-be/normalize-query query)]
|
||||
(-> (metabot-v3/fix-sql {:sql (lib/raw-native-query normalized-query)
|
||||
:dialect driver
|
||||
:error_message error_message
|
||||
:schema_ddl (metabot-v3/schema-sample normalized-query)})
|
||||
(-> (metabot/fix-sql {:sql (lib/raw-native-query normalized-query)
|
||||
:dialect driver
|
||||
:error_message error_message
|
||||
:schema_ddl (metabot/schema-sample normalized-query)})
|
||||
(select-keys [:fixes]))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
(ns metabase-enterprise.ai-sql-generation.api
|
||||
"`/api/ee/ai-sql-generation/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.core :as metabot-v3]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.metabot.core :as metabot]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -20,9 +20,9 @@
|
||||
{:keys [prompt database_id]} :- [:map
|
||||
[:prompt ms/NonBlankString]
|
||||
[:database_id ms/PositiveInt]]]
|
||||
(-> (metabot-v3/generate-sql {:dialect (driver.u/database->driver database_id)
|
||||
:instructions prompt
|
||||
:tables (metabot-v3/database-tables database_id)})
|
||||
(-> (metabot/generate-sql {:dialect (driver.u/database->driver database_id)
|
||||
:instructions prompt
|
||||
:tables (metabot/database-tables database_id)})
|
||||
(select-keys [:generated_sql])))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.premium-features.core :as premium-features :refer [defenterprise]]
|
||||
[metabase.sso.settings :as oss-sso-settings]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defenterprise ee-snowplow-features-data
|
||||
@@ -21,7 +22,7 @@
|
||||
:enabled (sso-settings/saml-enabled)}
|
||||
{:name :sso-slack
|
||||
:available (premium-features/enable-sso-slack?)
|
||||
:enabled (sso-settings/slack-connect-enabled)}
|
||||
:enabled (oss-sso-settings/slack-connect-enabled)}
|
||||
{:name :scim
|
||||
:available (premium-features/enable-scim?)
|
||||
:enabled (boolean (scim/scim-enabled))}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[metabase-enterprise.action-v2.api]
|
||||
[metabase-enterprise.advanced-config.api.logs]
|
||||
[metabase-enterprise.advanced-permissions.api.routes]
|
||||
[metabase-enterprise.agent-api.api]
|
||||
[metabase-enterprise.agent-api.workspace :as agent-api.workspace]
|
||||
[metabase-enterprise.ai-entity-analysis.api]
|
||||
[metabase-enterprise.ai-sql-fixer.api]
|
||||
[metabase-enterprise.ai-sql-generation.api]
|
||||
@@ -27,9 +27,8 @@
|
||||
[metabase-enterprise.embedding-hub.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.library.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
[metabase-enterprise.metabot-v3.tools.api]
|
||||
[metabase-enterprise.metabot.tools.api]
|
||||
[metabase-enterprise.oauth-server.admin-api]
|
||||
[metabase-enterprise.permission-debug.api]
|
||||
[metabase-enterprise.remote-sync.api]
|
||||
[metabase-enterprise.replacement.api]
|
||||
@@ -44,17 +43,15 @@
|
||||
[metabase-enterprise.transforms.api]
|
||||
[metabase-enterprise.upload-management.api]
|
||||
[metabase-enterprise.workspaces.api]
|
||||
[metabase.agent-api.api :as agent-api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
|
||||
(comment metabase-enterprise.advanced-config.api.logs/keep-me
|
||||
metabase-enterprise.llm.api/keep-me
|
||||
metabase-enterprise.agent-api.api/keep-me)
|
||||
(comment metabase-enterprise.advanced-config.api.logs/keep-me)
|
||||
|
||||
(def ^:private required-feature->message
|
||||
{:advanced-permissions (deferred-tru "Advanced Permissions")
|
||||
:agent-api (deferred-tru "Agent API")
|
||||
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
|
||||
:ai-sql-generation (deferred-tru "AI SQL Generation")
|
||||
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
|
||||
@@ -68,7 +65,6 @@
|
||||
:remote-sync (deferred-tru "Remote Sync")
|
||||
:etl-connections (deferred-tru "ETL Connections")
|
||||
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:semantic-search (deferred-tru "Semantic Search")
|
||||
@@ -93,7 +89,7 @@
|
||||
`/ee/<feature>/`).
|
||||
|
||||
TODO -- Please fix them! See #22687"
|
||||
{"/agent" (premium-handler metabase-enterprise.agent-api.api/routes :agent-api)
|
||||
{"/agent" {"/v1" {"/workspace" (premium-handler (agent-api.workspace/workspace-handler agent-api/+auth) :workspaces)}}
|
||||
"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
|
||||
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
|
||||
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})
|
||||
@@ -109,7 +105,6 @@
|
||||
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
|
||||
"/ai-sql-generation" (premium-handler metabase-enterprise.ai-sql-generation.api/routes :ai-sql-generation)
|
||||
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
|
||||
"/cloud-add-ons" metabase-enterprise.cloud-add-ons.api/routes
|
||||
@@ -130,8 +125,8 @@
|
||||
(premium-handler :etl-connections))
|
||||
"/library" (premium-handler metabase-enterprise.library.api/routes :library)
|
||||
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" metabase-enterprise.metabot-v3.api/routes
|
||||
"/metabot-tools" metabase-enterprise.metabot.tools.api/routes
|
||||
"/oauth-server" (premium-handler metabase-enterprise.oauth-server.admin-api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
;; TODO (Ngoc 2026-03-25) -- use :transforms-advanced feature flag once it exists
|
||||
"/transforms" (premium-handler metabase-enterprise.transforms.api/routes :transforms-python)
|
||||
|
||||
@@ -13,12 +13,9 @@
|
||||
[metabase-enterprise.database-replication.init]
|
||||
[metabase-enterprise.dependencies.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
[metabase-enterprise.remote-sync.init]
|
||||
[metabase-enterprise.scim.init]
|
||||
[metabase-enterprise.semantic-search.init]
|
||||
[metabase-enterprise.slackbot.init]
|
||||
[metabase-enterprise.sso.init]
|
||||
[metabase-enterprise.stale.init]
|
||||
[metabase-enterprise.support-access-grants.init]
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
(ns metabase-enterprise.llm.api
|
||||
(:require
|
||||
[metabase-enterprise.llm.tasks.describe-dashboard :refer [describe-dashboard]]
|
||||
[metabase-enterprise.llm.tasks.describe-question :refer [describe-question]]
|
||||
[metabase.analyze.query-results :as qr]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.parameters.schema :as parameters.schema]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/card/summarize"
|
||||
"Summarize a question."
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:dataset {:optional true} [:maybe :boolean]]
|
||||
[:dataset_query ms/Map]
|
||||
[:parameters {:optional true} [:maybe [:sequential ::parameters.schema/parameter]]]
|
||||
[:parameter_mappings {:optional true} [:maybe [:sequential ::parameters.schema/parameter-mapping]]]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:display ms/NonBlankString]
|
||||
[:visualization_settings ms/Map]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:result_metadata {:optional true} [:maybe qr/ResultsMetadata]]
|
||||
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
;; check that we have permissions to run the query that we're trying to save
|
||||
;(check-data-permissions-for-query dataset_query)
|
||||
{:summary (describe-question body)})
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/dashboard/summarize/:id"
|
||||
"Provide a summary of a dashboard."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
{:summary (describe-dashboard id)})
|
||||
@@ -1,3 +0,0 @@
|
||||
(ns metabase-enterprise.llm.init
|
||||
(:require
|
||||
[metabase-enterprise.llm.settings]))
|
||||
@@ -1,126 +0,0 @@
|
||||
(ns metabase-enterprise.llm.settings
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru tru]]))
|
||||
|
||||
(defsetting ee-openai-model
|
||||
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "gpt-4.1-mini"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openai-api-base-url
|
||||
(deferred-tru "The OpenAI embeddings base URL used in Metabase Enterprise.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://api.openai.com"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openai-api-key
|
||||
(deferred-tru "The OpenAI API Key used in Metabase Enterprise.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-anthropic-api-base-url
|
||||
(deferred-tru "The Anthropic API base URL used in Metabase Enterprise.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://api.anthropic.com"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-anthropic-api-key
|
||||
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openrouter-api-base-url
|
||||
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://openrouter.ai/api"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openrouter-api-key
|
||||
(deferred-tru "The OpenRouter API Key used in Metabase Enterprise.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(def ^:private supported-metabot-providers
|
||||
"Set of supported LLM provider prefixes for the `ee-ai-metabot-provider` setting."
|
||||
#{"anthropic" "openai" "openrouter"})
|
||||
|
||||
(defn- validate-metabot-provider!
|
||||
"Validate that `value` has the format `provider/model` with a supported provider prefix.
|
||||
Throws an exception with `:status-code 400` on invalid input."
|
||||
[value]
|
||||
(when-not (string? value)
|
||||
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
|
||||
{:status-code 400})))
|
||||
(let [[provider model] (str/split value #"/" 2)]
|
||||
(when-not (contains? supported-metabot-providers provider)
|
||||
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
|
||||
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
|
||||
{:status-code 400
|
||||
:provider provider
|
||||
:supported supported-metabot-providers})))
|
||||
(when (str/blank? model)
|
||||
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
|
||||
{:status-code 400
|
||||
:value value})))))
|
||||
|
||||
(defsetting ee-ai-metabot-provider
|
||||
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/anthropic/claude-haiku-4-5"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :ee-ai-metabot-provider new-value)))
|
||||
|
||||
(defsetting ee-ai-metabot-provider-lite
|
||||
(deferred-tru "The AI provider and model for lightweight Metabot tasks (e.g. user intent classification).")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/openai/gpt-oss-20b"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :ee-ai-metabot-provider-lite new-value)))
|
||||
|
||||
(defsetting ee-ai-metabot-internal-tasks-enabled?
|
||||
(deferred-tru "Controls whether Metabot performs internal tasks that might require background tasks or additional LLM calls (e.g. user intent classification).")
|
||||
:type :boolean
|
||||
:visibility :settings-manager
|
||||
:default true
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-ai-features-enabled
|
||||
(deferred-tru "Enable AI features.")
|
||||
:type :boolean
|
||||
:visibility :public
|
||||
:default false
|
||||
:export? false
|
||||
:setter (fn [new-value]
|
||||
(when (some? (ee-openai-api-key))
|
||||
(setting/set-value-of-type! :boolean :ee-ai-features-enabled new-value)))
|
||||
:doc false)
|
||||
@@ -0,0 +1,66 @@
|
||||
(ns metabase-enterprise.metabot.agent.tools.transforms
|
||||
"Enterprise implementations of Python transform tools."
|
||||
(:require
|
||||
[metabase-enterprise.metabot.tools.transforms :as transform-tools]
|
||||
[metabase-enterprise.metabot.tools.transforms-write :as transforms-write-tools]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.agent.tools.transforms :as agent-transforms]
|
||||
[metabase.metabot.util :as metabot.u]
|
||||
[metabase.premium-features.core :refer [defenterprise]]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
;;; Formatting helpers
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
|
||||
(defn- format-python-library-output
|
||||
[{:keys [path] :as lib}]
|
||||
(metabot.u/xml
|
||||
[:python-library {:path path}
|
||||
(when-let [content (:content lib)] [:content content])]))
|
||||
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
;;; Tool definitions
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
|
||||
(defenterprise get-transform-python-library-details-tool
|
||||
"Get information about a Python library by path."
|
||||
:feature :none
|
||||
[{:keys [path]}]
|
||||
(agent-transforms/add-output (transform-tools/get-transform-python-library-details {:path path})
|
||||
format-python-library-output))
|
||||
|
||||
(defenterprise write-transform-python-tool
|
||||
"Write new Python code or edit existing code for transforms.
|
||||
|
||||
Supports two modes:
|
||||
- edit: Targeted string replacements with partial edits
|
||||
- replace: Replace entire code atomically
|
||||
|
||||
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
|
||||
For replace mode, provide new_content with the complete Python code.
|
||||
|
||||
Use `get_transform_python_library_details` before writing any Python code to inspect the shared library.
|
||||
Use the shared library in your code by adding `import common` at the top of the file.
|
||||
Keep `import common` at the top of the file even if it is currently unused."
|
||||
:feature :none
|
||||
[{:keys [transform_id edit_action thinking transform_name transform_description
|
||||
source_database source_tables]}]
|
||||
(try
|
||||
(agent-transforms/add-output
|
||||
(transforms-write-tools/write-transform-python
|
||||
{:transform_id transform_id
|
||||
:edit_action edit_action
|
||||
:thinking thinking
|
||||
:transform_name transform_name
|
||||
:transform_description transform_description
|
||||
:source_database source_database
|
||||
:source_tables source_tables
|
||||
:memory-atom shared/*memory-atom*
|
||||
:context (shared/current-context)})
|
||||
agent-transforms/format-transform-write-output)
|
||||
(catch Exception e
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to write Python transform: " (or (ex-message e) "Unknown error"))}))))
|
||||
134
enterprise/backend/src/metabase_enterprise/metabot/tools/api.clj
Normal file
134
enterprise/backend/src/metabase_enterprise/metabot/tools/api.clj
Normal file
@@ -0,0 +1,134 @@
|
||||
(ns metabase-enterprise.metabot.tools.api
|
||||
"EE-only metabot tool endpoints for transforms and dependencies."
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.metabot.tools.dependencies :as metabot.tools.dependencies]
|
||||
[metabase-enterprise.metabot.tools.transforms :as metabot.tools.transforms.ee]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.metabot.tools.api :as tools.api]
|
||||
[metabase.metabot.tools.deftool :refer [deftool]]
|
||||
[metabase.metabot.tools.transforms :as metabot.tools.transforms]
|
||||
[metabase.metabot.util :as metabot.u]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
;;; ------------------------------------------------ Transform Schemas ------------------------------------------------
|
||||
|
||||
(mr/def ::basic-transform
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:type [:enum {:decode/tool-api-response name} "mbql" "native" "python"]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:entity_id {:optional true} [:maybe :string]]
|
||||
;; :source keys are not snake_cased to match what the FE expects / provides in user_is_viewing context
|
||||
[:source ::metabot.tools.transforms/transform-source]])
|
||||
|
||||
(mr/def ::full-transform
|
||||
[:merge
|
||||
::basic-transform
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]
|
||||
;; :target keys are not snake_cased to match what the FE expects / provides in user_is_viewing context
|
||||
[:target ::metabot.tools.transforms/transform-target]]])
|
||||
|
||||
;;; ------------------------------------------------ Transform Tools --------------------------------------------------
|
||||
|
||||
(mr/def ::get-transforms-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential ::basic-transform]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(deftool "/get-transforms"
|
||||
"Get a list of all known transforms."
|
||||
{:result-schema ::get-transforms-result
|
||||
:handler metabot.tools.transforms/get-transforms})
|
||||
|
||||
(mr/def ::get-transform-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:transform_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:transform_id :transform-id})}]])
|
||||
|
||||
(mr/def ::get-transform-details-result
|
||||
[:or
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential ::full-transform]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(deftool "/get-transform-details"
|
||||
"Get information about a transform."
|
||||
{:args-schema ::get-transform-details-arguments
|
||||
:result-schema ::get-transform-details-result
|
||||
:handler metabot.tools.transforms/get-transform-details})
|
||||
|
||||
(mr/def ::get-transform-python-library-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:path :string]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(update-keys % metabot.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::get-transform-python-library-details-result
|
||||
[:or
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:structured_output [:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:source :string]
|
||||
[:path :string]
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(deftool "/get-transform-python-library-details"
|
||||
"Get information about a Python library by path."
|
||||
{:args-schema ::get-transform-python-library-details-arguments
|
||||
:result-schema ::get-transform-python-library-details-result
|
||||
:handler metabot.tools.transforms.ee/get-transform-python-library-details})
|
||||
|
||||
;;; --------------------------------------------- Dependency Tools ----------------------------------------------------
|
||||
|
||||
(mr/def ::check-transform-dependencies-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:transform_id :int]
|
||||
[:source ::metabot.tools.transforms/transform-source]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:transform_id :id})}]])
|
||||
|
||||
(mr/def ::broken-question
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]])
|
||||
|
||||
(mr/def ::broken-transform
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]])
|
||||
|
||||
(mr/def ::check-transform-dependencies-result
|
||||
[:or
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:success :boolean]
|
||||
[:bad_transform_count :int]
|
||||
[:bad_transforms [:sequential ::broken-transform]]
|
||||
[:bad_question_count :int]
|
||||
[:bad_questions [:sequential ::broken-question]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(deftool "/check-transform-dependencies"
|
||||
"Check a proposed edit to a transform and return details of cards or transforms that would be broken by the change."
|
||||
{:args-schema ::check-transform-dependencies-arguments
|
||||
:result-schema ::check-transform-dependencies-result
|
||||
:handler metabot.tools.dependencies/check-transform-dependencies})
|
||||
|
||||
;;; ---------------------------------------------------- Routes -------------------------------------------------------
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-tools` EE-only tool routes for transforms and dependencies."
|
||||
(api.macros/ns-handler *ns* tools.api/+tool-session))
|
||||
@@ -1,10 +1,11 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.dependencies
|
||||
(ns metabase-enterprise.metabot.tools.dependencies
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.core :as dependencies]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.metabot.tools.util :as metabot.tools.u]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private ^:dynamic *max-reported-broken-transforms* 10)
|
||||
@@ -43,9 +44,10 @@
|
||||
{:question card :errors (get card-errors (:id card))})
|
||||
broken-cards))}))
|
||||
|
||||
(defn check-transform-dependencies
|
||||
(defenterprise check-transform-dependencies
|
||||
"Check a proposed edit to a SQL transform, and return transforms that will break in a format
|
||||
suitable for Metabot. Takes a map with :id and :source keys."
|
||||
:feature :none
|
||||
[{:keys [id source]}]
|
||||
(try
|
||||
(let [transform-to-check (api/check-404 (t2/select-one :model/Transform :id id))
|
||||
@@ -65,4 +67,4 @@
|
||||
{:success true :bad_transforms []})]
|
||||
{:structured_output result})
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
(metabot.tools.u/handle-agent-error e))))
|
||||
@@ -0,0 +1,13 @@
|
||||
(ns metabase-enterprise.metabot.tools.transforms
|
||||
(:require
|
||||
[metabase-enterprise.transforms-python.api :as transforms-python.api]
|
||||
[metabase.metabot.tools.util :as metabot.tools.u]))
|
||||
|
||||
(defn get-transform-python-library-details
|
||||
"Get information about a Python library by path."
|
||||
[{:keys [path]}]
|
||||
(try
|
||||
{:structured_output
|
||||
(transforms-python.api/get-python-library-by-path path)}
|
||||
(catch Exception e
|
||||
(metabot.tools.u/handle-agent-error e))))
|
||||
@@ -0,0 +1,89 @@
|
||||
(ns metabase-enterprise.metabot.tools.transforms-write
|
||||
"Tools for writing/editing Python transform source code."
|
||||
(:require
|
||||
[metabase.metabot.agent.streaming :as streaming]
|
||||
[metabase.metabot.tools.transforms-write :as transforms-write]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Write Transform Python Tool
|
||||
|
||||
(defn write-transform-python
|
||||
"Write or edit Python transform source code.
|
||||
|
||||
Parameters:
|
||||
- transform-id: Optional ID of existing transform to edit
|
||||
- edit-action: Either {:mode \"edit\" :edits [...]} or {:mode \"replace\" :new-content \"...\"}
|
||||
- thinking: Explanation of what changes are being made
|
||||
- transform-name: Name for new transform (required if no transform-id)
|
||||
- transform-description: Description for new transform
|
||||
- source-database: Database ID for source tables
|
||||
- source-tables: Map of table aliases to table IDs
|
||||
- memory-atom: Atom containing agent memory (injected by wrapper)
|
||||
- context: Request context with current transform if editing
|
||||
|
||||
Returns map with:
|
||||
- :structured-output - The suggested transform
|
||||
- :data-parts - Transform suggestion data part for streaming"
|
||||
[{:keys [transform_id edit_action thinking transform_name transform_description
|
||||
source_database source_tables memory-atom context]}]
|
||||
(log/info "Writing Python transform" {:transform-id transform_id
|
||||
:edit-mode (:mode edit_action)
|
||||
:has-context (some? context)})
|
||||
|
||||
(let [;; Get current transform from context or memory, or create fresh one
|
||||
current-transform (cond
|
||||
;; Try to get from context first
|
||||
(and transform_id context)
|
||||
(get-in context [:transforms (str transform_id)])
|
||||
|
||||
;; Then try memory
|
||||
(and transform_id memory-atom)
|
||||
(get-in @memory-atom [:state :transforms (str transform_id)])
|
||||
|
||||
;; Create fresh transform
|
||||
:else
|
||||
(transforms-write/create-fresh-transform :python transform_name transform_description
|
||||
source_database source_tables))
|
||||
|
||||
_ (when (and transform_id (nil? current-transform))
|
||||
(throw (ex-info (str "Transform with ID " transform_id " not found. "
|
||||
"Use search to find available transforms.")
|
||||
{:agent-error? true
|
||||
:transform-id transform_id})))
|
||||
|
||||
current-python (get-in current-transform [:source :query] "")
|
||||
|
||||
;; Apply edits based on mode
|
||||
new-python (case (:mode edit_action)
|
||||
"edit" (transforms-write/apply-edits current-python (:edits edit_action))
|
||||
"replace" (:new_content edit_action)
|
||||
;; Default to replace if no mode specified but new_content provided
|
||||
(if (:new_content edit_action)
|
||||
(:new_content edit_action)
|
||||
(throw (ex-info "Invalid edit_action: must specify mode 'edit' with edits or mode 'replace' with new_content"
|
||||
{:agent-error? true
|
||||
:edit-action edit_action}))))
|
||||
|
||||
;; Build suggested transform
|
||||
suggested-transform (cond-> current-transform
|
||||
true (assoc-in [:source :query] new-python)
|
||||
true (assoc-in [:source :type] "python")
|
||||
transform_name (assoc :name transform_name)
|
||||
transform_description (assoc :description transform_description)
|
||||
source_database (assoc-in [:source :source-database] source_database)
|
||||
source_tables (assoc-in [:source :source-tables] source_tables))]
|
||||
|
||||
;; Store in memory if we have an ID
|
||||
(when (and transform_id memory-atom)
|
||||
(swap! memory-atom assoc-in [:state :transforms (str transform_id)] suggested-transform))
|
||||
|
||||
(log/debug "Python transform written" {:transform-id transform_id
|
||||
:python-length (count new-python)})
|
||||
|
||||
{:structured-output {:transform suggested-transform
|
||||
:thinking thinking
|
||||
:message "Transform Python code updated successfully."}
|
||||
:data-parts [(streaming/transform-suggestion-part suggested-transform)]
|
||||
:instructions "The transform suggestion has been created and displayed to the user. Do not repeat the Python content."}))
|
||||
@@ -1,123 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools
|
||||
"Tool registry for the agent loop.
|
||||
Provides a unified namespace for tool vars and state-aware wrapping."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.analyze-chart :as tools.analyze-chart]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.charts :as tools.charts]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.clarification :as tools.clarification]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.construct :as tools.construct]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.create-alert :as tools.create-alert]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.document :as tools.document]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.metadata :as tools.metadata]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.navigation :as tools.navigation]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.resources :as tools.resources]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.search :as tools.search]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.slackbot-query :as tools.slackbot-query]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.snippets :as tools.snippets]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.sql :as tools.sql]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.static-viz :as tools.static-viz]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.subscriptions :as tools.subscriptions]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.todo :as tools.todo]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.transforms :as tools.transforms]
|
||||
[potemkin :as p]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(p/import-vars
|
||||
[tools.analyze-chart
|
||||
analyze-chart-tool]
|
||||
[tools.search search-tool
|
||||
sql-search-tool
|
||||
nlq-search-tool
|
||||
transform-search-tool]
|
||||
[tools.construct
|
||||
construct-notebook-query-tool]
|
||||
[tools.document
|
||||
document-schema-collect-tool
|
||||
document-construct-sql-chart-tool
|
||||
document-construct-model-chart-tool]
|
||||
[tools.metadata
|
||||
list-available-data-sources-tool
|
||||
list-available-fields-tool
|
||||
get-field-values-tool]
|
||||
[tools.transforms
|
||||
get-transform-details-tool
|
||||
get-transform-python-library-details-tool
|
||||
write-transform-sql-tool
|
||||
write-transform-python-tool]
|
||||
[tools.sql
|
||||
create-sql-query-tool
|
||||
create-sql-query-code-edit-tool
|
||||
edit-sql-query-tool
|
||||
edit-sql-query-code-edit-tool
|
||||
replace-sql-query-tool
|
||||
replace-sql-query-code-edit-tool]
|
||||
[tools.charts
|
||||
create-chart-tool
|
||||
edit-chart-tool]
|
||||
[tools.resources
|
||||
read-resource-tool]
|
||||
[tools.todo
|
||||
todo-write-tool
|
||||
todo-read-tool]
|
||||
[tools.navigation
|
||||
navigate-user-tool]
|
||||
[tools.snippets
|
||||
list-snippets-tool
|
||||
get-snippet-details-tool]
|
||||
[tools.clarification
|
||||
ask-for-sql-clarification-tool]
|
||||
[tools.autogen-dashboard
|
||||
create-autogenerated-dashboard-tool]
|
||||
[tools.subscriptions
|
||||
create-dashboard-subscription-tool]
|
||||
[tools.slackbot-query
|
||||
slackbot-construct-notebook-query-tool]
|
||||
[tools.static-viz
|
||||
static-viz-tool]
|
||||
[tools.create-alert
|
||||
create-alert-tool]
|
||||
[tools.create-dashboard-subscription
|
||||
slackbot-create-dashboard-subscription-tool])
|
||||
|
||||
(def ^:dynamic ^:private *memory-atom* nil)
|
||||
|
||||
(def ^:private state-dependent-tools
|
||||
"Set of tool names that require access to agent state."
|
||||
#{"analyze_chart"
|
||||
"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
|
||||
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
|
||||
"create_sql_query" "edit_sql_query" "replace_sql_query"
|
||||
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"
|
||||
"create_alert" "create_dashboard_subscription" "static_viz"})
|
||||
|
||||
(defn wrap-tools-with-state
|
||||
"Wrap state-dependent tools with access to the memory atom.
|
||||
Tools in `state-dependent-tools` will have access to the memory atom
|
||||
via the dynamic `*memory-atom*` binding.
|
||||
|
||||
Returns a new tools map where state-dependent tools are wrapped.
|
||||
Wrapped tools are returned as maps with :doc, :schema, :prompt, and :fn keys
|
||||
to satisfy the Claude API schema validation. Tool-specific instructions are
|
||||
loaded from `resources/metabot/prompts/tools/` by `extract-tool-instructions`
|
||||
in `prompts.clj`, keyed by `:prompt` metadata or `<tool-name>.md` by default."
|
||||
[tools memory-atom]
|
||||
(reduce-kv
|
||||
(fn [acc tool-name tool-var]
|
||||
(if (contains? state-dependent-tools tool-name)
|
||||
(let [{:keys [doc schema prompt decode]} (meta tool-var)
|
||||
wrapped-fn (fn [args]
|
||||
(binding [shared/*memory-atom* memory-atom
|
||||
*memory-atom* memory-atom]
|
||||
(tool-var args)))]
|
||||
(assoc acc tool-name (cond-> {:doc doc
|
||||
:schema schema
|
||||
:prompt prompt
|
||||
:fn wrapped-fn}
|
||||
decode (assoc :decode decode))))
|
||||
(assoc acc tool-name tool-var)))
|
||||
{}
|
||||
tools))
|
||||
@@ -1,126 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.search
|
||||
"Search tool wrappers for Metabot v3."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as search-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-search-output
|
||||
"Format search results as an LLM-ready string."
|
||||
[results]
|
||||
(let [results-xml (llm-rep/search-results->xml results)]
|
||||
(te/lines
|
||||
"<result>"
|
||||
results-xml
|
||||
""
|
||||
(str "Total results: " (count results))
|
||||
"</result>"
|
||||
"<instructions>"
|
||||
instructions/search-result-instructions "</instructions>")))
|
||||
|
||||
(defn- invalid-entity-types
|
||||
[entity-types allowed]
|
||||
(when (seq entity-types)
|
||||
(seq (remove allowed entity-types))))
|
||||
|
||||
(mu/defn ^{:tool-name "search"} search-tool
|
||||
"Search for tables, models, metrics, dashboards, and saved questions."
|
||||
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "metric" "dashboard" "question"})]
|
||||
{:output (str "Invalid entity_types for search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model, metric, dashboard, question.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "sql_search.md"} sql-search-tool
|
||||
"Search for SQL-queryable data sources (tables and models) within a database."
|
||||
[{:keys [semantic_queries keyword_queries entity_types database_id]} :- [:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:database_id :int]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model"})]
|
||||
{:output (str "Invalid entity_types for SQL search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:database-id database_id
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in SQL search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "nql_search.md"} nlq-search-tool
|
||||
"Search for NLQ-queryable data sources (models, metrics, tables)."
|
||||
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "model" "metric" "table"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"model" "metric" "table"})]
|
||||
{:output (str "Invalid entity_types for NLQ search: " (pr-str (vec invalid))
|
||||
". Allowed types: model, metric, table.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:profile-id "nlq"
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in NLQ search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "transform_search"} transform-search-tool
|
||||
"Search for transforms, tables, and models."
|
||||
[{:keys [semantic_queries keyword_queries entity_types search_native_query]} :- [:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:search_native_query {:optional true} [:maybe :boolean]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "transform"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "transform"})]
|
||||
{:output (str "Invalid entity_types for transform search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model, transform.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:search-native-query search_native_query
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in transform search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -1,4 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.init
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.task.suggested-prompts-generator]))
|
||||
@@ -0,0 +1,133 @@
|
||||
(ns metabase-enterprise.oauth-server.admin-api
|
||||
"Admin-only CRUD endpoints for managing OAuth clients."
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[oidc-provider.util :as oidc-util]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private OAuthClientResponse
|
||||
"Malli schema for an OAuth client in API responses (no secrets)."
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:client_id ms/NonBlankString]
|
||||
[:redirect_uris [:sequential ms/NonBlankString]]
|
||||
[:grant_types [:sequential ms/NonBlankString]]
|
||||
[:response_types [:sequential ms/NonBlankString]]
|
||||
[:scopes [:sequential :string]]
|
||||
[:client_name {:optional true} [:maybe :string]]
|
||||
[:application_type {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:registration_type ms/NonBlankString]
|
||||
[:created_at {:optional true} :any]
|
||||
[:updated_at {:optional true} :any]])
|
||||
|
||||
(def ^:private OAuthClientCreateResponse
|
||||
"Malli schema for the POST response — includes the plaintext secret (shown once)."
|
||||
[:merge
|
||||
OAuthClientResponse
|
||||
[:map
|
||||
[:client_secret ms/NonBlankString]]])
|
||||
|
||||
(def ^:private client-public-keys
|
||||
"Keys safe to return in API responses (no secret hashes)."
|
||||
[:id :client_id :redirect_uris :grant_types :response_types :scopes
|
||||
:client_name :application_type :registration_type :created_at :updated_at])
|
||||
|
||||
(defn- sanitize-client
|
||||
"Remove secret-related fields from a client row for API responses."
|
||||
[client]
|
||||
(select-keys client client-public-keys))
|
||||
|
||||
(api.macros/defendpoint :post "/clients" :- OAuthClientCreateResponse
|
||||
"Register a new static OAuth client. Returns the client with the plaintext secret (shown only once)."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [redirect_uris client_name grant_types response_types scopes application_type]}
|
||||
:- [:map
|
||||
[:redirect_uris [:sequential ms/NonBlankString]]
|
||||
[:client_name {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:application_type {:optional true} [:maybe ms/NonBlankString]]]]
|
||||
(let [client-id (str (java.util.UUID/randomUUID))
|
||||
client-secret (oidc-util/generate-client-secret)
|
||||
secret-hash (oidc-util/hash-client-secret client-secret)
|
||||
grant-types (or (not-empty grant_types) ["authorization_code"])
|
||||
response-types (or (not-empty response_types) ["code"])
|
||||
scopes (or (not-empty scopes) ["openid" "profile"])
|
||||
row {:client_id client-id
|
||||
:client_type "confidential"
|
||||
:client_secret_hash secret-hash
|
||||
:redirect_uris redirect_uris
|
||||
:grant_types grant-types
|
||||
:response_types response-types
|
||||
:scopes scopes
|
||||
:client_name client_name
|
||||
:application_type (or application_type "web")
|
||||
:registration_type "static"}
|
||||
[inserted] (t2/insert-returning-instances! :model/OAuthClient row)]
|
||||
(-> (sanitize-client inserted)
|
||||
(assoc :client_secret client-secret))))
|
||||
|
||||
(api.macros/defendpoint :get "/clients" :- [:sequential OAuthClientResponse]
|
||||
"List all OAuth clients. Supports optional `?registration_type=static|dynamic` filter."
|
||||
[_route-params
|
||||
{:keys [registration-type]} :- [:map
|
||||
[:registration-type {:optional true} [:maybe ms/NonBlankString]]]]
|
||||
(let [clients (if registration-type
|
||||
(t2/select :model/OAuthClient :registration_type registration-type)
|
||||
(t2/select :model/OAuthClient))]
|
||||
(mapv sanitize-client clients)))
|
||||
|
||||
(api.macros/defendpoint :get "/clients/:id" :- OAuthClientResponse
|
||||
"Get a single OAuth client by its database ID."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(-> (t2/select-one :model/OAuthClient :id id)
|
||||
api/check-404
|
||||
sanitize-client))
|
||||
|
||||
(api.macros/defendpoint :put "/clients/:id" :- OAuthClientResponse
|
||||
"Update an OAuth client. Cannot change client_id or client_secret."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:client_name {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:redirect_uris {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]
|
||||
[:application_type {:optional true} [:maybe ms/NonBlankString]]]]
|
||||
(let [updates (into {} (filter (comp some? val)) (select-keys body [:client_name :redirect_uris :grant_types :response_types :scopes :application_type]))]
|
||||
(api/check-404 (t2/select-one :model/OAuthClient :id id))
|
||||
(when (seq updates)
|
||||
(t2/update! :model/OAuthClient id updates))
|
||||
(-> (t2/select-one :model/OAuthClient :id id)
|
||||
sanitize-client)))
|
||||
|
||||
(api.macros/defendpoint :delete "/clients/:id" :- :nil
|
||||
"Delete an OAuth client."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(api/check-404 (t2/select-one :model/OAuthClient :id id))
|
||||
(t2/delete! :model/OAuthClient :id id)
|
||||
nil)
|
||||
|
||||
(defn- +no-cache-on-create
|
||||
"Middleware that adds Cache-Control: no-store to POST responses (client creation returns a secret)."
|
||||
[handler]
|
||||
(fn [request respond raise]
|
||||
(handler request
|
||||
(fn [resp]
|
||||
(respond
|
||||
(cond-> resp
|
||||
(= :post (:request-method request)) (update :headers merge {"Cache-Control" "no-store"
|
||||
"Pragma" "no-cache"}))))
|
||||
raise)))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/oauth-server` admin routes."
|
||||
(api.macros/ns-handler *ns* +no-cache-on-create api/+check-superuser +auth))
|
||||
@@ -279,7 +279,7 @@
|
||||
[]
|
||||
(let [api-key (semantic-settings/openai-api-key)]
|
||||
(when-not api-key
|
||||
(throw (ex-info "OpenAI API key not configured" {:setting "ee-openai-api-key"})))
|
||||
(throw (ex-info "OpenAI API key not configured" {:setting "llm-openai-api-key"})))
|
||||
[(str (semantic-settings/openai-api-base-url) "/v1/embeddings") api-key]))
|
||||
|
||||
(defmethod get-embedding "openai" [embedding-model text & {:as opts}]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(ns metabase-enterprise.semantic-search.settings
|
||||
(:require
|
||||
[metabase-enterprise.llm.settings :as llm-settings]
|
||||
[metabase.llm.settings :as llm-settings]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
@@ -45,12 +45,12 @@
|
||||
(defn openai-api-base-url
|
||||
"Get the OpenAI API base url from the existing LLM settings."
|
||||
[]
|
||||
(llm-settings/ee-openai-api-base-url))
|
||||
(llm-settings/llm-openai-api-base-url))
|
||||
|
||||
(defn openai-api-key
|
||||
"Get the OpenAI API key from the existing LLM settings."
|
||||
[]
|
||||
(llm-settings/ee-openai-api-key))
|
||||
(llm-settings/llm-openai-api-key))
|
||||
|
||||
(defsetting ee-embedding-service-base-url
|
||||
(deferred-tru "URL of the OpenAI-compatible embedding service (e.g. a LiteLLM proxy).")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
(ns metabase-enterprise.slackbot.init
|
||||
(:require
|
||||
[metabase-enterprise.slackbot.settings]))
|
||||
@@ -1,37 +0,0 @@
|
||||
(ns metabase-enterprise.slackbot.settings
|
||||
(:require
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.string :as u.str]))
|
||||
|
||||
(defsetting metabot-slack-signing-secret
|
||||
(deferred-tru "Signing secret for verifying requests from the Metabot Slack app")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :when-encryption-key-set
|
||||
:feature :metabot-v3
|
||||
:export? false
|
||||
:audit :no-value
|
||||
:getter (fn []
|
||||
(-> (setting/get-value-of-type :string :metabot-slack-signing-secret)
|
||||
(u.str/mask 4))))
|
||||
|
||||
(defn unobfuscated-metabot-slack-signing-secret
|
||||
"Get the unobfuscated value of [[metabot-slack-signing-secret]]."
|
||||
[]
|
||||
(setting/get-value-of-type :string :metabot-slack-signing-secret))
|
||||
|
||||
(defsetting slackbot-event-handler-pool-size
|
||||
(deferred-tru "Maximum number of concurrent Slack event handler threads.")
|
||||
:visibility :internal
|
||||
:type :integer
|
||||
:default 64
|
||||
:setter :none
|
||||
:export? false)
|
||||
|
||||
(defenterprise metabot-slack-signing-secret-setting
|
||||
"Returns the Slack signing secret for Metabot."
|
||||
:feature :metabot-v3
|
||||
[]
|
||||
(unobfuscated-metabot-slack-signing-secret))
|
||||
@@ -8,11 +8,11 @@
|
||||
[metabase-enterprise.sso.integrations.jwt :as jwt]
|
||||
[metabase-enterprise.sso.integrations.oidc :as oidc-integration]
|
||||
[metabase-enterprise.sso.integrations.saml]
|
||||
[metabase-enterprise.sso.integrations.slack-connect :as slack-connect-integration]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.session.core :as session]
|
||||
[metabase.sso.integrations.slack-connect :as slack-connect-integration]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
[metabase-enterprise.sso.providers.jwt]
|
||||
[metabase-enterprise.sso.providers.oidc]
|
||||
[metabase-enterprise.sso.providers.saml]
|
||||
[metabase-enterprise.sso.providers.slack-connect]
|
||||
[metabase-enterprise.sso.settings]))
|
||||
[metabase-enterprise.sso.settings]
|
||||
[metabase.sso.providers.slack-connect]))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.appearance.core :as appearance]
|
||||
[metabase.sso.settings :as oss-sso-settings]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [trs tru]]
|
||||
@@ -40,7 +41,7 @@
|
||||
|
||||
(defmethod check-user-provisioning :slack-connect
|
||||
[_]
|
||||
(maybe-throw-user-provisioning (sso-settings/slack-connect-user-provisioning-enabled)))
|
||||
(maybe-throw-user-provisioning (oss-sso-settings/slack-connect-user-provisioning-enabled)))
|
||||
|
||||
(defmethod check-user-provisioning :oidc
|
||||
[_]
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[metabase.util.string :as u.str]
|
||||
[saml20-clj.core :as saml]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -349,96 +348,6 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
:default "(member={dn})"
|
||||
:audit :getter)
|
||||
|
||||
;;; ------------------------------------------------ Slack Connect ------------------------------------------------
|
||||
|
||||
(defsetting slack-connect-client-id
|
||||
(deferred-tru "Client ID for your Slack app. Get this from https://api.slack.com/apps")
|
||||
:encryption :when-encryption-key-set
|
||||
:export? false
|
||||
:feature :sso-slack
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-client-secret
|
||||
(deferred-tru "Client Secret for your Slack app")
|
||||
:encryption :when-encryption-key-set
|
||||
:export? false
|
||||
:feature :sso-slack
|
||||
:audit :no-value
|
||||
:getter (fn []
|
||||
(-> (setting/get-value-of-type :string :slack-connect-client-secret)
|
||||
(u.str/mask 4))))
|
||||
|
||||
(defn unobfuscated-slack-connect-client-secret
|
||||
"Get the unobfuscated value of [[slack-connect-client-secret]]."
|
||||
[]
|
||||
(setting/get-value-of-type :string :slack-connect-client-secret))
|
||||
|
||||
(def slack-connect-auth-mode-sso
|
||||
"Authentication mode for full SSO login."
|
||||
"sso")
|
||||
|
||||
(def slack-connect-auth-mode-link-only
|
||||
"Authentication mode for account linking only (no session creation)."
|
||||
"link-only")
|
||||
|
||||
(defsetting slack-connect-authentication-mode
|
||||
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" or \"link-only\" (default)")
|
||||
:type :string
|
||||
:export? false
|
||||
:default slack-connect-auth-mode-link-only
|
||||
:feature :sso-slack
|
||||
:audit :getter
|
||||
:encryption :no
|
||||
:setter (fn [new-value]
|
||||
(when (and new-value
|
||||
(not (contains? #{slack-connect-auth-mode-sso slack-connect-auth-mode-link-only} new-value)))
|
||||
(throw (ex-info (tru "Invalid authentication mode. Must be \"sso\" or \"link-only\"")
|
||||
{:status-code 400})))
|
||||
(setting/set-value-of-type! :string :slack-connect-authentication-mode new-value)))
|
||||
|
||||
(defsetting slack-connect-user-provisioning-enabled
|
||||
(deferred-tru "When a user logs in via Slack Connect, create a Metabase account for them automatically if they don''t have one.")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default true
|
||||
:feature :sso-slack
|
||||
:getter (fn []
|
||||
(if (= (slack-connect-authentication-mode) slack-connect-auth-mode-link-only)
|
||||
false
|
||||
(setting/get-value-of-type :boolean :slack-connect-user-provisioning-enabled)))
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-attribute-team-id
|
||||
(deferred-tru "Slack OIDC claim for the team/workspace ID")
|
||||
:default "https://slack.com/team_id"
|
||||
:export? false
|
||||
:feature :sso-slack
|
||||
:encryption :when-encryption-key-set
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-configured
|
||||
(deferred-tru "Are the mandatory Slack Connect settings configured?")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default false
|
||||
:feature :sso-slack
|
||||
:setter :none
|
||||
:getter (fn [] (boolean
|
||||
(and (slack-connect-client-id)
|
||||
(slack-connect-client-secret)))))
|
||||
|
||||
(defsetting slack-connect-enabled
|
||||
(deferred-tru "Is Slack Connect authentication configured and enabled?")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default false
|
||||
:feature :sso-slack
|
||||
:audit :getter
|
||||
:getter (fn []
|
||||
(if (slack-connect-configured)
|
||||
(setting/get-value-of-type :boolean :slack-connect-enabled)
|
||||
false)))
|
||||
|
||||
;;; ------------------------------------------------ OIDC (Custom) ------------------------------------------------
|
||||
|
||||
(defsetting oidc-providers
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
|
||||
(get-manifest [user status]
|
||||
(testing (format "get slack manifest %s user" (mt/user-descriptor user))
|
||||
(mt/user-http-request user :get status "slack/manifest")))]
|
||||
(mt/with-temporary-setting-values [site-url "http://localhost:3000"]
|
||||
(mt/user-http-request user :get status "slack/manifest"))))]
|
||||
|
||||
(testing "if `advanced-permissions` is disabled, require admins"
|
||||
(mt/with-premium-features #{}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
[environ.core :as env]
|
||||
[java-time.api :as t]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.settings] ; for setting definitions
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase-enterprise.sso.test-setup :as sso.test-setup]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.normalize :as lib.normalize]
|
||||
[metabase.metabot.settings] ; for setting definitions
|
||||
[metabase.metabot.tools.util :as metabot.tools.u]
|
||||
[metabase.search.ingestion :as search.ingestion]
|
||||
[metabase.search.test-util :as search.tu]
|
||||
[metabase.session.models.session :as session.models]
|
||||
@@ -259,7 +259,7 @@
|
||||
[table-id field-display-name]
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (lib/query mp (lib.metadata/table mp table-id))
|
||||
field-prefix (metabot-v3.tools.u/table-field-id-prefix table-id)
|
||||
field-prefix (metabot.tools.u/table-field-id-prefix table-id)
|
||||
visible-cols (lib/visible-columns query)]
|
||||
(->> (keep-indexed (fn [i col]
|
||||
(when (= (lib/display-name query col) field-display-name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(ns metabase-enterprise.ai-entity-analysis.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-client]
|
||||
[metabase.metabot.client :as metabot-client]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest analyze-chart-test
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.metabot.table-utils :as table-utils]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest ^:parallel schema-sample-test
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
:embedding-simple
|
||||
:embedding-hub
|
||||
:hosting
|
||||
:llm-autodescription
|
||||
:metabot-v3
|
||||
:ai-entity-analysis
|
||||
:ai-sql-fixer
|
||||
@@ -79,7 +78,6 @@
|
||||
:embedding_sdk true
|
||||
:embedding_simple true
|
||||
:hosting true
|
||||
:llm_autodescription true
|
||||
:metabot_v3 true
|
||||
:ai_entity_analysis true
|
||||
:ai_sql_fixer true
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
(ns metabase-enterprise.llm.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.llm.client :as llm-client]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(deftest summarize-card-test
|
||||
(testing "POST /api/ee/autodescribe/card/summarize"
|
||||
(mt/dataset test-data
|
||||
(mt/with-temp [:model/Card card {:name "Orders"
|
||||
:dataset_query
|
||||
{:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)}}}]
|
||||
(let [fake-response {:title "Title" :description "Description"}
|
||||
json-response (json/encode fake-response)
|
||||
expected {:summary fake-response}]
|
||||
(testing "Card summarization works in the happy path"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content json-response}}]})]
|
||||
(is (= expected
|
||||
(mt/user-http-request :rasta :post 200 "ee/autodescribe/card/summarize" card))))))
|
||||
(testing "We can handle json in markdown"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content
|
||||
(format
|
||||
"```json%s```"
|
||||
json-response)}}]})]
|
||||
(is (= expected
|
||||
(mt/user-http-request :rasta :post 200 "ee/autodescribe/card/summarize" card))))))
|
||||
(testing "We can't handle bad responses"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content
|
||||
(format
|
||||
"This is not a good json message -- %s"
|
||||
json-response)}}]})]
|
||||
(is (= 500
|
||||
(get-in
|
||||
(mt/user-http-request :rasta :post 500 "ee/autodescribe/card/summarize" card)
|
||||
[:data :status-code]))))))
|
||||
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/assert-has-premium-feature-error "LLM Auto-description"
|
||||
(mt/user-http-request :rasta :post 402 "ee/autodescribe/card/summarize" card)))))))))
|
||||
|
||||
(deftest summarize-dashboard-test
|
||||
(testing "POST /api/ee/autodescribe/dashboard/summarize/:id"
|
||||
(mt/dataset test-data
|
||||
(mt/with-temp [:model/Card {card-id :id} {:name "Orders"
|
||||
:dataset_query
|
||||
{:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)}}}
|
||||
:model/Dashboard {dash-id :id} {:name "Dashboard"}
|
||||
:model/DashboardCard {_ :id} {:dashboard_id dash-id
|
||||
:card_id card-id}]
|
||||
(let [url (format "ee/autodescribe/dashboard/summarize/%s" dash-id)
|
||||
fake-response {:description "Description"
|
||||
:keywords "awesome, amazing"
|
||||
:questions "- What is this?"}
|
||||
json-response (json/encode fake-response)
|
||||
expected {:summary {:description "Keywords: awesome, amazing\n\nDescription: Description\n\nQuestions:\n- What is this?"}}]
|
||||
(testing "Card summarization works in the happy path"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content json-response}}]})]
|
||||
(is (= expected
|
||||
(mt/user-http-request :rasta :post 200 url))))))
|
||||
(testing "We can handle json in markdown"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content (format "```json%s```" json-response)}}]})]
|
||||
(is (= expected
|
||||
(mt/user-http-request :rasta :post 200 url))))))
|
||||
(testing "We can't handle bad responses"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(with-redefs [llm-client/*create-chat-completion-endpoint*
|
||||
(fn [_ _]
|
||||
{:choices [{:message {:content
|
||||
(format "This is not a good json message -- %s" json-response)}}]})]
|
||||
(is (= 500
|
||||
(get-in
|
||||
(mt/user-http-request :rasta :post 500 url)
|
||||
[:data :status-code]))))))
|
||||
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/assert-has-premium-feature-error "LLM Auto-description"
|
||||
(mt/user-http-request :rasta :post 402 url)))))))))
|
||||
@@ -1,19 +0,0 @@
|
||||
(ns metabase-enterprise.llm.settings-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.llm.settings :as llm.settings]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest ai-features-enabled-test
|
||||
(testing "LLM setting `ee-ai-features-enabled`"
|
||||
(mt/with-premium-features #{:llm-autodescription}
|
||||
(mt/with-temporary-setting-values [ee-openai-api-key nil
|
||||
ee-ai-features-enabled false]
|
||||
(testing "cannot be set and remains `false` when `ee-openai-api-key` is not set."
|
||||
(llm.settings/ee-ai-features-enabled! true)
|
||||
(is (= false
|
||||
(llm.settings/ee-ai-features-enabled))))
|
||||
(testing "can be set to `true` when `ee-openai-api-key` is set."
|
||||
(llm.settings/ee-openai-api-key! "TOTALLY_REAL_KEY")
|
||||
(llm.settings/ee-ai-features-enabled! true)
|
||||
(is (true? (llm.settings/ee-ai-features-enabled))))))))
|
||||
@@ -0,0 +1,47 @@
|
||||
(ns metabase-enterprise.metabot.api-test
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest feedback-endpoint-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [store-url "http://hm.example"
|
||||
fake-token "test-fake-token-for-feedback"]
|
||||
(testing "Submits feedback to Harbormaster with token and base URL"
|
||||
(mt/with-temporary-setting-values [store-api-url store-url]
|
||||
(let [captured (atom nil)
|
||||
feedback {:metabot_id 1
|
||||
:feedback {:positive true
|
||||
:message_id "m-1"
|
||||
:freeform_feedback "ok"}
|
||||
:conversation_data {}
|
||||
:version "v0.0.0"
|
||||
:submission_time "2025-01-01T00:00:00Z"
|
||||
:is_admin false}
|
||||
expected-url (str store-url "/api/v2/metabot/feedback/" fake-token)]
|
||||
(mt/with-dynamic-fn-redefs
|
||||
[premium-features/premium-embedding-token (constantly fake-token)
|
||||
http/post (fn [url opts]
|
||||
(reset! captured {:url url
|
||||
:body (json/decode+kw (:body opts))}))]
|
||||
(let [_resp (mt/user-http-request :rasta :post 204 "metabot/feedback" feedback)]
|
||||
(is (= {:url expected-url :body feedback}
|
||||
@captured)))))))
|
||||
|
||||
(testing "Returns 500 when http post fails"
|
||||
(mt/with-temporary-setting-values [store-api-url store-url]
|
||||
(mt/with-dynamic-fn-redefs
|
||||
[premium-features/premium-embedding-token (constantly fake-token)
|
||||
http/post (fn [_url _opts]
|
||||
(throw (ex-info "boom" {:status 404})))]
|
||||
(mt/user-http-request :rasta :post 500 "metabot/feedback" {:any "payload"}))))
|
||||
|
||||
(testing "Throws when premium token is missing"
|
||||
(mt/with-dynamic-fn-redefs
|
||||
[premium-features/premium-embedding-token (constantly nil)]
|
||||
(mt/user-http-request :rasta :post 400 "metabot/feedback" {:foo "bar"}))))))
|
||||
@@ -0,0 +1,141 @@
|
||||
(ns metabase-enterprise.metabot.tools.api-test
|
||||
"Tests for EE-only metabot tool endpoints (transforms, python libraries)."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.metabot.client :as metabot.client]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- ai-session-token
|
||||
([] (ai-session-token :rasta (str (random-uuid))))
|
||||
([metabot-id] (ai-session-token :rasta metabot-id))
|
||||
([user metabot-id]
|
||||
(-> user mt/user->id (#'metabot.client/get-ai-service-token metabot-id))))
|
||||
|
||||
(deftest get-transforms-test
|
||||
(mt/with-premium-features #{:metabot-v3 :transforms-basic :transforms-python}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
|
||||
(mt/with-temp [:model/Transform t1 {:name "People Transform"
|
||||
:description "Simple select on People table"
|
||||
:source {:type "query"
|
||||
:query (lib/native-query (mt/metadata-provider) "SELECT * FROM PEOPLE")}
|
||||
:target {:type "table"
|
||||
:name "t1_table"}}
|
||||
:model/Transform t2 {:name "MBQL Transform"
|
||||
:description "Simple MQBL query on Products table"
|
||||
:source {:type "query"
|
||||
:query (mt/mbql-query products)}
|
||||
:target {:type "table"
|
||||
:name "t2_table"}}
|
||||
:model/Transform t3 {:name "Python Transform"
|
||||
:description "Simple python transform"
|
||||
:source {:type "python"
|
||||
:source-database (mt/id)
|
||||
:body "print('hello world')"
|
||||
:source-tables []}
|
||||
:target {:type "table"
|
||||
:name "t2_table"}}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transforms"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(is (=? {:structured_output [(mt/obj->json->obj (select-keys t1 [:id :entity_id :name :description :source]))
|
||||
;; note: t2 not included because it's a (non-native) MBQL query
|
||||
(mt/obj->json->obj (select-keys t3 [:id :entity_id :name :description :source]))]
|
||||
:conversation_id conversation-id}
|
||||
(-> (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transforms"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:conversation_id conversation-id})
|
||||
(update :structured_output (fn [output]
|
||||
(->> output
|
||||
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %)))
|
||||
(sort-by :id))))))))))))
|
||||
|
||||
(deftest get-transform-test
|
||||
(mt/with-premium-features #{:metabot-v3 :transforms-basic :transforms-python}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
|
||||
(mt/with-temp [:model/Transform t1 {:name "People Transform"
|
||||
:description "Simple select on People table"
|
||||
:source {:type "query"
|
||||
:query (mt/native-query {:query "SELECT * FROM PEOPLE"})}
|
||||
:target {:type "table"
|
||||
:name "t1_table"}}
|
||||
:model/Transform t2 {:name "Python Transform"
|
||||
:description "Simple Python transform"
|
||||
:source {:type "python"
|
||||
:body "print('hello world')"
|
||||
:source-database (mt/id)
|
||||
:source-tables []}
|
||||
:target {:type "table"
|
||||
:name "t2_table"
|
||||
:database (mt/id)}}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:arguments {:transform_id (:id t1)}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With non-existent transform"
|
||||
(is (= "Not found."
|
||||
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:transform_id (+ 10000 (:id t2))}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(doseq [transform [t1 t2]]
|
||||
(testing (:name transform)
|
||||
(is (=? {:structured_output (mt/obj->json->obj (select-keys transform [:id :entity_id :name :description :source :target]))
|
||||
:conversation_id conversation-id}
|
||||
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:transform_id (:id transform)}
|
||||
:conversation_id conversation-id}))))))))))
|
||||
|
||||
(deftest get-transform-python-library-details-test
|
||||
(mt/with-premium-features #{:metabot-v3 :python-transforms :transforms-basic}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))
|
||||
saved-python-library (t2/select-one :model/PythonLibrary :path "common.py")]
|
||||
(when (seq saved-python-library)
|
||||
(t2/delete! :model/PythonLibrary))
|
||||
(try
|
||||
(testing "With no Python library present"
|
||||
(is (= "Not found."
|
||||
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path "common.py"}
|
||||
:conversation_id conversation-id}))))
|
||||
(mt/with-temp [:model/PythonLibrary lib1 {:path "common.py"
|
||||
:source "def hello():\n return 'world'"}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:arguments {:path (:path lib1)}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With non-existent library path"
|
||||
(is (=? {:allowed-paths ["common.py"]
|
||||
:message "Invalid library path. Only 'common' is currently supported."
|
||||
:path "nonexistent.py"}
|
||||
(mt/user-http-request :rasta :post 400 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path "nonexistent.py"}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(is (=? {:structured_output (select-keys lib1 [:source :path :created_at :updated_at])
|
||||
:conversation_id conversation-id}
|
||||
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path (:path lib1)}
|
||||
:conversation_id conversation-id})))))
|
||||
(finally
|
||||
(when (seq saved-python-library)
|
||||
(t2/insert! :model/PythonLibrary saved-python-library)))))))
|
||||
@@ -1,7 +1,7 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.dependencies-test
|
||||
(ns metabase-enterprise.metabot.tools.dependencies-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.dependencies :as metabot.dependencies]
|
||||
[metabase-enterprise.metabot.tools.dependencies :as metabot.dependencies]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.permissions.models.permissions :as perms]
|
||||
@@ -0,0 +1,26 @@
|
||||
(ns metabase-enterprise.metabot.tools.entity-details-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.test :as met]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.metabot.tools.entity-details :as entity-details]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- sandboxed-query []
|
||||
(let [mp (mt/metadata-provider)
|
||||
table (lib.metadata/table mp (mt/id :categories))
|
||||
id-field (lib.metadata/field mp (mt/id :categories :id))]
|
||||
(lib/filter (lib/query mp table) (lib/< id-field 3))))
|
||||
|
||||
(deftest sandboxed-field-values-test
|
||||
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
|
||||
(let [field-id (mt/id :categories :name)]
|
||||
(try
|
||||
(let [result (entity-details/get-table-details {:table-id (mt/id :categories)})
|
||||
name-field (some #(when (= "NAME" (:name %)) %) (get-in result [:structured-output :fields]))]
|
||||
(testing "returns sandboxed field values"
|
||||
(is (= ["African" "American"] (:field_values name-field)))))
|
||||
(finally
|
||||
(t2/delete! :model/FieldValues :field_id field-id :type :advanced))))))
|
||||
@@ -0,0 +1,69 @@
|
||||
(ns metabase-enterprise.metabot.tools.field-stats-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.test :as met]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.metabot.tools.field-stats :as metabot.tools.field-stats]
|
||||
[metabase.metabot.tools.util :as metabot.tools.u]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- table-query
|
||||
[metadata-provider table-id]
|
||||
(lib/query metadata-provider (lib.metadata/table metadata-provider table-id)))
|
||||
|
||||
(defn- visible-field-id [query field-id-prefix field-display-name]
|
||||
(->> (keep-indexed (fn [i col]
|
||||
(when (= (lib/display-name query col) field-display-name)
|
||||
i))
|
||||
(lib/visible-columns query))
|
||||
first
|
||||
(str field-id-prefix)))
|
||||
|
||||
(defn- sandboxed-query []
|
||||
(let [mp (mt/metadata-provider)
|
||||
table (lib.metadata/table mp (mt/id :categories))
|
||||
id-field (lib.metadata/field mp (mt/id :categories :id))]
|
||||
(lib/filter (lib/query mp table) (lib/< id-field 3))))
|
||||
|
||||
(deftest sandboxed-field-values-test
|
||||
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
|
||||
(let [field-id (mt/id :categories :name)
|
||||
table-id (mt/id :categories)
|
||||
mp (mt/metadata-provider)
|
||||
tq (table-query mp table-id)
|
||||
agent-field-id (visible-field-id tq (metabot.tools.u/table-field-id-prefix table-id) "Name")]
|
||||
(try
|
||||
(let [result (metabot.tools.field-stats/field-values
|
||||
{:entity-type "table", :entity-id table-id, :field-id agent-field-id, :limit 10})]
|
||||
(testing "returns sandboxed field values"
|
||||
(is (= ["African" "American"] (get-in result [:structured-output :value_metadata :field_values])))))
|
||||
(finally
|
||||
(t2/delete! :model/FieldValues :field_id field-id :type :advanced))))))
|
||||
|
||||
(deftest refingerprint-bypasses-sandboxing-test
|
||||
(testing "When Metabot triggers re-fingerprinting for a missing fingerprint, the fingerprint reflects the full
|
||||
(unsandboxed) data, not the sandboxed view of the current user."
|
||||
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
|
||||
(let [field-id (mt/id :categories :name)
|
||||
table-id (mt/id :categories)
|
||||
original-fp (t2/select-one-fn :fingerprint :model/Field :id field-id)
|
||||
full-distinct-count (get-in original-fp [:global :distinct-count])]
|
||||
(testing "precondition: the field has a fingerprint with a distinct count greater than 0"
|
||||
(is (> full-distinct-count 0)))
|
||||
(try
|
||||
;; Clear the fingerprint to force re-fingerprinting via get-or-create-fingerprint!
|
||||
(t2/update! :model/Field field-id {:fingerprint nil :fingerprint_version 0})
|
||||
(let [mp (mt/metadata-provider)
|
||||
tq (table-query mp table-id)
|
||||
agent-field-id (visible-field-id tq (metabot.tools.u/table-field-id-prefix table-id) "Name")]
|
||||
(metabot.tools.field-stats/field-values
|
||||
{:entity-type "table", :entity-id table-id, :field-id agent-field-id})
|
||||
(let [new-fp (t2/select-one-fn :fingerprint :model/Field :id field-id)]
|
||||
(testing "fingerprint was saved"
|
||||
(is (some? new-fp)))
|
||||
(testing "fingerprint reflects full table data, not the sandboxed subset"
|
||||
(is (= full-distinct-count (get-in new-fp [:global :distinct-count]))))))
|
||||
(finally
|
||||
(t2/update! :model/Field field-id {:fingerprint original-fp})))))))
|
||||
@@ -1,10 +1,10 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.semantic-search-test
|
||||
(ns metabase-enterprise.metabot.tools.semantic-search-test
|
||||
"Semantic search tests specific to the Metabot search tool integration."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as search]
|
||||
[metabase-enterprise.semantic-search.test-util :as semantic.tu]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.metabot.tools.search :as search]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.search.core :as search-core]
|
||||
[metabase.search.engine :as search.engine]
|
||||
@@ -0,0 +1,12 @@
|
||||
(ns metabase-enterprise.metabot.tools.transforms-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot.tools.transforms :as metabot.tools.transforms]))
|
||||
|
||||
;; Tests for get-transforms and get-transform-details have been moved to
|
||||
;; metabase.metabot.tools.transforms-test (OSS).
|
||||
|
||||
(deftest get-transform-python-library-details-test
|
||||
(testing "get-transform-python-library-details is available"
|
||||
;; Basic smoke test that the function exists and is callable
|
||||
(is (fn? metabot.tools.transforms/get-transform-python-library-details))))
|
||||
@@ -0,0 +1,25 @@
|
||||
(ns metabase-enterprise.metabot.tools.transforms-write-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot.tools.transforms-write :as transforms-write]
|
||||
[metabase.metabot.tools.transforms-write :as oss-transforms-write]))
|
||||
|
||||
;; SQL edit/write tests have been moved to metabase.metabot.tools.transforms-write-test (OSS).
|
||||
|
||||
(deftest create-fresh-python-template-test
|
||||
(testing "fresh Python transforms include common import"
|
||||
(let [result (oss-transforms-write/create-fresh-transform
|
||||
:python "Python Transform" nil nil nil)]
|
||||
(is (= "import common\nimport pandas as pd\n\ndef transform():\n # Your transformation logic here\n return pd.DataFrame([{\"message\": \"Hello from Python transform!\"}])\n"
|
||||
(get-in result [:source :query]))))))
|
||||
|
||||
(deftest create-fresh-python-transform-test
|
||||
(testing "creates fresh Python transform"
|
||||
(let [memory-atom (atom {:state {}})
|
||||
result (transforms-write/write-transform-python
|
||||
{:edit_action {:mode "replace"
|
||||
:new_content "def transform():\n return pd.DataFrame()"}
|
||||
:transform_name "Python Transform"
|
||||
:memory-atom memory-atom})]
|
||||
(is (= "Python Transform" (get-in result [:structured-output :transform :name])))
|
||||
(is (= "python" (get-in result [:structured-output :transform :source :type]))))))
|
||||
@@ -1,78 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.config-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest resolve-dynamic-metabot-id-test
|
||||
(testing "metabot ID resolution precedence"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil]
|
||||
(testing "explicit metabot-id takes precedence"
|
||||
(is (= "explicit-id"
|
||||
(metabot-v3.config/resolve-dynamic-metabot-id "explicit-id"))))
|
||||
|
||||
(testing "falls back to environment variable"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id "env-metabot-id"]
|
||||
(is (= "env-metabot-id"
|
||||
(metabot-v3.config/resolve-dynamic-metabot-id nil)))))
|
||||
|
||||
(testing "falls back to internal default when no explicit or env value"
|
||||
(is (= metabot-v3.config/internal-metabot-id
|
||||
(metabot-v3.config/resolve-dynamic-metabot-id nil))))))))
|
||||
|
||||
(deftest resolve-dynamic-profile-id-test
|
||||
(testing "profile ID resolution precedence"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/ai-service-profile-id nil]
|
||||
(testing "explicit profile-id takes highest precedence"
|
||||
(is (= "explicit-profile"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id "explicit-profile" "any-metabot-id"))))
|
||||
|
||||
(testing "environment variable takes second precedence"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/ai-service-profile-id "env-profile"]
|
||||
(is (= "env-profile"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id nil "any-metabot-id")))))
|
||||
|
||||
(testing "metabot-id mapping takes third precedence"
|
||||
(is (= "internal"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id nil metabot-v3.config/internal-metabot-id)))
|
||||
(is (= "embedding_next"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id nil metabot-v3.config/embedded-metabot-id))))
|
||||
|
||||
(testing "falls back to default when no matches"
|
||||
(is (= "embedding_next"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id nil "unknown-metabot-id"))))
|
||||
|
||||
(testing "single arity version uses dynamic metabot resolution"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id metabot-v3.config/embedded-metabot-id]
|
||||
(is (= "embedding_next"
|
||||
(metabot-v3.config/resolve-dynamic-profile-id nil)))))))))
|
||||
|
||||
(deftest integrated-resolution-test
|
||||
(testing "combination of metabot-id and profile-id precedence resolution"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(testing "explicit params override everything"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil
|
||||
metabot-v3.settings/ai-service-profile-id nil]
|
||||
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id "custom-metabot")
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id "custom-profile" metabot-id)]
|
||||
(is (= "custom-metabot" metabot-id))
|
||||
(is (= "custom-profile" profile-id)))))
|
||||
|
||||
(testing "environment variables work together"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id "env-metabot"
|
||||
metabot-v3.settings/ai-service-profile-id "env-profile"]
|
||||
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id nil)
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id nil metabot-id)]
|
||||
(is (= "env-metabot" metabot-id))
|
||||
(is (= "env-profile" profile-id)))))
|
||||
|
||||
(testing "defaults work together"
|
||||
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil
|
||||
metabot-v3.settings/ai-service-profile-id nil]
|
||||
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id nil)
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id nil metabot-id)]
|
||||
(is (= metabot-v3.config/internal-metabot-id metabot-id))
|
||||
(is (= "internal" profile-id))))))))
|
||||
@@ -0,0 +1,172 @@
|
||||
(ns metabase-enterprise.oauth-server.admin-api-test
|
||||
(:require
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------------------ POST /api/ee/oauth-server/clients ----------------------------
|
||||
|
||||
(deftest create-client-test
|
||||
(testing "POST /api/ee/oauth-server/clients"
|
||||
(testing "creates a static client and returns it with the plaintext secret"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(let [response (mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "My Test App"
|
||||
:grant_types ["authorization_code"]
|
||||
:scopes ["openid" "profile"]})]
|
||||
(is (=? {:id pos-int?
|
||||
:client_id string?
|
||||
:client_secret string?
|
||||
:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "My Test App"
|
||||
:registration_type "static"}
|
||||
response)
|
||||
"client_secret should be returned on creation")))))
|
||||
|
||||
(testing "returns 400 when redirect_uris is missing"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :crowberto :post 400
|
||||
"ee/oauth-server/clients"
|
||||
{:client_name "No Redirects"})))
|
||||
|
||||
(testing "returns 403 for non-admin user"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :rasta :post 403
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]})))
|
||||
|
||||
(testing "returns 402 without feature flag"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/user-http-request :crowberto :post 402
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]})))))
|
||||
|
||||
;;; ------------------------------------------------ GET /api/ee/oauth-server/clients -----------------------------
|
||||
|
||||
(deftest list-clients-test
|
||||
(testing "GET /api/ee/oauth-server/clients"
|
||||
(testing "returns list of clients without secrets"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(let [created (mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "List Test"})
|
||||
clients (mt/user-http-request :crowberto :get 200
|
||||
"ee/oauth-server/clients")]
|
||||
(is (sequential? clients))
|
||||
(is (some (fn [c] (= (:id created) (:id c))) clients)
|
||||
"created client should appear in list")
|
||||
(is (every? (fn [c] (and (nil? (:client_secret c))
|
||||
(nil? (:client_secret_hash c))))
|
||||
clients)
|
||||
"secrets and hashes must not be returned")))))
|
||||
|
||||
(testing "supports registration_type filter"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "Static Client"})
|
||||
(let [clients (mt/user-http-request :crowberto :get 200
|
||||
"ee/oauth-server/clients"
|
||||
:registration-type "static")]
|
||||
(is (every? #(= "static" (:registration_type %)) clients))))))
|
||||
|
||||
(testing "returns 403 for non-admin"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :rasta :get 403 "ee/oauth-server/clients")))
|
||||
|
||||
(testing "returns 402 without feature flag"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/user-http-request :crowberto :get 402 "ee/oauth-server/clients")))))
|
||||
|
||||
;;; ------------------------------------------------ GET /api/ee/oauth-server/clients/:id -------------------------
|
||||
|
||||
(deftest get-client-test
|
||||
(testing "GET /api/ee/oauth-server/clients/:id"
|
||||
(testing "returns single client without secret"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(let [created (mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "Get Test"})
|
||||
response (mt/user-http-request :crowberto :get 200
|
||||
(str "ee/oauth-server/clients/" (:id created)))]
|
||||
(is (= (:id created) (:id response)))
|
||||
(is (= (:client_id created) (:client_id response)))
|
||||
(is (nil? (:client_secret response)))
|
||||
(is (nil? (:client_secret_hash response)))))))
|
||||
|
||||
(testing "returns 404 for non-existent id"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :crowberto :get 404
|
||||
"ee/oauth-server/clients/999999")))
|
||||
|
||||
(testing "returns 403 for non-admin"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :rasta :get 403
|
||||
"ee/oauth-server/clients/1")))))
|
||||
|
||||
;;; ------------------------------------------------ PUT /api/ee/oauth-server/clients/:id -------------------------
|
||||
|
||||
(deftest update-client-test
|
||||
(testing "PUT /api/ee/oauth-server/clients/:id"
|
||||
(testing "updates client_name and redirect_uris"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(let [created (mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "Before Update"})
|
||||
response (mt/user-http-request :crowberto :put 200
|
||||
(str "ee/oauth-server/clients/" (:id created))
|
||||
{:client_name "After Update"
|
||||
:redirect_uris ["https://new.example.com/callback"]})]
|
||||
(is (= "After Update" (:client_name response)))
|
||||
(is (= ["https://new.example.com/callback"] (:redirect_uris response)))
|
||||
(is (nil? (:client_secret response)))))))
|
||||
|
||||
(testing "returns 404 for non-existent id"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :crowberto :put 404
|
||||
"ee/oauth-server/clients/999999"
|
||||
{:client_name "Nope"})))
|
||||
|
||||
(testing "returns 403 for non-admin"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :rasta :put 403
|
||||
"ee/oauth-server/clients/1"
|
||||
{:client_name "Nope"})))))
|
||||
|
||||
;;; ------------------------------------------------ DELETE /api/ee/oauth-server/clients/:id ----------------------
|
||||
|
||||
(deftest delete-client-test
|
||||
(testing "DELETE /api/ee/oauth-server/clients/:id"
|
||||
(testing "deletes the client"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(t2/with-transaction [_conn nil {:rollback-only true}]
|
||||
(let [created (mt/user-http-request :crowberto :post 200
|
||||
"ee/oauth-server/clients"
|
||||
{:redirect_uris ["https://example.com/callback"]
|
||||
:client_name "Delete Me"})]
|
||||
(mt/user-http-request :crowberto :delete 204
|
||||
(str "ee/oauth-server/clients/" (:id created)))
|
||||
;; Verify it's gone
|
||||
(mt/user-http-request :crowberto :get 404
|
||||
(str "ee/oauth-server/clients/" (:id created)))))))
|
||||
|
||||
(testing "returns 404 for non-existent id"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :crowberto :delete 404
|
||||
"ee/oauth-server/clients/999999")))
|
||||
|
||||
(testing "returns 403 for non-admin"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/user-http-request :rasta :delete 403
|
||||
"ee/oauth-server/clients/1")))))
|
||||
@@ -63,7 +63,7 @@
|
||||
(let [embedding-model {:provider "openai"
|
||||
:model-name "text-embedding-3-small"
|
||||
:vector-dimensions 1536}]
|
||||
(mt/with-temporary-setting-values [ee-openai-api-key nil]
|
||||
(mt/with-temporary-setting-values [llm-openai-api-key nil]
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"OpenAI API key not configured"
|
||||
@@ -174,7 +174,7 @@
|
||||
(is (thrown? Exception (decode [{:embedding invalid-base64}]))))))))
|
||||
|
||||
(deftest test-get-embedding
|
||||
(mt/with-temporary-setting-values [ee-openai-api-key "mock-openai-api-key"
|
||||
(mt/with-temporary-setting-values [llm-openai-api-key "mock-openai-api-key"
|
||||
ee-embedding-service-base-url "http://mock-embedding-service"
|
||||
ee-embedding-service-api-key "mock-embedding-service-key"]
|
||||
(let [mock-embedding [1.0 2.0 3.0 4.0]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase-enterprise.sso.test-setup :as sso.test-setup]
|
||||
[metabase.auth-identity.core :as auth-identity]
|
||||
[metabase.sso.oidc.state :as oidc.state]
|
||||
[metabase.sso.settings :as sso-settings]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[metabase.test.http-client :as client]
|
||||
@@ -68,49 +68,40 @@
|
||||
(with-test-encryption!
|
||||
(sso.test-setup/do-with-other-sso-types-disabled!
|
||||
(fn []
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret "test-slack-client-secret"]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret "test-slack-client-secret"]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "The client secret must also be included for SSO to be configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}))))))
|
||||
|
||||
(testing "SSO requests fail if they don't have a valid premium-features token"
|
||||
(sso.test-setup/call-with-default-slack-config!
|
||||
(fn []
|
||||
(mt/with-premium-features #{}
|
||||
(let [response (mt/client :get 402 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})]
|
||||
(is (str/includes? (str (:message response) response) "paid feature")))))))))))
|
||||
(testing "The client secret must also be included for SSO to be configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))))))
|
||||
|
||||
;;; -------------------------------------------------- Redirect Tests --------------------------------------------------
|
||||
|
||||
@@ -348,17 +339,16 @@
|
||||
;; Don't use sso.test-setup/with-slack-default-setup! here because it sets env vars
|
||||
;; which take precedence over DB values when reading settings.
|
||||
;; This test only needs to verify the setter validates input correctly.
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-authentication-mode nil]
|
||||
(testing "valid values are accepted"
|
||||
(sso-settings/slack-connect-authentication-mode! "sso")
|
||||
(is (= "sso" (sso-settings/slack-connect-authentication-mode)))
|
||||
(sso-settings/slack-connect-authentication-mode! "link-only")
|
||||
(is (= "link-only" (sso-settings/slack-connect-authentication-mode))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-authentication-mode nil]
|
||||
(testing "valid values are accepted"
|
||||
(sso-settings/slack-connect-authentication-mode! "sso")
|
||||
(is (= "sso" (sso-settings/slack-connect-authentication-mode)))
|
||||
(sso-settings/slack-connect-authentication-mode! "link-only")
|
||||
(is (= "link-only" (sso-settings/slack-connect-authentication-mode))))
|
||||
|
||||
(testing "invalid values are rejected"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid authentication mode"
|
||||
(sso-settings/slack-connect-authentication-mode! "invalid"))))))))
|
||||
(testing "invalid values are rejected"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid authentication mode"
|
||||
(sso-settings/slack-connect-authentication-mode! "invalid")))))))
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.sso.providers.slack-connect]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.auth-identity.core :as auth-identity]
|
||||
[metabase.auth-identity.provider :as provider]
|
||||
[metabase.sso.oidc.discovery :as oidc.discovery]
|
||||
[metabase.sso.oidc.state :as oidc.state]
|
||||
[metabase.sso.oidc.tokens :as oidc.tokens]
|
||||
[metabase.sso.providers.slack-connect]
|
||||
[metabase.sso.settings :as sso-settings]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -16,7 +16,6 @@
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :test-users))
|
||||
(use-fixtures :each (fn [f] (mt/with-premium-features #{:sso-slack} (f))))
|
||||
|
||||
(def ^:private slack-discovery-doc
|
||||
{:authorization_endpoint "https://slack.com/oauth/v2/authorize"
|
||||
@@ -41,7 +40,7 @@
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (= "test-client-id" (:client-id config)))
|
||||
(is (= "test-secret" (:client-secret config)))
|
||||
(is (= "https://slack.com" (:issuer-uri config)))
|
||||
@@ -53,7 +52,7 @@
|
||||
[slack-connect-client-id nil
|
||||
slack-connect-client-secret "test-secret"]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (nil? config)))))
|
||||
|
||||
(testing "Returns nil when client secret is missing"
|
||||
@@ -61,7 +60,7 @@
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (nil? config))))))
|
||||
|
||||
;;; -------------------------------------------------- Claim Extraction Tests --------------------------------------------------
|
||||
@@ -75,7 +74,7 @@
|
||||
"https://slack.com/team_image_230" "https://example.com/team.png"
|
||||
:email_verified true
|
||||
:locale "en-US"}
|
||||
slack-attrs (#'metabase-enterprise.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
|
||||
slack-attrs (#'metabase.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
|
||||
(is (= "U12345678" (get slack-attrs "slack-user-id")))
|
||||
(is (= "T12345678" (get slack-attrs "slack-team-id")))
|
||||
(is (= "https://example.com/team.png" (get slack-attrs "slack-team-image")))
|
||||
@@ -86,7 +85,7 @@
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-attribute-team-id "https://slack.com/team_id"]
|
||||
(let [id-token-claims {:sub "U12345678"}
|
||||
slack-attrs (#'metabase-enterprise.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
|
||||
slack-attrs (#'metabase.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
|
||||
(is (= "U12345678" (get slack-attrs "slack-user-id")))
|
||||
(is (nil? (get slack-attrs "slack-team-id")))
|
||||
(is (nil? (get slack-attrs "slack-team-image")))
|
||||
@@ -95,52 +94,40 @@
|
||||
|
||||
;;; -------------------------------------------------- Authentication Tests --------------------------------------------------
|
||||
|
||||
(deftest authenticate-missing-premium-feature-test
|
||||
(testing "Returns error when premium feature is not available"
|
||||
(mt/with-premium-features #{}
|
||||
(deftest authenticate-not-enabled-test
|
||||
(testing "Returns error when Slack Connect is not enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false]
|
||||
(let [request {}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :feature-not-available (:error result)))
|
||||
(is (= :slack-connect-not-enabled (:error result)))
|
||||
(is (some? (:message result)))))))
|
||||
|
||||
(deftest authenticate-not-enabled-test
|
||||
(testing "Returns error when Slack Connect is not enabled"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false]
|
||||
(let [request {}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :slack-connect-not-enabled (:error result)))
|
||||
(is (some? (:message result))))))))
|
||||
|
||||
(deftest authenticate-not-configured-test
|
||||
(testing "Returns error when Slack Connect is not configured"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(let [request {}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :slack-connect-not-enabled (:error result)))
|
||||
(is (some? (:message result))))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(let [request {}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :slack-connect-not-enabled (:error result)))
|
||||
(is (some? (:message result)))))))
|
||||
|
||||
(deftest authenticate-link-only-requires-session-test
|
||||
(testing "Returns error in link-only mode when no authenticated session"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"]
|
||||
(let [request {:authenticated-user (delay nil)}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :authentication-required (:error result)))
|
||||
(is (some? (:message result))))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"]
|
||||
(let [request {:authenticated-user (delay nil)}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (false? (:success? result)))
|
||||
(is (= :authentication-required (:error result)))
|
||||
(is (some? (:message result)))))))
|
||||
|
||||
(deftest authenticate-link-only-allows-callback-test
|
||||
(testing "Does not require session for callback in link-only mode"
|
||||
@@ -175,14 +162,56 @@
|
||||
|
||||
(deftest authenticate-enhances-with-slack-data-test
|
||||
(testing "Enhances successful auth result with Slack-specific data"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-attribute-team-id "https://slack.com/team_id"]
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-attribute-team-id "https://slack.com/team_id"]
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub "U12345678"
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "test@example.com"
|
||||
"https://slack.com/team_id" "T12345678"
|
||||
:email_verified true}})]
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:oidc-nonce "test-nonce"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (= :slack (get-in result [:user-data :sso_source])))
|
||||
(is (= "U12345678" (get-in result [:user-data :login_attributes "slack-user-id"])))
|
||||
(is (= "T12345678" (get-in result [:user-data :login_attributes "slack-team-id"])))
|
||||
(is (= "true" (get-in result [:user-data :login_attributes "slack-email-verified"])))
|
||||
(is (some? (:slack-data result))))))))
|
||||
|
||||
;;; -------------------------------------------------- Login Tests --------------------------------------------------
|
||||
|
||||
(deftest link-only-mode-no-session-creation-test
|
||||
(testing "No session is created when authentication mode is link-only"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"
|
||||
slack-connect-user-provisioning-enabled false]
|
||||
(mt/with-temp [:model/User user {:email "link-only-no-session@example.com"}]
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
@@ -194,79 +223,88 @@
|
||||
:claims {:sub "U12345678"
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "test@example.com"
|
||||
"https://slack.com/team_id" "T12345678"
|
||||
:email_verified true}})]
|
||||
(let [request {:code "test-code"
|
||||
:email "link-only-no-session@example.com"}})]
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:oidc-nonce "test-nonce"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (= :slack (get-in result [:user-data :sso_source])))
|
||||
(is (= "U12345678" (get-in result [:user-data :login_attributes "slack-user-id"])))
|
||||
(is (= "T12345678" (get-in result [:user-data :login_attributes "slack-team-id"])))
|
||||
(is (= "true" (get-in result [:user-data :login_attributes "slack-email-verified"])))
|
||||
(is (some? (:slack-data result)))))))))
|
||||
|
||||
;;; -------------------------------------------------- Login Tests --------------------------------------------------
|
||||
|
||||
(deftest link-only-mode-no-session-creation-test
|
||||
(testing "No session is created when authentication mode is link-only"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"
|
||||
slack-connect-user-provisioning-enabled false]
|
||||
(mt/with-temp [:model/User user {:email "link-only-no-session@example.com"}]
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub "U12345678"
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "link-only-no-session@example.com"}})]
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (nil? (:session result)) "Result should not contain a session")
|
||||
(is (nil? (:user result)) "Result should not contain a user (dissoc'd by :after method)")
|
||||
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
|
||||
"No new session should be created in the database"))))))))
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (nil? (:session result)) "Result should not contain a session")
|
||||
(is (nil? (:user result)) "Result should not contain a user (dissoc'd by :after method)")
|
||||
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
|
||||
"No new session should be created in the database")))))))
|
||||
|
||||
;;; -------------------------------------------------- AuthIdentity Creation Tests --------------------------------------------------
|
||||
|
||||
(deftest sso-mode-creates-auth-identity-for-new-user-test
|
||||
(testing "SSO mode creates AuthIdentity when a new user logs in via Slack"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "sso"
|
||||
slack-connect-user-provisioning-enabled true]
|
||||
(let [test-email "newuser-slack@example.com"
|
||||
slack-user-id "U_NEW_12345"]
|
||||
;; Ensure user doesn't exist
|
||||
(t2/delete! :model/User :email test-email)
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "sso"
|
||||
slack-connect-user-provisioning-enabled true]
|
||||
(let [test-email "newuser-slack@example.com"
|
||||
slack-user-id "U_NEW_12345"]
|
||||
;; Ensure user doesn't exist
|
||||
(t2/delete! :model/User :email test-email)
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub slack-user-id
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email test-email
|
||||
:given_name "New"
|
||||
:family_name "User"}})]
|
||||
(try
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (some? (:user result)) "Result should contain a user")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [user (t2/select-one :model/User :email test-email)
|
||||
auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? user) "User should be created")
|
||||
(is (some? auth-identity) "AuthIdentity should be created")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id")))
|
||||
(finally
|
||||
(t2/delete! :model/User :email test-email))))))))
|
||||
|
||||
(deftest sso-mode-creates-auth-identity-for-existing-user-test
|
||||
(testing "SSO mode creates AuthIdentity when an existing user logs in via Slack"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "sso"]
|
||||
(let [slack-user-id "U_EXISTING_789"]
|
||||
(mt/with-temp [:model/User user {:email "existing-slack@example.com"
|
||||
:first_name "Existing"
|
||||
:last_name "User"}]
|
||||
;; Ensure no AuthIdentity exists for this user
|
||||
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
@@ -285,128 +323,72 @@
|
||||
:claims {:sub slack-user-id
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email test-email
|
||||
:given_name "New"
|
||||
:family_name "User"}})]
|
||||
(try
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (some? (:user result)) "Result should contain a user")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [user (t2/select-one :model/User :email test-email)
|
||||
auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? user) "User should be created")
|
||||
(is (some? auth-identity) "AuthIdentity should be created")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id")))
|
||||
(finally
|
||||
(t2/delete! :model/User :email test-email)))))))))
|
||||
|
||||
(deftest sso-mode-creates-auth-identity-for-existing-user-test
|
||||
(testing "SSO mode creates AuthIdentity when an existing user logs in via Slack"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "sso"]
|
||||
(let [slack-user-id "U_EXISTING_789"]
|
||||
(mt/with-temp [:model/User user {:email "existing-slack@example.com"
|
||||
:first_name "Existing"
|
||||
:last_name "User"}]
|
||||
;; Ensure no AuthIdentity exists for this user
|
||||
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub slack-user-id
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "existing-slack@example.com"}})]
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? auth-identity) "AuthIdentity should be created for existing user")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id"))))))))))
|
||||
:email "existing-slack@example.com"}})]
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? auth-identity) "AuthIdentity should be created for existing user")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id")))))))))
|
||||
|
||||
(deftest link-only-mode-creates-auth-identity-test
|
||||
(testing "Link-only mode creates AuthIdentity for the authenticated user"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"]
|
||||
(let [slack-user-id "U_LINK_456"]
|
||||
(mt/with-temp [:model/User user {:email "linkuser@example.com"
|
||||
:first_name "Link"
|
||||
:last_name "User"}]
|
||||
;; Ensure no AuthIdentity exists for this user
|
||||
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub slack-user-id
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "linkuser@example.com"}})]
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (nil? (:session result)) "No session should be created in link-only mode")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? auth-identity) "AuthIdentity should be created in link-only mode")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id"))
|
||||
;; Verify no new session was created
|
||||
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
|
||||
"No new session should be created")))))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-authentication-mode "link-only"]
|
||||
(let [slack-user-id "U_LINK_456"]
|
||||
(mt/with-temp [:model/User user {:email "linkuser@example.com"
|
||||
:first_name "Link"
|
||||
:last_name "User"}]
|
||||
;; Ensure no AuthIdentity exists for this user
|
||||
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
|
||||
(with-redefs [oidc.discovery/discover-oidc-configuration
|
||||
(fn [_issuer] slack-discovery-doc)
|
||||
oidc.state/validate-oidc-callback
|
||||
(fn [_request _state _provider & _opts]
|
||||
{:valid? true
|
||||
:nonce "test-nonce"
|
||||
:redirect "/"})
|
||||
http/post
|
||||
(fn [_url _opts]
|
||||
{:status 200
|
||||
:body {:id_token "valid-token"
|
||||
:access_token "access-token-123"}})
|
||||
oidc.tokens/validate-id-token
|
||||
(fn [_token _config _nonce]
|
||||
{:valid? true
|
||||
:claims {:sub slack-user-id
|
||||
:iss "https://slack.com"
|
||||
:aud "test-client-id"
|
||||
:email "linkuser@example.com"}})]
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
(is (nil? (:session result)) "No session should be created in link-only mode")
|
||||
;; Verify AuthIdentity was created
|
||||
(let [auth-identity (t2/select-one :model/AuthIdentity
|
||||
:user_id (:id user)
|
||||
:provider "slack-connect")]
|
||||
(is (some? auth-identity) "AuthIdentity should be created in link-only mode")
|
||||
(is (= slack-user-id (:provider_id auth-identity))
|
||||
"AuthIdentity should have correct provider_id"))
|
||||
;; Verify no new session was created
|
||||
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
|
||||
"No new session should be created"))))))))
|
||||
|
||||
;;; -------------------------------------------------- Settings Validation Tests --------------------------------------------------
|
||||
|
||||
@@ -431,28 +413,25 @@
|
||||
|
||||
(deftest slack-connect-enabled-test
|
||||
(testing "Returns true when configured and enabled"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-enabled true]
|
||||
(is (true? (sso-settings/slack-connect-enabled))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-enabled true]
|
||||
(is (true? (sso-settings/slack-connect-enabled)))))
|
||||
|
||||
(testing "Returns false when configured but not enabled"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-enabled false]
|
||||
(is (false? (sso-settings/slack-connect-enabled))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"
|
||||
slack-connect-enabled false]
|
||||
(is (false? (sso-settings/slack-connect-enabled)))))
|
||||
|
||||
(testing "Returns false when not configured even if enabled is true"
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id nil
|
||||
slack-connect-client-secret nil
|
||||
slack-connect-enabled true]
|
||||
(is (false? (sso-settings/slack-connect-enabled)))))))
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id nil
|
||||
slack-connect-client-secret nil
|
||||
slack-connect-enabled true]
|
||||
(is (false? (sso-settings/slack-connect-enabled))))))
|
||||
|
||||
(deftest slack-connect-authentication-mode-validation-test
|
||||
(testing "Accepts valid authentication modes"
|
||||
|
||||
@@ -229,9 +229,8 @@ export const createMockSettings = (
|
||||
"deprecation-notice-version": undefined,
|
||||
"development-mode?": false,
|
||||
"disable-cors-on-localhost": false,
|
||||
"ee-ai-features-enabled": false,
|
||||
"ee-openai-model": "",
|
||||
"ee-openai-api-key": "",
|
||||
"llm-openai-model": "",
|
||||
"llm-openai-api-key": "",
|
||||
"llm-anthropic-model": "",
|
||||
"email-configured?": false,
|
||||
"email-smtp-host": null,
|
||||
|
||||
@@ -509,7 +509,7 @@ interface AdminSettings {
|
||||
}
|
||||
interface SettingsManagerSettings {
|
||||
"bcc-enabled?": boolean;
|
||||
"ee-openai-api-key"?: string;
|
||||
"llm-openai-api-key"?: string;
|
||||
"openai-api-key": string | null;
|
||||
"openai-available-models"?: OpenAiModel[];
|
||||
"openai-model": string | null;
|
||||
@@ -541,7 +541,6 @@ interface PublicSettings {
|
||||
"custom-homepage": boolean;
|
||||
"custom-homepage-dashboard": DashboardId | null;
|
||||
"development-mode?": boolean;
|
||||
"ee-ai-features-enabled"?: boolean;
|
||||
"email-configured?": boolean;
|
||||
"embedding-app-origin": string | null;
|
||||
"embedding-app-origins-sdk": string | null;
|
||||
@@ -710,9 +709,8 @@ export interface EnterpriseSettings extends Settings {
|
||||
"no-object-illustration"?: IllustrationSettingValue;
|
||||
"no-object-illustration-custom"?: string;
|
||||
"landing-page"?: string;
|
||||
"ee-ai-features-enabled"?: boolean;
|
||||
"ee-openai-api-key"?: string;
|
||||
"ee-openai-model"?: string;
|
||||
"llm-openai-api-key"?: string;
|
||||
"llm-openai-model"?: string;
|
||||
"session-timeout": TimeoutValue | null;
|
||||
"search-engine": SearchEngineSettingValue | null;
|
||||
"scim-enabled"?: boolean | null;
|
||||
|
||||
@@ -15,6 +15,12 @@ import { canAccessTransforms } from "./transforms/selectors";
|
||||
|
||||
type Props = { children: React.ReactElement };
|
||||
|
||||
/** Paths that are handled by the backend server, not the frontend SPA router. */
|
||||
export const BACKEND_ONLY_PATH_PREFIXES = ["/oauth/"];
|
||||
|
||||
export const isBackendOnlyPath = (path: string): boolean =>
|
||||
BACKEND_ONLY_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
|
||||
const getRedirectUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUrlParam = params.get("redirect");
|
||||
@@ -68,7 +74,19 @@ const UserIsNotAuthenticated = connectedReduxRedirect<Props, State>({
|
||||
authenticatingSelector: (state) =>
|
||||
state.auth.loginPending || !state.auth.redirect,
|
||||
authenticatedSelector: (state) => !state.currentUser,
|
||||
redirectAction: routerActions.replace,
|
||||
redirectAction: (location: {
|
||||
pathname: string;
|
||||
query?: Record<string, string>;
|
||||
}) => {
|
||||
if (isBackendOnlyPath(location.pathname)) {
|
||||
const params = new URLSearchParams(location.query);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${location.pathname}?${qs}` : location.pathname;
|
||||
window.location.replace(url);
|
||||
return routerActions.replace("/");
|
||||
}
|
||||
return routerActions.replace(location);
|
||||
},
|
||||
context: MetabaseReduxContext,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderWithProviders, screen } from "__support__/ui";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import { MetabaseReduxContext } from "./lib/redux";
|
||||
import { isBackendOnlyPath } from "./route-guards";
|
||||
|
||||
describe("route-guards", () => {
|
||||
describe("patched redux-auth-wrapper", () => {
|
||||
@@ -63,6 +64,25 @@ describe("route-guards", () => {
|
||||
expect(screen.queryByText(text)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBackendOnlyPath", () => {
|
||||
it("should return true for /oauth/ paths", () => {
|
||||
expect(isBackendOnlyPath("/oauth/authorize")).toBe(true);
|
||||
expect(isBackendOnlyPath("/oauth/authorize/decision")).toBe(true);
|
||||
expect(isBackendOnlyPath("/oauth/token")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for frontend paths", () => {
|
||||
expect(isBackendOnlyPath("/")).toBe(false);
|
||||
expect(isBackendOnlyPath("/auth/login")).toBe(false);
|
||||
expect(isBackendOnlyPath("/collection/root")).toBe(false);
|
||||
expect(isBackendOnlyPath("/question/1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not match partial prefixes", () => {
|
||||
expect(isBackendOnlyPath("/oauthx/foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setupRouteGuard(opts: {
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
config
|
||||
content-verification
|
||||
dashboards
|
||||
documents
|
||||
eid-translation
|
||||
embedding
|
||||
enterprise/api
|
||||
@@ -114,7 +115,9 @@
|
||||
formatter
|
||||
initialization-status
|
||||
internal-stats
|
||||
llm
|
||||
login-history
|
||||
metabot
|
||||
notification
|
||||
permissions
|
||||
premium-features
|
||||
@@ -128,6 +131,7 @@
|
||||
session
|
||||
settings
|
||||
setup
|
||||
slackbot
|
||||
sso
|
||||
startup
|
||||
system
|
||||
@@ -140,7 +144,8 @@
|
||||
util
|
||||
version
|
||||
view-log
|
||||
warehouse-schema})
|
||||
warehouse-schema
|
||||
workspaces})
|
||||
|
||||
(defn- affected-modules
|
||||
"Set of modules that are direct or indirect dependents of `modules`, and thus are affected by changes to them.
|
||||
|
||||
432
resources/migrations/060/20260311_oauth_server_tables.yaml
Normal file
432
resources/migrations/060/20260311_oauth_server_tables.yaml
Normal file
@@ -0,0 +1,432 @@
|
||||
## OAuth/OIDC provider server tables for embedded OAuth support
|
||||
|
||||
databaseChangeLog:
|
||||
- logicalFilePath: migrations/060_update_migrations.yaml
|
||||
|
||||
# oauth_client table
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:00
|
||||
author: epaget
|
||||
comment: Create oauth_client table for embedded OAuth provider
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: oauth_client
|
||||
remarks: Stores OAuth2/OIDC client registrations for the embedded provider
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: int
|
||||
remarks: Auto-incrementing primary key
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: client_id
|
||||
type: varchar(255)
|
||||
remarks: OAuth client_id (UUID)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: client_secret_hash
|
||||
type: ${text.type}
|
||||
remarks: PBKDF2 hash of client secret
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: redirect_uris
|
||||
type: ${text.type}
|
||||
remarks: JSON array of redirect URIs
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: grant_types
|
||||
type: ${text.type}
|
||||
remarks: JSON array of grant types
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: response_types
|
||||
type: ${text.type}
|
||||
remarks: JSON array of response types
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: scopes
|
||||
type: ${text.type}
|
||||
remarks: JSON array of scopes
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: token_endpoint_auth_method
|
||||
type: varchar(50)
|
||||
remarks: OAuth token endpoint auth method (e.g. client_secret_basic)
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: client_name
|
||||
type: varchar(255)
|
||||
remarks: Human-readable client name
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: client_uri
|
||||
type: ${text.type}
|
||||
remarks: URL of the client home page
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: logo_uri
|
||||
type: ${text.type}
|
||||
remarks: URL of the client logo image
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: contacts
|
||||
type: ${text.type}
|
||||
remarks: JSON array of contact strings
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: registration_type
|
||||
type: varchar(50)
|
||||
remarks: "static or dynamic"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: client_type
|
||||
type: varchar(50)
|
||||
remarks: "confidential or public"
|
||||
defaultValue: "confidential"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: registration_access_token_hash
|
||||
type: ${text.type}
|
||||
remarks: Hashed RFC 7592 registration access token
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp of client registration
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp of last client update
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:01
|
||||
author: epaget
|
||||
comment: Add unique index on oauth_client.client_id
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: oauth_client
|
||||
constraintName: uq_oauth_client_client_id
|
||||
columnNames: client_id
|
||||
|
||||
# oauth_authorization_code table
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:02
|
||||
author: epaget
|
||||
comment: Create oauth_authorization_code table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: oauth_authorization_code
|
||||
remarks: Stores authorization codes for the embedded OAuth provider
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: int
|
||||
remarks: Auto-incrementing primary key
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: code
|
||||
type: varchar(255)
|
||||
remarks: The authorization code string
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: user_id
|
||||
type: int
|
||||
remarks: FK to core_user.id
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: client_id
|
||||
type: varchar(255)
|
||||
remarks: The OAuth client_id
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: redirect_uri
|
||||
type: ${text.type}
|
||||
remarks: Redirect URI for this authorization
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: scope
|
||||
type: ${text.type}
|
||||
remarks: JSON array of scope strings
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: nonce
|
||||
type: varchar(255)
|
||||
remarks: OIDC nonce value
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: expiry
|
||||
type: bigint
|
||||
remarks: Milliseconds since epoch
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: code_challenge
|
||||
type: varchar(255)
|
||||
remarks: PKCE code challenge
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: code_challenge_method
|
||||
type: varchar(10)
|
||||
remarks: PKCE code challenge method
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: resource
|
||||
type: ${text.type}
|
||||
remarks: JSON array of resource URIs (RFC 8707)
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp of code creation
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:03
|
||||
author: epaget
|
||||
comment: Add unique index on oauth_authorization_code.code
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: oauth_authorization_code
|
||||
constraintName: uq_oauth_authorization_code_code
|
||||
columnNames: code
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:04
|
||||
author: epaget
|
||||
comment: Add index on oauth_authorization_code.expiry for cleanup
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: oauth_authorization_code
|
||||
indexName: idx_oauth_authorization_code_expiry
|
||||
columns:
|
||||
- column:
|
||||
name: expiry
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:05
|
||||
author: epaget
|
||||
comment: Add FK on oauth_authorization_code.user_id
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: oauth_authorization_code
|
||||
baseColumnNames: user_id
|
||||
referencedTableName: core_user
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_oauth_authorization_code_user_id
|
||||
|
||||
# oauth_access_token table
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:06
|
||||
author: epaget
|
||||
comment: Create oauth_access_token table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: oauth_access_token
|
||||
remarks: Stores access tokens for the embedded OAuth provider
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: int
|
||||
remarks: Auto-incrementing primary key
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: token
|
||||
type: varchar(255)
|
||||
remarks: The access token string
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: user_id
|
||||
type: int
|
||||
remarks: FK to core_user.id (null for client_credentials)
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: client_id
|
||||
type: varchar(255)
|
||||
remarks: The OAuth client_id
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: scope
|
||||
type: ${text.type}
|
||||
remarks: JSON array of scopes
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: expiry
|
||||
type: bigint
|
||||
remarks: Milliseconds since epoch
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: resource
|
||||
type: ${text.type}
|
||||
remarks: JSON array of resource URIs
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: revoked_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Null if active
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp of token creation
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:07
|
||||
author: epaget
|
||||
comment: Add unique index on oauth_access_token.token
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: oauth_access_token
|
||||
constraintName: uq_oauth_access_token_token
|
||||
columnNames: token
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:08
|
||||
author: epaget
|
||||
comment: Add index on oauth_access_token.expiry
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: oauth_access_token
|
||||
indexName: idx_oauth_access_token_expiry
|
||||
columns:
|
||||
- column:
|
||||
name: expiry
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:09
|
||||
author: epaget
|
||||
comment: Add FK on oauth_access_token.user_id
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: oauth_access_token
|
||||
baseColumnNames: user_id
|
||||
referencedTableName: core_user
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_oauth_access_token_user_id
|
||||
|
||||
# oauth_refresh_token table
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:10
|
||||
author: epaget
|
||||
comment: Create oauth_refresh_token table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: oauth_refresh_token
|
||||
remarks: Stores refresh tokens for the embedded OAuth provider
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: int
|
||||
remarks: Auto-incrementing primary key
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: token
|
||||
type: varchar(255)
|
||||
remarks: The refresh token string
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: user_id
|
||||
type: int
|
||||
remarks: FK to core_user.id
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: client_id
|
||||
type: varchar(255)
|
||||
remarks: The OAuth client_id
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: scope
|
||||
type: ${text.type}
|
||||
remarks: JSON array of scopes
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: resource
|
||||
type: ${text.type}
|
||||
remarks: JSON array of resource URIs
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: expiry
|
||||
type: bigint
|
||||
remarks: Milliseconds since epoch (null means no expiry)
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: revoked_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Null if active
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp of token creation
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-11T00:00:11
|
||||
author: epaget
|
||||
comment: Add unique index on oauth_refresh_token.token
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: oauth_refresh_token
|
||||
constraintName: uq_oauth_refresh_token_token
|
||||
columnNames: token
|
||||
@@ -0,0 +1,20 @@
|
||||
## Add application_type column to oauth_client for OIDC provider 0.5.0
|
||||
|
||||
databaseChangeLog:
|
||||
- logicalFilePath: migrations/060_update_migrations.yaml
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-03-20T00:00:00
|
||||
author: epaget
|
||||
comment: Add application_type column to oauth_client (web or native)
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: oauth_client
|
||||
columns:
|
||||
- column:
|
||||
name: application_type
|
||||
type: varchar(50)
|
||||
remarks: OAuth application type (web or native), defaults to web
|
||||
defaultValue: web
|
||||
constraints:
|
||||
nullable: true
|
||||
@@ -1,23 +1,21 @@
|
||||
(ns metabase-enterprise.agent-api.api
|
||||
(ns metabase.agent-api.api
|
||||
"Customer-facing Agent API for headless BI applications.
|
||||
Endpoints are versioned (e.g., /v1/search) and use standard HTTP semantics."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[malli.core :as mc]
|
||||
[metabase-enterprise.agent-api.workspace :as agent-workspace]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as tools.api]
|
||||
[metabase-enterprise.metabot-v3.tools.deftool :as deftool]
|
||||
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details]
|
||||
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-filters]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as metabot-search]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.macros.scope :as scope]
|
||||
[metabase.api.routes.common :as api.routes.common]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.auth-identity.core :as auth-identity]
|
||||
[metabase.metabot.tools.api :as tools.api]
|
||||
[metabase.metabot.tools.deftool :as deftool]
|
||||
[metabase.metabot.tools.entity-details :as entity-details]
|
||||
[metabase.metabot.tools.field-stats :as field-stats]
|
||||
[metabase.metabot.tools.filters :as metabot-filters]
|
||||
[metabase.metabot.tools.search :as metabot-search]
|
||||
[metabase.metabot.util :as metabot.u]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.streaming :as qp.streaming]
|
||||
[metabase.request.core :as request]
|
||||
@@ -62,7 +60,7 @@
|
||||
|
||||
(mr/def ::field
|
||||
"A field from a table or metric. The field_id format is '<prefix><entity-id>-<field-index>' where prefix indicates the source (t=table, c=metric) and index is the position in the entity's fields."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:field_id :string]
|
||||
[:name :string]
|
||||
[:type {:optional true} [:maybe ::field-type]]
|
||||
@@ -77,7 +75,7 @@
|
||||
|
||||
(mr/def ::metric-summary
|
||||
"Summary of a metric associated with a table. Includes the field_id of the default time dimension for temporal breakouts."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type [:= :metric]]
|
||||
[:name :string]
|
||||
@@ -86,7 +84,7 @@
|
||||
|
||||
(mr/def ::segment
|
||||
"A predefined filter condition that can be applied to queries via the segment_id in filters."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:display_name {:optional true} [:maybe :string]]
|
||||
@@ -94,7 +92,7 @@
|
||||
|
||||
(mr/def ::measure
|
||||
"A reusable aggregation expression associated with a table. Reference via measure_id in the aggregations array."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:display_name {:optional true} [:maybe :string]]
|
||||
@@ -102,7 +100,7 @@
|
||||
|
||||
(mr/def ::related-table
|
||||
"A table related to the queried entity via foreign key. The related_by field indicates the FK field name."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type [:= :table]]
|
||||
[:name :string]
|
||||
@@ -116,7 +114,7 @@
|
||||
|
||||
(mr/def ::table
|
||||
"Full details of a table including its fields, related tables, metrics, and segments."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type ::entity-type]
|
||||
[:name :string]
|
||||
@@ -133,7 +131,7 @@
|
||||
|
||||
(mr/def ::metric
|
||||
"A metric with its queryable dimensions and segments. The default_time_dimension_field_id is the field_id of the recommended time dimension for temporal breakouts."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type [:= :metric]]
|
||||
[:name :string]
|
||||
@@ -145,7 +143,7 @@
|
||||
|
||||
(mr/def ::statistics
|
||||
"Statistical summary of a field's values computed during database sync. Includes counts, percentages, numeric summaries (min/max/avg/quartiles/sd), and date ranges."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:distinct_count {:optional true} [:maybe :int]]
|
||||
[:percent_null {:optional true} [:maybe number?]]
|
||||
[:min {:optional true} [:maybe number?]]
|
||||
@@ -164,14 +162,14 @@
|
||||
|
||||
(mr/def ::field-values
|
||||
"Statistics and sample values for a specific field."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:field_id {:optional true} [:maybe :string]]
|
||||
[:statistics {:optional true} [:maybe ::statistics]]
|
||||
[:values {:optional true} [:maybe [:sequential :any]]]])
|
||||
|
||||
(mr/def ::search-result-item
|
||||
"A table or metric returned from search."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type [:enum "table" "metric"]]
|
||||
[:name :string]
|
||||
@@ -185,7 +183,7 @@
|
||||
|
||||
(mr/def ::search-response
|
||||
"Search results containing tables and metrics matching the query."
|
||||
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
|
||||
[:data [:sequential ::search-result-item]]
|
||||
[:total_count :int]])
|
||||
|
||||
@@ -328,7 +326,7 @@
|
||||
:description "Order by regular fields only. To order by aggregation results, use sort_order on the aggregation."}
|
||||
[:maybe [:sequential ::tools.api/order-by]]]
|
||||
[:limit {:optional true} [:maybe ms/PositiveInt]]]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::construct-query-metric-request
|
||||
"Request schema for constructing a query from a metric.
|
||||
@@ -338,7 +336,7 @@
|
||||
[:metric_id ms/PositiveInt]
|
||||
[:filters {:optional true} [:maybe [:sequential ::tools.api/filter]]]
|
||||
[:group_by {:optional true} [:maybe [:sequential ::tools.api/group-by]]]]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::construct-query-request
|
||||
"Request schema for /v1/construct-query. Accepts either table_id or metric_id."
|
||||
@@ -551,13 +549,12 @@
|
||||
(handler (assoc request :token-scopes (or (:scopes result) #{::scope/unrestricted}))
|
||||
respond raise))
|
||||
(respond (error-response (:error result) (:message result))))))))))
|
||||
(def ^:private +auth
|
||||
(def +auth
|
||||
"Agent API authentication middleware. Supports both session-based and stateless JWT authentication."
|
||||
(api.routes.common/wrap-middleware-for-open-api-spec-generation enforce-authentication))
|
||||
|
||||
;;; ---------------------------------------------------- Routes ------------------------------------------------------
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/agent/` routes."
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)
|
||||
(agent-workspace/workspace-handler +auth)))
|
||||
"`/api/agent/` routes. Workspace routes are mounted separately via the EE routes file."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -177,7 +177,8 @@
|
||||
input-schema (merge-input-schemas defs form)
|
||||
resp-schema (response-schema->json-schema defs (:response-schema form))
|
||||
annotations (infer-annotations method (:annotations tool-md))
|
||||
task-support (:task-support tool-md)]
|
||||
task-support (:task-support tool-md)
|
||||
scope (get-in form [:metadata :scope])]
|
||||
(cond-> {:name tool-name
|
||||
:description description
|
||||
:endpoint {:method (u/upper-case-en (name method))
|
||||
@@ -185,7 +186,8 @@
|
||||
input-schema (assoc :inputSchema input-schema)
|
||||
resp-schema (assoc :responseSchema resp-schema)
|
||||
(seq annotations) (assoc :annotations annotations)
|
||||
task-support (assoc :execution {:taskSupport (name task-support)}))))
|
||||
task-support (assoc :execution {:taskSupport (name task-support)})
|
||||
(string? scope) (assoc :scope scope))))
|
||||
|
||||
(defn check-tool-uniqueness
|
||||
"Throws if `tools` contains duplicate `:name` values. The exception message lists each
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[compojure.route :as route]
|
||||
[metabase.actions-rest.api]
|
||||
[metabase.activity-feed.api]
|
||||
[metabase.agent-api.api]
|
||||
[metabase.analytics.api]
|
||||
[metabase.api-keys.api]
|
||||
[metabase.api.docs]
|
||||
@@ -30,7 +31,9 @@
|
||||
[metabase.llm.api]
|
||||
[metabase.logger.api]
|
||||
[metabase.login-history.api]
|
||||
[metabase.mcp.api]
|
||||
[metabase.measures.api]
|
||||
[metabase.metabot.api]
|
||||
[metabase.metrics.api]
|
||||
[metabase.model-persistence.api]
|
||||
[metabase.native-query-snippets.api]
|
||||
@@ -68,6 +71,7 @@
|
||||
|
||||
(comment metabase.actions-rest.api/keep-me
|
||||
metabase.activity-feed.api/keep-me
|
||||
metabase.agent-api.api/keep-me
|
||||
metabase.analytics.api/keep-me
|
||||
metabase.api-keys.api/keep-me
|
||||
metabase.api.util/keep-me
|
||||
@@ -86,6 +90,7 @@
|
||||
metabase.indexed-entities.api/keep-me
|
||||
metabase.logger.api/keep-me
|
||||
metabase.login-history.api/keep-me
|
||||
metabase.mcp.api/keep-me
|
||||
metabase.measures.api/keep-me
|
||||
metabase.metrics.api/keep-me
|
||||
metabase.model-persistence.api/keep-me
|
||||
@@ -147,6 +152,7 @@
|
||||
(def ^:private route-map
|
||||
{"/action" (+auth 'metabase.actions-rest.api)
|
||||
"/activity" (+auth 'metabase.activity-feed.api)
|
||||
"/agent" metabase.agent-api.api/routes
|
||||
"/alert" (+auth metabase.pulse.api/alert-routes)
|
||||
"/analytics" (+auth 'metabase.analytics.api)
|
||||
"/api-key" (+auth 'metabase.api-keys.api)
|
||||
@@ -177,7 +183,9 @@
|
||||
"/llm" (+auth metabase.llm.api/routes)
|
||||
"/logger" (+auth 'metabase.logger.api)
|
||||
"/login-history" (+auth 'metabase.login-history.api)
|
||||
"/mcp" metabase.mcp.api/handler
|
||||
"/measure" (+auth 'metabase.measures.api)
|
||||
"/metabot" metabase.metabot.api/routes
|
||||
"/metric" (+auth 'metabase.metrics.api)
|
||||
"/model-index" (+auth 'metabase.indexed-entities.api)
|
||||
"/native-query-snippet" (+auth 'metabase.native-query-snippets.api)
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
[metabase.channel.slack :as slack]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.premium-features.core :refer [defenterprise defenterprise-schema]]
|
||||
[metabase.settings.core :as setting]
|
||||
[metabase.slackbot.api :as slackbot]
|
||||
[metabase.slackbot.config :as slackbot.config]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
@@ -17,12 +19,6 @@
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defenterprise clear-slack-bot-settings!
|
||||
"Clears enterprise slackbot settings when the Slack token is cleared.
|
||||
OSS implementation is a no-op."
|
||||
metabase-enterprise.slackbot.api
|
||||
[])
|
||||
|
||||
(defn- truncate-url
|
||||
"Cut length of long URLs to avoid spamming the Slack channel"
|
||||
[url]
|
||||
@@ -112,7 +108,7 @@
|
||||
:slack-app-token nil
|
||||
:slack-bug-report-channel nil})
|
||||
(slack/clear-channel-cache!)
|
||||
(clear-slack-bot-settings!))
|
||||
(slackbot/clear-slack-bot-settings!))
|
||||
;; Set new token
|
||||
(do
|
||||
(when (and (not config/is-test?)
|
||||
@@ -174,22 +170,13 @@
|
||||
[:socket_mode_enabled :boolean]
|
||||
[:token_rotation_enabled :boolean]]]])
|
||||
|
||||
(defenterprise-schema get-slack-manifest :- SlackManifest
|
||||
"Returns the Slack app manifest. OSS returns basic manifest; EE with metabot-v3 returns full MetaBot manifest."
|
||||
metabase-enterprise.slackbot.api
|
||||
(defn get-slack-manifest
|
||||
"Returns the Slack manifest for bootstrapping new Slack apps."
|
||||
[]
|
||||
{:display_information {:name "Metabot"
|
||||
:description "Bringing the power of Metabase to your Slack #channels!"
|
||||
:background_color "#509EE3"}
|
||||
:features {:bot_user {:display_name "Metabot"}}
|
||||
:oauth_config {:scopes {:bot ["users:read"
|
||||
"channels:read"
|
||||
"channels:join"
|
||||
"files:write"
|
||||
"chat:write"
|
||||
"chat:write.customize"
|
||||
"chat:write.public"
|
||||
"groups:read"]}}})
|
||||
(let [base-url (system/site-url)]
|
||||
(when-not base-url
|
||||
(throw (ex-info (tru "You must configure a site-url for Slack integration to work.") {:status-code 503})))
|
||||
(slackbot.config/build-slack-manifest base-url)))
|
||||
|
||||
(api.macros/defendpoint :get "/manifest" :- SlackManifest
|
||||
"Returns the JSON manifest file that should be used to bootstrap new Slack apps"
|
||||
|
||||
@@ -124,15 +124,16 @@
|
||||
:model/Comment
|
||||
:model/CommentReaction
|
||||
;; 59+
|
||||
:model/Measure]
|
||||
:model/Measure
|
||||
;; 60+
|
||||
:model/Metabot
|
||||
:model/MetabotConversation
|
||||
:model/MetabotMessage
|
||||
:model/MetabotPrompt]
|
||||
(when config/ee-available?
|
||||
[:model/Sandbox
|
||||
:model/Tenant
|
||||
:model/ConnectionImpersonation
|
||||
:model/Metabot
|
||||
:model/MetabotConversation
|
||||
:model/MetabotMessage
|
||||
:model/MetabotPrompt])))
|
||||
:model/ConnectionImpersonation])))
|
||||
|
||||
(defn- objects->columns+values
|
||||
"Given a sequence of objects/rows fetched from the H2 DB, return a the `columns` that should be used in the `INSERT`
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
[metabase.llm.init]
|
||||
[metabase.logger.init]
|
||||
[metabase.login-history.init]
|
||||
[metabase.metabot.init]
|
||||
[metabase.model-persistence.init]
|
||||
[metabase.models.init]
|
||||
[metabase.notification.init]
|
||||
@@ -57,6 +58,7 @@
|
||||
[metabase.session.init]
|
||||
[metabase.settings.init]
|
||||
[metabase.setup.init]
|
||||
[metabase.slackbot.init]
|
||||
[metabase.sql-tools.init]
|
||||
[metabase.sso.init]
|
||||
[metabase.store-api.init]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase.internal-stats.core
|
||||
(:require
|
||||
[metabase.internal-stats.embedding :as embedding-stats]
|
||||
[metabase.internal-stats.metabot :as metabot-stats]
|
||||
[metabase.internal-stats.query-executions :as query-execution-stats]
|
||||
[metabase.internal-stats.questions :as question-stats]
|
||||
[metabase.internal-stats.users :as user-stats]
|
||||
@@ -26,3 +27,7 @@
|
||||
(p/import-vars
|
||||
[question-stats
|
||||
question-statistics-all-time])
|
||||
|
||||
(p/import-vars
|
||||
[metabot-stats
|
||||
metabot-stats])
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
(ns metabase-enterprise.metabot-v3.core
|
||||
"API namespace for the `metabase-enterprise.metabot-v3` module."
|
||||
(ns metabase.internal-stats.metabot
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
[metabase-enterprise.metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.table-utils]
|
||||
[metabase-enterprise.metabot-v3.util]
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[potemkin :as p]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.metabot-v3.api
|
||||
routes]
|
||||
[metabase-enterprise.metabot-v3.client
|
||||
analyze-chart
|
||||
analyze-dashboard
|
||||
fix-sql
|
||||
generate-sql]
|
||||
[metabase-enterprise.metabot-v3.table-utils
|
||||
database-tables
|
||||
used-tables
|
||||
schema-sample])
|
||||
|
||||
(defenterprise metabot-stats
|
||||
"Calculate total Metabot token usage over a window of the the previous UTC day 00:00-23:59"
|
||||
:feature :none
|
||||
(defn metabot-stats
|
||||
"Calculate total Metabot token usage over a window of the previous UTC day 00:00-23:59"
|
||||
[]
|
||||
(let [yesterday-utc (-> (t/offset-date-time (t/zone-offset "+00"))
|
||||
(t/minus (t/days 1)))]
|
||||
@@ -84,7 +84,7 @@
|
||||
(memoize/ttl
|
||||
(fn [api-key]
|
||||
(try
|
||||
(let [url (str (llm.settings/llm-anthropic-api-url) "/v1/models")
|
||||
(let [url (str (llm.settings/llm-anthropic-api-base-url) "/v1/models")
|
||||
response (http/get url
|
||||
{:headers {"x-api-key" api-key
|
||||
"anthropic-version" (llm.settings/llm-anthropic-api-version)}})
|
||||
@@ -119,7 +119,7 @@
|
||||
:messages messages}
|
||||
start-time (u/start-timer)]
|
||||
(try
|
||||
(let [url (str (llm.settings/llm-anthropic-api-url) "/v1/messages")
|
||||
(let [url (str (llm.settings/llm-anthropic-api-base-url) "/v1/messages")
|
||||
response (http/post url
|
||||
{:headers (build-request-headers (get-api-key-or-throw))
|
||||
:body (json/encode (build-request-body request))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
(ns metabase.llm.api
|
||||
"API endpoints for LLM-powered SQL generation (OSS)."
|
||||
"API endpoints for LLM-powered features: SQL generation and autodescription."
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.set :as set]
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.analytics.snowplow :as snowplow]
|
||||
[metabase.analyze.query-results :as qr]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
@@ -13,9 +14,13 @@
|
||||
[metabase.llm.anthropic :as llm.anthropic]
|
||||
[metabase.llm.context :as llm.context]
|
||||
[metabase.llm.settings :as llm.settings]
|
||||
[metabase.llm.tasks.describe-dashboard :refer [describe-dashboard]]
|
||||
[metabase.llm.tasks.describe-question :refer [describe-question]]
|
||||
[metabase.parameters.schema :as parameters.schema]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[stencil.core :as stencil]
|
||||
[throttle.core :as throttle]
|
||||
[toucan2.core :as t2])
|
||||
@@ -245,6 +250,40 @@
|
||||
:engine engine})
|
||||
(throw e))))))))
|
||||
|
||||
;;; ------------------------------------------------ Autodescribe ------------------------------------------------
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/describe/card"
|
||||
"Summarize a question."
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:dataset {:optional true} [:maybe :boolean]]
|
||||
[:dataset_query ms/Map]
|
||||
[:parameters {:optional true} [:maybe [:sequential ::parameters.schema/parameter]]]
|
||||
[:parameter_mappings {:optional true} [:maybe [:sequential ::parameters.schema/parameter-mapping]]]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:display ms/NonBlankString]
|
||||
[:visualization_settings ms/Map]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:result_metadata {:optional true} [:maybe qr/ResultsMetadata]]
|
||||
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
{:summary (describe-question body)})
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/describe/dashboard/:id"
|
||||
"Provide a summary of a dashboard."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
{:summary (describe-dashboard id)})
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/llm` routes."
|
||||
(handlers/routes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.llm.client
|
||||
(ns metabase.llm.client
|
||||
"A wrapper around the OpenAI client API.
|
||||
|
||||
The `create-chat-completion` function combines several useful middlewares to create a client that can be used for AI
|
||||
@@ -19,8 +19,8 @@
|
||||
[OpenAI Client](https://platform.openai.com/docs/libraries) with [Llama API](https://docs.llama-api.com/essentials/chat).
|
||||
"
|
||||
(:require
|
||||
[metabase-enterprise.llm.settings :as llm-settings]
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.llm.settings :as llm-settings]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[wkok.openai-clojure.api :as openai.api]))
|
||||
@@ -40,8 +40,6 @@
|
||||
(assoc
|
||||
usage-summary
|
||||
:event :llm-usage))
|
||||
;; TODO -- Remove before final PR/merge
|
||||
;(tap> usage-summary)
|
||||
response))
|
||||
([params] (wrap-usage* params nil))))
|
||||
|
||||
@@ -94,29 +92,29 @@
|
||||
([params] (wrap-openai-exceptions* params nil))))
|
||||
|
||||
(defn wrap-model-defaults
|
||||
"Add the EE default model from settings into the request"
|
||||
"Add the default model from settings into the request."
|
||||
[openai-fn]
|
||||
(fn wrap-model-defaults*
|
||||
([params options]
|
||||
(openai-fn
|
||||
(merge
|
||||
{:model (llm-settings/ee-openai-model)}
|
||||
{:model (llm-settings/llm-openai-model)}
|
||||
params)
|
||||
options))
|
||||
([params] (wrap-model-defaults* params nil))))
|
||||
|
||||
(defn wrap-ee-auth
|
||||
"Add the EE API key from settings into the request"
|
||||
(defn wrap-auth
|
||||
"Add the API key from settings into the request."
|
||||
[openai-fn]
|
||||
(fn wrap-ee-auth*
|
||||
(fn wrap-auth*
|
||||
([params options]
|
||||
(openai-fn
|
||||
params
|
||||
(merge
|
||||
{:api-key (llm-settings/ee-openai-api-key)}
|
||||
{:api-key (llm-settings/llm-openai-api-key)}
|
||||
options)))
|
||||
([params]
|
||||
(wrap-ee-auth* params nil))))
|
||||
(wrap-auth* params nil))))
|
||||
|
||||
(defn wrap-find-result
|
||||
"Return the first choice for which a message is present and has content."
|
||||
@@ -190,7 +188,7 @@
|
||||
bottom up and postprocessing middlewares happen from the top down."
|
||||
[]
|
||||
(-> *create-chat-completion-endpoint*
|
||||
wrap-ee-auth
|
||||
wrap-auth
|
||||
wrap-model-defaults
|
||||
wrap-openai-exceptions
|
||||
wrap-usage
|
||||
@@ -1,26 +1,29 @@
|
||||
(ns metabase.llm.settings
|
||||
"Settings for OSS LLM integration."
|
||||
"Settings for LLM integration (API keys, model defaults, provider configuration)."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru tru]]))
|
||||
|
||||
;;; ------------------------------------------------- Anthropic -------------------------------------------------
|
||||
|
||||
(defsetting llm-anthropic-api-key
|
||||
(deferred-tru "Anthropic API key for AI-assisted SQL generation.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:setter (fn [new-value]
|
||||
(let [trimmed (when (string? new-value)
|
||||
(not-empty (str/trim new-value)))]
|
||||
(when (and trimmed (not (str/starts-with? trimmed "sk-ant-")))
|
||||
(throw (ex-info (tru "Invalid Anthropic API key format. Key must start with ''sk-ant-''.")
|
||||
{:status-code 400})))
|
||||
(setting/set-value-of-type! :string :llm-anthropic-api-key trimmed)))
|
||||
(deferred-tru "The Anthropic API Key.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:deprecated-name :ee-anthropic-api-key
|
||||
:setter (fn [new-value]
|
||||
(let [trimmed (when (string? new-value)
|
||||
(not-empty (str/trim new-value)))]
|
||||
(when (and trimmed (not (str/starts-with? trimmed "sk-ant-")))
|
||||
(throw (ex-info (tru "Invalid Anthropic API key format. Key must start with ''sk-ant-''.")
|
||||
{:status-code 400})))
|
||||
(setting/set-value-of-type! :string :llm-anthropic-api-key trimmed)))
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-anthropic-api-key-configured?
|
||||
"Whether an Anthropic API key has been configured for AI-assisted SQL generation."
|
||||
"Whether an Anthropic API key has been configured."
|
||||
:type :boolean
|
||||
:visibility :public
|
||||
:setter :none
|
||||
@@ -29,29 +32,141 @@
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-anthropic-model
|
||||
(deferred-tru "Anthropic model for AI-assisted SQL generation.")
|
||||
(deferred-tru "The Anthropic model to use.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "claude-opus-4-5-20251101"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-anthropic-api-url
|
||||
(deferred-tru "Anthropic Base API URL for AI-assisted SQL generation.")
|
||||
:encryption :no
|
||||
:visibility :internal
|
||||
:default "https://api.anthropic.com"
|
||||
:export? false
|
||||
:doc false)
|
||||
(defsetting llm-anthropic-api-base-url
|
||||
(deferred-tru "The Anthropic API base URL.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://api.anthropic.com"
|
||||
:export? false
|
||||
:deprecated-name :ee-anthropic-api-base-url
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-anthropic-api-version
|
||||
(deferred-tru "Anthropic API version for AI-assisted SQL generation.")
|
||||
(deferred-tru "The Anthropic API version.")
|
||||
:encryption :no
|
||||
:visibility :internal
|
||||
:default "2023-06-01"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
;;; -------------------------------------------------- OpenAI ---------------------------------------------------
|
||||
|
||||
(defsetting llm-openai-model
|
||||
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "gpt-4.1-mini"
|
||||
:export? false
|
||||
:deprecated-name :ee-openai-model
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-openai-api-base-url
|
||||
(deferred-tru "The OpenAI API base URL.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://api.openai.com"
|
||||
:export? false
|
||||
:deprecated-name :ee-openai-api-base-url
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-openai-api-key
|
||||
(deferred-tru "The OpenAI API Key.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:deprecated-name :ee-openai-api-key
|
||||
:doc false)
|
||||
|
||||
;;; ------------------------------------------------- OpenRouter ------------------------------------------------
|
||||
|
||||
(defsetting llm-openrouter-api-base-url
|
||||
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://openrouter.ai/api"
|
||||
:export? false
|
||||
:deprecated-name :ee-openrouter-api-base-url
|
||||
:doc false)
|
||||
|
||||
(defsetting llm-openrouter-api-key
|
||||
(deferred-tru "The OpenRouter API Key.")
|
||||
:sensitive? true
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:deprecated-name :ee-openrouter-api-key
|
||||
:doc false)
|
||||
|
||||
;;; -------------------------------------------------- Metabot --------------------------------------------------
|
||||
|
||||
(def ^:private supported-metabot-providers
|
||||
"Set of supported LLM provider prefixes for the `llm-metabot-provider` setting."
|
||||
#{"anthropic" "openai" "openrouter"})
|
||||
|
||||
(defn- validate-metabot-provider!
|
||||
"Validate that `value` has the format `provider/model` with a supported provider prefix.
|
||||
Throws an exception with `:status-code 400` on invalid input."
|
||||
[value]
|
||||
(when-not (string? value)
|
||||
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
|
||||
{:status-code 400})))
|
||||
(let [[provider model] (str/split value #"/" 2)]
|
||||
(when-not (contains? supported-metabot-providers provider)
|
||||
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
|
||||
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
|
||||
{:status-code 400
|
||||
:provider provider
|
||||
:supported supported-metabot-providers})))
|
||||
(when (str/blank? model)
|
||||
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
|
||||
{:status-code 400
|
||||
:value value})))))
|
||||
|
||||
(defsetting llm-metabot-provider
|
||||
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/anthropic/claude-haiku-4-5"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:deprecated-name :ee-ai-metabot-provider
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :llm-metabot-provider new-value)))
|
||||
|
||||
(defsetting llm-metabot-provider-lite
|
||||
(deferred-tru "The AI provider and model for lightweight Metabot tasks (e.g. user intent classification).")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/openai/gpt-oss-20b"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:deprecated-name :ee-ai-metabot-provider-lite
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :llm-metabot-provider-lite new-value)))
|
||||
|
||||
(defsetting llm-metabot-internal-tasks-enabled?
|
||||
(deferred-tru "Controls whether Metabot performs internal tasks that might require background tasks or additional LLM calls (e.g. user intent classification).")
|
||||
:type :boolean
|
||||
:visibility :settings-manager
|
||||
:default true
|
||||
:export? false
|
||||
:deprecated-name :ee-ai-metabot-internal-tasks-enabled?
|
||||
:doc false)
|
||||
|
||||
;;; -------------------------------------------------- General --------------------------------------------------
|
||||
|
||||
(defsetting llm-max-tokens
|
||||
(deferred-tru "Maximum tokens for LLM responses.")
|
||||
:type :integer
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
(ns metabase-enterprise.llm.tasks.describe-dashboard
|
||||
(ns metabase.llm.tasks.describe-dashboard
|
||||
"LLM task(s) for generating dashboard descriptions"
|
||||
(:require
|
||||
[clojure.set :refer [rename-keys]]
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.llm.client :as llm-client]
|
||||
[metabase.llm.client :as llm-client]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -1,8 +1,8 @@
|
||||
(ns metabase-enterprise.llm.tasks.describe-question
|
||||
(ns metabase.llm.tasks.describe-question
|
||||
"LLM task(s) for generating question descriptions"
|
||||
(:require
|
||||
[clojure.set :refer [rename-keys]]
|
||||
[metabase-enterprise.llm.client :as llm-client]
|
||||
[metabase.llm.client :as llm-client]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]))
|
||||
344
src/metabase/mcp/api.clj
Normal file
344
src/metabase/mcp/api.clj
Normal file
@@ -0,0 +1,344 @@
|
||||
(ns metabase.mcp.api
|
||||
"MCP (Model Context Protocol) Streamable HTTP transport handler.
|
||||
Exposes Metabase's agent tools via JSON-RPC 2.0 over a single `/api/mcp` endpoint."
|
||||
(:require
|
||||
[clojure.core.async :as a]
|
||||
[clojure.string :as str]
|
||||
[compojure.response :as compojure.response]
|
||||
[java-time.api :as t]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros.scope :as scope]
|
||||
[metabase.api.open-api :as open-api]
|
||||
[metabase.mcp.tools :as mcp.tools]
|
||||
[metabase.oauth-server.core :as oauth-server]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.server.streaming-response :as streaming-response]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[oidc-provider.protocol :as proto])
|
||||
(:import
|
||||
(java.io BufferedWriter OutputStreamWriter)
|
||||
(java.net URI)
|
||||
(java.nio.charset StandardCharsets)
|
||||
(java.util UUID)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; --------------------------------------------------- Sessions ----------------------------------------------------
|
||||
|
||||
(defonce ^:private sessions
|
||||
(atom {}))
|
||||
|
||||
(def ^:private session-ttl-ms
|
||||
"Sessions expire after 1 hour."
|
||||
(* 60 60 1000))
|
||||
|
||||
(defn- sweep-expired-sessions!
|
||||
"Remove all sessions whose TTL has elapsed. Called on session creation to
|
||||
prevent abandoned sessions from accumulating in memory."
|
||||
[]
|
||||
(swap! sessions (fn [m]
|
||||
(into {} (remove (fn [[_ {:keys [timer]}]]
|
||||
(>= (u/since-ms timer) session-ttl-ms)))
|
||||
m))))
|
||||
|
||||
(defn- create-session!
|
||||
[user-id]
|
||||
(sweep-expired-sessions!)
|
||||
(let [session-id (str (UUID/randomUUID))]
|
||||
(swap! sessions assoc session-id {:timer (u/start-timer)
|
||||
:initialized? false
|
||||
:user-id user-id})
|
||||
session-id))
|
||||
|
||||
(defn- delete-session! [session-id]
|
||||
(swap! sessions dissoc session-id))
|
||||
|
||||
(defn- session-for-user
|
||||
"Return session state when the session exists, is unexpired, and belongs to
|
||||
`user-id`; otherwise return nil."
|
||||
[session-id user-id]
|
||||
(when-let [session (get @sessions session-id)]
|
||||
(if (>= (u/since-ms (:timer session)) session-ttl-ms)
|
||||
(do (delete-session! session-id) nil)
|
||||
(when (= user-id (:user-id session))
|
||||
session))))
|
||||
|
||||
(defn- session-initialized? [session-id]
|
||||
(get-in @sessions [session-id :initialized?]))
|
||||
|
||||
(defn- mark-session-initialized! [session-id]
|
||||
(swap! sessions assoc-in [session-id :initialized?] true))
|
||||
|
||||
;;; -------------------------------------------------- Auth --------------------------------------------------------
|
||||
|
||||
(defn- validate-bearer-token
|
||||
"Look up and validate an OAuth bearer token. Returns `{:user-id <int> :scopes <set>}` on success, nil on failure."
|
||||
[token-string]
|
||||
(when-let [provider (oauth-server/get-provider)]
|
||||
(when-let [token-data (proto/get-access-token (:token-store provider) token-string)]
|
||||
(let [expiry (:expiry token-data)]
|
||||
(when (or (nil? expiry)
|
||||
(t/after? (t/instant expiry) (t/instant)))
|
||||
(let [user-id (some-> (:user-id token-data) parse-long)
|
||||
scopes (when-let [scope-vec (:scope token-data)]
|
||||
(into #{} scope-vec))]
|
||||
(when user-id
|
||||
{:user-id user-id
|
||||
:scopes (or scopes #{})})))))))
|
||||
|
||||
;;; ------------------------------------------------- JSON-RPC 2.0 --------------------------------------------------
|
||||
|
||||
(def ^:private server-info
|
||||
{:name "metabase"
|
||||
:version "0.1.0"})
|
||||
|
||||
(def ^:private protocol-version "2025-03-26")
|
||||
|
||||
(defn- jsonrpc-response [id result]
|
||||
{:jsonrpc "2.0" :id id :result result})
|
||||
|
||||
(defn- jsonrpc-error [id code message]
|
||||
{:jsonrpc "2.0" :id id :error {:code code :message message}})
|
||||
|
||||
(defn- handle-initialize [id params]
|
||||
(when-let [client-info (:clientInfo params)]
|
||||
(log/infof "MCP client connected: %s %s" (:name client-info) (:version client-info)))
|
||||
(jsonrpc-response
|
||||
id
|
||||
{:protocolVersion protocol-version
|
||||
:capabilities {:tools {}}
|
||||
:serverInfo server-info}))
|
||||
|
||||
(defn- handle-tools-list [id _params token-scopes]
|
||||
(jsonrpc-response id {:tools (mcp.tools/list-tools token-scopes)}))
|
||||
|
||||
(defn- handle-tools-call [id params token-scopes]
|
||||
(let [tool-name (:name params)
|
||||
arguments (or (:arguments params) {})]
|
||||
(jsonrpc-response id (mcp.tools/call-tool token-scopes tool-name arguments))))
|
||||
|
||||
(defn- handle-ping [id _params]
|
||||
(jsonrpc-response id {}))
|
||||
|
||||
(defmacro ^:private when-initialized
|
||||
"Return a JSON-RPC error if the session is not yet initialized, otherwise evaluate `body`."
|
||||
[id session-id & body]
|
||||
`(if (session-initialized? ~session-id)
|
||||
(do ~@body)
|
||||
(jsonrpc-error ~id -32600 "Session not initialized: send notifications/initialized first")))
|
||||
|
||||
(defn- dispatch-request
|
||||
"Dispatch a single JSON-RPC request. Returns a response map or nil for notifications."
|
||||
[msg session-id token-scopes]
|
||||
(let [id (:id msg)
|
||||
method (:method msg)
|
||||
params (:params msg)]
|
||||
(try
|
||||
(case method
|
||||
"notifications/initialized" (do (mark-session-initialized! session-id) nil)
|
||||
"tools/list" (when-initialized id session-id (handle-tools-list id params token-scopes))
|
||||
"tools/call" (when-initialized id session-id (handle-tools-call id params token-scopes))
|
||||
"ping" (when-initialized id session-id (handle-ping id params))
|
||||
(if id
|
||||
(jsonrpc-error id -32601 (str "Method not found: " method))
|
||||
nil))
|
||||
(catch Throwable e
|
||||
(log/error e "Error dispatching JSON-RPC method" method)
|
||||
(jsonrpc-error id -32603 (or (ex-message e) "Internal error"))))))
|
||||
|
||||
;;; ----------------------------------------------------- SSE ------------------------------------------------------
|
||||
|
||||
(defn- accepts-sse?
|
||||
"Return true if the request's Accept header includes text/event-stream."
|
||||
[request]
|
||||
(some-> (get-in request [:headers "accept"])
|
||||
(str/includes? "text/event-stream")))
|
||||
|
||||
(defn- sse-body
|
||||
"Format a sequence of JSON-RPC messages as SSE event text."
|
||||
[messages]
|
||||
(str/join (map #(str "event: message\ndata: " (json/encode %) "\n\n") messages)))
|
||||
|
||||
;;; -------------------------------------------------- Responses ---------------------------------------------------
|
||||
|
||||
(defn- json-response
|
||||
([status body]
|
||||
(json-response status body nil))
|
||||
([status body extra-headers]
|
||||
{:status status
|
||||
:headers (merge {"Content-Type" "application/json"} extra-headers)
|
||||
:body (json/encode body)}))
|
||||
|
||||
(defn- sse-response
|
||||
"Return a plain Ring response with SSE-formatted body for POST requests."
|
||||
([messages]
|
||||
(sse-response messages nil))
|
||||
([messages extra-headers]
|
||||
{:status 200
|
||||
:headers (merge {"Content-Type" "text/event-stream"
|
||||
"Cache-Control" "no-cache"}
|
||||
extra-headers)
|
||||
:body (sse-body messages)}))
|
||||
|
||||
;;; ------------------------------------------------- Validation --------------------------------------------------
|
||||
|
||||
(defn- validate-origin
|
||||
"Validate the Origin header to prevent DNS rebinding attacks (MCP spec requirement).
|
||||
Returns a 403 response if Origin is present and doesn't match the request's Host header.
|
||||
Non-browser clients that omit the Origin header are allowed through."
|
||||
[request]
|
||||
(when-let [origin (get-in request [:headers "origin"])]
|
||||
(let [host (get-in request [:headers "host"])]
|
||||
(when-not (try
|
||||
(let [origin-host (.getHost (URI. origin))
|
||||
request-host (first (str/split (str host) #":"))]
|
||||
(= origin-host request-host))
|
||||
(catch Exception _ false))
|
||||
(json-response 403 (jsonrpc-error nil -32600 "Origin not allowed"))))))
|
||||
|
||||
(defn- session-error-response
|
||||
"Return an HTTP error response when the session is invalid, or nil if valid.
|
||||
Returns 400 for a missing session header, 404 for an invalid/expired session (MCP spec)."
|
||||
[session-id user-id]
|
||||
(cond
|
||||
(str/blank? session-id)
|
||||
(json-response 400 (jsonrpc-error nil -32600 "Missing Mcp-Session-Id header"))
|
||||
|
||||
(nil? (session-for-user session-id user-id))
|
||||
(json-response 404 (jsonrpc-error nil -32600 "Session not found or expired"))))
|
||||
|
||||
;;; -------------------------------------------------- Handlers ---------------------------------------------------
|
||||
|
||||
(defn- handle-post
|
||||
"Handle a POST request containing one or more JSON-RPC messages."
|
||||
[user-id request]
|
||||
(let [body (:body request)
|
||||
session-id (get-in request [:headers "mcp-session-id"])
|
||||
batch? (sequential? body)
|
||||
session-err (delay (session-error-response session-id user-id))]
|
||||
(cond
|
||||
(nil? body)
|
||||
(json-response 400 (jsonrpc-error nil -32700 "Parse error: empty body"))
|
||||
|
||||
(and (not (map? body)) (not batch?))
|
||||
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: expected object or array"))
|
||||
|
||||
;; JSON-RPC 2.0: empty batch is invalid
|
||||
(and batch? (empty? body))
|
||||
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: empty batch"))
|
||||
|
||||
;; MCP spec: "The initialize request MUST NOT be part of a JSON-RPC batch"
|
||||
(and batch? (some #(= "initialize" (:method %)) body))
|
||||
(json-response 400 (jsonrpc-error nil -32600 "initialize must not be batched"))
|
||||
|
||||
;; Initialize: create session and return response with session header
|
||||
(and (not batch?) (= "initialize" (:method body)))
|
||||
(let [session-id (create-session! user-id)
|
||||
init-response (handle-initialize (:id body) (:params body))]
|
||||
(if (accepts-sse? request)
|
||||
(sse-response [init-response] {"Mcp-Session-Id" session-id})
|
||||
(json-response 200 init-response {"Mcp-Session-Id" session-id})))
|
||||
|
||||
;; All other requests require a valid session (400 for missing header, 404 for invalid)
|
||||
(some? @session-err) @session-err
|
||||
|
||||
:else
|
||||
(let [messages (if batch? body [body])
|
||||
results (mapv #(dispatch-request % session-id (:token-scopes request)) messages)
|
||||
responses (filterv some? results)]
|
||||
(cond
|
||||
(empty? responses)
|
||||
{:status 202 :headers {} :body ""}
|
||||
|
||||
(accepts-sse? request)
|
||||
(sse-response responses)
|
||||
|
||||
(and (not batch?) (= 1 (count responses)))
|
||||
(json-response 200 (first responses))
|
||||
|
||||
:else
|
||||
(json-response 200 responses))))))
|
||||
|
||||
(defn- handle-get
|
||||
"Handle a GET request for SSE stream (keepalive for server-initiated notifications)."
|
||||
[user-id request respond raise]
|
||||
(let [session-id (get-in request [:headers "mcp-session-id"])
|
||||
session-err (session-error-response session-id user-id)]
|
||||
(cond
|
||||
(some? session-err)
|
||||
(respond session-err)
|
||||
|
||||
(not (session-initialized? session-id))
|
||||
(respond (json-response 400 (jsonrpc-error nil -32600 "Session not initialized")))
|
||||
|
||||
:else
|
||||
(let [resp (streaming-response/streaming-response
|
||||
{:content-type "text/event-stream"
|
||||
:headers {"Cache-Control" "no-cache"}
|
||||
:status 200}
|
||||
[os canceled-chan]
|
||||
(let [writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))]
|
||||
(loop []
|
||||
(when-not (a/poll! canceled-chan)
|
||||
(.write writer ": keepalive\n\n")
|
||||
(.flush writer)
|
||||
(Thread/sleep 30000)
|
||||
(recur)))))]
|
||||
(compojure.response/send* resp request respond raise)))))
|
||||
|
||||
(defn- handle-delete
|
||||
"Handle a DELETE request to tear down a session."
|
||||
[user-id request]
|
||||
(let [session-id (get-in request [:headers "mcp-session-id"])
|
||||
session-err (session-error-response session-id user-id)]
|
||||
(or session-err
|
||||
(do (delete-session! session-id)
|
||||
{:status 200 :headers {"Content-Type" "application/json"} :body ""}))))
|
||||
|
||||
;;; ---------------------------------------------------- Handler ---------------------------------------------------
|
||||
|
||||
(defn- www-authenticate-discovery []
|
||||
(str "Bearer realm=\"mcp\" resource_metadata=\"" (system/site-url) "/.well-known/oauth-protected-resource/api/mcp\""))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} handler
|
||||
"Ring async handler for the MCP endpoint.
|
||||
Uses JSON-RPC 2.0 over HTTP rather than REST, so the OpenAPI spec is empty."
|
||||
(open-api/handler-with-open-api-spec
|
||||
(fn [request respond raise]
|
||||
(let [origin-error (validate-origin request)
|
||||
bearer-token (oauth-server/extract-bearer-token request)
|
||||
session-auth api/*current-user-id*]
|
||||
(letfn [(dispatch [user-id token-scopes]
|
||||
(request/with-current-user user-id
|
||||
(try
|
||||
(let [request (assoc request :token-scopes token-scopes)]
|
||||
(case (:request-method request)
|
||||
:post (respond (handle-post user-id request))
|
||||
:get (handle-get user-id request respond raise)
|
||||
:delete (respond (handle-delete user-id request))
|
||||
(respond (json-response 405 (jsonrpc-error nil -32600 "Method not allowed")))))
|
||||
(catch Throwable e
|
||||
(raise e)))))]
|
||||
(cond
|
||||
(some? origin-error)
|
||||
(respond origin-error)
|
||||
|
||||
;; Session auth (browser/cookie) — unrestricted scopes
|
||||
session-auth
|
||||
(dispatch session-auth #{::scope/unrestricted})
|
||||
|
||||
;; Bearer token auth — validate and extract scopes
|
||||
bearer-token
|
||||
(if-let [{:keys [user-id scopes]} (validate-bearer-token bearer-token)]
|
||||
(dispatch user-id scopes)
|
||||
(respond (json-response 401 (jsonrpc-error nil -32603 "Invalid bearer token")
|
||||
{"WWW-Authenticate" "Bearer error=\"invalid_token\""})))
|
||||
|
||||
;; No auth at all — return 401 with discovery
|
||||
:else
|
||||
(respond (json-response 401 (jsonrpc-error nil -32603 "Authentication required")
|
||||
{"WWW-Authenticate" (www-authenticate-discovery)}))))))
|
||||
(constantly nil)))
|
||||
214
src/metabase/mcp/tools.clj
Normal file
214
src/metabase/mcp/tools.clj
Normal file
@@ -0,0 +1,214 @@
|
||||
(ns metabase.mcp.tools
|
||||
"MCP tool dispatch. Generates tool definitions from defendpoint metadata and delegates
|
||||
tool calls to existing agent API endpoints."
|
||||
(:require
|
||||
[clojure.core.async :as a]
|
||||
[clojure.string :as str]
|
||||
[metabase.agent-api.api :as agent-api]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros.defendpoint.tools-manifest :as tools-manifest]
|
||||
[metabase.api.macros.scope :as scope]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.server.streaming-response :as streaming-response]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json])
|
||||
(:import
|
||||
(java.io ByteArrayOutputStream)
|
||||
(java.net URLEncoder)
|
||||
(metabase.server.streaming_response StreamingResponse)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(comment streaming-response/keep-me) ; ensure StreamingResponse class is loaded
|
||||
|
||||
(defn- generate-manifest
|
||||
"Generate tools manifest from agent API endpoint metadata."
|
||||
[]
|
||||
(tools-manifest/generate-tools-manifest
|
||||
{'metabase.agent-api.api "/api/agent"}))
|
||||
|
||||
(def ^:private manifest-delay
|
||||
(delay (generate-manifest)))
|
||||
|
||||
(defn- manifest
|
||||
"Return the tools manifest. Cached in prod, recomputed each call in dev."
|
||||
[]
|
||||
(if config/is-dev?
|
||||
(generate-manifest)
|
||||
@manifest-delay))
|
||||
|
||||
(defn- scope-matches?
|
||||
"Does `token-scopes` grant access to a tool with the given `tool-scope`?
|
||||
- nil/empty token-scopes or ::scope/unrestricted → always matches
|
||||
- nil tool-scope → always matches (tool has no scope restriction)
|
||||
- wildcard scopes like \"agent:*\" match any tool scope starting with \"agent:\""
|
||||
[token-scopes tool-scope]
|
||||
(or (nil? token-scopes)
|
||||
(empty? token-scopes)
|
||||
(contains? token-scopes ::scope/unrestricted)
|
||||
(nil? tool-scope)
|
||||
(contains? token-scopes tool-scope)
|
||||
(some (fn [s]
|
||||
(when (string? s)
|
||||
(when-let [prefix (when (str/ends-with? s ":*")
|
||||
(subs s 0 (- (count s) 1)))]
|
||||
(str/starts-with? tool-scope prefix))))
|
||||
token-scopes)))
|
||||
|
||||
(defn list-tools
|
||||
"Return the tool definitions suitable for MCP `tools/list` responses.
|
||||
When `token-scopes` is provided, only tools whose scope matches are included."
|
||||
[token-scopes]
|
||||
(into []
|
||||
(comp (filter #(= "execute_query" (:name %)))
|
||||
;; TODO: tool manifest is broken so we scope to only known good tools for testing
|
||||
#_(filter #(scope-matches? token-scopes (:scope %)))
|
||||
(map (fn [tool]
|
||||
{:name (:name tool)
|
||||
:description (:description tool)
|
||||
:inputSchema (:inputSchema tool)})))
|
||||
(:tools (manifest))))
|
||||
|
||||
(defn all-tool-scopes
|
||||
"Return the set of all distinct OAuth scopes across all tools."
|
||||
[]
|
||||
(into #{}
|
||||
(keep :scope)
|
||||
(:tools (manifest))))
|
||||
|
||||
(defn- build-tool-index []
|
||||
(into {} (map (juxt :name identity)) (:tools (manifest))))
|
||||
|
||||
(def ^:private tool-index-delay
|
||||
(delay (build-tool-index)))
|
||||
|
||||
(defn- tool-index
|
||||
"Lookup from tool name to its full manifest definition. Cached in prod, recomputed each call in dev."
|
||||
[]
|
||||
(if config/is-dev?
|
||||
(build-tool-index)
|
||||
@tool-index-delay))
|
||||
|
||||
;;; ------------------------------------------------- Tool Dispatch -------------------------------------------------
|
||||
|
||||
(defn- text-content
|
||||
"Wrap a value as MCP text content."
|
||||
[v]
|
||||
{:content [{:type "text" :text (if (string? v) v (json/encode v))}]})
|
||||
|
||||
(defn- error-content
|
||||
"Wrap an error message as MCP error content."
|
||||
[message]
|
||||
{:content [{:type "text" :text message}] :isError true})
|
||||
|
||||
(defn- capture-streaming-response
|
||||
"Execute a StreamingResponse in-process by writing to a ByteArrayOutputStream,
|
||||
then parse the JSON output and return it as MCP text content.
|
||||
|
||||
NOTE: This intentionally bypasses the normal StreamingResponse lifecycle (thread pool,
|
||||
gzip, donechan delivery) because the agent-api streaming functions only write JSON to
|
||||
the output stream and don't depend on `streaming-response/*response*` or other
|
||||
infrastructure bindings. BOT-1120 tracks streaming MCP responses without buffering
|
||||
in memory, which will replace this approach."
|
||||
[^StreamingResponse response]
|
||||
(let [baos (ByteArrayOutputStream.)
|
||||
canceled-chan (a/promise-chan)
|
||||
f (.f response)]
|
||||
(f baos canceled-chan)
|
||||
(text-content (json/decode+kw (.toString baos "UTF-8")))))
|
||||
|
||||
(defn- deliver-agent-api-response
|
||||
"Dispatch to agent API routes and deliver response to promise."
|
||||
[result method path token-scopes body]
|
||||
(agent-api/routes
|
||||
(cond-> {:request-method method
|
||||
:uri path
|
||||
:metabase-user-id api/*current-user-id*
|
||||
:token-scopes token-scopes}
|
||||
body (assoc :body body))
|
||||
(fn [response] (deliver result response))
|
||||
(fn [error] (deliver result {:status 500 :body {:message (ex-message error)}}))))
|
||||
|
||||
(defn- invoke-agent-api
|
||||
"Invoke an Agent API endpoint with a synthetic Ring request.
|
||||
Returns MCP content (text-content on success, error-content on failure).
|
||||
|
||||
Propagates `token-scopes` from the original MCP request so that scope restrictions
|
||||
are preserved through the synthetic request."
|
||||
[method path token-scopes & [body]]
|
||||
(let [result (promise)]
|
||||
(deliver-agent-api-response result method path token-scopes body)
|
||||
(let [response (deref result 30000 {:status 504 :body {:message "Timeout"}})]
|
||||
(cond
|
||||
(instance? StreamingResponse (:body response))
|
||||
(capture-streaming-response (:body response))
|
||||
|
||||
(= 200 (:status response))
|
||||
(text-content (:body response))
|
||||
|
||||
:else
|
||||
(error-content (or (some-> response :body :message)
|
||||
(some-> response :body :error)
|
||||
(str "Agent API error: " (:status response))))))))
|
||||
|
||||
(defn- interpolate-path
|
||||
"Replace `{param}` placeholders in `path` with values from `arguments`.
|
||||
Returns `[resolved-path remaining-args]` where remaining-args has path params removed."
|
||||
[path arguments]
|
||||
(let [params (re-seq #"\{([^}]+)\}" path)
|
||||
resolved (reduce (fn [p [placeholder k]]
|
||||
(let [v (get arguments (keyword k))]
|
||||
(when (nil? v)
|
||||
(throw (ex-info (str "Missing required path parameter: " k)
|
||||
{:parameter k :path path})))
|
||||
(str/replace p placeholder (URLEncoder/encode (str v) "UTF-8"))))
|
||||
path
|
||||
params)
|
||||
path-keys (set (map (comp keyword second) params))]
|
||||
[resolved (apply dissoc arguments path-keys)]))
|
||||
|
||||
(defn- strip-api-prefix
|
||||
"Strip the `/api/agent` prefix from manifest paths, since `invoke-agent-api`
|
||||
calls `agent-api/routes` directly which expects `/v1/...` paths."
|
||||
[path]
|
||||
(str/replace-first path #"^/api/agent" ""))
|
||||
|
||||
(defn- invoke-agent-api-with-params
|
||||
"Invoke an Agent API endpoint with query parameters for GET/DELETE requests.
|
||||
Appends `params` as a query string to `path`."
|
||||
[method path token-scopes params]
|
||||
(if (and (seq params) (not= :post method))
|
||||
(let [query-string (->> params
|
||||
(map (fn [[k v]] (str (name k) "=" (URLEncoder/encode (str v) "UTF-8"))))
|
||||
(str/join "&"))]
|
||||
(invoke-agent-api method (str path "?" query-string) token-scopes))
|
||||
(invoke-agent-api method path token-scopes (when (= :post method) params))))
|
||||
|
||||
(defn- dispatch-via-agent-api
|
||||
"Generic dispatch for tools whose responseFormat is \"json\".
|
||||
Looks up method/path from the tool definition, interpolates path params,
|
||||
and calls `invoke-agent-api`. For POST requests, remaining args are sent as the
|
||||
request body. For GET/DELETE requests, remaining args are sent as query params."
|
||||
[tool-def arguments token-scopes]
|
||||
(let [{:keys [method path]} (:endpoint tool-def)
|
||||
method (keyword (u/lower-case-en method))
|
||||
[resolved-path
|
||||
remaining-args] (interpolate-path path arguments)
|
||||
api-path (strip-api-prefix resolved-path)]
|
||||
(invoke-agent-api-with-params method api-path token-scopes remaining-args)))
|
||||
|
||||
(defn call-tool
|
||||
"Dispatch an MCP `tools/call` request to the appropriate handler.
|
||||
`token-scopes` from the original MCP session are propagated to the synthetic
|
||||
agent-api request so that scope restrictions are preserved.
|
||||
Returns MCP content on success, or error content on failure."
|
||||
[token-scopes tool-name arguments]
|
||||
(if-let [tool-def (get (tool-index) tool-name)]
|
||||
(if-not (scope-matches? token-scopes (:scope tool-def))
|
||||
(error-content (str "Insufficient scope to call tool: " tool-name
|
||||
". Required scope: " (:scope tool-def)))
|
||||
(try
|
||||
(dispatch-via-agent-api tool-def arguments token-scopes)
|
||||
(catch Exception e
|
||||
(error-content (or (ex-message e) "Internal error")))))
|
||||
(error-content (str "Unknown tool: " tool-name))))
|
||||
@@ -1,11 +1,11 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.analytics
|
||||
(ns metabase.metabot.agent.analytics
|
||||
"Snowplow and Prometheus analytics and intent-classification helpers for the metabot agent."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.llm.settings :as llm]
|
||||
[metabase-enterprise.metabot-v3.self :as self]
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.llm.settings :as llm]
|
||||
[metabase.metabot.self :as self]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
(let [context (json/encode (take-last 3 messages))
|
||||
text (transduce (keep (fn [{:keys [type text]}] (when (= type :text) text)))
|
||||
str
|
||||
(self/call-llm (llm/ee-ai-metabot-provider-lite)
|
||||
(self/call-llm (llm/llm-metabot-provider-lite)
|
||||
nil
|
||||
[{:role "user" :content (build-intent-prompt context)}]
|
||||
[]
|
||||
@@ -1,18 +1,18 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.core
|
||||
(ns metabase.metabot.agent.core
|
||||
"Main agent loop implementation using reducible streaming infrastructure."
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.llm.settings :as llm.settings]
|
||||
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
|
||||
[metabase-enterprise.metabot-v3.agent.memory :as memory]
|
||||
[metabase-enterprise.metabot-v3.agent.messages :as messages]
|
||||
[metabase-enterprise.metabot-v3.agent.profiles :as profiles]
|
||||
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
|
||||
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
|
||||
[metabase-enterprise.metabot-v3.self :as self]
|
||||
[metabase.analytics.prometheus :as prometheus]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.llm.settings :as llm.settings]
|
||||
[metabase.metabot.agent.analytics :as agent-analytics]
|
||||
[metabase.metabot.agent.memory :as memory]
|
||||
[metabase.metabot.agent.messages :as messages]
|
||||
[metabase.metabot.agent.profiles :as profiles]
|
||||
[metabase.metabot.agent.streaming :as streaming]
|
||||
[metabase.metabot.agent.tools :as agent-tools]
|
||||
[metabase.metabot.self :as self]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
@@ -400,7 +400,7 @@
|
||||
Streams parts to the consumer as they arrive while simultaneously accumulating
|
||||
them for memory updates and control flow decisions."
|
||||
[{:keys [agent rf result iteration usage-atom] :as loop-state}]
|
||||
(with-span :debug {:name :metabot-v3.agent/loop-step
|
||||
(with-span :debug {:name :metabot.agent/loop-step
|
||||
:iteration iteration}
|
||||
(let [{:keys [profile tools context memory-atom tracking-opts]} agent
|
||||
max-iter (:max-iterations profile 10)
|
||||
@@ -493,11 +493,11 @@
|
||||
(let [profile-id (:profile-id opts)
|
||||
debug? (:debug? opts)
|
||||
labels {:profile-id (name profile-id)}
|
||||
track-user-intent? (and (llm.settings/ee-ai-metabot-internal-tasks-enabled?)
|
||||
track-user-intent? (and (llm.settings/llm-metabot-internal-tasks-enabled?)
|
||||
(some-> opts :tracking-opts :track-user-intent?))]
|
||||
(reify clojure.lang.IReduceInit
|
||||
(reduce [_ rf init]
|
||||
(with-span :info {:name :metabot-v3.agent/run-agent-loop
|
||||
(with-span :info {:name :metabot.agent/run-agent-loop
|
||||
:profile-id profile-id
|
||||
:msg-count (count (:messages opts))}
|
||||
(prometheus/inc! :metabase-metabot/agent-requests labels)
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.links
|
||||
(ns metabase.metabot.agent.links
|
||||
"Link resolution for metabase:// URIs in streaming text.
|
||||
Converts internal metabase:// links to proper Metabase URLs using agent memory state."
|
||||
(:require
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer
|
||||
(ns metabase.metabot.agent.markdown-link-buffer
|
||||
"Streaming markdown link buffer for resolving metabase:// links.
|
||||
|
||||
Buffers markdown links during streaming to prevent incomplete markdown rendering
|
||||
@@ -7,7 +7,7 @@
|
||||
Provides both a pure functional state machine and a transducer for stream processing."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.links :as links]
|
||||
[metabase.metabot.agent.links :as links]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.memory
|
||||
(ns metabase.metabot.agent.memory
|
||||
"Memory and state management for the agent loop.
|
||||
Tracks conversation history, tool execution steps, and conversation state."
|
||||
(:require
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.messages
|
||||
(ns metabase.metabot.agent.messages
|
||||
"Message formatting and history construction for the agent loop.
|
||||
|
||||
The agent stores conversation history as AISDK parts — the format-neutral
|
||||
@@ -6,9 +6,9 @@
|
||||
is responsible for converting these parts into its own wire format (Claude
|
||||
messages, Chat Completions messages, OpenAI Responses input items, etc.)."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.memory :as memory]
|
||||
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
|
||||
[metabase-enterprise.metabot-v3.agent.user-context :as user-context]
|
||||
[metabase.metabot.agent.memory :as memory]
|
||||
[metabase.metabot.agent.prompts :as prompts]
|
||||
[metabase.metabot.agent.user-context :as user-context]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.profiles
|
||||
(ns metabase.metabot.agent.profiles
|
||||
"Profile configurations for different metabot use cases.
|
||||
Each profile defines model settings, iteration limits, and available tools."
|
||||
Each profile defines model settings, iteration limits, and available tools.
|
||||
|
||||
Tools are referenced as vars. Metadata on the var (`:tool-name`, `:schema`,
|
||||
`:capabilities`, etc.) describes the tool; the var itself is invoked to run it.
|
||||
`defenterprise` tools with `:ee-feature` metadata are filtered out at runtime
|
||||
when the feature is not available."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[malli.error :as me]
|
||||
[metabase-enterprise.llm.settings :as llm]
|
||||
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
|
||||
[metabase.llm.settings :as llm]
|
||||
[metabase.metabot.agent.tools :as agent-tools]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(def ^:private tool-definition-schema
|
||||
[:map
|
||||
[:tool-name [:and :string [:fn #(not (str/blank? %))]]]
|
||||
[:schema [:fn some?]]
|
||||
[:doc {:optional true} [:maybe :string]]
|
||||
[:prompt {:optional true} [:maybe :string]]])
|
||||
(def ^:private tool-var-schema
|
||||
"Schema for a tool var: must be a var with :tool-name and :schema in metadata."
|
||||
[:fn {:error/message "Expected a var with :tool-name and :schema metadata"}
|
||||
(fn [v]
|
||||
(and (var? v)
|
||||
(string? (:tool-name (meta v)))
|
||||
(some? (:schema (meta v)))))])
|
||||
|
||||
(def ^:private *profiles
|
||||
"Map of profile-id to profile configuration"
|
||||
(atom {}))
|
||||
|
||||
(defn- validate-tool-definition!
|
||||
(defn- validate-tool-var!
|
||||
"Validate that a var has the required tool metadata."
|
||||
[tool-var]
|
||||
(let [definition (select-keys (meta tool-var) [:tool-name :schema :doc :prompt])]
|
||||
(when-not (mr/validate tool-definition-schema definition)
|
||||
(throw (ex-info "Invalid tool definition metadata"
|
||||
{:tool tool-var
|
||||
:definition definition
|
||||
:errors (me/humanize (mu/explain tool-definition-schema definition))})))))
|
||||
(when-not (mr/validate tool-var-schema tool-var)
|
||||
(throw (ex-info "Invalid tool var: missing :tool-name or :schema metadata"
|
||||
{:tool-var tool-var
|
||||
:metadata (meta tool-var)
|
||||
:errors (me/humanize (mu/explain tool-var-schema tool-var))})))
|
||||
true)
|
||||
|
||||
(mu/defn ^:private register-profile!
|
||||
"Register new profile configuration.
|
||||
@@ -37,22 +45,22 @@
|
||||
- :prompt-template - Selmer template name from resources/metabot/prompts/system/
|
||||
- :max-iterations - Maximum agent loop iterations
|
||||
- :temperature - LLM temperature setting
|
||||
- :tools - Vector of tool vars available to this profile
|
||||
- :tools - Vector of tool vars (e.g. #'agent-tools/search-tool)
|
||||
|
||||
Note: `:model` is resolved at runtime from the `ee-ai-metabot-provider` setting
|
||||
(see [[get-profile]]), not stored in the profile."
|
||||
Tool vars are validated at registration time to ensure they have required metadata."
|
||||
[profile :- [:map
|
||||
[:name :keyword]
|
||||
[:prompt-template :string]
|
||||
[:max-iterations :int]
|
||||
[:temperature :float]
|
||||
[:tools [:vector :any]]]]
|
||||
(when-not (apply distinct? (map #(:tool-name (meta %)) (:tools profile)))
|
||||
(let [dups (->> (frequencies (map #(:tool-name (meta %)) (:tools profile)))
|
||||
(filter (fn [[_ cnt]] (< 1 cnt))))]
|
||||
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)}))))
|
||||
(doseq [tool (:tools profile)]
|
||||
(validate-tool-definition! tool))
|
||||
(let [tool-vars (:tools profile)]
|
||||
(doseq [tool-var tool-vars]
|
||||
(validate-tool-var! tool-var))
|
||||
(when-not (apply distinct? (map #(:tool-name (meta %)) tool-vars))
|
||||
(let [dups (->> (frequencies (map #(:tool-name (meta %)) tool-vars))
|
||||
(filter (fn [[_ cnt]] (< 1 cnt))))]
|
||||
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)})))))
|
||||
(swap! *profiles assoc (:name profile) profile))
|
||||
|
||||
(register-profile!
|
||||
@@ -179,7 +187,7 @@
|
||||
"Filter tool vars by user capabilities.
|
||||
Removes tools that require capabilities the user doesn't have.
|
||||
Capabilities from the API arrive as strings (e.g. \"frontend:navigate_user_v1\")
|
||||
while tool metadata uses keywords (e.g. :frontend-navigate-user-v1), so we
|
||||
while tool definitions use keywords (e.g. :frontend-navigate-user-v1), so we
|
||||
normalize to keywords before comparing."
|
||||
[tool-vars capabilities]
|
||||
(let [capabilities-set (into #{} (map capability->keyword) capabilities)]
|
||||
@@ -187,24 +195,35 @@
|
||||
(every? capabilities-set (:capabilities (meta tool-var))))
|
||||
tool-vars)))
|
||||
|
||||
(defn- ee-feature-available?
|
||||
"Check if a tool's :ee-feature is available (or if the tool has no :ee-feature gate)."
|
||||
[tool-var]
|
||||
(if-let [feature (:ee-feature (meta tool-var))]
|
||||
(premium-features/has-feature? feature)
|
||||
true))
|
||||
|
||||
(defn- tool-map
|
||||
[tools]
|
||||
(into {} (map (juxt #(:tool-name (meta %)) identity) tools)))
|
||||
"Create a map of tool-name -> tool-var from a sequence of tool vars."
|
||||
[tool-vars]
|
||||
(into {} (map (juxt #(:tool-name (meta %)) identity) tool-vars)))
|
||||
|
||||
;;; API
|
||||
|
||||
(defn get-profile
|
||||
"Get profile configuration by profile-id keyword.
|
||||
The `:model` in the returned profile is resolved from the `ee-ai-metabot-provider`
|
||||
The `:model` in the returned profile is resolved from the `llm-metabot-provider`
|
||||
setting at call time, so it always reflects the current admin configuration."
|
||||
[profile-id]
|
||||
(when-let [profile (get @*profiles profile-id)]
|
||||
(assoc profile :model (llm/ee-ai-metabot-provider))))
|
||||
(assoc profile :model (llm/llm-metabot-provider))))
|
||||
|
||||
(defn get-tools-for-profile
|
||||
"Get tool registry filtered by profile configuration and user capabilities."
|
||||
"Get tool registry filtered by profile configuration and user capabilities.
|
||||
Filters out EE-only tools when the feature is not available, then filters by
|
||||
capabilities. Returns a map of tool-name -> tool-var."
|
||||
[profile-id capabilities]
|
||||
(some-> (get-profile profile-id)
|
||||
:tools
|
||||
(->> (filter ee-feature-available?))
|
||||
(filter-by-capabilities capabilities)
|
||||
tool-map))
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.prompts
|
||||
(ns metabase.metabot.agent.prompts
|
||||
"System for loading and rendering prompt templates using Selmer.
|
||||
|
||||
Handles:
|
||||
@@ -119,7 +119,7 @@
|
||||
"Load template with caching. Cache is simple in-memory map.
|
||||
|
||||
In development, you can clear the cache to reload templates:
|
||||
(reset! metabot-v3.agent.prompts/template-cache {})"
|
||||
(reset! metabot.agent.prompts/template-cache {})"
|
||||
[resource-type resource-name loader-fn]
|
||||
(let [key (cache-key resource-type resource-name)]
|
||||
(or (get @template-cache key)
|
||||
@@ -167,7 +167,7 @@
|
||||
|
||||
For each tool, loads a markdown prompt file from `resources/metabot/prompts/tools/`.
|
||||
The filename is determined by:
|
||||
1. `:prompt` key in the tool's metadata (if present), or
|
||||
1. `:prompt` key in the tool definition map (if present), or
|
||||
2. `\"<tool-name>.md\"` as default.
|
||||
|
||||
Only tools with a corresponding prompt resource file are included.
|
||||
@@ -175,8 +175,8 @@
|
||||
Returns vector of maps: [{:tool_name \"search\" :instructions \"...\"}]"
|
||||
[tools]
|
||||
(vec
|
||||
(for [[tool-name tool] tools
|
||||
:let [fname (or (-> tool meta :prompt)
|
||||
(for [[tool-name tool-def] tools
|
||||
:let [fname (or (:prompt tool-def)
|
||||
(str tool-name ".md"))
|
||||
prompt (some-> (io/resource (str "metabot/prompts/tools/" fname))
|
||||
slurp)]
|
||||
@@ -1,10 +1,10 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.streaming
|
||||
(ns metabase.metabot.agent.streaming
|
||||
"Streaming helpers for the agent loop.
|
||||
Provides utilities for creating AI-SDK data parts, including navigation,
|
||||
and transducers for stream processing."
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as markdown-link-buffer]
|
||||
[metabase.metabot.agent.markdown-link-buffer :as markdown-link-buffer]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
131
src/metabase/metabot/agent/tools.clj
Normal file
131
src/metabase/metabot/agent/tools.clj
Normal file
@@ -0,0 +1,131 @@
|
||||
(ns metabase.metabot.agent.tools
|
||||
"Tool registry for the agent loop.
|
||||
Provides tool vars and state-aware wrapping.
|
||||
|
||||
Each tool is a var whose metadata contains `:tool-name`, `:schema`, and optionally
|
||||
`:capabilities`, `:prompt`, `:decode`, and `:system-instructions`. The var itself
|
||||
is a function that takes the tool arguments and returns a result map."
|
||||
(:require
|
||||
[metabase.metabot.agent.tools.analyze-chart :as tools.analyze-chart]
|
||||
[metabase.metabot.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
|
||||
[metabase.metabot.agent.tools.charts :as tools.charts]
|
||||
[metabase.metabot.agent.tools.clarification :as tools.clarification]
|
||||
[metabase.metabot.agent.tools.construct :as tools.construct]
|
||||
[metabase.metabot.agent.tools.create-alert :as tools.create-alert]
|
||||
[metabase.metabot.agent.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
|
||||
[metabase.metabot.agent.tools.document :as tools.document]
|
||||
[metabase.metabot.agent.tools.metadata :as tools.metadata]
|
||||
[metabase.metabot.agent.tools.navigation :as tools.navigation]
|
||||
[metabase.metabot.agent.tools.resources :as tools.resources]
|
||||
[metabase.metabot.agent.tools.search :as tools.search]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.agent.tools.slackbot-query :as tools.slackbot-query]
|
||||
[metabase.metabot.agent.tools.snippets :as tools.snippets]
|
||||
[metabase.metabot.agent.tools.sql :as tools.sql]
|
||||
[metabase.metabot.agent.tools.static-viz :as tools.static-viz]
|
||||
[metabase.metabot.agent.tools.subscriptions :as tools.subscriptions]
|
||||
[metabase.metabot.agent.tools.todo :as tools.todo]
|
||||
[metabase.metabot.agent.tools.transforms :as tools.transforms]
|
||||
[potemkin :as p]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(p/import-vars
|
||||
[tools.analyze-chart
|
||||
analyze-chart-tool]
|
||||
[tools.search search-tool
|
||||
sql-search-tool
|
||||
nlq-search-tool
|
||||
transform-search-tool]
|
||||
[tools.construct
|
||||
construct-notebook-query-tool]
|
||||
[tools.document
|
||||
document-schema-collect-tool
|
||||
document-construct-sql-chart-tool
|
||||
document-construct-model-chart-tool]
|
||||
[tools.metadata
|
||||
list-available-data-sources-tool
|
||||
list-available-fields-tool
|
||||
get-field-values-tool]
|
||||
[tools.transforms
|
||||
get-transform-details-tool
|
||||
get-transform-python-library-details-tool
|
||||
write-transform-sql-tool
|
||||
write-transform-python-tool]
|
||||
[tools.sql
|
||||
create-sql-query-tool
|
||||
create-sql-query-code-edit-tool
|
||||
edit-sql-query-tool
|
||||
edit-sql-query-code-edit-tool
|
||||
replace-sql-query-tool
|
||||
replace-sql-query-code-edit-tool]
|
||||
[tools.charts
|
||||
create-chart-tool
|
||||
edit-chart-tool]
|
||||
[tools.resources
|
||||
read-resource-tool]
|
||||
[tools.todo
|
||||
todo-write-tool
|
||||
todo-read-tool]
|
||||
[tools.navigation
|
||||
navigate-user-tool]
|
||||
[tools.snippets
|
||||
list-snippets-tool
|
||||
get-snippet-details-tool]
|
||||
[tools.clarification
|
||||
ask-for-sql-clarification-tool]
|
||||
[tools.autogen-dashboard
|
||||
create-autogenerated-dashboard-tool]
|
||||
[tools.subscriptions
|
||||
create-dashboard-subscription-tool]
|
||||
[tools.slackbot-query
|
||||
slackbot-construct-notebook-query-tool]
|
||||
[tools.static-viz
|
||||
static-viz-tool]
|
||||
[tools.create-alert
|
||||
create-alert-tool]
|
||||
[tools.create-dashboard-subscription
|
||||
slackbot-create-dashboard-subscription-tool])
|
||||
|
||||
(def ^:private state-dependent-tools
|
||||
"Set of tool names that require access to agent state."
|
||||
#{"analyze_chart"
|
||||
"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
|
||||
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
|
||||
"create_sql_query" "edit_sql_query" "replace_sql_query"
|
||||
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"
|
||||
"create_alert" "create_dashboard_subscription" "static_viz"})
|
||||
|
||||
(defn wrap-tools-with-state
|
||||
"Wrap state-dependent tools with access to the memory atom.
|
||||
Tools in `state-dependent-tools` will have access to the memory atom
|
||||
via the dynamic `shared/*memory-atom*` binding.
|
||||
|
||||
Takes a tools map where values are tool vars. Returns a tools map where
|
||||
values are tool definition maps (with :doc, :schema, :prompt, :fn, and
|
||||
optionally :decode keys).
|
||||
|
||||
Tool-specific instructions are loaded from `resources/metabot/prompts/tools/`
|
||||
by `extract-tool-instructions` in `prompts.clj`, keyed by `:prompt` or
|
||||
`<tool-name>.md` by default."
|
||||
[tools memory-atom]
|
||||
(reduce-kv
|
||||
(fn [acc tool-name tool-var]
|
||||
(let [m (meta tool-var)
|
||||
tool-def {:tool-name (:tool-name m)
|
||||
:doc (:doc m)
|
||||
:schema (:schema m)
|
||||
:prompt (:prompt m)
|
||||
:decode (:decode m)
|
||||
:system-instructions (:system-instructions m)
|
||||
:capabilities (:capabilities m)
|
||||
:fn (if (contains? state-dependent-tools tool-name)
|
||||
(fn [args]
|
||||
(binding [shared/*memory-atom* memory-atom]
|
||||
(tool-var args)))
|
||||
#_:clj-kondo/ignore
|
||||
(fn [args] ; fn wrapper because `tool-var` fails `fn?` schema
|
||||
(tool-var args)))}]
|
||||
(assoc acc tool-name tool-def)))
|
||||
{}
|
||||
tools))
|
||||
@@ -1,13 +1,13 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.analyze-chart
|
||||
(ns metabase.metabot.agent.tools.analyze-chart
|
||||
"Native agent tool for chart statistics analysis.
|
||||
|
||||
Analyzes charts from viewing context. The frontend provides pre-materialized
|
||||
series data in chart_configs, which is seeded into agent memory during
|
||||
initialization. This avoids re-executing queries."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.stats.core :as stats.core]
|
||||
[metabase-enterprise.metabot-v3.stats.repr :as stats.repr]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.stats.core :as stats.core]
|
||||
[metabase.metabot.stats.repr :as stats.repr]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[tech.v3.resource :as resource]))
|
||||
@@ -49,18 +49,19 @@ Do not use headers (##). Do not list statistics. Do not analyze series separatel
|
||||
(some-> (get (shared/current-chart-configs-state) chart-config-id)
|
||||
stringify-series-keys))
|
||||
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:chart_config_id :string]
|
||||
[:deep {:optional true :default true} [:maybe :boolean]]])
|
||||
|
||||
(mu/defn ^{:tool-name "analyze_chart"
|
||||
:prompt "analyze_chart"}
|
||||
analyze-chart-tool
|
||||
:prompt "analyze_chart"} analyze-chart-tool
|
||||
"Compute statistics and generate analysis context for a chart.
|
||||
Use this to analyze trends, outliers, volatility, and patterns in chart data.
|
||||
|
||||
The chart_config_id references a chart from the user's current viewing context.
|
||||
Available chart IDs are provided in the system context."
|
||||
[{:keys [chart_config_id deep]}
|
||||
:- [:map {:closed true}
|
||||
[:chart_config_id :string]
|
||||
[:deep {:optional true :default true} [:maybe :boolean]]]]
|
||||
[{:keys [chart_config_id deep]} :- schema]
|
||||
(try
|
||||
(if-let [chart-config (resolve-chart-config-from-memory chart_config_id)]
|
||||
;; Wrap TMD operations in resource context to ensure off-heap memory is released
|
||||
@@ -1,16 +1,24 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard
|
||||
(ns metabase.metabot.agent.tools.autogen-dashboard
|
||||
"Autogenerated dashboard tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
|
||||
:capabilities #{:automagic-dashboards}}
|
||||
create-autogenerated-dashboard-tool
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:source [:map
|
||||
[:table_id {:optional true} [:maybe :int]]
|
||||
[:model_id {:optional true} [:maybe :int]]
|
||||
[:metric_id {:optional true} [:maybe :int]]
|
||||
[:query_id {:optional true} [:maybe :string]]
|
||||
[:report_id {:optional true} [:maybe :int]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
|
||||
:capabilities #{:automagic-dashboards}} create-autogenerated-dashboard-tool
|
||||
"Create an automatically generated dashboard using Metabase's X-ray feature.
|
||||
|
||||
X-ray builds complete dashboards with multiple visualizations that showcase
|
||||
@@ -23,14 +31,7 @@
|
||||
- metric_id: Create dashboard for a metric
|
||||
- query_id: Create dashboard for query results from this conversation
|
||||
- report_id: Create dashboard for an existing report/question"
|
||||
[{:keys [source]}
|
||||
:- [:map {:closed true}
|
||||
[:source [:map
|
||||
[:table_id {:optional true} [:maybe :int]]
|
||||
[:model_id {:optional true} [:maybe :int]]
|
||||
[:metric_id {:optional true} [:maybe :int]]
|
||||
[:query_id {:optional true} [:maybe :string]]
|
||||
[:report_id {:optional true} [:maybe :int]]]]]]
|
||||
[{:keys [source]} :- schema]
|
||||
(try
|
||||
(let [result (autogen-dashboard-tools/create-autogenerated-dashboard
|
||||
{:source source
|
||||
@@ -1,12 +1,12 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.charts
|
||||
(ns metabase.metabot.agent.tools.charts
|
||||
"Chart tool wrappers."
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.edit-chart :as edit-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.tools.create-chart :as create-chart-tools]
|
||||
[metabase.metabot.tools.edit-chart :as edit-chart-tools]
|
||||
[metabase.metabot.tools.instructions :as instructions]
|
||||
[metabase.metabot.tools.llm-representations :as llm-rep]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
@@ -18,18 +18,23 @@
|
||||
(str "<result>\n" chart-xml "\n</result>\n"
|
||||
"<instructions>\n" (instructions/chart-created-instructions chart-id) "\n</instructions>")))
|
||||
|
||||
(def ^:private chart-type-enum
|
||||
[:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"])
|
||||
|
||||
(def ^:private create-chart-schema
|
||||
[:map {:closed true}
|
||||
[:data_source [:map {:closed true}
|
||||
[:query_id :string]]]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "create_chart"} create-chart-tool
|
||||
"Create a chart from a query.
|
||||
|
||||
Provide a query_id in data_source and a chart_type in viz_settings."
|
||||
[{:keys [data_source viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:data_source [:map {:closed true}
|
||||
[:query_id :string]]]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
|
||||
[{:keys [data_source viz_settings]} :- create-chart-schema]
|
||||
(try
|
||||
(let [result (create-chart-tools/create-chart
|
||||
{:query-id (get data_source :query_id)
|
||||
@@ -46,17 +51,17 @@
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to create chart: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(def ^:private edit-chart-schema
|
||||
[:map {:closed true}
|
||||
[:chart_id :string]
|
||||
[:new_viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "edit_chart"} edit-chart-tool
|
||||
"Edit an existing chart's visualization type.
|
||||
|
||||
Provide a new chart_type in new_viz_settings."
|
||||
[{:keys [chart_id new_viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:chart_id :string]
|
||||
[:new_viz_settings [:map {:closed true}
|
||||
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
|
||||
[{:keys [chart_id new_viz_settings]} :- edit-chart-schema]
|
||||
(try
|
||||
(let [result (edit-chart-tools/edit-chart
|
||||
{:chart-id chart_id
|
||||
@@ -1,8 +1,8 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.clarification
|
||||
(ns metabase.metabot.agent.tools.clarification
|
||||
"Clarification tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.tools.ask-clarification :as ask-clarification-tools]
|
||||
[metabase.metabot.tools.ask-clarification :as ask-clarification-tools]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -13,15 +13,17 @@
|
||||
(when (seq options)
|
||||
(str "\n\nOptions:\n" (str/join "\n" (map #(str "- " %) options))))))
|
||||
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:question :string]
|
||||
[:options {:optional true} [:maybe [:sequential :string]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "ask_for_sql_clarification"} ask-for-sql-clarification-tool
|
||||
"Ask the user for clarification about their SQL query request.
|
||||
|
||||
Use this tool when you need more information from the user to properly
|
||||
complete their SQL request. The agent will stop and wait for the user's response."
|
||||
[{:keys [question options]}
|
||||
:- [:map {:closed true}
|
||||
[:question :string]
|
||||
[:options {:optional true} [:maybe [:sequential :string]]]]]
|
||||
[{:keys [question options]} :- schema]
|
||||
(try
|
||||
(let [result (ask-clarification-tools/ask-for-sql-clarification {:question question
|
||||
:options options})]
|
||||
@@ -1,17 +1,17 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.construct
|
||||
(ns metabase.metabot.agent.tools.construct
|
||||
"Notebook query construction tool wrappers."
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as filter-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-u]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.metabot.agent.streaming :as streaming]
|
||||
[metabase.metabot.tmpl :as te]
|
||||
[metabase.metabot.tools.create-chart :as create-chart-tools]
|
||||
[metabase.metabot.tools.filters :as filter-tools]
|
||||
[metabase.metabot.tools.instructions :as instructions]
|
||||
[metabase.metabot.tools.llm-representations :as llm-rep]
|
||||
[metabase.metabot.util :as metabot-u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
@@ -316,8 +316,7 @@
|
||||
{:output (str "Unsupported query_type: " query-type)})))
|
||||
|
||||
(mu/defn ^{:tool-name "construct_notebook_query"
|
||||
:decode decode-tool-args}
|
||||
construct-notebook-query-tool
|
||||
:decode decode-tool-args} construct-notebook-query-tool
|
||||
"Construct and visualize a notebook query from a metric, model, or table."
|
||||
[{:keys [_reasoning query visualization]} :- construct-notebook-query-args-schema]
|
||||
(try
|
||||
@@ -1,9 +1,9 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.create-alert
|
||||
(ns metabase.metabot.agent.tools.create-alert
|
||||
"Agent tool wrapper for creating alerts on saved questions.
|
||||
Gets slack_channel_id from agent context and calls the create-alert tool endpoint directly."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-alert :as tools.create-alert]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.tools.create-alert :as tools.create-alert]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
@@ -59,15 +59,17 @@ CRITICAL: When a user asks to be alerted or notified about a saved question's re
|
||||
NEVER tell the user you have created an alert without actually calling the create_alert tool. If you cannot call the tool
|
||||
(e.g. missing required information), explain what is needed instead of pretending the alert was created.")
|
||||
|
||||
(mu/defn ^{:tool-name "create_alert"
|
||||
:system-instructions create-alert-system-instructions}
|
||||
create-alert-tool
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:card_id :int]
|
||||
[:send_condition [:enum "has_result" "goal_above" "goal_below"]]
|
||||
[:schedule schedule-schema]
|
||||
[:send_once {:optional true :default false} :boolean]])
|
||||
|
||||
(mu/defn ^{:tool-name "create_alert"
|
||||
:system-instructions create-alert-system-instructions} create-alert-tool
|
||||
"Create an alert based on a saved question's results on a recurring schedule."
|
||||
[{:keys [card_id send_condition schedule send_once]} :- [:map {:closed true}
|
||||
[:card_id :int]
|
||||
[:send_condition [:enum "has_result" "goal_above" "goal_below"]]
|
||||
[:schedule schedule-schema]
|
||||
[:send_once {:optional true :default false} :boolean]]]
|
||||
[{:keys [card_id send_condition schedule send_once]} :- schema]
|
||||
(let [slack-channel-id (:slack_channel_id (shared/current-context))]
|
||||
(when-not slack-channel-id
|
||||
(throw (ex-info "This tool can only be used from a Slack channel"
|
||||
@@ -1,10 +1,10 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.create-dashboard-subscription
|
||||
(ns metabase.metabot.agent.tools.create-dashboard-subscription
|
||||
"Agent tool wrapper for creating dashboard subscriptions.
|
||||
Gets slack_channel_id from agent context and calls the create-dashboard-subscription tool endpoint directly."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.create-alert :as tools.create-alert]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
|
||||
[metabase.metabot.agent.tools.create-alert :as tools.create-alert]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
@@ -31,13 +31,15 @@ Before calling the tool, ensure you have ALL of the following:
|
||||
|
||||
If any required information is missing, ask the user for it rather than assuming or fabricating values.")
|
||||
|
||||
(mu/defn ^{:tool-name "create_dashboard_subscription"
|
||||
:system-instructions create-dashboard-subscription-system-instructions}
|
||||
slackbot-create-dashboard-subscription-tool
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:dashboard_id :int]
|
||||
[:schedule tools.create-alert/schedule-schema]])
|
||||
|
||||
(mu/defn ^{:tool-name "create_dashboard_subscription"
|
||||
:system-instructions create-dashboard-subscription-system-instructions} slackbot-create-dashboard-subscription-tool
|
||||
"Create a recurring subscription that delivers a dashboard's contents to a Slack channel."
|
||||
[{:keys [dashboard_id schedule]} :- [:map {:closed true}
|
||||
[:dashboard_id :int]
|
||||
[:schedule tools.create-alert/schedule-schema]]]
|
||||
[{:keys [dashboard_id schedule]} :- schema]
|
||||
(let [slack-channel-id (:slack_channel_id (shared/current-context))]
|
||||
(when-not slack-channel-id
|
||||
(throw (ex-info "This tool can only be used from a Slack channel"
|
||||
@@ -1,10 +1,10 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.document
|
||||
(ns metabase.metabot.agent.tools.document
|
||||
"Document-generation specific tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.construct :as construct-tools]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
|
||||
[metabase.metabot.agent.tools.construct :as construct-tools]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.table-utils :as table-utils]
|
||||
[metabase.metabot.tools.create-sql-query :as create-sql-query-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.warehouses.core :as warehouses]))
|
||||
@@ -45,8 +45,7 @@
|
||||
(remove nil?))
|
||||
references))
|
||||
|
||||
(mu/defn ^{:tool-name "document_schema_collect"}
|
||||
document-schema-collect-tool
|
||||
(mu/defn ^{:tool-name "document_schema_collect"} document-schema-collect-tool
|
||||
"Collects the schema of a database in order to construct a SQL query.
|
||||
|
||||
**Usage:**
|
||||
@@ -87,19 +86,20 @@
|
||||
(log/error e "Error collecting document schema")
|
||||
{:output (str "Failed to collect schema: " (or (ex-message e) "Unknown error"))})))
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_sql_chart"}
|
||||
document-construct-sql-chart-tool
|
||||
(def ^:private sql-chart-schema
|
||||
[:map {:closed true}
|
||||
[:database_id :int]
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:analysis :string]
|
||||
[:approach :string]
|
||||
[:sql :string]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_sql_chart"} document-construct-sql-chart-tool
|
||||
"Construct SQL-backed chart draft payload for document insertion."
|
||||
[{:keys [database_id name description analysis approach sql viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:database_id :int]
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:analysis :string]
|
||||
[:approach :string]
|
||||
[:sql :string]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]]]
|
||||
[{:keys [database_id name description analysis approach sql viz_settings]} :- sql-chart-schema]
|
||||
(try
|
||||
(let [result (create-sql-query-tools/create-sql-query {:database-id database_id
|
||||
:sql sql})
|
||||
@@ -126,16 +126,17 @@
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to construct SQL chart draft: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_model_chart"}
|
||||
document-construct-model-chart-tool
|
||||
(def ^:private model-chart-schema
|
||||
[:map {:closed true}
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:query :map]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_model_chart"} document-construct-model-chart-tool
|
||||
"Construct notebook/model-backed chart draft payload for document insertion."
|
||||
[{:keys [name description query viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:query :map]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]]]
|
||||
[{:keys [name description query viz_settings]} :- model-chart-schema]
|
||||
(try
|
||||
(let [chart-type (get viz_settings :chart_type)
|
||||
result (construct-tools/construct-notebook-query-tool
|
||||
@@ -1,14 +1,14 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.metadata
|
||||
(ns metabase.metabot.agent.tools.metadata
|
||||
"Metadata tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.get-metadata :as metadata-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase.metabot.config :as metabot.config]
|
||||
[metabase.metabot.tools.entity-details :as entity-details-tools]
|
||||
[metabase.metabot.tools.field-stats :as field-stats-tools]
|
||||
[metabase.metabot.tools.get-metadata :as metadata-tools]
|
||||
[metabase.metabot.tools.instructions :as instructions]
|
||||
[metabase.metabot.tools.llm-representations :as llm-rep]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -50,28 +50,34 @@
|
||||
"List all data sources (metrics and models) available to the metabot instance."
|
||||
[_args :- [:maybe [:map {:closed true}]]]
|
||||
(add-output
|
||||
(entity-details-tools/answer-sources {:metabot-id metabot-v3.config/embedded-metabot-id
|
||||
(entity-details-tools/answer-sources {:metabot-id metabot.config/embedded-metabot-id
|
||||
:with-field-values? false})
|
||||
format-answer-sources-output))
|
||||
|
||||
(def ^:private list-available-fields-schema
|
||||
[:map {:closed true}
|
||||
[:table_ids [:sequential :int]]
|
||||
[:model_ids [:sequential :int]]
|
||||
[:metric_ids [:sequential :int]]])
|
||||
|
||||
(mu/defn ^{:tool-name "list_available_fields"} list-available-fields-tool
|
||||
"Retrieve metadata for tables, models, and metrics."
|
||||
[{:keys [table_ids model_ids metric_ids]} :- [:map {:closed true}
|
||||
[:table_ids [:sequential :int]]
|
||||
[:model_ids [:sequential :int]]
|
||||
[:metric_ids [:sequential :int]]]]
|
||||
[{:keys [table_ids model_ids metric_ids]} :- list-available-fields-schema]
|
||||
(add-output
|
||||
(metadata-tools/get-metadata {:table-ids table_ids
|
||||
:model-ids model_ids
|
||||
:metric-ids metric_ids})
|
||||
format-metadata-output))
|
||||
|
||||
(def ^:private get-field-values-schema
|
||||
[:map {:closed true}
|
||||
[:data_source [:enum "table" "model" "metric"]]
|
||||
[:source_id :int]
|
||||
[:field_id :string]])
|
||||
|
||||
(mu/defn ^{:tool-name "get_field_values"} get-field-values-tool
|
||||
"Return metadata for a given field of a given data source."
|
||||
[{:keys [data_source source_id field_id]} :- [:map {:closed true}
|
||||
[:data_source [:enum "table" "model" "metric"]]
|
||||
[:source_id :int]
|
||||
[:field_id :string]]]
|
||||
[{:keys [data_source source_id field_id]} :- get-field-values-schema]
|
||||
(add-output
|
||||
(field-stats-tools/field-values {:entity-type data_source
|
||||
:entity-id source_id
|
||||
@@ -1,37 +1,38 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.navigation
|
||||
(ns metabase.metabot.agent.tools.navigation
|
||||
"Navigation tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.navigate :as navigate-tools]
|
||||
[metabase.metabot.agent.tools.shared :as shared]
|
||||
[metabase.metabot.tools.navigate :as navigate-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "navigate_user"
|
||||
:capabilities #{:frontend-navigate-user-v1}}
|
||||
navigate-user-tool
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:destination [:or
|
||||
[:map {:closed true}
|
||||
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
|
||||
[:map {:closed true}
|
||||
[:page [:enum "sql_editor"]]
|
||||
[:database_id :int]]
|
||||
[:map {:closed true}
|
||||
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
|
||||
[:entity_id :int]]
|
||||
[:map {:closed true}
|
||||
[:query_id :string]]
|
||||
[:map {:closed true}
|
||||
[:chart_id :string]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "navigate_user"
|
||||
:capabilities #{:frontend-navigate-user-v1}} navigate-user-tool
|
||||
"Navigate the user to a specific page or entity in Metabase.
|
||||
|
||||
Use this tool to direct users to:
|
||||
- Pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor
|
||||
- Entities: table, model, question, metric, dashboard
|
||||
- Query results or charts from the current conversation"
|
||||
[{:keys [destination]}
|
||||
:- [:map {:closed true}
|
||||
[:destination [:or
|
||||
[:map {:closed true}
|
||||
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
|
||||
[:map {:closed true}
|
||||
[:page [:enum "sql_editor"]]
|
||||
[:database_id :int]]
|
||||
[:map {:closed true}
|
||||
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
|
||||
[:entity_id :int]]
|
||||
[:map {:closed true}
|
||||
[:query_id :string]]
|
||||
[:map {:closed true}
|
||||
[:chart_id :string]]]]]]
|
||||
[{:keys [destination]} :- schema]
|
||||
(try
|
||||
(let [result (navigate-tools/navigate {:destination destination
|
||||
:memory-atom shared/*memory-atom*})
|
||||
@@ -1,12 +1,16 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.resources
|
||||
(ns metabase.metabot.agent.tools.resources
|
||||
"Resource reading tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.read-resource :as read-resource-tools]
|
||||
[metabase.metabot.tools.read-resource :as read-resource-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private schema
|
||||
[:map {:closed true}
|
||||
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "read_resource"} read-resource-tool
|
||||
"Read detailed information about Metabase resources via URI patterns.
|
||||
|
||||
@@ -16,9 +20,7 @@
|
||||
- metabase://metric/{id}/dimensions - Get metric dimensions
|
||||
- metabase://transform/{id} - Get transform details
|
||||
- metabase://dashboard/{id} - Get dashboard details"
|
||||
[{:keys [uris]}
|
||||
:- [:map {:closed true}
|
||||
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]]]
|
||||
[{:keys [uris]} :- schema]
|
||||
(try
|
||||
(read-resource-tools/read-resource {:uris uris})
|
||||
(catch Exception e
|
||||
103
src/metabase/metabot/agent/tools/search.clj
Normal file
103
src/metabase/metabot/agent/tools/search.clj
Normal file
@@ -0,0 +1,103 @@
|
||||
(ns metabase.metabot.agent.tools.search
|
||||
"Search tool wrappers for Metabot v3."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.metabot.tmpl :as te]
|
||||
[metabase.metabot.tools.instructions :as instructions]
|
||||
[metabase.metabot.tools.llm-representations :as llm-rep]
|
||||
[metabase.metabot.tools.search :as search-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-search-output
|
||||
"Format search results as an LLM-ready string."
|
||||
[results]
|
||||
(let [results-xml (llm-rep/search-results->xml results)]
|
||||
(te/lines
|
||||
"<result>"
|
||||
results-xml
|
||||
""
|
||||
(str "Total results: " (count results))
|
||||
"</result>"
|
||||
"<instructions>"
|
||||
instructions/search-result-instructions "</instructions>")))
|
||||
|
||||
(defn- invalid-entity-types
|
||||
[entity-types allowed]
|
||||
(when (seq entity-types)
|
||||
(seq (remove allowed entity-types))))
|
||||
|
||||
(defn- do-search
|
||||
[label allowed-types search-opts {:keys [semantic_queries keyword_queries entity_types] :as _args}]
|
||||
(if-let [invalid (invalid-entity-types entity_types allowed-types)]
|
||||
{:output (str "Invalid entity_types for " label ": " (pr-str (vec invalid))
|
||||
". Allowed types: " (str/join ", " (sort allowed-types)) ".")}
|
||||
(try
|
||||
(let [results (search-tools/search (merge {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:limit 10}
|
||||
search-opts))]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e (str "Error in " label))
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(def ^:private search-schema
|
||||
[:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "search"} search-tool
|
||||
"Search for tables, models, metrics, dashboards, and saved questions."
|
||||
[args :- search-schema]
|
||||
(do-search "search" #{"table" "model" "metric" "dashboard" "question"} {} args))
|
||||
|
||||
(def ^:private sql-search-schema
|
||||
[:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:database_id :int]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model"]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "search"
|
||||
:prompt "sql_search.md"} sql-search-tool
|
||||
"Search for SQL-queryable data sources (tables and models) within a database."
|
||||
[{:keys [database_id] :as args} :- sql-search-schema]
|
||||
(do-search "SQL search" #{"table" "model"} {:database-id database_id} args))
|
||||
|
||||
(def ^:private nlq-search-schema
|
||||
[:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "model" "metric" "table"]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "search"
|
||||
:prompt "nql_search.md"} nlq-search-tool
|
||||
"Search for NLQ-queryable data sources (models, metrics, tables)."
|
||||
[args :- nlq-search-schema]
|
||||
(do-search "NLQ search" #{"model" "metric" "table"} {:profile-id "nlq"} args))
|
||||
|
||||
(def ^:private transform-search-schema
|
||||
[:map {:closed true}
|
||||
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:search_native_query {:optional true} [:maybe :boolean]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "transform"]]]]])
|
||||
|
||||
(mu/defn ^{:tool-name "search"
|
||||
:prompt "transform_search"} transform-search-tool
|
||||
"Search for transforms, tables, and models."
|
||||
[{:keys [search_native_query] :as args} :- transform-search-schema]
|
||||
(do-search "transform search" #{"table" "model" "transform"}
|
||||
{:search-native-query search_native_query} args))
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.shared
|
||||
(ns metabase.metabot.agent.tools.shared
|
||||
"Shared tool helpers and state accessors."
|
||||
(:require
|
||||
[metabase.util.log :as log]))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user