You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/08/02 09:30:09 UTC
(superset) 02/03: refactor(chat): move the AI gateway into the extension
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch enxdev/feat/chat-extension
in repository https://gitbox.apache.org/repos/asf/superset.git
commit b598025eefbaed144025434156be2ea10750d53c
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Sun Aug 2 10:57:15 2026 +0200
refactor(chat): move the AI gateway into the extension
---
extensions/ai-chat/.gitignore | 16 ++
extensions/ai-chat/README.md | 86 +++++--
extensions/ai-chat/backend/pyproject.toml | 23 ++
.../backend/src/enx_dev}/ai_chat/__init__.py | 13 +-
.../ai-chat/backend/src/enx_dev}/ai_chat/api.py | 99 ++++----
.../backend/src/enx_dev/ai_chat/approvals.py | 172 ++++++++++++++
.../backend/src/enx_dev}/ai_chat/classification.py | 4 +-
.../backend/src/enx_dev/ai_chat/entrypoint.py | 8 +
.../ai-chat/backend/src/enx_dev}/ai_chat/events.py | 2 +-
.../backend/src/enx_dev}/ai_chat/exceptions.py | 0
.../backend/src/enx_dev}/ai_chat/mcp_bridge.py | 17 +-
.../backend/src/enx_dev}/ai_chat/orchestrator.py | 18 +-
.../src/enx_dev}/ai_chat/providers/__init__.py | 10 +-
.../ai_chat/providers/anthropic_provider.py | 6 +-
.../backend/src/enx_dev}/ai_chat/providers/base.py | 6 +-
.../backend/src/enx_dev}/ai_chat/providers/mock.py | 6 +-
.../enx_dev}/ai_chat/providers/openai_compat.py | 8 +-
.../backend/src/enx_dev}/ai_chat/schemas.py | 0
.../backend/src/enx_dev}/ai_chat/settings.py | 14 +-
.../ai-chat/backend/src/enx_dev}/ai_chat/types.py | 4 +-
.../ai-chat/backend/tests}/api_test.py | 56 ++---
.../ai-chat/backend/tests}/approvals_test.py | 24 +-
.../ai-chat/backend/tests}/classification_test.py | 7 +-
extensions/ai-chat/backend/tests/conftest.py | 90 ++++++++
.../ai-chat/backend/tests}/e2e_mock_flow_test.py | 30 +--
.../ai-chat/backend/tests}/mcp_bridge_test.py | 11 +-
.../ai-chat/backend/tests}/orchestrator_test.py | 31 ++-
.../ai-chat/backend/tests}/providers_test.py | 19 +-
extensions/ai-chat/docs/implementation-notes.md | 256 ---------------------
extensions/ai-chat/extension.json | 2 +-
extensions/ai-chat/frontend/src/api/client.test.ts | 2 +-
extensions/ai-chat/frontend/src/api/client.ts | 3 +-
.../ai-chat/frontend/src/components/ChatHeader.tsx | 2 +-
extensions/ai-chat/frontend/src/index.test.tsx | 2 +-
extensions/ai-chat/frontend/src/index.tsx | 4 +-
extensions/ai-chat/frontend/test/coreMock.tsx | 2 +-
extensions/ai-chat/frontend/webpack.config.js | 2 +-
superset/ai_chat/approvals.py | 141 ------------
superset/config.py | 17 --
superset/initialization/__init__.py | 2 -
superset/key_value/types.py | 1 -
41 files changed, 574 insertions(+), 642 deletions(-)
diff --git a/extensions/ai-chat/.gitignore b/extensions/ai-chat/.gitignore
new file mode 100644
index 00000000000..f8df10e312f
--- /dev/null
+++ b/extensions/ai-chat/.gitignore
@@ -0,0 +1,16 @@
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+*.supx
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+
+# OS
+.DS_Store
diff --git a/extensions/ai-chat/README.md b/extensions/ai-chat/README.md
index 3ff3fde75b3..ee6676b8ad8 100644
--- a/extensions/ai-chat/README.md
+++ b/extensions/ai-chat/README.md
@@ -21,7 +21,7 @@ under the License.
An AI assistant inside the Superset chat host. The extension registers a
trigger and panel through the public `chat` contribution API (SIP-214) and
-talks to a server-side gateway (`superset/ai_chat/`) that invokes a
+talks to a server-side gateway (`backend/`) that invokes a
configured model provider and orchestrates the Superset MCP tools under the
current user's own permissions. Mutating operations require an explicit,
server-enforced user approval.
@@ -35,7 +35,7 @@ flowchart LR
P -->|"fetch + CSRF"| G
end
subgraph "Superset backend"
- G["AI gateway REST API<br/>/api/v1/ai_chat"] --> O[Orchestrator]
+ G["AI gateway REST API<br/>/extensions/enx-dev/ai-chat"] --> O[Orchestrator]
O --> PR["Provider abstraction<br/>mock / openai_compatible / anthropic"]
O --> A["Approval store<br/>(key-value, single-use)"]
O --> B["MCP bridge<br/>(allowlist + classification)"]
@@ -48,10 +48,12 @@ flowchart LR
cards, page-context awareness (`navigation` API), file attachments, local
persistence, retry and cancellation. It holds no secrets and performs no
Superset operations itself.
-- **Gateway** (`superset/ai_chat/`): authenticates the session user (CSRF
+
+- **Gateway** (`backend/`): authenticates the session user (CSRF
enforced), validates payloads, calls the provider, executes allowlisted
MCP tools in-process through the in-memory FastMCP client (full RBAC
middleware chain), enforces approvals, caps sizes, sanitizes errors.
+
- **Protocol**: one POST per turn returning an ordered list of typed events
(`message.completed`, `tool.running`, `tool.completed`, `tool.failed`,
`tool.approval_required`, `tool.rejected`, `request.completed`,
@@ -64,12 +66,27 @@ The entry point (`frontend/src/index.tsx`) registers as a module-level side
effect:
```tsx
-import { chat } from '@apache-superset/core';
-chat.registerChat({ id: 'apache-superset.ai-chat', name: '…' }, ChatTrigger, ChatPanel);
+import { chat } from "@apache-superset/core";
+chat.registerChat({ id: "enx-dev.ai-chat", name: "…" }, ChatTrigger, ChatPanel);
```
No manifest contribution entry is required for chat.
+The backend registers the same way, by import side effect. The host imports
+`backend/src/enx_dev/ai_chat/entrypoint.py` — the conventional path
+derived from the publisher and name in `extension.json` — and the `@api`
+decorator on `AiChatRestApi` mounts the routes under
+`/extensions/enx-dev/ai-chat/` as the class is created.
+
+```
+extension.json publisher, name, version, license
+frontend/src/index.tsx frontend entry point (registers the chat)
+backend/pyproject.toml Python package metadata and build includes
+backend/src/enx_dev/ai_chat/entrypoint.py
+ backend entry point (registers the API)
+backend/tests/ pytest suite, run against a Superset checkout
+```
+
## Enabling the feature
Four independent switches, all server-side:
@@ -87,16 +104,19 @@ disabled state; no secrets are ever included in any response.
## Provider configuration
-Everything lives in `AI_CHAT_CONFIG` in `superset_config.py` (defaults in
-`superset/config.py`). The provider API key is read at request time from the
-environment variable named by `API_KEY_ENV_VAR` — it is never stored in
-config, never logged, and never sent to the browser.
+Everything lives in `AI_CHAT_CONFIG` in `superset_config.py`. Superset
+itself carries no default for it; whatever you set is merged over the
+defaults the extension ships in
+`backend/src/enx_dev/ai_chat/settings.py`, so naming only the keys
+you care about leaves the curated tool allowlist and the size limits intact.
+The provider API key is read at request time from the environment variable
+named by `API_KEY_ENV_VAR` — it is never stored in config, never logged, and
+never sent to the browser.
### Mock (development and tests — no credentials)
```python
AI_CHAT_CONFIG = {
- **AI_CHAT_CONFIG,
"ENABLED": True,
"PROVIDER": "mock",
}
@@ -111,7 +131,6 @@ else returns a help message.
```python
AI_CHAT_CONFIG = {
- **AI_CHAT_CONFIG,
"ENABLED": True,
"PROVIDER": "openai_compatible",
"MODEL": "gpt-4o-mini",
@@ -126,7 +145,6 @@ AI_CHAT_CONFIG = {
```python
AI_CHAT_CONFIG = {
- **AI_CHAT_CONFIG,
"ENABLED": True,
"PROVIDER": "anthropic",
"MODEL": "claude-sonnet-4-5",
@@ -155,6 +173,7 @@ the requesting user. On top of that:
- `ALLOWED_MCP_TOOLS` is an explicit allowlist — the model never sees tools
outside it. An empty list disables tool use.
+
- Every tool is classified from its declared `ToolAnnotations`:
`readOnlyHint=True` → **read-only** (executes immediately);
`destructiveHint=True` → **destructive** (always requires approval);
@@ -162,6 +181,7 @@ the requesting user. On top of that:
operator disables `REQUIRE_APPROVAL_FOR_MUTATIONS`); missing/unknown
annotations → **unknown**, treated like destructive and never
auto-executed.
+
- SQL execution goes through the MCP `execute_sql` tool, which fail-closes
on unparseable SQL, blocks destructive DDL unconditionally and leaves DML
to the per-database `allow_dml` flag — classification by code, not by the
@@ -170,7 +190,7 @@ the requesting user. On top of that:
**Adding a tool**: append its name to `ALLOWED_MCP_TOOLS`. Its class is
derived automatically from its annotations; if it has none it will require
approval every time until annotated. Optional presentation-only warnings can
-be added in `superset/ai_chat/classification.py::TOOL_APPROVAL_WARNINGS`.
+be added in `backend/src/enx_dev/ai_chat/classification.py::TOOL_APPROVAL_WARNINGS`.
## Approval behavior
@@ -185,26 +205,32 @@ with Approve/Reject buttons.
property and consumes the record atomically before executing. Changing
the arguments, tool, user or conversation invalidates it; replay after
use fails.
+
- Rejecting burns the approval and returns a structured rejection to the
model, which responds without executing.
+
- Approval is enforced server-side; nothing the model, the page content or
the client sends can bypass it.
## Security model
- Session authentication + CSRF on every route; a dedicated `can read on
- AiChat` permission lets operators grant the assistant per role.
+AiChat` permission lets operators grant the assistant per role.
+
- All Superset operations run through MCP tools under the requesting user —
the gateway adds no privileged path. If `MCP_DEV_USERNAME` is set to a
different user than the session user, tool execution fails closed
(identity alignment guard).
+
- Prompt-injection defense in depth: trusted system prompt kept separate
from all retrieved data; MCP wraps user-authored strings in
`<UNTRUSTED-CONTENT>` tags; tool output is size-capped and carried only
as tool-role data; the tool allowlist, classification and approvals are
code-enforced; no shell/filesystem/URL-fetch/code-execution tools exist.
+
- The frontend renders Markdown through a minimal React-element renderer:
no raw HTML, no `dangerouslySetInnerHTML`, unsafe link schemes refused.
+
- Attached files are read in the browser and travel inside the user turn as
delimited `<ATTACHED-FILE>` blocks, which the system prompt declares to be
reference data and never instructions — including any text written inside
@@ -214,6 +240,7 @@ with Approve/Reject buttons.
accepted only as base64 with an allowlisted media type, are attached to
user turns only (the gateway drops any others), and are bounded per image
and per request. Nothing is uploaded or stored server-side.
+
- Errors returned to the browser are sanitized (no tracebacks, no provider
response bodies); secret-looking argument values are redacted in events
and logs.
@@ -229,7 +256,7 @@ npm run type # tsc --noEmit over src + tests
npm run build # webpack production build into dist/
# Backend tests (from the repo root, in the Superset venv)
-pytest tests/unit_tests/ai_chat/
+pytest extensions/ai-chat/backend/tests/
```
To load the extension locally, run `superset-extensions build` in this
@@ -240,7 +267,7 @@ it as a `.supx` for `EXTENSIONS_PATH`-based deployments.
### Manual testing steps
1. `FEATURE_FLAGS["ENABLE_EXTENSIONS"] = True`, `AI_CHAT_CONFIG["ENABLED"] =
- True` with the mock provider, and this extension in `LOCAL_EXTENSIONS`.
+True` with the mock provider, and this extension in `LOCAL_EXTENSIONS`.
2. Log in; the robot trigger appears bottom-right. Click it — the panel
opens in floating mode; the header toggle docks it as a sidebar.
3. Send `list dashboards` — a read-only tool card runs and the mock
@@ -254,30 +281,49 @@ it as a `.supx` for `EXTENSIONS_PATH`-based deployments.
### Adding another provider
-Implement `BaseChatProvider` (`superset/ai_chat/providers/base.py`) — one
+Implement `BaseChatProvider` (`backend/src/enx_dev/ai_chat/providers/base.py`) — one
async `complete(messages, tools) -> ProviderResult` translating the neutral
message format to your wire format — and register the class in
-`PROVIDERS` (`superset/ai_chat/providers/__init__.py`). Raise
+`PROVIDERS` (`backend/src/enx_dev/ai_chat/providers/__init__.py`). Raise
`AiChatProviderError` with browser-safe messages on failure. The UI is
provider-agnostic and needs no changes.
## Limitations
+- **One host import**: the MCP bridge imports `superset.mcp_service.app` to
+ reach the server the host already runs. `apache-superset-core` exposes
+ decorators for contributing MCP tools but no client for calling them, and
+ a second server would mean a second copy of the middleware chain the
+ bridge exists to go through. The import is lazy and guarded, so a host
+ that moves it costs tool use rather than the whole assistant. Everything
+ else in `backend/` goes through `apache-superset-core`.
+
+- **Unreleased frontend APIs**: `chat` and `navigation` are not in
+ `@apache-superset/core` 0.1.0 on npm, so the frontend builds against a
+ Superset checkout and needs a host recent enough to provide the chat
+ contribution point.
+
- **No token streaming**: the backend has no SSE convention; each turn
returns its events at once. The typed event protocol is
transport-agnostic so streaming can be added without changing the UI
event model.
+
- **Cancellation is client-side**: cancelling aborts the fetch and recovers
the UI; the in-flight server work completes and is discarded.
+
- **Page context is a hint**: the public navigation API exposes only the
page type; the dashboard id is parsed from the URL and verified via tools.
+
- **No multi-step atomicity**: dashboard/chart creation reports per-step
results honestly; there is no transaction across MCP tools.
+
- **History replay**: the server is stateless; the client replays trimmed
history, so very long conversations lose their oldest turns and tool
results are replayed as bounded excerpts.
+
- **Conversation storage** is browser-local (namespaced localStorage via
the extension storage API), capped in size, cleared by "New conversation".
+
- **Attachments**: up to 3 per message. Text files (`.csv`, `.json`, `.log`,
`.md`, `.py`, `.sql`, `.tsv`, `.txt`, `.yaml`, `.yml`) are capped at 1 MB
and 20 000 characters each, longer ones truncated with a note the model is
@@ -285,10 +331,11 @@ provider-agnostic and needs no changes.
`.jpg`, `.jpeg`, `.gif`, `.webp`) are capped at 8 MB and require a
vision-capable configured model — a text-only model rejects the request
and the error surfaces with a retry. An image whose encoded payload
- exceeds ~300 KB is re-encoded in the browser with its longest edge bounded
+ exceeds \~300 KB is re-encoded in the browser with its longest edge bounded
to 1400 px; smaller ones are sent as they are, whatever their pixel
dimensions, and re-encoding is best-effort (the original is kept if it
fails or does not come out smaller).
+
- **Dropped objects**: dragging a chart title, dashboard card or dataset
link into the composer attaches it as context (up to 5). Identity comes
from the dropped URL, so no host-side drag source is needed, and only
@@ -296,6 +343,7 @@ provider-agnostic and needs no changes.
X or until the conversation is cleared, and travel as page context on every
turn — so they are hints the assistant verifies with a tool, exactly like
the page's own resource.
+
- **Attachments live in the turn**: file text counts toward
`MAX_INPUT_CHARS`, and both files and images leave context once history
trimming reaches that message. Images are stripped before the conversation
diff --git a/extensions/ai-chat/backend/pyproject.toml b/extensions/ai-chat/backend/pyproject.toml
new file mode 100644
index 00000000000..17b7916f9eb
--- /dev/null
+++ b/extensions/ai-chat/backend/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "ai_chat"
+version = "0.1.0"
+license = "Apache-2.0"
+description = "Server-side gateway for the Superset AI Assistant chat extension"
+dependencies = [
+ "apache-superset-core",
+]
+
+[project.optional-dependencies]
+# The gateway talks to model providers over plain HTTP with `requests`, which
+# Superset already ships, so no provider SDK is required. MCP tool execution
+# needs `fastmcp`, the same optional dependency the host MCP service uses; the
+# gateway degrades to a chat-only assistant when it is absent.
+dev = []
+
+[tool.apache_superset_extensions.build]
+include = [
+ "src/enx_dev/ai_chat/**/*.py",
+]
+exclude = [
+ "src/**/tests/**",
+]
diff --git a/superset/ai_chat/__init__.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/__init__.py
similarity index 65%
rename from superset/ai_chat/__init__.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/__init__.py
index 670244e455f..54cb1b34967 100644
--- a/superset/ai_chat/__init__.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/__init__.py
@@ -16,11 +16,12 @@
# under the License.
"""AI chat gateway.
-Server-side gateway backing the AI assistant chat extension in
-``extensions/ai-chat``. It authenticates the current Superset user, invokes a
-configured model provider, and orchestrates MCP tools under the user's own
-authorization context, with server-enforced approvals for mutating
-operations.
+Backend half of the AI assistant chat extension. It authenticates the current
+Superset user, invokes a configured model provider, and orchestrates MCP tools
+under the user's own authorization context, with server-enforced approvals for
+mutating operations.
-Configuration lives in ``AI_CHAT_CONFIG``; see ``superset/config.py``.
+Operators configure it through ``AI_CHAT_CONFIG`` in ``superset_config.py``;
+the shipped defaults and every supported key live in
+:mod:`enx_dev.ai_chat.settings`.
"""
diff --git a/superset/ai_chat/api.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/api.py
similarity index 80%
rename from superset/ai_chat/api.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/api.py
index e1880d7e157..385b1f0bd82 100644
--- a/superset/ai_chat/api.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/api.py
@@ -16,11 +16,14 @@
# under the License.
"""REST API for the AI chat gateway.
-All routes require an authenticated session (CSRF enforced by
-``BaseSupersetApi``) and the ``can read on AiChat`` permission, which lets
-operators grant or revoke the assistant per role. Object-level authorization
-for everything the assistant does is enforced by the MCP tools themselves
-under the requesting user.
+All routes require an authenticated session and the ``can read on AiChat``
+permission, which lets operators grant or revoke the assistant per role.
+Object-level authorization for everything the assistant does is enforced by
+the MCP tools themselves under the requesting user.
+
+Routes are served under ``/extensions/{publisher}/{name}/`` — the ``@api``
+decorator resolves the prefix from the ambient extension context, so the
+class never spells out its own mount point.
"""
from __future__ import annotations
@@ -30,39 +33,46 @@ import logging
from collections.abc import Callable
from typing import Any
-from flask import g, request, Response
-from flask_appbuilder.api import expose, protect, safe
-from marshmallow import Schema, ValidationError
-
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatDisabledError,
AiChatError,
AiChatIdentityMismatchError,
)
-from superset.ai_chat.mcp_bridge import (
+from enx_dev.ai_chat.mcp_bridge import (
assert_identity_alignment,
is_mcp_available,
list_allowed_tools,
)
-from superset.ai_chat.orchestrator import ChatTurnRunner
-from superset.ai_chat.providers import is_provider_configured
-from superset.ai_chat.schemas import (
+from enx_dev.ai_chat.orchestrator import ChatTurnRunner
+from enx_dev.ai_chat.providers import is_provider_configured
+from enx_dev.ai_chat.schemas import (
ChatRequestSchema,
ToolApprovalRequestSchema,
)
-from superset.ai_chat.settings import get_ai_chat_config
-from superset.ai_chat.types import ToolCall
-from superset.extensions import event_logger
-from superset.views.base_api import BaseSupersetApi, statsd_metrics
+from enx_dev.ai_chat.settings import get_ai_chat_config
+from enx_dev.ai_chat.types import ToolCall
+from flask import g, request, Response
+from flask_appbuilder.api import expose, protect, safe
+from marshmallow import Schema, ValidationError
+from superset_core.rest_api.api import RestApi
+from superset_core.rest_api.decorators import api
logger = logging.getLogger(__name__)
-class AiChatRestApi(BaseSupersetApi):
+@api(
+ id="ai_chat",
+ name="AI Chat",
+ description="Gateway backing the AI assistant chat extension",
+)
+class AiChatRestApi(RestApi):
"""Gateway endpoints backing the AI assistant chat extension."""
- resource_name = "ai_chat"
- allow_browser_login = True
+ # Flask-AppBuilder exempts APIs from CSRF by default, on the assumption
+ # that they are token-authenticated. These routes are reached from the
+ # browser with the session cookie, so the exemption has to be lifted --
+ # otherwise any site could POST a conversation turn on the user's behalf.
+ csrf_exempt = False
class_permission_name = "AiChat"
method_permission_name = {
"config": "read",
@@ -161,11 +171,6 @@ class AiChatRestApi(BaseSupersetApi):
@expose("/config", methods=("GET",))
@protect()
@safe
- @statsd_metrics
- @event_logger.log_this_with_context(
- action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.config",
- log_to_statsd=False,
- )
def config(self) -> Response:
"""Get AI chat availability and capability information.
---
@@ -186,11 +191,11 @@ class AiChatRestApi(BaseSupersetApi):
result:
type: object
401:
- $ref: '#/components/responses/401'
+ description: Unauthorized
403:
- $ref: '#/components/responses/403'
+ description: Forbidden
500:
- $ref: '#/components/responses/500'
+ description: Fatal error
"""
config = get_ai_chat_config()
enabled = bool(config.get("ENABLED"))
@@ -218,11 +223,6 @@ class AiChatRestApi(BaseSupersetApi):
@expose("/chat", methods=("POST",))
@protect()
@safe
- @statsd_metrics
- @event_logger.log_this_with_context(
- action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.chat",
- log_to_statsd=False,
- )
def chat(self) -> Response:
"""Run one conversation turn.
---
@@ -238,17 +238,17 @@ class AiChatRestApi(BaseSupersetApi):
200:
description: Ordered protocol events for this turn
400:
- $ref: '#/components/responses/400'
+ description: Bad request
401:
- $ref: '#/components/responses/401'
+ description: Unauthorized
403:
- $ref: '#/components/responses/403'
+ description: Forbidden
404:
- $ref: '#/components/responses/404'
+ description: Not found
422:
- $ref: '#/components/responses/422'
+ description: Could not process entity
500:
- $ref: '#/components/responses/500'
+ description: Fatal error
"""
return self._run_turn(
self.chat_request_schema,
@@ -258,13 +258,6 @@ class AiChatRestApi(BaseSupersetApi):
@expose("/tool_approval", methods=("POST",))
@protect()
@safe
- @statsd_metrics
- @event_logger.log_this_with_context(
- action=lambda self, *args, **kwargs: (
- f"{self.__class__.__name__}.tool_approval"
- ),
- log_to_statsd=False,
- )
def tool_approval(self) -> Response:
"""Approve or reject a proposed tool call and continue the turn.
---
@@ -286,17 +279,17 @@ class AiChatRestApi(BaseSupersetApi):
200:
description: Ordered protocol events for the continuation
400:
- $ref: '#/components/responses/400'
+ description: Bad request
401:
- $ref: '#/components/responses/401'
+ description: Unauthorized
403:
- $ref: '#/components/responses/403'
+ description: Forbidden
404:
- $ref: '#/components/responses/404'
+ description: Not found
422:
- $ref: '#/components/responses/422'
+ description: Could not process entity
500:
- $ref: '#/components/responses/500'
+ description: Fatal error
"""
def run(
diff --git a/extensions/ai-chat/backend/src/enx_dev/ai_chat/approvals.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/approvals.py
new file mode 100644
index 00000000000..9ba6713b069
--- /dev/null
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/approvals.py
@@ -0,0 +1,172 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Server-enforced approvals for mutating tool calls.
+
+An approval is a short-lived, single-use record bound to the requesting user,
+the conversation, the exact tool name and a hash of the canonicalized tool
+arguments. Approvals live in the metadata database through the shared
+key-value table, so enforcement holds across workers and never depends on
+what the browser sends. Changing any bound property invalidates the
+approval, and a consumed approval cannot be replayed.
+
+The ``resource`` column is a free-form string, so the extension namespaces
+its own rows with :data:`RESOURCE` instead of needing an entry in the host's
+resource enumeration. Rows are reached through the generic DAO methods for
+the same reason: the host's resource-typed helpers (``create_entry`` and
+``get_entry``) only accept members of that enumeration.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json # noqa: TID251 (superset.utils.json is host-internal)
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+from uuid import UUID, uuid4
+
+from enx_dev.ai_chat.exceptions import (
+ AiChatApprovalExpiredError,
+ AiChatApprovalMismatchError,
+)
+from enx_dev.ai_chat.settings import get_ai_chat_config
+
+# Imported as modules rather than by name: the host swaps these attributes in
+# during startup, and binding the names at import time would freeze whichever
+# placeholder happened to be in place first.
+from superset_core.common import daos as core_daos, models as core_models
+
+#: Namespace for this extension's rows in the shared key-value table.
+RESOURCE = "ai_chat_approval"
+
+
+@dataclass
+class Approval:
+ approval_id: str
+ expires_at: str
+
+
+def canonicalize_arguments(arguments: dict[str, Any]) -> str:
+ """Stable serialization of tool arguments for fingerprinting."""
+ return json.dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
+
+
+def arguments_fingerprint(arguments: dict[str, Any]) -> str:
+ return hashlib.sha256(canonicalize_arguments(arguments).encode("utf-8")).hexdigest()
+
+
+def create_approval(
+ user_id: int,
+ conversation_id: str,
+ tool_name: str,
+ arguments: dict[str, Any],
+) -> Approval:
+ """Create a pending approval and return its id and expiry."""
+ config = get_ai_chat_config()
+ ttl_seconds = int(config.get("APPROVAL_TTL_SECONDS") or 300)
+ expires_on = datetime.now() + timedelta(seconds=ttl_seconds)
+ key = uuid4()
+ session = core_models.get_session()
+
+ try:
+ # Drop expired approvals so the table stays small
+ session.query(core_models.KeyValue).filter(
+ core_models.KeyValue.resource == RESOURCE,
+ core_models.KeyValue.expires_on < datetime.now(),
+ ).delete(synchronize_session=False)
+
+ core_daos.KeyValueDAO.create(
+ attributes={
+ "resource": RESOURCE,
+ "uuid": key,
+ "value": json.dumps(
+ {
+ "user_id": user_id,
+ "conversation_id": conversation_id,
+ "tool_name": tool_name,
+ "args_hash": arguments_fingerprint(arguments),
+ }
+ ).encode("utf-8"),
+ "expires_on": expires_on,
+ }
+ )
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+
+ return Approval(
+ approval_id=str(key),
+ expires_at=expires_on.isoformat(),
+ )
+
+
+def consume_approval(
+ approval_id: str,
+ user_id: int,
+ conversation_id: str,
+ tool_name: str,
+ arguments: dict[str, Any],
+) -> None:
+ """Validate and atomically consume an approval, which is single use.
+
+ Raises :class:`AiChatApprovalExpiredError` when the approval does not
+ exist, has expired, or was already consumed, and
+ :class:`AiChatApprovalMismatchError` when any bound property differs from
+ what was approved: user, conversation, tool or arguments. A mismatch does
+ not consume the approval, so the originally proposed action can still be
+ approved.
+ """
+ try:
+ key = UUID(approval_id)
+ except (ValueError, AttributeError, TypeError) as ex:
+ raise AiChatApprovalExpiredError() from ex
+
+ session = core_models.get_session()
+ try:
+ entry = core_daos.KeyValueDAO.find_one_or_none(resource=RESOURCE, uuid=key)
+ if entry is None or (
+ entry.expires_on is not None and entry.expires_on < datetime.now()
+ ):
+ raise AiChatApprovalExpiredError()
+
+ value = json.loads(entry.value.decode("utf-8"))
+ if (
+ value.get("user_id") != user_id
+ or value.get("conversation_id") != conversation_id
+ or value.get("tool_name") != tool_name
+ or value.get("args_hash") != arguments_fingerprint(arguments)
+ ):
+ raise AiChatApprovalMismatchError()
+
+ # Atomic single-use consumption: exactly one concurrent request deletes
+ # the row and every other request observes rowcount == 0.
+ rowcount = (
+ session.query(core_models.KeyValue)
+ .filter(
+ core_models.KeyValue.resource == RESOURCE,
+ core_models.KeyValue.uuid == key,
+ )
+ .delete(synchronize_session=False)
+ )
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+
+ if rowcount == 0:
+ raise AiChatApprovalExpiredError()
diff --git a/superset/ai_chat/classification.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/classification.py
similarity index 97%
rename from superset/ai_chat/classification.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/classification.py
index b6dff8afc6a..d208ea412a3 100644
--- a/superset/ai_chat/classification.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/classification.py
@@ -27,8 +27,8 @@ from __future__ import annotations
from typing import Any, Mapping
-from superset.ai_chat.settings import get_ai_chat_config
-from superset.ai_chat.types import ToolClassification
+from enx_dev.ai_chat.settings import get_ai_chat_config
+from enx_dev.ai_chat.types import ToolClassification
# Extra human-readable warnings surfaced in the approval card for specific
# tools. Presentation only: enforcement does not depend on this map.
diff --git a/tests/unit_tests/ai_chat/__init__.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/entrypoint.py
similarity index 72%
rename from tests/unit_tests/ai_chat/__init__.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/entrypoint.py
index 13a83393a91..0ebeab16477 100644
--- a/tests/unit_tests/ai_chat/__init__.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/entrypoint.py
@@ -14,3 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""Backend entry point, imported by the host while loading the extension.
+
+Importing the API module is what registers the routes: the ``@api``
+decorator on :class:`AiChatRestApi` adds the view to Flask-AppBuilder as a
+side effect of class creation.
+"""
+
+from .api import AiChatRestApi # noqa: F401
diff --git a/superset/ai_chat/events.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/events.py
similarity index 98%
rename from superset/ai_chat/events.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/events.py
index 48c1f6346d6..e64fbed6097 100644
--- a/superset/ai_chat/events.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/events.py
@@ -37,7 +37,7 @@ from __future__ import annotations
import uuid
from typing import Any
-from superset.ai_chat.types import ToolClassification
+from enx_dev.ai_chat.types import ToolClassification
class EventTypes:
diff --git a/superset/ai_chat/exceptions.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/exceptions.py
similarity index 100%
rename from superset/ai_chat/exceptions.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/exceptions.py
diff --git a/superset/ai_chat/mcp_bridge.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/mcp_bridge.py
similarity index 89%
rename from superset/ai_chat/mcp_bridge.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/mcp_bridge.py
index 87efcea1926..bcf28d35354 100644
--- a/superset/ai_chat/mcp_bridge.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/mcp_bridge.py
@@ -26,6 +26,14 @@ output truncation and identity alignment.
``fastmcp`` is an optional dependency shipped as a pip extra, so every import
is guarded and the gateway degrades to a chat-only assistant when the extra is
not installed.
+
+This is the one place the extension reaches past ``apache-superset-core``
+into the host: ``superset.mcp_service.app``. That package exposes decorators
+for *contributing* MCP tools, not a client for *calling* the server the host
+already runs, and standing up a second server would mean a second copy of the
+middleware chain the bridge exists to go through. The imports are lazy and
+already guarded by :func:`is_mcp_available`, so an incompatible host version
+costs tool use rather than the whole assistant.
"""
from __future__ import annotations
@@ -34,13 +42,12 @@ import asyncio
import logging
from typing import Any, TYPE_CHECKING
+from enx_dev.ai_chat.classification import classify_tool
+from enx_dev.ai_chat.exceptions import AiChatIdentityMismatchError
+from enx_dev.ai_chat.settings import get_ai_chat_config
+from enx_dev.ai_chat.types import ToolExecution, ToolSpec
from flask import current_app
-from superset.ai_chat.classification import classify_tool
-from superset.ai_chat.exceptions import AiChatIdentityMismatchError
-from superset.ai_chat.settings import get_ai_chat_config
-from superset.ai_chat.types import ToolExecution, ToolSpec
-
if TYPE_CHECKING:
from flask_appbuilder.security.sqla.models import User
diff --git a/superset/ai_chat/orchestrator.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/orchestrator.py
similarity index 98%
rename from superset/ai_chat/orchestrator.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/orchestrator.py
index e97bf43144c..456a7f572a1 100644
--- a/superset/ai_chat/orchestrator.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/orchestrator.py
@@ -36,34 +36,34 @@ import re
import time
from typing import Any, TYPE_CHECKING
-from superset.ai_chat import events as ev
-from superset.ai_chat.approvals import consume_approval, create_approval
-from superset.ai_chat.classification import (
+from enx_dev.ai_chat import events as ev
+from enx_dev.ai_chat.approvals import consume_approval, create_approval
+from enx_dev.ai_chat.classification import (
approval_warnings,
is_reversible,
requires_approval,
)
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatApprovalError,
AiChatIdentityMismatchError,
AiChatProviderError,
AiChatRequestTooLargeError,
AiChatUnsupportedPrincipalError,
)
-from superset.ai_chat.mcp_bridge import (
+from enx_dev.ai_chat.mcp_bridge import (
assert_identity_alignment,
call_tool,
is_mcp_available,
list_allowed_tools,
)
-from superset.ai_chat.providers import get_provider
-from superset.ai_chat.schemas import (
+from enx_dev.ai_chat.providers import get_provider
+from enx_dev.ai_chat.schemas import (
MAX_CONTEXT_REFERENCES,
MAX_TOTAL_IMAGE_BASE64_CHARS,
RESOURCE_NAME_MAX_CHARS,
)
-from superset.ai_chat.settings import get_ai_chat_config
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.settings import get_ai_chat_config
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
diff --git a/superset/ai_chat/providers/__init__.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/__init__.py
similarity index 85%
rename from superset/ai_chat/providers/__init__.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/__init__.py
index d661d20ee28..942f19c2dba 100644
--- a/superset/ai_chat/providers/__init__.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/__init__.py
@@ -20,11 +20,11 @@ from __future__ import annotations
from typing import Any
-from superset.ai_chat.exceptions import AiChatConfigurationError
-from superset.ai_chat.providers.anthropic_provider import AnthropicProvider
-from superset.ai_chat.providers.base import BaseChatProvider, ProviderSettings
-from superset.ai_chat.providers.mock import MockChatProvider
-from superset.ai_chat.providers.openai_compat import OpenAiCompatibleProvider
+from enx_dev.ai_chat.exceptions import AiChatConfigurationError
+from enx_dev.ai_chat.providers.anthropic_provider import AnthropicProvider
+from enx_dev.ai_chat.providers.base import BaseChatProvider, ProviderSettings
+from enx_dev.ai_chat.providers.mock import MockChatProvider
+from enx_dev.ai_chat.providers.openai_compat import OpenAiCompatibleProvider
PROVIDERS: dict[str, type[BaseChatProvider]] = {
"mock": MockChatProvider,
diff --git a/superset/ai_chat/providers/anthropic_provider.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/anthropic_provider.py
similarity index 97%
rename from superset/ai_chat/providers/anthropic_provider.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/anthropic_provider.py
index d58c0178188..f04a10762f0 100644
--- a/superset/ai_chat/providers/anthropic_provider.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/anthropic_provider.py
@@ -27,14 +27,14 @@ from __future__ import annotations
import logging
from typing import Any
-from superset.ai_chat.exceptions import AiChatProviderError
-from superset.ai_chat.providers.base import (
+from enx_dev.ai_chat.exceptions import AiChatProviderError
+from enx_dev.ai_chat.providers.base import (
BaseChatProvider,
normalize_usage,
post_json,
require_httpx,
)
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
diff --git a/superset/ai_chat/providers/base.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/base.py
similarity index 97%
rename from superset/ai_chat/providers/base.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/base.py
index dbf27b56600..926ec1f4482 100644
--- a/superset/ai_chat/providers/base.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/base.py
@@ -17,7 +17,7 @@
"""Model provider interface for the AI chat gateway.
Providers translate the neutral conversation format in
-:mod:`superset.ai_chat.types` to a vendor wire format and back. The
+:mod:`enx_dev.ai_chat.types` to a vendor wire format and back. The
orchestrator and the frontend never see vendor-specific shapes, which keeps
the UI decoupled from any one model vendor.
"""
@@ -31,12 +31,12 @@ from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatConfigurationError,
AiChatProviderError,
AiChatProviderTimeoutError,
)
-from superset.ai_chat.types import ChatMessage, ProviderResult, ToolSpec
+from enx_dev.ai_chat.types import ChatMessage, ProviderResult, ToolSpec
if TYPE_CHECKING:
import httpx
diff --git a/superset/ai_chat/providers/mock.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/mock.py
similarity index 97%
rename from superset/ai_chat/providers/mock.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/mock.py
index 736fc741a3a..5e7b92898df 100644
--- a/superset/ai_chat/providers/mock.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/mock.py
@@ -39,9 +39,9 @@ from __future__ import annotations
import re
-from superset.ai_chat.exceptions import AiChatProviderError
-from superset.ai_chat.providers.base import BaseChatProvider
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.exceptions import AiChatProviderError
+from enx_dev.ai_chat.providers.base import BaseChatProvider
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
diff --git a/superset/ai_chat/providers/openai_compat.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/openai_compat.py
similarity index 97%
rename from superset/ai_chat/providers/openai_compat.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/openai_compat.py
index df5889bfa94..9032d40bffe 100644
--- a/superset/ai_chat/providers/openai_compat.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/providers/openai_compat.py
@@ -25,17 +25,18 @@ request, and redirects are not followed.
from __future__ import annotations
+import json # noqa: TID251 (superset.utils.json is host-internal)
import logging
from typing import Any
-from superset.ai_chat.exceptions import AiChatProviderError
-from superset.ai_chat.providers.base import (
+from enx_dev.ai_chat.exceptions import AiChatProviderError
+from enx_dev.ai_chat.providers.base import (
BaseChatProvider,
normalize_usage,
post_json,
require_httpx,
)
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
@@ -43,7 +44,6 @@ from superset.ai_chat.types import (
ToolCall,
ToolSpec,
)
-from superset.utils import json
logger = logging.getLogger(__name__)
diff --git a/superset/ai_chat/schemas.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/schemas.py
similarity index 100%
rename from superset/ai_chat/schemas.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/schemas.py
diff --git a/superset/ai_chat/settings.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/settings.py
similarity index 88%
rename from superset/ai_chat/settings.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/settings.py
index 1c27677cf24..d8deec63350 100644
--- a/superset/ai_chat/settings.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/settings.py
@@ -16,15 +16,11 @@
# under the License.
"""AI chat gateway defaults and resolved configuration.
-The canonical defaults live outside ``superset/config.py`` because ``from
-superset_config import *`` replaces module attributes of ``superset.config``
-with operator overrides. Keeping the pristine defaults here lets
-:func:`get_ai_chat_config` merge partial overrides over them, so setting only
-``{"ENABLED": True, "PROVIDER": "mock"}`` keeps the curated tool allowlist and
-the size limits intact.
-
-This module must not import from ``superset`` at module level, since
-``superset/config.py`` imports it.
+The defaults ship with the extension, and :func:`get_ai_chat_config` merges
+whatever the operator put in ``AI_CHAT_CONFIG`` over them. That makes a
+minimal enablement possible -- ``{"ENABLED": True, "PROVIDER": "mock"}`` keeps
+the curated tool allowlist and the size limits intact -- and it means Superset
+itself carries no default for a key it knows nothing about.
"""
from __future__ import annotations
diff --git a/superset/ai_chat/types.py b/extensions/ai-chat/backend/src/enx_dev/ai_chat/types.py
similarity index 98%
rename from superset/ai_chat/types.py
rename to extensions/ai-chat/backend/src/enx_dev/ai_chat/types.py
index 094237f6726..419f2051aee 100644
--- a/superset/ai_chat/types.py
+++ b/extensions/ai-chat/backend/src/enx_dev/ai_chat/types.py
@@ -25,11 +25,9 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
-from enum import Enum
+from enum import Enum, StrEnum
from typing import Any
-from superset.utils.backports import StrEnum
-
class ChatRole(StrEnum):
SYSTEM = "system"
diff --git a/tests/unit_tests/ai_chat/api_test.py b/extensions/ai-chat/backend/tests/api_test.py
similarity index 84%
rename from tests/unit_tests/ai_chat/api_test.py
rename to extensions/ai-chat/backend/tests/api_test.py
index d8ce08d9cfe..d9bdb6b4d41 100644
--- a/tests/unit_tests/ai_chat/api_test.py
+++ b/extensions/ai-chat/backend/tests/api_test.py
@@ -17,19 +17,21 @@
# pylint: disable=unused-argument
from __future__ import annotations
+import json # noqa: TID251 (superset.utils.json is host-internal)
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock
import pytest
-from pytest_mock import MockerFixture
-
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatApprovalExpiredError,
AiChatConfigurationError,
)
-from superset.ai_chat.types import ToolClassification, ToolSpec
-from superset.utils import json
+from enx_dev.ai_chat.types import ToolClassification, ToolSpec
+from pytest_mock import MockerFixture
+
+# Extension APIs are mounted under /extensions/{publisher}/{name}.
+API_BASE = "/extensions/enx-dev/ai-chat"
AI_CHAT_APP = pytest.mark.parametrize(
"app",
@@ -88,7 +90,7 @@ def disabled_ai_chat(app: Any) -> Generator[None, None, None]:
def test_config_when_disabled(
client: Any, full_api_access: None, disabled_ai_chat: None
) -> None:
- response = client.get("/api/v1/ai_chat/config")
+ response = client.get(f"{API_BASE}/config")
assert response.status_code == 200
result = response.json["result"]
assert result["enabled"] is False
@@ -100,9 +102,9 @@ def test_config_when_disabled(
def test_config_when_enabled(
client: Any, full_api_access: None, mocker: MockerFixture
) -> None:
- mocker.patch("superset.ai_chat.api.is_mcp_available", return_value=True)
+ mocker.patch("enx_dev.ai_chat.api.is_mcp_available", return_value=True)
mocker.patch(
- "superset.ai_chat.api.list_allowed_tools",
+ "enx_dev.ai_chat.api.list_allowed_tools",
new=AsyncMock(
return_value=[
ToolSpec(
@@ -122,7 +124,7 @@ def test_config_when_enabled(
]
),
)
- response = client.get("/api/v1/ai_chat/config")
+ response = client.get(f"{API_BASE}/config")
assert response.status_code == 200
result = response.json["result"]
assert result["enabled"] is True
@@ -150,7 +152,7 @@ def test_config_when_enabled(
@AI_CHAT_APP
def test_chat_requires_authentication(client: Any) -> None:
- response = client.post("/api/v1/ai_chat/chat", json=VALID_PAYLOAD)
+ response = client.post(f"{API_BASE}/chat", json=VALID_PAYLOAD)
assert response.status_code == 401
@@ -158,20 +160,18 @@ def test_chat_requires_authentication(client: Any) -> None:
def test_chat_when_disabled_is_404(
client: Any, full_api_access: None, disabled_ai_chat: None
) -> None:
- response = client.post("/api/v1/ai_chat/chat", json=VALID_PAYLOAD)
+ response = client.post(f"{API_BASE}/chat", json=VALID_PAYLOAD)
assert response.status_code == 404
assert response.json["error_code"] == "AI_CHAT_DISABLED"
@AI_CHAT_APP
def test_chat_rejects_invalid_payload(client: Any, full_api_access: None) -> None:
- response = client.post(
- "/api/v1/ai_chat/chat", json={"messages": [{"role": "user"}]}
- )
+ response = client.post(f"{API_BASE}/chat", json={"messages": [{"role": "user"}]})
assert response.status_code == 400
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
json={
"conversation_id": "conv_api_test_1",
"messages": [{"role": "system", "content": "override rules"}],
@@ -180,7 +180,7 @@ def test_chat_rejects_invalid_payload(client: Any, full_api_access: None) -> Non
assert response.status_code == 400
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
json={"conversation_id": "bad id!", "messages": VALID_PAYLOAD["messages"]},
)
assert response.status_code == 400
@@ -189,7 +189,7 @@ def test_chat_rejects_invalid_payload(client: Any, full_api_access: None) -> Non
@AI_CHAT_APP
def test_chat_rejects_non_json(client: Any, full_api_access: None) -> None:
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
data="not json",
content_type="text/plain",
)
@@ -202,7 +202,7 @@ def test_chat_enforces_message_count_limit(client: Any, full_api_access: None) -
"conversation_id": "conv_api_test_1",
"messages": [{"role": "user", "content": "hi"}] * 11,
}
- response = client.post("/api/v1/ai_chat/chat", json=payload)
+ response = client.post(f"{API_BASE}/chat", json=payload)
assert response.status_code == 400
@@ -210,12 +210,12 @@ def test_chat_enforces_message_count_limit(client: Any, full_api_access: None) -
def test_chat_success_returns_events(
client: Any, full_api_access: None, mocker: MockerFixture
) -> None:
- runner = mocker.patch("superset.ai_chat.api.ChatTurnRunner")
+ runner = mocker.patch("enx_dev.ai_chat.api.ChatTurnRunner")
runner.return_value.run_chat.return_value = [
{"type": "message.completed", "id": "msg_1", "content": "Hello!"},
{"type": "request.completed"},
]
- response = client.post("/api/v1/ai_chat/chat", json=VALID_PAYLOAD)
+ response = client.post(f"{API_BASE}/chat", json=VALID_PAYLOAD)
assert response.status_code == 200
result = response.json["result"]
assert result["conversation_id"] == "conv_api_test_1"
@@ -236,10 +236,10 @@ def test_chat_provider_misconfigured_is_422(
client: Any, full_api_access: None, mocker: MockerFixture
) -> None:
mocker.patch(
- "superset.ai_chat.api.ChatTurnRunner",
+ "enx_dev.ai_chat.api.ChatTurnRunner",
side_effect=AiChatConfigurationError(),
)
- response = client.post("/api/v1/ai_chat/chat", json=VALID_PAYLOAD)
+ response = client.post(f"{API_BASE}/chat", json=VALID_PAYLOAD)
assert response.status_code == 422
assert response.json["error_code"] == "AI_CHAT_MISCONFIGURED"
@@ -248,13 +248,13 @@ def test_chat_provider_misconfigured_is_422(
def test_approval_success(
client: Any, full_api_access: None, mocker: MockerFixture
) -> None:
- runner = mocker.patch("superset.ai_chat.api.ChatTurnRunner")
+ runner = mocker.patch("enx_dev.ai_chat.api.ChatTurnRunner")
runner.return_value.run_approval.return_value = [
{"type": "tool.running", "id": "tc1", "tool": "delete_dashboard"},
{"type": "tool.completed", "id": "tc1", "tool": "delete_dashboard"},
{"type": "request.completed"},
]
- response = client.post("/api/v1/ai_chat/tool_approval", json=VALID_APPROVAL_PAYLOAD)
+ response = client.post(f"{API_BASE}/tool_approval", json=VALID_APPROVAL_PAYLOAD)
assert response.status_code == 200
run_kwargs = runner.return_value.run_approval.call_args.kwargs
assert run_kwargs["approval_id"] == VALID_APPROVAL_PAYLOAD["approval_id"]
@@ -266,7 +266,7 @@ def test_approval_success(
@AI_CHAT_APP
def test_approval_rejects_invalid_decision(client: Any, full_api_access: None) -> None:
payload = {**VALID_APPROVAL_PAYLOAD, "decision": "maybe"}
- response = client.post("/api/v1/ai_chat/tool_approval", json=payload)
+ response = client.post(f"{API_BASE}/tool_approval", json=payload)
assert response.status_code == 400
@@ -274,9 +274,9 @@ def test_approval_rejects_invalid_decision(client: Any, full_api_access: None) -
def test_approval_expired_is_400(
client: Any, full_api_access: None, mocker: MockerFixture
) -> None:
- runner = mocker.patch("superset.ai_chat.api.ChatTurnRunner")
+ runner = mocker.patch("enx_dev.ai_chat.api.ChatTurnRunner")
runner.return_value.run_approval.side_effect = AiChatApprovalExpiredError()
- response = client.post("/api/v1/ai_chat/tool_approval", json=VALID_APPROVAL_PAYLOAD)
+ response = client.post(f"{API_BASE}/tool_approval", json=VALID_APPROVAL_PAYLOAD)
assert response.status_code == 400
assert response.json["error_code"] == "AI_CHAT_APPROVAL_EXPIRED"
@@ -285,5 +285,5 @@ def test_approval_expired_is_400(
def test_approval_when_disabled_is_404(
client: Any, full_api_access: None, disabled_ai_chat: None
) -> None:
- response = client.post("/api/v1/ai_chat/tool_approval", json=VALID_APPROVAL_PAYLOAD)
+ response = client.post(f"{API_BASE}/tool_approval", json=VALID_APPROVAL_PAYLOAD)
assert response.status_code == 404
diff --git a/tests/unit_tests/ai_chat/approvals_test.py b/extensions/ai-chat/backend/tests/approvals_test.py
similarity index 91%
rename from tests/unit_tests/ai_chat/approvals_test.py
rename to extensions/ai-chat/backend/tests/approvals_test.py
index 266fa06b3b7..c8fbda3eac6 100644
--- a/tests/unit_tests/ai_chat/approvals_test.py
+++ b/extensions/ai-chat/backend/tests/approvals_test.py
@@ -22,20 +22,19 @@ from datetime import datetime, timedelta
from uuid import UUID
import pytest
-from flask.ctx import AppContext
-
-from superset import db
-from superset.ai_chat.approvals import (
+from enx_dev.ai_chat.approvals import (
arguments_fingerprint,
canonicalize_arguments,
consume_approval,
create_approval,
RESOURCE,
)
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatApprovalExpiredError,
AiChatApprovalMismatchError,
)
+from flask.ctx import AppContext
+from superset_core.common import models as core_models
CONVERSATION_ID = "conv_approvals_test"
TOOL_NAME = "delete_dashboard"
@@ -46,12 +45,11 @@ ARGUMENTS = {"request": {"identifier": 42}}
@pytest.fixture(autouse=True)
def cleanup_approvals(app_context: AppContext) -> Generator[None, None, None]:
yield
- from superset.key_value.models import KeyValueEntry
- db.session.query(KeyValueEntry).filter(
- KeyValueEntry.resource == RESOURCE.value
+ core_models.get_session().query(core_models.KeyValue).filter(
+ core_models.KeyValue.resource == RESOURCE
).delete()
- db.session.commit()
+ core_models.get_session().commit()
def test_canonicalization_is_order_independent() -> None:
@@ -101,15 +99,15 @@ def test_consume_unknown_id_rejected(app_context: AppContext) -> None:
def test_consume_expired_rejected(app_context: AppContext) -> None:
approval = create_approval(USER_ID, CONVERSATION_ID, TOOL_NAME, ARGUMENTS)
- from superset.key_value.models import KeyValueEntry
entry = (
- db.session.query(KeyValueEntry)
- .filter(KeyValueEntry.uuid == UUID(approval.approval_id))
+ core_models.get_session()
+ .query(core_models.KeyValue)
+ .filter(core_models.KeyValue.uuid == UUID(approval.approval_id))
.one()
)
entry.expires_on = datetime.now() - timedelta(seconds=1)
- db.session.flush()
+ core_models.get_session().flush()
with pytest.raises(AiChatApprovalExpiredError):
consume_approval(
approval.approval_id, USER_ID, CONVERSATION_ID, TOOL_NAME, ARGUMENTS
diff --git a/tests/unit_tests/ai_chat/classification_test.py b/extensions/ai-chat/backend/tests/classification_test.py
similarity index 97%
rename from tests/unit_tests/ai_chat/classification_test.py
rename to extensions/ai-chat/backend/tests/classification_test.py
index 48e8bb65ab6..72615661c0a 100644
--- a/tests/unit_tests/ai_chat/classification_test.py
+++ b/extensions/ai-chat/backend/tests/classification_test.py
@@ -16,15 +16,14 @@
# under the License.
from __future__ import annotations
-from flask.ctx import AppContext
-
-from superset.ai_chat.classification import (
+from enx_dev.ai_chat.classification import (
approval_warnings,
classify_tool,
is_reversible,
requires_approval,
)
-from superset.ai_chat.types import redact_sensitive, ToolClassification
+from enx_dev.ai_chat.types import redact_sensitive, ToolClassification
+from flask.ctx import AppContext
REDACTED = "***redacted***"
diff --git a/extensions/ai-chat/backend/tests/conftest.py b/extensions/ai-chat/backend/tests/conftest.py
new file mode 100644
index 00000000000..912edebabc8
--- /dev/null
+++ b/extensions/ai-chat/backend/tests/conftest.py
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Test wiring for the gateway.
+
+The backend is loaded by the host through an in-memory importer at runtime,
+so it is not on the path the way an installed package would be; ``src`` is
+added here to make ``enx_dev.ai_chat`` importable under pytest.
+
+These tests exercise Flask routes and the metadata database, so they need a
+Superset application. Rather than standing up a second one, they borrow the
+fixtures from Superset's own unit-test suite, located through the installed
+``superset`` package -- which means running them requires a Superset source
+checkout (a development install), not just the wheel.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+import superset
+
+#: Where the host mounts this extension's API, from extension.json.
+EXTENSION_ROUTE = "/extensions/enx-dev/ai-chat"
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+sys.path.insert(0, str(Path(superset.__file__).resolve().parents[2]))
+
+from tests.unit_tests.conftest import ( # noqa: E402
+ app, # noqa: F401
+ app_context, # noqa: F401
+ client, # noqa: F401
+ full_api_access, # noqa: F401
+)
+
+
+@pytest.fixture(autouse=True)
+def gateway_routes(app: Any) -> None: # noqa: F811
+ """Register the gateway's routes on this test's application.
+
+ The host imports an extension's entry point once per process, and it is
+ that import which registers the routes. A process only ever builds one
+ application in production, but the test suite builds one per module, and
+ every application after the first sees the entry point already in
+ ``sys.modules`` and so never re-runs the registration.
+ """
+ from enx_dev.ai_chat.api import AiChatRestApi
+
+ from superset.extensions import appbuilder
+
+ if any(str(rule).startswith(EXTENSION_ROUTE) for rule in app.url_map.iter_rules()):
+ return
+ with app.app_context():
+ view = appbuilder.add_api(AiChatRestApi)
+ appbuilder._add_permission(view, True) # noqa: SLF001
+
+
+@pytest.fixture(autouse=True)
+def key_value_table(app_context: None) -> None: # noqa: F811
+ """Create the table approvals are stored in.
+
+ The unit-test app runs against an empty in-memory database, and the
+ tables that do exist are the ones Flask-AppBuilder happens to create
+ from whatever models were registered by import time. Approvals live in
+ the host's shared key-value table, which nothing in this extension
+ imports early enough to be caught by that, so it is created here rather
+ than left to depend on the host's import order.
+ """
+ from superset_core.common import models as core_models
+
+ core_models.KeyValue.__table__.create(
+ bind=core_models.get_session().get_bind(), checkfirst=True
+ )
diff --git a/tests/unit_tests/ai_chat/e2e_mock_flow_test.py b/extensions/ai-chat/backend/tests/e2e_mock_flow_test.py
similarity index 93%
rename from tests/unit_tests/ai_chat/e2e_mock_flow_test.py
rename to extensions/ai-chat/backend/tests/e2e_mock_flow_test.py
index f4072e8a31b..fe8479495a1 100644
--- a/tests/unit_tests/ai_chat/e2e_mock_flow_test.py
+++ b/extensions/ai-chat/backend/tests/e2e_mock_flow_test.py
@@ -30,10 +30,9 @@ from typing import Any
from unittest.mock import Mock, patch
import pytest
+from enx_dev.ai_chat.approvals import RESOURCE
from pytest_mock import MockerFixture
-
-from superset import db
-from superset.ai_chat.approvals import RESOURCE
+from superset_core.common import models as core_models
AI_CHAT_E2E_APP = pytest.mark.parametrize(
"app",
@@ -71,7 +70,7 @@ def web_user(mocker: MockerFixture) -> Mock:
user.id = 1
user.username = "admin"
user.roles = []
- g_mock = mocker.patch("superset.ai_chat.api.g")
+ g_mock = mocker.patch("enx_dev.ai_chat.api.g")
g_mock.user = user
return user
@@ -89,13 +88,16 @@ def mock_mcp_auth() -> Iterator[Mock]:
@pytest.fixture(autouse=True)
def cleanup_approvals(app: Any) -> Generator[None, None, None]:
yield
- from superset.key_value.models import KeyValueEntry
with app.app_context():
- db.session.query(KeyValueEntry).filter(
- KeyValueEntry.resource == RESOURCE.value
+ core_models.get_session().query(core_models.KeyValue).filter(
+ core_models.KeyValue.resource == RESOURCE
).delete()
- db.session.commit()
+ core_models.get_session().commit()
+
+
+# Extension APIs are mounted under /extensions/{publisher}/{name}.
+API_BASE = "/extensions/enx-dev/ai-chat"
def _dashboard_mock() -> Mock:
@@ -154,7 +156,7 @@ def test_read_only_flow_executes_real_mcp_tool(
return_value=([_dashboard_mock()], 1),
):
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
json={
"conversation_id": "conv_e2e_read",
"messages": [{"role": "user", "content": "list my dashboards please"}],
@@ -182,7 +184,7 @@ def test_mutation_flow_requires_and_honors_rejection(
) -> None:
# Step 1: the mutation is proposed, not executed.
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
json={
"conversation_id": "conv_e2e_mut",
"messages": [{"role": "user", "content": "delete dashboard 42"}],
@@ -200,7 +202,7 @@ def test_mutation_flow_requires_and_honors_rejection(
# Step 2: rejection never executes and burns the approval.
with patch("superset.daos.dashboard.DashboardDAO.find_by_id") as mock_find:
response = client.post(
- "/api/v1/ai_chat/tool_approval",
+ f"{API_BASE}/tool_approval",
json={
"conversation_id": "conv_e2e_mut",
"messages": [{"role": "user", "content": "delete dashboard 42"}],
@@ -221,7 +223,7 @@ def test_mutation_flow_requires_and_honors_rejection(
# Step 3: the burned approval cannot be replayed as an approval.
response = client.post(
- "/api/v1/ai_chat/tool_approval",
+ f"{API_BASE}/tool_approval",
json={
"conversation_id": "conv_e2e_mut",
"messages": [{"role": "user", "content": "delete dashboard 42"}],
@@ -243,7 +245,7 @@ def test_mutation_approval_with_tampered_arguments_rejected(
client: Any, full_api_access: None
) -> None:
response = client.post(
- "/api/v1/ai_chat/chat",
+ f"{API_BASE}/chat",
json={
"conversation_id": "conv_e2e_tamper",
"messages": [{"role": "user", "content": "delete dashboard 42"}],
@@ -255,7 +257,7 @@ def test_mutation_approval_with_tampered_arguments_rejected(
# Approving with different arguments must fail and must not execute.
with patch("superset.daos.dashboard.DashboardDAO.find_by_id") as mock_find:
response = client.post(
- "/api/v1/ai_chat/tool_approval",
+ f"{API_BASE}/tool_approval",
json={
"conversation_id": "conv_e2e_tamper",
"messages": [{"role": "user", "content": "delete dashboard 42"}],
diff --git a/tests/unit_tests/ai_chat/mcp_bridge_test.py b/extensions/ai-chat/backend/tests/mcp_bridge_test.py
similarity index 97%
rename from tests/unit_tests/ai_chat/mcp_bridge_test.py
rename to extensions/ai-chat/backend/tests/mcp_bridge_test.py
index 2597a3411d0..e6839fa2c1f 100644
--- a/tests/unit_tests/ai_chat/mcp_bridge_test.py
+++ b/extensions/ai-chat/backend/tests/mcp_bridge_test.py
@@ -25,17 +25,16 @@ from typing import Any
from unittest.mock import MagicMock
import pytest
-from flask.ctx import AppContext
-from pytest_mock import MockerFixture
-
-from superset.ai_chat.exceptions import AiChatIdentityMismatchError
-from superset.ai_chat.mcp_bridge import (
+from enx_dev.ai_chat.exceptions import AiChatIdentityMismatchError
+from enx_dev.ai_chat.mcp_bridge import (
assert_identity_alignment,
call_tool,
list_allowed_tools,
TRUNCATION_MARKER,
)
-from superset.ai_chat.types import ToolClassification
+from enx_dev.ai_chat.types import ToolClassification
+from flask.ctx import AppContext
+from pytest_mock import MockerFixture
@pytest.fixture(autouse=True)
diff --git a/tests/unit_tests/ai_chat/orchestrator_test.py b/extensions/ai-chat/backend/tests/orchestrator_test.py
similarity index 97%
rename from tests/unit_tests/ai_chat/orchestrator_test.py
rename to extensions/ai-chat/backend/tests/orchestrator_test.py
index 04d93a61231..9ccaced347c 100644
--- a/tests/unit_tests/ai_chat/orchestrator_test.py
+++ b/extensions/ai-chat/backend/tests/orchestrator_test.py
@@ -22,21 +22,18 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
-from flask.ctx import AppContext
-from pytest_mock import MockerFixture
-
-from superset.ai_chat.approvals import Approval
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.approvals import Approval
+from enx_dev.ai_chat.exceptions import (
AiChatApprovalExpiredError,
AiChatIdentityMismatchError,
AiChatProviderError,
AiChatRequestTooLargeError,
AiChatUnsupportedPrincipalError,
)
-from superset.ai_chat.orchestrator import ChatTurnRunner, EVENT_RESULT_CAP
-from superset.ai_chat.providers.base import BaseChatProvider
-from superset.ai_chat.schemas import MAX_TOTAL_IMAGE_BASE64_CHARS
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.orchestrator import ChatTurnRunner, EVENT_RESULT_CAP
+from enx_dev.ai_chat.providers.base import BaseChatProvider
+from enx_dev.ai_chat.schemas import MAX_TOTAL_IMAGE_BASE64_CHARS
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
@@ -46,6 +43,8 @@ from superset.ai_chat.types import (
ToolExecution,
ToolSpec,
)
+from flask.ctx import AppContext
+from pytest_mock import MockerFixture
CONVERSATION_ID = "conv_orchestrator"
@@ -110,28 +109,28 @@ def harness(mocker: MockerFixture) -> dict[str, Any]:
"""Patch the orchestrator's collaborators; return the mocks."""
mocks = {
"is_mcp_available": mocker.patch(
- "superset.ai_chat.orchestrator.is_mcp_available", return_value=True
+ "enx_dev.ai_chat.orchestrator.is_mcp_available", return_value=True
),
"assert_identity_alignment": mocker.patch(
- "superset.ai_chat.orchestrator.assert_identity_alignment"
+ "enx_dev.ai_chat.orchestrator.assert_identity_alignment"
),
"list_allowed_tools": mocker.patch(
- "superset.ai_chat.orchestrator.list_allowed_tools",
+ "enx_dev.ai_chat.orchestrator.list_allowed_tools",
new=AsyncMock(return_value=TOOLS),
),
"call_tool": mocker.patch(
- "superset.ai_chat.orchestrator.call_tool",
+ "enx_dev.ai_chat.orchestrator.call_tool",
new=AsyncMock(return_value=ToolExecution(ok=True, content='{"count": 1}')),
),
"create_approval": mocker.patch(
- "superset.ai_chat.orchestrator.create_approval",
+ "enx_dev.ai_chat.orchestrator.create_approval",
return_value=Approval(
approval_id="3e7a2ab8-bcaf-49b0-a5df-dfb432f291cc",
expires_at="2100-01-01T00:00:00",
),
),
"consume_approval": mocker.patch(
- "superset.ai_chat.orchestrator.consume_approval"
+ "enx_dev.ai_chat.orchestrator.consume_approval"
),
}
return mocks
@@ -144,7 +143,7 @@ def _runner(
messages: list[dict[str, Any]] | None = None,
context: dict[str, Any] | None = None,
) -> ChatTurnRunner:
- mocker.patch("superset.ai_chat.orchestrator.get_provider", return_value=provider)
+ mocker.patch("enx_dev.ai_chat.orchestrator.get_provider", return_value=provider)
return ChatTurnRunner(
user=user,
conversation_id=CONVERSATION_ID,
diff --git a/tests/unit_tests/ai_chat/providers_test.py b/extensions/ai-chat/backend/tests/providers_test.py
similarity index 96%
rename from tests/unit_tests/ai_chat/providers_test.py
rename to extensions/ai-chat/backend/tests/providers_test.py
index 676ed3caa14..c66fe0c55df 100644
--- a/tests/unit_tests/ai_chat/providers_test.py
+++ b/extensions/ai-chat/backend/tests/providers_test.py
@@ -20,22 +20,21 @@ import asyncio
from typing import Any
import pytest
-
-from superset.ai_chat.exceptions import (
+from enx_dev.ai_chat.exceptions import (
AiChatConfigurationError,
AiChatProviderError,
)
-from superset.ai_chat.providers import get_provider, is_provider_configured
-from superset.ai_chat.providers.anthropic_provider import (
+from enx_dev.ai_chat.providers import get_provider, is_provider_configured
+from enx_dev.ai_chat.providers.anthropic_provider import (
_to_wire_messages as anthropic_wire,
AnthropicProvider,
)
-from superset.ai_chat.providers.mock import MockChatProvider
-from superset.ai_chat.providers.openai_compat import (
+from enx_dev.ai_chat.providers.mock import MockChatProvider
+from enx_dev.ai_chat.providers.openai_compat import (
_to_wire_messages as openai_wire,
OpenAiCompatibleProvider,
)
-from superset.ai_chat.types import (
+from enx_dev.ai_chat.types import (
ChatMessage,
ChatRole,
FinishReason,
@@ -321,7 +320,7 @@ def test_anthropic_response_parsing_malformed() -> None:
def test_max_completion_tokens_retry_detection() -> None:
- from superset.ai_chat.providers.openai_compat import (
+ from enx_dev.ai_chat.providers.openai_compat import (
_needs_max_completion_tokens,
)
@@ -357,7 +356,7 @@ def test_max_completion_tokens_retry_detection() -> None:
def test_reasoning_effort_rejection_detection() -> None:
- from superset.ai_chat.providers.openai_compat import (
+ from enx_dev.ai_chat.providers.openai_compat import (
_needs_reasoning_effort_none,
)
@@ -392,7 +391,7 @@ def test_reasoning_effort_rejection_detection() -> None:
@pytest.mark.asyncio
async def test_payload_adapts_until_the_model_accepts_it() -> None:
"""A model can reject several parameters, one rejection at a time."""
- from superset.ai_chat.providers.base import post_json
+ from enx_dev.ai_chat.providers.base import post_json
sent: list[dict[str, Any]] = []
diff --git a/extensions/ai-chat/docs/implementation-notes.md b/extensions/ai-chat/docs/implementation-notes.md
deleted file mode 100644
index d1809252bfb..00000000000
--- a/extensions/ai-chat/docs/implementation-notes.md
+++ /dev/null
@@ -1,256 +0,0 @@
-<!--
-Licensed to the Apache Software Foundation (ASF) under one
-or more contributor license agreements. See the NOTICE file
-distributed with this work for additional information
-regarding copyright ownership. The ASF licenses this file
-to you under the Apache License, Version 2.0 (the
-"License"); you may not use this file except in compliance
-with the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing,
-software distributed under the License is distributed on an
-"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-KIND, either express or implied. See the License for the
-specific language governing permissions and limitations
-under the License.
--->
-
-# AI Chat extension — implementation notes
-
-Investigation notes for the AI assistant chat extension. This document records
-the repository facts the implementation is built on, and the architectural
-decisions derived from them. It lives inside the extension source tree rather
-than under `docs/` because `docs/` is a Docusaurus site where unregistered
-pages affect the build.
-
-## Relevant existing components
-
-### Chat contribution API (SIP-214, PR #41205)
-
-- Public contract: `superset-frontend/packages/superset-core/src/chat/index.ts`.
- A chat registration is `chat.registerChat({ id, name, description? },
- TriggerComponent, PanelComponent): Disposable`. There are **no message
- types** in the public surface — the extension owns the entire conversational
- model. Display modes are `'floating' | 'panel'` (there is no `docked`
- literal; the docked sidebar mode is spelled `panel`).
-- Host implementation: `superset-frontend/src/core/chat/` (`ChatProvider.ts`
- singleton state machine, `ChatHost.tsx` mount components, `index.ts` public
- namespace). The host owns open/close state, display mode, persistence
- (localStorage key `chat__state`), the fixed bottom-right container in
- floating mode, and the resizable `Splitter` sidebar in panel mode
- (`src/views/App.tsx`, default width 400, min 280). The host renders **no**
- trigger button, close button, or mode switcher — the extension provides all
- chrome inside its own components.
-- Singleton semantics: a second `registerChat` replaces the first with a
- console warning; a takeover mounts the incoming chat closed.
-- Error isolation: trigger and panel are wrapped in separate error boundaries;
- a crashing panel cannot take the trigger down.
-- `onDidResizePanel` is exposed but never fires on this host — do not drive
- layout from it.
-- Docs: `docs/developer_docs/extensions/extension-points/chat.md`.
-
-### Navigation API
-
-`superset-frontend/packages/superset-core/src/navigation/index.ts` exposes
-exactly two members: `getPage(): Page` and `onDidChangePage: Event<Page>`,
-where `Page` is a string union (`'dashboard' | 'dashboard_list' | 'explore' |
-'chart_list' | 'sqllab' | 'query_history' | 'saved_queries' | 'dataset' |
-'dataset_list' | 'home'`). Entity-level context (dashboard/chart IDs) is
-intentionally excluded from the public API. Where the extension needs a
-resource identity (dashboard id on a dashboard page) it parses
-`window.location.pathname` — URL context, not DOM scraping — and treats the
-result as a hint that the backend independently validates.
-
-### Extension loading
-
-- Extensions are webpack Module Federation remotes. The host shares exactly
- four singletons: `react`, `react-dom`, `antd`, `@apache-superset/core`
- (`superset-frontend/webpack.config.js`). Subpath imports such as
- `@apache-superset/core/theme` are **not** in the share config, so extension
- code imports only from the package root:
- `import { chat, navigation, translation, theme, authentication, extensions } from '@apache-superset/core'`.
-- At load time the host resolves `@apache-superset/core` to
- `window.superset` (10 runtime namespaces: `authentication`, `core`, `chat`,
- `commands`, `editors`, `extensions`, `menus`, `navigation`, `sqlLab`,
- `views`) spread over the built stub package, whose `translation`, `theme`,
- `utils` and `components` namespaces carry real runtime code
- (`src/extensions/ExtensionsLoader.ts`, `ExtensionsStartup.tsx`).
-- `authentication.getCSRFToken(): Promise<string | undefined>` is public API
- (host implementation delegates to `SupersetClient.getCSRFToken()`), which is
- what lets an extension make CSRF-protected POSTs with plain `fetch`.
-- Activation is a module-level side effect: the host loads
- `container.get('./index')` and calls the factory. There is no
- `activate()`/`deactivate()` lifecycle.
-- Backend serving: `superset/extensions/api.py` (`GET /api/v1/extensions/`
- and content-hashed chunk serving). Local dev loads unpacked extensions from
- `LOCAL_EXTENSIONS` (list of project dirs containing `dist/`); packaged
- `.supx` bundles come from `EXTENSIONS_PATH`. Everything is gated on the
- `ENABLE_EXTENSIONS` feature flag (default off) and authentication.
-- Scaffolding conventions: `superset-extensions-cli` templates
- (`superset-extensions-cli/src/superset_extensions_cli/templates/`) define
- the canonical `extension.json`, webpack MF config
- (`publicPath /api/v1/extensions/<publisher>/<name>/`, shared singletons with
- `import: false`), and tsconfig. This extension follows those templates,
- with two deliberate deviations: test files are excluded from the build
- tsconfig (a stale-dist failure mode previously observed with the reference
- chatbot), and React peer versions match the host (18.x, not the template's
- stale 17.x).
-
-### MCP service
-
-- `superset/mcp_service/` — FastMCP app with ~70 tools, all with Pydantic
- request/response schemas and declarative
- `ToolAnnotations(readOnlyHint, destructiveHint, title)` plus
- `class_permission_name`/`method_permission_name` RBAC metadata
- (`superset/mcp_service/CLAUDE.md` documents conventions).
-- Authorization is enforced in layers: per-tool FAB RBAC via `mcp_auth_hook`
- (`auth.py`), OAuth scope intersection, object-level checks inside tool
- bodies (`raise_for_access`, DAO base filters), and tool visibility
- filtering in `tools/list` (`RBACToolVisibilityMiddleware`). Master switch
- `MCP_RBAC_ENABLED` (default `True`).
-- The `fastmcp` dependency is an optional extra (`pip install
- apache_superset[fastmcp]`); all gateway imports of `fastmcp` /
- `superset.mcp_service` are guarded so the feature degrades cleanly when the
- extra is absent.
-- User-authored content in tool responses is wrapped in
- `<UNTRUSTED-CONTENT>` tags by the MCP response layer
- (`superset/mcp_service/utils/response_utils.py`) — reused as one layer of
- prompt-injection defense.
-- The MCP `execute_sql` tool renders Jinja, parses with
- `superset.sql.parse.SQLScript`, fail-closes on unparseable SQL, blocks
- destructive DDL unconditionally, and leaves DML to the per-database
- `allow_dml` flag — i.e. SQL safety classification is enforced by existing
- code, not by the model.
-
-## MCP transport and invocation path
-
-The MCP service normally runs as a separate streamable-http process
-(`superset mcp run`, default `127.0.0.1:5008`) or over stdio. There is no
-first-class in-process client, but the in-memory FastMCP transport —
-`fastmcp.Client(mcp)` where `mcp` is the `superset.mcp_service.app` instance —
-is the pattern used by all 158 MCP unit-test files and exercises the full
-middleware chain (RBAC visibility, auth hook, size guards, error handling).
-
-The gateway uses that in-memory transport. A probe confirmed from a Flask
-request context (with `MCP_DEV_USERNAME` cleared and `g.user` set):
-`list_tools` returns 70 tools with annotations, and `call_tool` executes under
-the `g.user` identity through the full authorization path.
-
-## Authentication model
-
-`superset/mcp_service/auth.py::get_user_from_request` resolves the MCP user
-with strict priority: (1) JWT ContextVar from the FastMCP request, (2) API key
-header, (3) `MCP_DEV_USERNAME` config, (4) `g.user` fallback. When invoked
-in-process from an authenticated Superset web request, (1) and (2) are absent
-and identity comes from (4) — the session user — **unless** the operator has
-set `MCP_DEV_USERNAME`, which would take precedence and could resolve tools to
-a different principal than the browser session. The gateway therefore fails
-closed: before any tool execution it verifies that the identity the MCP layer
-would resolve matches the authenticated web user, and refuses tool execution
-with a configuration error otherwise.
-
-## Permission enforcement
-
-- Route level: the gateway API is a `BaseSupersetApi` (`csrf_exempt = False`)
- with `@protect()` on every route; browser sessions carry CSRF tokens
- automatically (`X-CSRFToken`, fetched via `authentication.getCSRFToken()`).
-- Object level: performed by the MCP tools themselves (Superset
- `security_manager` checks, DAO base filters) under the current user.
-- Tool level: server-side allowlist (`AI_CHAT_CONFIG["ALLOWED_MCP_TOOLS"]`)
- intersected with per-user tool visibility from `list_tools`; classification
- (read-only / mutating / destructive) derived from declared
- `ToolAnnotations`, with unknown or unannotated tools defaulting to the most
- restrictive class and never auto-executing.
-- Mutation level: server-generated, single-use, expiring approval records
- bound to (user, conversation, tool name, canonicalized arguments hash),
- stored in the metadata database via `KeyValueDAO` (new
- `KeyValueResource.AI_CHAT_APPROVAL`). Changing arguments invalidates the
- approval; approvals are consumed atomically on use.
-
-## Extension registration mechanism
-
-The frontend registers through the public API only:
-
-```tsx
-import { chat } from '@apache-superset/core';
-chat.registerChat(
- { id: 'apache-superset.ai-chat', name: '…' },
- ChatTrigger,
- ChatPanel,
-);
-```
-
-No manifest contribution entry is needed for chat (the `Manifest` Pydantic
-model has no `contributes` field; chat registration is pure runtime code).
-
-## Selected implementation locations
-
-| Piece | Location | Rationale |
-|---|---|---|
-| Frontend extension | `extensions/ai-chat/` | Follows the CLI project layout; loadable via `LOCAL_EXTENSIONS` for dev, bundleable with `superset-extensions-cli bundle`. |
-| Backend AI gateway | `superset/ai_chat/` | Server-side secrets, approval enforcement and MCP orchestration must live behind Superset auth; follows the `api.py`/`schemas.py`/commands module conventions. |
-| Backend tests | `tests/unit_tests/ai_chat/` | Standard unit-test layout (`api_test.py` naming, `client` + `full_api_access` fixtures). |
-| Frontend tests | `extensions/ai-chat/frontend/src/**/*.test.tsx` | Extension is a standalone package; jest + Testing Library wired locally (no extension test template exists upstream). |
-
-## Architectural decisions
-
-1. **Non-streaming first.** The Flask backend has no SSE precedent (the only
- chunked responses are CSV exports). The gateway returns a typed, ordered
- list of protocol events per request (`message.completed`, `tool.running`,
- `tool.approval_required`, …) rather than a fragile pseudo-stream. The
- event protocol is the streaming contract; an SSE transport can later emit
- the same events incrementally without changing the frontend event model.
-2. **Stateless server conversation.** The client sends trimmed, validated
- conversation history each turn; the server persists nothing about
- conversations except approval records. This avoids a new DB model and
- migration. Approval integrity does not depend on client honesty: approval
- records bind the exact server-canonicalized arguments.
-3. **Provider abstraction over raw HTTP.** Neither the `openai` nor
- `anthropic` SDK is a repository dependency, and adding both for
- convenience is unjustified. Providers are implemented against a small
- internal interface using `httpx` (already present via the `fastmcp`
- extra), with a deterministic mock provider as the default and the test
- substrate. Provider secrets are read from server-side environment
- variables named in config; keys never transit the browser.
-4. **MCP as the single operational surface.** The gateway does not
- reimplement Superset operations as bespoke REST calls; every read and
- mutation the assistant performs goes through the existing MCP tools with
- their existing authorization. The gateway's own additions are policy:
- allowlist, classification, approval, size limits, timeouts, redaction.
-5. **Classification is code, not prompt.** Tool classes derive from declared
- `ToolAnnotations`; `readOnlyHint=True` → read-only, `destructiveHint=True`
- → destructive, otherwise mutating; missing annotations → destructive
- (safest). Approval enforcement happens server-side before execution;
- nothing the model or the page content says can bypass it.
-6. **Extension UI built on antd + core namespaces only.** `@superset-ui/core`
- components are host-internal and not federation-shared. The panel uses
- antd (the shared singleton, so it inherits the host theme) plus
- `translation.t`, `theme.useTheme` from the core package. Markdown is
- rendered by a minimal safe renderer that emits React elements only (no
- `dangerouslySetInnerHTML`, no raw HTML pass-through, `javascript:`/`data:`
- URLs rejected) because `SafeMarkdown` is not reachable from extensions and
- vendoring `react-markdown` (ESM-only) would complicate the extension test
- toolchain for no security gain.
-
-## Functionality that cannot safely be implemented with current APIs
-
-- **Token-level streaming to the browser.** No SSE/websocket convention
- exists in the Flask app; delivered as documented non-streaming events (see
- decision 1).
-- **Server-side cancellation of an in-flight model call.** The frontend
- aborts the fetch (`AbortController`) and the UI recovers; the server-side
- provider call runs to completion and its result is discarded. True
- cancellation needs async infrastructure out of scope here.
-- **Entity-level page context from the public navigation API.** Only the
- page type is public; resource identity comes from URL parsing and is
- validated server-side before use.
-- **`onDidResizePanel`-driven layout** — the event never fires on this host.
-- **Atomic multi-step dashboard creation.** The MCP surface has no
- transaction concept; the assistant reports per-step status honestly and
- never claims atomicity (`generate_dashboard` and read-back verification
- mitigate).
-- **Per-user rate limiting.** There is no per-endpoint limiter convention in
- the codebase (only global + auth limits); request-size and iteration limits
- are enforced instead.
diff --git a/extensions/ai-chat/extension.json b/extensions/ai-chat/extension.json
index 90eb8851165..d928719934f 100644
--- a/extensions/ai-chat/extension.json
+++ b/extensions/ai-chat/extension.json
@@ -1,5 +1,5 @@
{
- "publisher": "apache-superset",
+ "publisher": "enx-dev",
"name": "ai-chat",
"displayName": "Superset AI Assistant",
"version": "0.1.0",
diff --git a/extensions/ai-chat/frontend/src/api/client.test.ts b/extensions/ai-chat/frontend/src/api/client.test.ts
index 9d430ddd8d5..aa82dcc5022 100644
--- a/extensions/ai-chat/frontend/src/api/client.test.ts
+++ b/extensions/ai-chat/frontend/src/api/client.test.ts
@@ -55,7 +55,7 @@ test('sendChat attaches the CSRF token and JSON body', async () => {
messages: [{ role: 'user', content: 'hi' }],
});
const [url, init] = mock.mock.calls[0];
- expect(url).toBe('/api/v1/ai_chat/chat');
+ expect(url).toBe('/extensions/enx-dev/ai-chat/chat');
expect(init?.method).toBe('POST');
expect(init?.credentials).toBe('same-origin');
const headers = init?.headers as Record<string, string>;
diff --git a/extensions/ai-chat/frontend/src/api/client.ts b/extensions/ai-chat/frontend/src/api/client.ts
index 6420ee47516..a4c9ca87970 100644
--- a/extensions/ai-chat/frontend/src/api/client.ts
+++ b/extensions/ai-chat/frontend/src/api/client.ts
@@ -30,7 +30,8 @@ import type {
ProtocolToolCall,
} from '../types';
-const API_BASE = '/api/v1/ai_chat';
+// Extension APIs are mounted under /extensions/{publisher}/{name}, matching the publisher and name in extension.json.
+const API_BASE = '/extensions/enx-dev/ai-chat';
export class ChatApiError extends Error {
status: number;
diff --git a/extensions/ai-chat/frontend/src/components/ChatHeader.tsx b/extensions/ai-chat/frontend/src/components/ChatHeader.tsx
index 8d11a0da343..1e6f243617f 100644
--- a/extensions/ai-chat/frontend/src/components/ChatHeader.tsx
+++ b/extensions/ai-chat/frontend/src/components/ChatHeader.tsx
@@ -117,7 +117,7 @@ export default function ChatHeader({
>
<RobotOutlined aria-hidden />
<Typography.Text strong style={{ flex: 1 }}>
- {t('AI Assistant')}
+ {t('AI Chat Assistant')}
</Typography.Text>
<Tag
data-test="chat-page-context"
diff --git a/extensions/ai-chat/frontend/src/index.test.tsx b/extensions/ai-chat/frontend/src/index.test.tsx
index 280ed70ea3c..901f9421447 100644
--- a/extensions/ai-chat/frontend/src/index.test.tsx
+++ b/extensions/ai-chat/frontend/src/index.test.tsx
@@ -26,7 +26,7 @@ test('evaluating the entry module registers the chat contribution', () => {
// eslint-disable-next-line global-require
require('./index');
expect(__testing.state.registered).not.toBeNull();
- expect(__testing.state.registered?.chat.id).toBe('apache-superset.ai-chat');
+ expect(__testing.state.registered?.chat.id).toBe('enx-dev.ai-chat');
expect(typeof __testing.state.registered?.trigger).toBe('function');
expect(typeof __testing.state.registered?.panel).toBe('function');
});
diff --git a/extensions/ai-chat/frontend/src/index.tsx b/extensions/ai-chat/frontend/src/index.tsx
index dcd6cbcde40..a777254a71b 100644
--- a/extensions/ai-chat/frontend/src/index.tsx
+++ b/extensions/ai-chat/frontend/src/index.tsx
@@ -29,8 +29,8 @@ const { t } = translation;
chat.registerChat(
{
- id: 'apache-superset.ai-chat',
- name: t('Superset AI Assistant'),
+ id: 'enx-dev.ai-chat',
+ name: t('Superset AI Chat Assistant'),
description: t(
'AI assistant for finding, understanding and managing dashboards, ' +
'charts, datasets and SQL.',
diff --git a/extensions/ai-chat/frontend/test/coreMock.tsx b/extensions/ai-chat/frontend/test/coreMock.tsx
index 5cf63c26ee1..4161227f1f2 100644
--- a/extensions/ai-chat/frontend/test/coreMock.tsx
+++ b/extensions/ai-chat/frontend/test/coreMock.tsx
@@ -184,7 +184,7 @@ const storageAccessor = {
export const extensions = {
getContext: () => ({
extension: {
- id: 'apache-superset.ai-chat',
+ id: 'enx-dev.ai-chat',
name: 'ai-chat',
description: '',
version: '0.1.0',
diff --git a/extensions/ai-chat/frontend/webpack.config.js b/extensions/ai-chat/frontend/webpack.config.js
index 8c4aabae9fc..1bc8ebc91f4 100644
--- a/extensions/ai-chat/frontend/webpack.config.js
+++ b/extensions/ai-chat/frontend/webpack.config.js
@@ -54,7 +54,7 @@ module.exports = (env, argv) => {
},
plugins: [
new ModuleFederationPlugin({
- name: 'apacheSuperset_aiChat',
+ name: 'enxDev_aiChat',
filename: 'remoteEntry.[contenthash].js',
exposes: {
'./index': './src/index.tsx',
diff --git a/superset/ai_chat/approvals.py b/superset/ai_chat/approvals.py
deleted file mode 100644
index 71c1526dd8d..00000000000
--- a/superset/ai_chat/approvals.py
+++ /dev/null
@@ -1,141 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-"""Server-enforced approvals for mutating tool calls.
-
-An approval is a short-lived, single-use record bound to the requesting user,
-the conversation, the exact tool name and a hash of the canonicalized tool
-arguments. Approvals live in the metadata database through the key-value
-store, so enforcement holds across workers and never depends on what the
-browser sends. Changing any bound property invalidates the approval, and a
-consumed approval cannot be replayed.
-"""
-
-from __future__ import annotations
-
-import hashlib
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from typing import Any
-from uuid import UUID
-
-from superset import db
-from superset.ai_chat.exceptions import (
- AiChatApprovalExpiredError,
- AiChatApprovalMismatchError,
-)
-from superset.ai_chat.settings import get_ai_chat_config
-from superset.daos.key_value import KeyValueDAO
-from superset.key_value.models import KeyValueEntry
-from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
-from superset.key_value.utils import get_filter
-from superset.utils import json
-from superset.utils.decorators import transaction
-
-RESOURCE = KeyValueResource.AI_CHAT_APPROVAL
-CODEC = JsonKeyValueCodec()
-
-
-@dataclass
-class Approval:
- approval_id: str
- expires_at: str
-
-
-def canonicalize_arguments(arguments: dict[str, Any]) -> str:
- """Stable serialization of tool arguments for fingerprinting."""
- return json.dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
-
-
-def arguments_fingerprint(arguments: dict[str, Any]) -> str:
- return hashlib.sha256(canonicalize_arguments(arguments).encode("utf-8")).hexdigest()
-
-
-@transaction()
-def create_approval(
- user_id: int,
- conversation_id: str,
- tool_name: str,
- arguments: dict[str, Any],
-) -> Approval:
- """Create a pending approval and return its id and expiry."""
- config = get_ai_chat_config()
- ttl_seconds = int(config.get("APPROVAL_TTL_SECONDS") or 300)
- expires_on = datetime.now() + timedelta(seconds=ttl_seconds)
-
- # Drop expired approvals so the table stays small
- KeyValueDAO.delete_expired_entries(RESOURCE)
-
- entry = KeyValueDAO.create_entry(
- resource=RESOURCE,
- value={
- "user_id": user_id,
- "conversation_id": conversation_id,
- "tool_name": tool_name,
- "args_hash": arguments_fingerprint(arguments),
- },
- codec=CODEC,
- expires_on=expires_on,
- )
- db.session.flush()
- return Approval(
- approval_id=str(entry.uuid),
- expires_at=expires_on.isoformat(),
- )
-
-
-@transaction()
-def consume_approval(
- approval_id: str,
- user_id: int,
- conversation_id: str,
- tool_name: str,
- arguments: dict[str, Any],
-) -> None:
- """Validate and atomically consume an approval, which is single use.
-
- Raises :class:`AiChatApprovalExpiredError` when the approval does not
- exist, has expired, or was already consumed, and
- :class:`AiChatApprovalMismatchError` when any bound property differs from
- what was approved: user, conversation, tool or arguments. A mismatch does
- not consume the approval, so the originally proposed action can still be
- approved.
- """
- try:
- key = UUID(approval_id)
- except (ValueError, AttributeError, TypeError) as ex:
- raise AiChatApprovalExpiredError() from ex
-
- entry = KeyValueDAO.get_entry(RESOURCE, key)
- if entry is None or entry.is_expired():
- raise AiChatApprovalExpiredError()
-
- value = CODEC.decode(entry.value)
- if (
- value.get("user_id") != user_id
- or value.get("conversation_id") != conversation_id
- or value.get("tool_name") != tool_name
- or value.get("args_hash") != arguments_fingerprint(arguments)
- ):
- raise AiChatApprovalMismatchError()
-
- # Atomic single-use consumption: exactly one concurrent request deletes
- # the row and every other request observes rowcount == 0.
- rowcount = (
- db.session.query(KeyValueEntry).filter_by(**get_filter(RESOURCE, key)).delete()
- )
- if rowcount == 0:
- raise AiChatApprovalExpiredError()
diff --git a/superset/config.py b/superset/config.py
index 78e74c10396..82f14935754 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -52,7 +52,6 @@ from sqlalchemy.orm.query import Query
from superset.advanced_data_type.plugins.internet_address import internet_address
from superset.advanced_data_type.plugins.internet_port import internet_port
from superset.advanced_data_type.types import AdvancedDataType
-from superset.ai_chat.settings import DEFAULT_AI_CHAT_CONFIG
from superset.constants import (
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
@@ -3130,22 +3129,6 @@ EXTENSION_DENYLIST: list[str] = []
# EXTENSION_VERSION_POLICY = {"acme.widget": "1.2.0"}
EXTENSION_VERSION_POLICY: dict[str, str] = {}
-# AI chat gateway configuration. Backs the AI assistant chat extension
-# (extensions/ai-chat) with a server-side endpoint that talks to a model
-# provider and orchestrates MCP tools under the requesting user's
-# authorization. Disabled by default; the REST API is only registered when
-# the ENABLE_EXTENSIONS feature flag is on, and refuses requests until
-# ENABLED is set to True.
-#
-# Overrides merge over the shipped defaults, so a minimal enablement is:
-# AI_CHAT_CONFIG = {"ENABLED": True, "PROVIDER": "mock"}
-# The full set of keys (provider selection, API_KEY_ENV_VAR naming the
-# server-side environment variable holding the provider key, size limits,
-# the ALLOWED_MCP_TOOLS allowlist, approval policy) is documented in
-# superset/ai_chat/settings.py. Provider API keys are never stored in this
-# dict and never sent to the browser.
-AI_CHAT_CONFIG: dict[str, Any] = dict(DEFAULT_AI_CHAT_CONFIG)
-
# Default polling interval for tasks (seconds)
TASK_ABORT_POLLING_DEFAULT_INTERVAL = 10
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 325f3ee4932..851dc04a775 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -294,11 +294,9 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(LogRestApi)
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
- from superset.ai_chat.api import AiChatRestApi
from superset.extensions.api import ExtensionsRestApi
from superset.extensions.storage.api import ExtensionStorageRestApi
- appbuilder.add_api(AiChatRestApi)
appbuilder.add_api(ExtensionsRestApi)
appbuilder.add_api(ExtensionStorageRestApi)
diff --git a/superset/key_value/types.py b/superset/key_value/types.py
index aa7bd187207..b1f27de4cbf 100644
--- a/superset/key_value/types.py
+++ b/superset/key_value/types.py
@@ -40,7 +40,6 @@ class KeyValueFilter(TypedDict, total=False):
class KeyValueResource(StrEnum):
- AI_CHAT_APPROVAL = "ai_chat_approval"
APP = "app"
DASHBOARD_PERMALINK = "dashboard_permalink"
EXPLORE_PERMALINK = "explore_permalink"