Files
supabase/apps/studio/pages/api/ai/sql/generate-v4.ts
Matt RossmanandJoshen Lim 4bb36b944f feat(studio): let High Compliance projects opt-in to Assistant data access (#50548)
Orgs with the HIPAA add-on had the Assistant's opt-in level forced to
`disabled` on any project marked High Compliance, regardless of what the
org picked in its AI settings. The restriction predated our AI provider
BAAs. The consequence is those users see the Assistant failing to answer
questions about their data w/ no clear path how to fix it, even though
the LLM provider supports this use case.

This PR removes these Assistant restrictions on the server and client so
those projects honor the org's chosen level. Braintrust conversation
tracing is unchanged and still blocked for these projects, see [this
test
case](https://github.com/supabase/supabase/blob/b9800ccf16/apps/studio/lib/ai/braintrust-logger.test.ts#L16-L20).
See
[comments](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-485a0d46)
for legal approval and conditions.

The client-side changes enable features like "Debug with AI" on SQL
query failures, “Generate/Rename with AI” for snippet titles, and
generated Assistant chat titles for these customers.

The AI opt-in copy now adds a reminder to obtain consent from data
subjects, linking the [shared responsibility
model](https://supabase.com/docs/guides/deployment/shared-responsibility-model)
based also on [this
comment](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-f81ee610).

<img width="400" alt="CleanShot 2026-09-17 at 5 01 02 PM@2x"
src="https://github.com/user-attachments/assets/d02123f2-3e32-4d83-9f98-7d15e59222ef"
/>

To test with a HIPAA-enabled project in staging, you can use this [Plan
Change
[Staging]](https://app.hex.tech/supabase/app/Plan-Change-Staging-032BD32jo1EaisCS85qunf/latest)
Hex to add the HIPAA add-on. Once the add-on is present, you can turn on
High Compliance from a project's settings. Also in org settings, crank
up the Assistant data opt-in level and verify the Assistant is able to
answer questions about the project's data.

My results testing with opt-in level "Schema, Logs & Database Data":

| High compliance setting | Data opt-in working |
|--------|--------|
| <img width="1302" height="422" alt="CleanShot 2026-09-17 at 5 03 36
PM@2x"
src="https://github.com/user-attachments/assets/c416371b-2eb8-49df-9c07-6d8eababb443"
/> | <img width="1566" height="1516" alt="CleanShot 2026-09-17 at 5 05
14 PM@2x"
src="https://github.com/user-attachments/assets/39624355-7f8f-46ce-9f08-a8acfb9da830"
/> |

Closes AI-1153


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features

- AI-assisted query renaming, snippet title generation, debugging, and
tools now follow organization AI opt-in settings rather than project
HIPAA status.
- Debugging assistance and AI actions remain available for eligible
users without additional HIPAA-based blocking.
- AI metadata warnings consistently show standard opt-in messaging and
permission settings.
- AI settings remind users to obtain consent before entering personal
data and link to shared responsibility guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-18 08:21:50 -04:00

288 lines
8.4 KiB
TypeScript

import pgMeta from '@supabase/pg-meta'
import type { JwtPayload } from '@supabase/supabase-js'
import { pipeUIMessageStreamToResponse, safeValidateUIMessages, toUIMessageStream } from 'ai'
import { IS_PLATFORM } from 'common'
import type { NextApiRequest, NextApiResponse } from 'next'
import z from 'zod'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { getAIDetails } from '@/lib/ai/ai-details'
import { NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context'
import {
assistantMessageMetadataSchema,
messagesIncludeLogsSnippets,
} from '@/lib/ai/assistant-message-metadata'
import { isTracingAllowed } from '@/lib/ai/braintrust-logger'
import { generateAssistantResponse } from '@/lib/ai/generate-assistant-response'
import { isExplorerEnabled } from '@/lib/ai/is-explorer-enabled'
import { getModel } from '@/lib/ai/model'
import {
DEFAULT_ASSISTANT_BASE_MODEL_ID,
getAssistantModelEntry,
isAssistantBaseModelId,
isKnownAssistantModelId,
type AssistantModelId,
} from '@/lib/ai/model.utils'
import { getTools } from '@/lib/ai/tools'
import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools'
import { apiWrapper } from '@/lib/api/apiWrapper'
import { executeQuery } from '@/lib/api/self-hosted/query'
import { getURL } from '@/lib/helpers'
import { trustedUserEmail } from '@/lib/server/configcat'
export const maxDuration = 120
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb',
},
},
}
async function handler(req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload) {
const { method } = req
switch (method) {
case 'POST':
return handlePost(req, res, claims)
default:
res.setHeader('Allow', ['POST'])
res.status(405).json({
data: null,
error: { message: `Method ${method} Not Allowed` },
})
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
const requestBodySchema = z.object({
messages: z.array(z.any()),
projectRef: z.string(),
connectionString: z.string(),
schema: z.string().optional(),
table: z.string().optional(),
chatId: z.string().optional(),
chatName: z.string().optional(),
supportMode: z.boolean().optional(),
orgSlug: z.string().optional(),
model: z.string().optional(),
})
async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload) {
const authorization = req.headers.authorization
const accessToken = authorization?.replace('Bearer ', '')
if (IS_PLATFORM && !accessToken) {
return res.status(401).json({ error: 'Authorization token is required' })
}
const userId = claims?.sub
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const { data, error: parseError } = requestBodySchema.safeParse(body)
if (parseError) {
return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues })
}
const {
messages: rawMessages,
projectRef,
connectionString,
orgSlug: rawOrgSlug,
chatId,
chatName,
model: rawRequestedModel,
supportMode,
} = data
const requestedModel: AssistantModelId | undefined =
rawRequestedModel && isKnownAssistantModelId(rawRequestedModel) ? rawRequestedModel : undefined
const messagesValidation = await safeValidateUIMessages({
messages: rawMessages,
metadataSchema: assistantMessageMetadataSchema,
})
if (!messagesValidation.success) {
return res.status(400).json({
error: 'Invalid request body',
message: messagesValidation.error.message,
})
}
const messages = messagesValidation.data
const includesLogsSnippets = messagesIncludeLogsSnippets(messages)
let aiOptInLevel: AiOptInLevel = 'disabled'
let hasAccessToAdvanceModel = false
let orgHasHipaaAddon: boolean | undefined
let projectIsSensitive: boolean | null | undefined
let projectRegion: string | undefined
let orgId: number | undefined
let orgSlug: string | undefined
let planId: string | undefined
if (!IS_PLATFORM) {
aiOptInLevel = 'schema'
hasAccessToAdvanceModel = true
}
if (IS_PLATFORM && rawOrgSlug && authorization && projectRef) {
try {
const aiDetails = await getAIDetails({ orgSlug: rawOrgSlug, projectRef, authorization })
aiOptInLevel = aiDetails.aiOptInLevel
hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel
orgHasHipaaAddon = aiDetails.hasHipaaAddon
orgId = aiDetails.orgId
orgSlug = aiDetails.orgSlug
planId = aiDetails.planId
projectIsSensitive = aiDetails.isSensitive
projectRegion = aiDetails.region
} catch (error) {
return res.status(400).json({
error: 'There was an error fetching your organization details',
})
}
}
const explorerEnabled = await isExplorerEnabled(trustedUserEmail(claims?.email))
const envThrottled = process.env.IS_THROTTLED !== 'false'
let effectiveModel: AssistantModelId = requestedModel ?? DEFAULT_ASSISTANT_BASE_MODEL_ID
if (!hasAccessToAdvanceModel || (envThrottled && !isAssistantBaseModelId(effectiveModel))) {
effectiveModel = DEFAULT_ASSISTANT_BASE_MODEL_ID
}
const {
modelParams,
error: modelError,
systemProviderOptions,
} = await getModel({
provider: 'openai',
modelEntry: getAssistantModelEntry(effectiveModel),
})
if (modelError) {
return res.status(500).json({ error: modelError.message })
}
try {
const abortController = new AbortController()
req.on('close', () => abortController.abort())
req.on('aborted', () => abortController.abort())
// Fires when the response finishes streaming or the connection drops, which
// is what tears down the remote MCP connection opened in getTools.
res.on('close', () => abortController.abort())
const tools = await getTools({
projectRef,
connectionString,
authorization,
aiOptInLevel,
accessToken,
baseUrl: getURL(),
supportMode,
isExplorerEnabled: explorerEnabled,
signal: abortController.signal,
})
// Get a list of all schemas to add to context
const getSchemas = async (): Promise<string> => {
const pgMetaSchemasList = pgMeta.schemas.list()
type Schemas = z.infer<(typeof pgMetaSchemasList)['zod']>
const { result: schemas } = await executeSql<Schemas>(
{
projectRef,
connectionString,
sql: pgMetaSchemasList.sql,
},
undefined,
{
'Content-Type': 'application/json',
...(authorization && { Authorization: authorization }),
},
IS_PLATFORM ? undefined : executeQuery
)
return schemas?.length > 0
? `The available database schema names are: ${JSON.stringify(schemas)}`
: NO_SCHEMA_ACCESS_MESSAGE
}
const result = await generateAssistantResponse({
messages,
...modelParams,
tools,
aiOptInLevel,
getSchemas: aiOptInLevel !== 'disabled' ? getSchemas : undefined,
projectRef,
chatId,
chatName,
allowTracing: isTracingAllowed({
orgHasHipaaAddon,
projectIsSensitive,
projectRegion,
}),
supportMode,
userId,
orgId,
orgSlug,
planId,
includesLogsSnippets,
isExplorerEnabled: explorerEnabled,
requestedModel,
systemProviderOptions,
abortSignal: abortController.signal,
onSpanCreated: (spanId) => {
res.setHeader('x-braintrust-span-id', spanId)
},
})
const stream = toUIMessageStream({
stream: result.stream,
sendReasoning: true,
onError: (error) => {
console.error('Assistant stream error:', error)
const encoded = encodeNotebookToolError(error)
if (encoded !== null) return encoded
if (error == null) {
return 'unknown error'
}
if (typeof error === 'string') {
return error
}
if (error instanceof Error) {
return error.message
}
return JSON.stringify(error)
},
})
pipeUIMessageStreamToResponse({
response: res,
stream,
headers: { 'Content-Encoding': 'none' },
})
} catch (error) {
console.error('Error in handlePost:', error)
if (error instanceof Error) {
return res.status(500).json({ message: error.message })
}
return res.status(500).json({ message: 'An unexpected error occurred.' })
}
}