From adf8b0c67c1699631575a76d881c2a75c1caf565 Mon Sep 17 00:00:00 2001
From: Matt Rossman <22670878+mattrossman@users.noreply.github.com>
Date: Wed, 25 Mar 2026 11:29:23 -0400
Subject: [PATCH] feat(assistant): per-endpoint reasoningEffort + model config
cleanup (#43981)
We're exploring support for newer models like
[gpt-5.4-nano](https://openai.com/index/introducing-gpt-5-4-mini-and-nano/)
in Assistant. This model doesn't support the `'minimal'` reasoning
effort level we use for gpt-5-mini which leads to vague errors.
Also, we've [previously
discussed](https://supabase.slack.com/archives/C0161K73J1J/p1771544464850199?thread_ts=1771493920.775699&cid=C0161K73J1J)
that reasoning adds unnecessary latency to otherwise simple AI
completion endpoints like `title-v2`. We want more control of reasoning
level independent of model/endpoint.
This PR aims to solve both problems by:
- making reasoning effort configurable on a per-request basis
- adding compile-time guardrails to prevent selecting an incompatible
reasoning level for models
- adding a `DEFAULT_COMPLETION_MODEL` with minimal reasoning that we can
update with newer models that support disabling reasoning (independent
of Assistant chat model reasoning)
Other improvements to our model config logic:
- Fixes bug in `onboarding/design.ts` and `assistant.eval.ts` where
`providerOptions` was being dropped
- `getModel()` now returns a bundled `modelParams` object (spread into
AI SDK calls) so `providerOptions` can't be accidentally omitted (this
[has happened
before](https://supabase.slack.com/archives/C0161K73J1J/p1771518443534309?thread_ts=1771493920.775699&cid=C0161K73J1J))
- Introduces an `ASSISTANT_MODELS` registry as a single source of truth
for assistant model config, eliminating hardcoded model IDs across the
codebase
- Aligns free/pro model conditional logic with `assistant.advance_model`
entitlement naming conventions instead of the `isLimited` pattern
- Adds `console.error` logging of Assistant stream errors so we can
interpret reasoning effort compatibility errors in the future (instead
of just opaque "Sorry, I'm having trouble responding right now" card)
- Removes unnecessary type casts and generally making the model config
logic stricter
- Removes pre-existing dead code: `anthropic` provider variant in
`GetModelParams` / `PROVIDERS` registry that was never implemented in
`getModel()`
Now if you try to select an unsupported reasoning level you get a type
error:
And if for some reason an invalid reasoning level slips through, you now
get a server-side error surfacing the issue:
I've tested gpt-5 and gpt-5-mini are still working on the staging
preview and verified the models were selected properly in Braintrust
logs. Both models are available on my Pro test account, and my Free test
account shows the Pro upgrade CTA.
Closes AI-446
Closes AI-551
---
.../ui/AIAssistantPanel/AIAssistant.tsx | 18 ++-
.../ui/AIAssistantPanel/AssistantChatForm.tsx | 5 +-
.../ui/AIAssistantPanel/ModelSelector.tsx | 79 +++++------
apps/studio/evals/assistant.eval.ts | 17 ++-
apps/studio/lib/ai/model.test.ts | 94 ++++++-------
apps/studio/lib/ai/model.ts | 114 ++++++++--------
apps/studio/lib/ai/model.utils.test.ts | 107 ++++++++++++---
apps/studio/lib/ai/model.utils.ts | 124 +++++++++++++++---
apps/studio/lib/ai/org-ai-details.test.ts | 10 +-
apps/studio/lib/ai/org-ai-details.ts | 4 +-
apps/studio/lib/api/generate-v4.test.ts | 6 +-
apps/studio/lib/api/rate.test.ts | 5 +-
apps/studio/pages/api/ai/code/complete.ts | 9 +-
apps/studio/pages/api/ai/feedback/classify.ts | 12 +-
apps/studio/pages/api/ai/feedback/rate.ts | 13 +-
apps/studio/pages/api/ai/onboarding/design.ts | 7 +-
apps/studio/pages/api/ai/sql/cron-v2.ts | 12 +-
apps/studio/pages/api/ai/sql/filter-v1.ts | 12 +-
apps/studio/pages/api/ai/sql/generate-v4.ts | 52 +++++---
apps/studio/pages/api/ai/sql/policy.ts | 12 +-
apps/studio/pages/api/ai/sql/title-v2.ts | 12 +-
apps/studio/state/ai-assistant-state.tsx | 9 +-
22 files changed, 452 insertions(+), 281 deletions(-)
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
index b5a3dccff51..0b4566c2a09 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
@@ -19,6 +19,12 @@ import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useHotKey } from 'hooks/ui/useHotKey'
+import {
+ DEFAULT_ASSISTANT_BASE_MODEL_ID,
+ defaultAssistantModelId,
+ isAssistantBaseModelId,
+ isKnownAssistantModelId,
+} from 'lib/ai/model.utils'
import { IS_PLATFORM } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import type { AssistantModel } from 'state/ai-assistant-state'
@@ -27,6 +33,7 @@ import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { Button, cn, KeyboardShortcut } from 'ui'
import { Admonition } from 'ui-patterns'
+
import { ButtonTooltip } from '../ButtonTooltip'
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'
import type { SqlSnippet } from './AIAssistant.types'
@@ -72,14 +79,15 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const selectedModel = useMemo(() => {
// While entitlements are loading, use the stored model without enforcing access
if (isLoadingEntitlements) {
- return snap.model ?? 'gpt-5-mini'
+ return snap.model ?? DEFAULT_ASSISTANT_BASE_MODEL_ID
}
- const defaultModel: AssistantModel = hasAccessToAdvanceModel ? 'gpt-5' : 'gpt-5-mini'
+ const defaultModel = defaultAssistantModelId(hasAccessToAdvanceModel)
const model = snap.model ?? defaultModel
- if (!hasAccessToAdvanceModel && model === 'gpt-5') {
- return 'gpt-5-mini'
+ if (!isKnownAssistantModelId(model)) return defaultModel
+ if (!hasAccessToAdvanceModel && !isAssistantBaseModelId(model)) {
+ return DEFAULT_ASSISTANT_BASE_MODEL_ID
}
return model
@@ -391,7 +399,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
onCloseAssistant={() => closeSidebar(SIDEBAR_KEYS.AI_ASSISTANT)}
showMetadataWarning={showMetadataWarning}
updatedOptInSinceMCP={updatedOptInSinceMCP}
- isHipaaProjectDisallowed={isHipaaProjectDisallowed as boolean}
+ isHipaaProjectDisallowed={isHipaaProjectDisallowed}
aiOptInLevel={aiOptInLevel}
/>
{hasMessages ? (
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
index 2839ee44f4f..a97064a2f8e 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
@@ -5,6 +5,7 @@ import { useBreakpoint } from 'common'
import { ExpandingTextArea } from 'ui'
import { cn } from 'ui/src/lib/utils'
import { ButtonTooltip } from '../ButtonTooltip'
+import type { AssistantModelId } from 'lib/ai/model.utils'
import { type SqlSnippet } from './AIAssistant.types'
import { ModelSelector } from './ModelSelector'
import { getSnippetContent, SnippetRow } from './SnippetRow'
@@ -45,9 +46,9 @@ export interface FormProps {
/* If currently editing an existing message */
isEditing?: boolean
/* The currently selected AI model */
- selectedModel: 'gpt-5' | 'gpt-5-mini'
+ selectedModel: AssistantModelId
/* Callback when a model is chosen */
- onSelectModel: (model: 'gpt-5' | 'gpt-5-mini') => void
+ onSelectModel: (model: AssistantModelId) => void
}
const AssistantChatFormComponent = forwardRef(
diff --git a/apps/studio/components/ui/AIAssistantPanel/ModelSelector.tsx b/apps/studio/components/ui/AIAssistantPanel/ModelSelector.tsx
index d7c66b64d13..c558d7bd692 100644
--- a/apps/studio/components/ui/AIAssistantPanel/ModelSelector.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/ModelSelector.tsx
@@ -17,10 +17,12 @@ import {
Tooltip,
} from 'ui'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
+import { ASSISTANT_MODELS, isAdvanceOnlyModelId } from 'lib/ai/model.utils'
+import type { AssistantModelId } from 'lib/ai/model.utils'
interface ModelSelectorProps {
- selectedModel: 'gpt-5' | 'gpt-5-mini'
- onSelectModel: (model: 'gpt-5' | 'gpt-5-mini') => void
+ selectedModel: AssistantModelId
+ onSelectModel: (model: AssistantModelId) => void
}
export const ModelSelector = ({ selectedModel, onSelectModel }: ModelSelectorProps) => {
@@ -33,16 +35,19 @@ export const ModelSelector = ({ selectedModel, onSelectModel }: ModelSelectorPro
const slug = organization?.slug ?? '_'
- const upgradeHref = `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=ai-assistant-model`
+ const upgradeHref = `/org/${slug}/billing?panel=subscriptionPlan&source=ai-assistant-model`
- const handleSelectModel = (model: 'gpt-5' | 'gpt-5-mini') => {
- if (model === 'gpt-5' && !hasAccessToAdvanceModel) {
+ const handleSelectModel = (modelId: AssistantModelId) => {
+ if (isLoadingEntitlements && isAdvanceOnlyModelId(modelId)) {
+ return
+ }
+ if (isAdvanceOnlyModelId(modelId) && !hasAccessToAdvanceModel) {
setOpen(false)
void router.push(upgradeHref)
return
}
- onSelectModel(model)
+ onSelectModel(modelId)
setOpen(false)
}
@@ -61,39 +66,35 @@ export const ModelSelector = ({ selectedModel, onSelectModel }: ModelSelectorPro
- handleSelectModel('gpt-5-mini')}
- className="flex justify-between"
- >
- gpt-5-mini
- {selectedModel === 'gpt-5-mini' && }
-
- handleSelectModel('gpt-5')}
- className="flex justify-between"
- >
- gpt-5
- {hasAccessToAdvanceModel ? (
- selectedModel === 'gpt-5' ? (
-
- ) : null
- ) : (
-
-
-
-
- Upgrade
-
-
-
-
- gpt-5 is available on Pro plans and above
-
-
- )}
-
+ {ASSISTANT_MODELS.map((m) => (
+ handleSelectModel(m.id)}
+ className="flex justify-between"
+ >
+ {m.id}
+ {isAdvanceOnlyModelId(m.id) &&
+ !hasAccessToAdvanceModel &&
+ !isLoadingEntitlements ? (
+
+
+
+
+ Upgrade
+
+
+
+
+ {m.id} is available on Pro plans and above
+
+
+ ) : (
+ selectedModel === m.id &&
+ )}
+
+ ))}
diff --git a/apps/studio/evals/assistant.eval.ts b/apps/studio/evals/assistant.eval.ts
index 573f6e79730..f3aaae14db7 100644
--- a/apps/studio/evals/assistant.eval.ts
+++ b/apps/studio/evals/assistant.eval.ts
@@ -1,6 +1,7 @@
import assert from 'node:assert'
-import { openai } from '@ai-sdk/openai'
import { Eval } from 'braintrust'
+import { getModel } from 'lib/ai/model'
+import { DEFAULT_ASSISTANT_BASE_MODEL_ID, getAssistantModelEntry } from 'lib/ai/model.utils'
import { generateAssistantResponse } from 'lib/ai/generate-assistant-response'
import { getMockTools } from 'lib/ai/tools/mock-tools'
@@ -25,9 +26,19 @@ Eval('Assistant', {
trialCount: process.env.CI ? 3 : 1,
data: () => dataset,
task: async (input) => {
+ const modelEntry = getAssistantModelEntry(DEFAULT_ASSISTANT_BASE_MODEL_ID)
+ const modelResponse = await getModel({ provider: 'openai', modelEntry })
+ if (modelResponse.error) throw modelResponse.error
+
const result = await generateAssistantResponse({
- model: openai('gpt-5-mini'),
- messages: [{ id: '1', role: 'user', parts: [{ type: 'text', text: input.prompt }] }],
+ ...modelResponse.modelParams,
+ messages: [
+ {
+ id: '1',
+ role: 'user',
+ parts: [{ type: 'text', text: input.prompt }],
+ },
+ ],
tools: await getMockTools(input.mockTables ? { list_tables: input.mockTables } : undefined),
})
diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts
index 70a4626d529..e8671bcf553 100644
--- a/apps/studio/lib/ai/model.test.ts
+++ b/apps/studio/lib/ai/model.test.ts
@@ -1,7 +1,8 @@
import { openai } from '@ai-sdk/openai'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as bedrockModule from './bedrock'
-import { getModel, ModelErrorMessage } from './model'
+import { getModel } from './model'
+import { DEFAULT_COMPLETION_MODEL, openaiModelEntry } from './model.utils'
vi.mock('@ai-sdk/openai', () => ({
openai: vi.fn(() => 'openai-model'),
@@ -9,7 +10,7 @@ vi.mock('@ai-sdk/openai', () => ({
vi.mock('./bedrock', async () => ({
...(await vi.importActual('./bedrock')),
- createRoutedBedrock: vi.fn(() => async (modelId: string) => 'bedrock-model'),
+ createRoutedBedrock: vi.fn(() => async (_modelId: string) => 'bedrock-model'),
checkAwsCredentials: vi.fn(),
}))
@@ -18,82 +19,81 @@ describe('getModel', () => {
beforeEach(() => {
vi.resetAllMocks()
- vi.stubEnv('AWS_BEDROCK_ROLE_ARN', 'test')
})
afterEach(() => {
process.env = { ...originalEnv }
})
- it('should return bedrock model and promptProviderOptions when default supports caching', async () => {
+ it('returns bedrock model without promptProviderOptions', async () => {
vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true)
- vi.stubEnv('IS_THROTTLED', 'false')
+ vi.stubEnv('AWS_BEDROCK_ROLE_ARN', 'test')
- const { model, error, promptProviderOptions } = await getModel({
+ const { modelParams, error, promptProviderOptions } = await getModel({
+ provider: 'bedrock',
routingKey: 'test',
- isLimited: false,
})
- expect(model).toEqual('bedrock-model')
- // Default bedrock model supportsCaching=false in registry, but if caller
- // specifies high-tier, provider options would be present
- expect(promptProviderOptions === undefined || typeof promptProviderOptions === 'object').toBe(
- true
- )
- expect(error).toBeUndefined()
- })
-
- it('should return bedrock model when throttled (limited) with default model', async () => {
- vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true)
- vi.stubEnv('IS_THROTTLED', 'true')
-
- const { model, error, promptProviderOptions } = await getModel({
- routingKey: 'test',
- isLimited: true,
- })
-
- expect(model).toEqual('bedrock-model')
+ expect(modelParams?.model).toEqual('bedrock-model')
expect(promptProviderOptions).toBeUndefined()
expect(error).toBeUndefined()
})
- it('should return OpenAI model when AWS credentials are not available but OPENAI_API_KEY is set', async () => {
+ it('returns error when bedrock credentials are not available', async () => {
vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false)
- process.env.OPENAI_API_KEY = 'test-key'
- const { model, promptProviderOptions } = await getModel({
- routingKey: 'test',
- isLimited: false,
+ const { error } = await getModel({ provider: 'bedrock', routingKey: 'test' })
+ expect(error).toBeDefined()
+ })
+
+ it('returns openai model with default model', async () => {
+ vi.stubEnv('OPENAI_API_KEY', 'test-key')
+
+ const { modelParams, promptProviderOptions } = await getModel({
+ provider: 'openai',
+ modelEntry: openaiModelEntry({ id: 'gpt-5-mini' }),
})
- expect(model).toEqual('openai-model')
- // Default openai model in registry is gpt-5-mini
+ expect(modelParams?.model).toEqual('openai-model')
expect(openai).toHaveBeenCalledWith('gpt-5-mini')
expect(promptProviderOptions).toBeUndefined()
})
- it('should return error when neither AWS credentials nor OPENAI_API_KEY is available', async () => {
- vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false)
- delete process.env.OPENAI_API_KEY
+ it('returns error when OPENAI_API_KEY is not available', async () => {
+ vi.stubEnv('OPENAI_API_KEY', '')
- const { error } = await getModel({ routingKey: 'test-key', isLimited: false })
- expect(error).toEqual(new Error(ModelErrorMessage))
+ const { error } = await getModel({
+ provider: 'openai',
+ modelEntry: openaiModelEntry({ id: 'gpt-5-mini' }),
+ })
+ expect(error).toEqual(new Error('OPENAI_API_KEY not available'))
})
- it('returns specified provider and model when provided (openai gpt-5)', async () => {
- vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false)
- process.env.OPENAI_API_KEY = 'test-key'
- process.env.IS_THROTTLED = 'false'
+ it('returns openai gpt-5 when hasAccessToAdvanceModel and not throttled', async () => {
+ vi.stubEnv('OPENAI_API_KEY', 'test-key')
+ vi.stubEnv('IS_THROTTLED', 'false')
- const { model, error } = await getModel({
+ const { modelParams, error } = await getModel({
provider: 'openai',
- model: 'gpt-5',
- routingKey: 'rk',
- isLimited: false,
+ modelEntry: openaiModelEntry({ id: 'gpt-5', reasoningEffort: 'minimal' }),
})
expect(error).toBeUndefined()
- expect(model).toEqual('openai-model')
+ expect(modelParams?.model).toEqual('openai-model')
expect(openai).toHaveBeenCalledWith('gpt-5')
+ expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('minimal')
+ })
+
+ it('applies reasoningEffort from DEFAULT_COMPLETION_MODEL', async () => {
+ vi.stubEnv('OPENAI_API_KEY', 'test-key')
+
+ const { modelParams, error } = await getModel({
+ provider: 'openai',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
+ })
+
+ expect(error).toBeUndefined()
+ expect(openai).toHaveBeenCalledWith('gpt-5-mini')
+ expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('minimal')
})
})
diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts
index ff878b6297d..3c6eaf97758 100644
--- a/apps/studio/lib/ai/model.ts
+++ b/apps/studio/lib/ai/model.ts
@@ -1,110 +1,104 @@
import { openai } from '@ai-sdk/openai'
import { LanguageModel } from 'ai'
+
import { checkAwsCredentials, createRoutedBedrock } from './bedrock'
import {
BedrockModel,
- Model,
- OpenAIModel,
- PROVIDERS,
- ProviderModelConfig,
- ProviderName,
getDefaultModelForProvider,
+ Model,
+ OpenAIModelEntry,
+ OpenAIModelId,
+ ProviderModelConfig,
+ PROVIDERS,
} from './model.utils'
type PromptProviderOptions = Record
type ProviderOptions = Record
type ModelSuccess = {
- model: LanguageModel
+ /** Spread directly into AI SDK calls: `streamText({ ...modelParams, ... })` */
+ modelParams: { model: LanguageModel; providerOptions?: ProviderOptions }
promptProviderOptions?: PromptProviderOptions
- providerOptions?: ProviderOptions
error?: never
}
export type ModelError = {
- model?: never
+ modelParams?: never
promptProviderOptions?: never
- providerOptions?: never
error: Error
}
type ModelResponse = ModelSuccess | ModelError
-export const ModelErrorMessage = 'No valid AI model available based on available credentials.'
-
-export type GetModelParams = {
- provider?: ProviderName
- model?: Model
- routingKey: string
- isLimited?: boolean
-}
+export type GetModelParams =
+ | {
+ provider: 'openai'
+ /**
+ * Specifies which OpenAI model to use and its reasoning effort.
+ * Create entries via `openaiModelEntry()` — reasoning effort is validated against the model
+ * at compile time. Use `DEFAULT_COMPLETION_MODEL` for simple endpoints (minimal reasoning).
+ * Callers are responsible for resolving the correct entry (including throttling/entitlement
+ * fallbacks) before calling getModel.
+ */
+ modelEntry: OpenAIModelEntry
+ }
+ | {
+ provider: 'bedrock'
+ /** Used for consistent hashing across Bedrock regions. */
+ routingKey: string
+ }
/**
- * Retrieves a LanguageModel from a specific provider and model.
- * - If provider/model not specified, auto-selects based on available credentials (prefers Bedrock).
- * - If isLimited is true, uses the provider's default model.
- * - Returns promptProviderOptions that callers can attach to the system message.
+ * Retrieves a LanguageModel from a specific provider and model entry.
+ * Callers are responsible for resolving the correct model entry (including throttling/entitlement
+ * fallbacks) before calling this function.
+ * Returns promptProviderOptions that callers can attach to the system message.
*/
-export async function getModel({
- provider,
- model,
- routingKey,
- isLimited = true,
-}: GetModelParams): Promise {
- const envThrottled = process.env.IS_THROTTLED !== 'false'
+export async function getModel(params: GetModelParams): Promise {
+ const { provider } = params
- let preferredProvider: ProviderName | undefined = provider
-
- const hasAwsCredentials = await checkAwsCredentials()
- const hasAwsBedrockRoleArn = !!process.env.AWS_BEDROCK_ROLE_ARN
- const hasOpenAIKey = !!process.env.OPENAI_API_KEY
-
- // Auto-pick a provider if not specified defaulting to Bedrock
- if (!preferredProvider) {
- if (hasAwsBedrockRoleArn && hasAwsCredentials) {
- preferredProvider = 'bedrock'
- } else if (hasOpenAIKey) {
- preferredProvider = 'openai'
- }
- }
-
- if (!preferredProvider) {
- return { error: new Error(ModelErrorMessage) }
- }
-
- const providerRegistry = PROVIDERS[preferredProvider]
+ const providerRegistry = PROVIDERS[provider]
if (!providerRegistry) {
- return { error: new Error(`Unknown provider: ${preferredProvider}`) }
+ return { error: new Error(`Unknown provider: ${provider}`) }
}
const models = providerRegistry.models as Record
+ const modelEntry = params.provider === 'openai' ? params.modelEntry : undefined
- const useDefault = isLimited || envThrottled || !model || !models[model]
+ const useDefault = !modelEntry?.id || !models[modelEntry.id]
- const chosenModelId = useDefault ? getDefaultModelForProvider(preferredProvider) : model
+ const chosenModelId = useDefault ? getDefaultModelForProvider(provider) : modelEntry?.id
- if (preferredProvider === 'bedrock') {
+ if (provider === 'bedrock') {
+ const hasAwsCredentials = await checkAwsCredentials()
+ const hasAwsBedrockRoleArn = !!process.env.AWS_BEDROCK_ROLE_ARN
if (!hasAwsBedrockRoleArn || !hasAwsCredentials) {
return { error: new Error('AWS Bedrock credentials not available') }
}
- const bedrock = createRoutedBedrock(routingKey)
+ const bedrock = createRoutedBedrock(params.routingKey)
const model = await bedrock(chosenModelId as BedrockModel)
const promptProviderOptions = (
providerRegistry.models as Record
)[chosenModelId as BedrockModel]?.promptProviderOptions
- return { model, promptProviderOptions }
+ return { modelParams: { model }, promptProviderOptions }
}
- if (preferredProvider === 'openai') {
- if (!hasOpenAIKey) {
+ if (provider === 'openai') {
+ if (!process.env.OPENAI_API_KEY) {
return { error: new Error('OPENAI_API_KEY not available') }
}
+ const baseProviderOptions = providerRegistry.providerOptions?.openai ?? {}
+ const openaiProviderOptions = modelEntry?.reasoningEffort
+ ? { ...baseProviderOptions, reasoningEffort: modelEntry.reasoningEffort }
+ : baseProviderOptions
return {
- model: openai(chosenModelId as OpenAIModel),
- promptProviderOptions: models[chosenModelId as OpenAIModel]?.promptProviderOptions,
- providerOptions: providerRegistry.providerOptions,
+ modelParams: {
+ model: openai(chosenModelId as OpenAIModelId),
+ providerOptions: { openai: openaiProviderOptions },
+ },
+ promptProviderOptions: models[chosenModelId as OpenAIModelId]?.promptProviderOptions,
}
}
- return { error: new Error(`Unsupported provider: ${preferredProvider}`) }
+ return { error: new Error(`Unsupported provider: ${provider}`) }
}
diff --git a/apps/studio/lib/ai/model.utils.test.ts b/apps/studio/lib/ai/model.utils.test.ts
index dfddf461656..2de1e5dadf2 100644
--- a/apps/studio/lib/ai/model.utils.test.ts
+++ b/apps/studio/lib/ai/model.utils.test.ts
@@ -1,6 +1,19 @@
import { describe, expect, it } from 'vitest'
-import { getDefaultModelForProvider, PROVIDERS } from './model.utils'
+import {
+ ASSISTANT_MODELS,
+ DEFAULT_ASSISTANT_ADVANCE_MODEL_ID,
+ DEFAULT_ASSISTANT_BASE_MODEL_ID,
+ DEFAULT_COMPLETION_MODEL,
+ defaultAssistantModelId,
+ getAssistantModelEntry,
+ getDefaultModelForProvider,
+ isAdvanceOnlyModelId,
+ isAssistantBaseModelId,
+ isKnownAssistantModelId,
+ openaiModelEntry,
+ PROVIDERS,
+} from './model.utils'
import type { ProviderName } from './model.utils'
describe('model.utils', () => {
@@ -15,11 +28,6 @@ describe('model.utils', () => {
expect(result).toBe('gpt-5-mini')
})
- it('should return correct default for anthropic provider', () => {
- const result = getDefaultModelForProvider('anthropic')
- expect(result).toBe('claude-3-5-haiku-20241022')
- })
-
it('should return undefined for unknown provider', () => {
const result = getDefaultModelForProvider('unknown' as ProviderName)
expect(result).toBeUndefined()
@@ -43,15 +51,8 @@ describe('model.utils', () => {
expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5-mini')
})
- it('should have anthropic provider with models', () => {
- expect(PROVIDERS.anthropic).toBeDefined()
- expect(PROVIDERS.anthropic.models).toBeDefined()
- expect(Object.keys(PROVIDERS.anthropic.models)).toContain('claude-sonnet-4-20250514')
- expect(Object.keys(PROVIDERS.anthropic.models)).toContain('claude-3-5-haiku-20241022')
- })
-
it('should have exactly one default model per provider', () => {
- const providers: ProviderName[] = ['bedrock', 'openai', 'anthropic']
+ const providers: ProviderName[] = ['bedrock', 'openai']
providers.forEach((provider) => {
const models = PROVIDERS[provider].models
@@ -61,11 +62,11 @@ describe('model.utils', () => {
})
it('should have valid model configurations', () => {
- const providers: ProviderName[] = ['bedrock', 'openai', 'anthropic']
+ const providers: ProviderName[] = ['bedrock', 'openai']
providers.forEach((provider) => {
const models = PROVIDERS[provider].models
- Object.entries(models).forEach(([modelId, config]) => {
+ Object.entries(models).forEach(([_modelId, config]) => {
expect(config).toHaveProperty('default')
expect(typeof config.default).toBe('boolean')
})
@@ -76,13 +77,83 @@ describe('model.utils', () => {
const sonnetModel = PROVIDERS.bedrock.models['anthropic.claude-3-7-sonnet-20250219-v1:0']
expect(sonnetModel.promptProviderOptions).toBeDefined()
expect(sonnetModel.promptProviderOptions?.bedrock).toBeDefined()
- expect(sonnetModel.promptProviderOptions?.bedrock?.cachePoint).toEqual({ type: 'default' })
+ expect(sonnetModel.promptProviderOptions?.bedrock?.cachePoint).toEqual({
+ type: 'default',
+ })
})
it('should have openai provider with providerOptions', () => {
expect(PROVIDERS.openai.providerOptions).toBeDefined()
expect(PROVIDERS.openai.providerOptions?.openai).toBeDefined()
- expect(PROVIDERS.openai.providerOptions?.openai?.reasoningEffort).toBe('minimal')
+ expect(PROVIDERS.openai.providerOptions?.openai?.reasoningEffort).toBeUndefined()
+ })
+ })
+
+ describe('assistant model registry', () => {
+ it('should have non-empty base and advance tiers', () => {
+ expect(
+ ASSISTANT_MODELS.filter((m) => !m.requiresAdvanceModelEntitlement).length
+ ).toBeGreaterThan(0)
+ expect(
+ ASSISTANT_MODELS.filter((m) => m.requiresAdvanceModelEntitlement).length
+ ).toBeGreaterThan(0)
+ })
+
+ it('all model IDs should be unique', () => {
+ const ids = ASSISTANT_MODELS.map((m) => m.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ })
+
+ it('should have all models in openai provider registry', () => {
+ ASSISTANT_MODELS.forEach((entry) => {
+ expect(Object.keys(PROVIDERS.openai.models)).toContain(entry.id)
+ })
+ })
+
+ it('defaults should satisfy unions', () => {
+ expect(DEFAULT_ASSISTANT_BASE_MODEL_ID).toBe('gpt-5-mini')
+ expect(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID).toBe('gpt-5')
+ expect(defaultAssistantModelId(false)).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID)
+ expect(defaultAssistantModelId(true)).toBe(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID)
+ })
+
+ it('isAssistantBaseModelId / isAdvanceOnlyModelId', () => {
+ expect(isAssistantBaseModelId('gpt-5-mini')).toBe(true)
+ expect(isAssistantBaseModelId('gpt-5')).toBe(false)
+ expect(isAdvanceOnlyModelId('gpt-5')).toBe(true)
+ expect(isAdvanceOnlyModelId('gpt-5-mini')).toBe(false)
+ })
+
+ it('isKnownAssistantModelId', () => {
+ expect(isKnownAssistantModelId('gpt-5-mini')).toBe(true)
+ expect(isKnownAssistantModelId('gpt-5')).toBe(true)
+ expect(isKnownAssistantModelId('unknown')).toBe(false)
+ })
+
+ it('getAssistantModelEntry returns config for known ids', () => {
+ expect(getAssistantModelEntry('gpt-5-mini').reasoningEffort).toBe('minimal')
+ expect(getAssistantModelEntry('gpt-5').reasoningEffort).toBe('minimal')
+ expect(getAssistantModelEntry('gpt-5-mini')).toEqual(
+ ASSISTANT_MODELS.find((m) => m.id === 'gpt-5-mini')
+ )
+ })
+
+ it('DEFAULT_COMPLETION_MODEL is gpt-5-mini with minimal reasoning effort', () => {
+ expect(DEFAULT_COMPLETION_MODEL.id).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID)
+ expect(DEFAULT_COMPLETION_MODEL.reasoningEffort).toBe('minimal')
+ })
+
+ it('openaiModelEntry enforces valid reasoning effort at compile time', () => {
+ // Valid: supported effort level
+ const withEffort = openaiModelEntry({
+ id: 'gpt-5-mini',
+ reasoningEffort: 'low',
+ })
+ expect(withEffort.reasoningEffort).toBe('low')
+
+ // Valid: no effort
+ const withoutEffort = openaiModelEntry({ id: 'gpt-5-mini' })
+ expect(withoutEffort.reasoningEffort).toBeUndefined()
})
})
})
diff --git a/apps/studio/lib/ai/model.utils.ts b/apps/studio/lib/ai/model.utils.ts
index 54fc126d775..15dc0f8fabc 100644
--- a/apps/studio/lib/ai/model.utils.ts
+++ b/apps/studio/lib/ai/model.utils.ts
@@ -1,12 +1,115 @@
-export type ProviderName = 'bedrock' | 'openai' | 'anthropic'
+export type ProviderName = 'bedrock' | 'openai'
export type BedrockModel = 'anthropic.claude-3-7-sonnet-20250219-v1:0' | 'openai.gpt-oss-120b-1:0'
-export type OpenAIModel = 'gpt-5' | 'gpt-5-mini'
+export type OpenAIModelId = 'gpt-5' | 'gpt-5-mini'
-export type AnthropicModel = 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022'
+// Source: https://developers.openai.com/api/docs/guides/reasoning + per-model pages
+export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
-export type Model = BedrockModel | OpenAIModel | AnthropicModel
+// Per-model reasoning effort compatibility.
+// When adding a model, verify supported levels in the community matrix and add an entry:
+// https://community.openai.com/t/request-for-compatibility-matrix-reasoning-effort-sampling-parameters-across-gpt-5-series/1371738/2
+type ModelReasoningSupport = {
+ 'gpt-5': 'minimal' | 'low' | 'medium' | 'high'
+ 'gpt-5-mini': 'minimal' | 'low' | 'medium' | 'high'
+}
+
+type ReasoningEffortFor = ModelId extends keyof ModelReasoningSupport
+ ? ModelReasoningSupport[ModelId]
+ : never
+
+/** Type-safe factory for configuring OpenAI models with compatible reasoning efforts. */
+export function openaiModelEntry<
+ ModelId extends OpenAIModelId,
+ RequiresAdvance extends boolean = false,
+>(config: {
+ id: ModelId
+ /** When true, the model requires the `assistant.advance_model` entitlement (paid plans). Defaults to false. */
+ requiresAdvanceModelEntitlement?: RequiresAdvance
+ /**
+ * When omitted, OpenAI applies its own default reasoning effort for the model,
+ * which may not be zero. Use an explicit level to control cost and latency.
+ */
+ reasoningEffort?: ReasoningEffortFor
+}): {
+ id: ModelId
+ requiresAdvanceModelEntitlement: RequiresAdvance
+ reasoningEffort?: ReasoningEffortFor
+} {
+ return {
+ requiresAdvanceModelEntitlement: false as RequiresAdvance,
+ ...config,
+ }
+}
+
+export type OpenAIModelEntry = ReturnType
+
+/** Default model entry for simple completion endpoints where latency is more important than reasoning. */
+export const DEFAULT_COMPLETION_MODEL = openaiModelEntry({
+ id: 'gpt-5-mini',
+ reasoningEffort: 'minimal',
+})
+
+// Single source of truth for all Assistant chat model variants and their reasoning levels.
+// Models with requiresAdvanceModelEntitlement false are available to all users; true requires the assistant.advance_model entitlement.
+export const ASSISTANT_MODELS = [
+ openaiModelEntry({
+ id: 'gpt-5-mini',
+ requiresAdvanceModelEntitlement: false,
+ reasoningEffort: 'minimal',
+ }),
+ openaiModelEntry({
+ id: 'gpt-5',
+ requiresAdvanceModelEntitlement: true,
+ reasoningEffort: 'minimal',
+ }),
+] as const
+
+export type AssistantBaseModelId = Extract<
+ (typeof ASSISTANT_MODELS)[number],
+ { requiresAdvanceModelEntitlement: false }
+>['id']
+export type AssistantModelId = (typeof ASSISTANT_MODELS)[number]['id']
+
+const ASSISTANT_MODELS_MAP = Object.fromEntries(ASSISTANT_MODELS.map((m) => [m.id, m])) as Record<
+ AssistantModelId,
+ (typeof ASSISTANT_MODELS)[number]
+>
+
+export const DEFAULT_ASSISTANT_BASE_MODEL_ID = 'gpt-5-mini' satisfies AssistantBaseModelId
+
+export const DEFAULT_ASSISTANT_ADVANCE_MODEL_ID = 'gpt-5' satisfies AssistantModelId
+
+export function defaultAssistantModelId(hasAccessToAdvanceModel: boolean): AssistantModelId {
+ return hasAccessToAdvanceModel
+ ? DEFAULT_ASSISTANT_ADVANCE_MODEL_ID
+ : DEFAULT_ASSISTANT_BASE_MODEL_ID
+}
+
+export function isKnownAssistantModelId(id: string): id is AssistantModelId {
+ return Object.hasOwn(ASSISTANT_MODELS_MAP, id)
+}
+
+export function isAssistantBaseModelId(id: string): id is AssistantBaseModelId {
+ return (
+ id in ASSISTANT_MODELS_MAP &&
+ !ASSISTANT_MODELS_MAP[id as AssistantModelId].requiresAdvanceModelEntitlement
+ )
+}
+
+export function isAdvanceOnlyModelId(id: string): boolean {
+ return (
+ id in ASSISTANT_MODELS_MAP &&
+ ASSISTANT_MODELS_MAP[id as AssistantModelId].requiresAdvanceModelEntitlement
+ )
+}
+
+export function getAssistantModelEntry(id: AssistantModelId): (typeof ASSISTANT_MODELS)[number] {
+ return ASSISTANT_MODELS_MAP[id]
+}
+
+export type Model = BedrockModel | OpenAIModelId
export type ProviderModelConfig = {
/** Optional providerOptions to attach to the system message for this model */
@@ -21,11 +124,7 @@ export type ProviderRegistry = {
providerOptions?: Record
}
openai: {
- models: Record
- providerOptions?: Record
- }
- anthropic: {
- models: Record
+ models: Record
providerOptions?: Record
}
}
@@ -54,17 +153,10 @@ export const PROVIDERS: ProviderRegistry = {
},
providerOptions: {
openai: {
- reasoningEffort: 'minimal',
store: false,
},
},
},
- anthropic: {
- models: {
- 'claude-sonnet-4-20250514': { default: false },
- 'claude-3-5-haiku-20241022': { default: true },
- },
- },
}
export function getDefaultModelForProvider(provider: ProviderName): Model | undefined {
diff --git a/apps/studio/lib/ai/org-ai-details.test.ts b/apps/studio/lib/ai/org-ai-details.test.ts
index 98e213ca829..ff0ebce2f20 100644
--- a/apps/studio/lib/ai/org-ai-details.test.ts
+++ b/apps/studio/lib/ai/org-ai-details.test.ts
@@ -102,7 +102,7 @@ describe('ai/org-ai-details', () => {
})
})
- it('should return AI opt-in level and limited status', async () => {
+ it('should return AI opt-in level and assistant advance-model flag', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
@@ -126,14 +126,14 @@ describe('ai/org-ai-details', () => {
expect(result).toEqual({
aiOptInLevel: 'schema_only',
- isLimited: true,
+ hasAccessToAdvanceModel: false,
isHipaaEnabled: false,
orgId: 1,
planId: 'free',
})
})
- it('should mark pro plan as not limited', async () => {
+ it('should set hasAccessToAdvanceModel when entitlement grants access', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
@@ -155,7 +155,7 @@ describe('ai/org-ai-details', () => {
projectRef: 'test-project',
})
- expect(result.isLimited).toBe(false)
+ expect(result.hasAccessToAdvanceModel).toBe(true)
})
it('should throw error when project and org do not match', async () => {
@@ -277,7 +277,7 @@ describe('ai/org-ai-details', () => {
projectRef: 'test-project',
})
- expect(result.isLimited).toBe(false) // Has advance model entitlement
+ expect(result.hasAccessToAdvanceModel).toBe(true)
})
it('should return isHipaaEnabled true when subscription has HIPAA addon and project is sensitive', async () => {
diff --git a/apps/studio/lib/ai/org-ai-details.ts b/apps/studio/lib/ai/org-ai-details.ts
index 659c0d12aec..68e70af900c 100644
--- a/apps/studio/lib/ai/org-ai-details.ts
+++ b/apps/studio/lib/ai/org-ai-details.ts
@@ -37,12 +37,12 @@ export const getOrgAIDetails = async ({
}
const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags)
- const isLimited = !advanceModelAccess.hasAccess
+ const hasAccessToAdvanceModel = advanceModelAccess.hasAccess
const isHipaaEnabled = subscriptionHasHipaaAddon(subscription) && !!projectSettings?.is_sensitive
return {
aiOptInLevel,
- isLimited,
+ hasAccessToAdvanceModel,
isHipaaEnabled,
orgId: selectedOrg?.id,
planId: selectedOrg?.plan.id,
diff --git a/apps/studio/lib/api/generate-v4.test.ts b/apps/studio/lib/api/generate-v4.test.ts
index d584b7e5222..ffb69fe453d 100644
--- a/apps/studio/lib/api/generate-v4.test.ts
+++ b/apps/studio/lib/api/generate-v4.test.ts
@@ -47,16 +47,14 @@ test('generateV4 calls the tool sanitizer', async () => {
vi.mock('lib/ai/org-ai-details', () => ({
getOrgAIDetails: vi.fn().mockResolvedValue({
aiOptInLevel: 'schema_and_log_and_data',
- isLimited: false,
+ hasAccessToAdvanceModel: true,
}),
}))
vi.mock('lib/ai/model', () => ({
getModel: vi.fn().mockResolvedValue({
- model: {},
- error: null,
+ modelParams: { model: {} },
promptProviderOptions: {},
- providerOptions: {},
}),
}))
diff --git a/apps/studio/lib/api/rate.test.ts b/apps/studio/lib/api/rate.test.ts
index e9d758aad92..002a46f89fc 100644
--- a/apps/studio/lib/api/rate.test.ts
+++ b/apps/studio/lib/api/rate.test.ts
@@ -45,14 +45,13 @@ test('rate calls the tool sanitizer', async () => {
vi.mock('lib/ai/org-ai-details', () => ({
getOrgAIDetails: vi.fn().mockResolvedValue({
aiOptInLevel: 'schema_and_log_and_data',
- isLimited: false,
+ hasAccessToAdvanceModel: true,
}),
}))
vi.mock('lib/ai/model', () => ({
getModel: vi.fn().mockResolvedValue({
- model: {},
- error: null,
+ modelParams: { model: {} },
}),
}))
diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts
index 300db283fec..3af08806aab 100644
--- a/apps/studio/pages/api/ai/code/complete.ts
+++ b/apps/studio/pages/api/ai/code/complete.ts
@@ -5,6 +5,7 @@ import { source } from 'common-tags'
import { executeSql } from 'data/sql/execute-sql-query'
import { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import { getOrgAIDetails } from 'lib/ai/org-ai-details'
import {
EDGE_FUNCTION_PROMPT,
@@ -65,13 +66,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
// For code completion, we always use the limited model
const {
- model,
+ modelParams,
error: modelError,
promptProviderOptions,
- providerOptions,
} = await getModel({
provider: 'openai',
- routingKey: projectRef,
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -155,8 +155,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
})
const { text } = await generateText({
- model,
- providerOptions,
+ ...modelParams,
stopWhen: stepCountIs(5),
messages: coreMessages,
tools,
diff --git a/apps/studio/pages/api/ai/feedback/classify.ts b/apps/studio/pages/api/ai/feedback/classify.ts
index d4427b12d7d..843d81da108 100644
--- a/apps/studio/pages/api/ai/feedback/classify.ts
+++ b/apps/studio/pages/api/ai/feedback/classify.ts
@@ -1,5 +1,6 @@
import { generateText, Output } from 'ai'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
@@ -28,13 +29,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'feedback',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -42,8 +39,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
const { output } = await generateText({
- model,
- providerOptions,
+ ...modelParams,
output: Output.object({
schema: z.object({
feedback_category: z.enum(['support', 'feedback', 'unknown']),
diff --git a/apps/studio/pages/api/ai/feedback/rate.ts b/apps/studio/pages/api/ai/feedback/rate.ts
index 18a80e1476a..bd52bca4d62 100644
--- a/apps/studio/pages/api/ai/feedback/rate.ts
+++ b/apps/studio/pages/api/ai/feedback/rate.ts
@@ -5,6 +5,7 @@ import { rateMessageResponseSchema } from 'components/ui/AIAssistantPanel/Messag
import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { IS_TRACING_ENABLED } from 'lib/ai/braintrust-logger'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import { getOrgAIDetails } from 'lib/ai/org-ai-details'
import { sanitizeMessagePart } from 'lib/ai/tools/tool-sanitizer'
import apiWrapper from 'lib/api/apiWrapper'
@@ -96,14 +97,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
})
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- isLimited: true,
- routingKey: 'feedback',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -111,8 +107,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
const { output } = await generateText({
- model,
- providerOptions,
+ ...modelParams,
output: Output.object({ schema: rateMessageResponseSchema }),
prompt: `
Your job is to look at a Supabase Assistant conversation, which the user has given feedback on, and classify it.
diff --git a/apps/studio/pages/api/ai/onboarding/design.ts b/apps/studio/pages/api/ai/onboarding/design.ts
index 465e9aae53c..b2bfab74e01 100644
--- a/apps/studio/pages/api/ai/onboarding/design.ts
+++ b/apps/studio/pages/api/ai/onboarding/design.ts
@@ -4,6 +4,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import apiWrapper from 'lib/api/apiWrapper'
export const maxDuration = 60
@@ -64,9 +65,9 @@ const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
export default wrapper
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
- const { model, error: modelError } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'onboarding',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -76,7 +77,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { messages } = req.body
const result = streamText({
- model,
+ ...modelParams,
system: source`
You are a Supabase expert who helps people set up their Supabase project. You specializes in database schema design. You are to help the user design a database schema for their application but also suggest Supabase services they should use.
diff --git a/apps/studio/pages/api/ai/sql/cron-v2.ts b/apps/studio/pages/api/ai/sql/cron-v2.ts
index 14e435d6171..4b0bf65b8ac 100644
--- a/apps/studio/pages/api/ai/sql/cron-v2.ts
+++ b/apps/studio/pages/api/ai/sql/cron-v2.ts
@@ -1,6 +1,7 @@
import { generateText, Output } from 'ai'
import { source } from 'common-tags'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
@@ -33,13 +34,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'cron',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -47,8 +44,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
const result = await generateText({
- model,
- providerOptions,
+ ...modelParams,
output: Output.object({ schema: cronSchema }),
prompt: source`
You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron.
diff --git a/apps/studio/pages/api/ai/sql/filter-v1.ts b/apps/studio/pages/api/ai/sql/filter-v1.ts
index 8a9f30cc019..a477bff502a 100644
--- a/apps/studio/pages/api/ai/sql/filter-v1.ts
+++ b/apps/studio/pages/api/ai/sql/filter-v1.ts
@@ -1,6 +1,7 @@
import { generateText, Output } from 'ai'
import { source } from 'common-tags'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import apiWrapper from 'lib/api/apiWrapper'
import {
filterGroupSchemaForAI,
@@ -34,13 +35,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { prompt, filterProperties } = parseResult.data
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'sql',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -62,8 +59,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}))
const result = await generateText({
- model,
- providerOptions,
+ ...modelParams,
output: Output.object({ schema: filterGroupSchemaForAI }),
prompt: source`
You are an expert Postgres filter builder. Convert the user's request into structured filters.
diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts
index c8a349421e8..48f85f42e0a 100644
--- a/apps/studio/pages/api/ai/sql/generate-v4.ts
+++ b/apps/studio/pages/api/ai/sql/generate-v4.ts
@@ -6,6 +6,14 @@ import { executeSql } from 'data/sql/execute-sql-query'
import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { generateAssistantResponse } from 'lib/ai/generate-assistant-response'
import { getModel } from 'lib/ai/model'
+import {
+ DEFAULT_ASSISTANT_ADVANCE_MODEL_ID,
+ DEFAULT_ASSISTANT_BASE_MODEL_ID,
+ getAssistantModelEntry,
+ isAssistantBaseModelId,
+ isKnownAssistantModelId,
+ type AssistantModelId,
+} from 'lib/ai/model.utils'
import { getOrgAIDetails } from 'lib/ai/org-ai-details'
import { getTools } from 'lib/ai/tools'
import apiWrapper from 'lib/api/apiWrapper'
@@ -53,7 +61,7 @@ const requestBodySchema = z.object({
chatId: z.string().optional(),
chatName: z.string().optional(),
orgSlug: z.string().optional(),
- model: z.enum(['gpt-5', 'gpt-5-mini']).optional(),
+ model: z.string().optional(),
})
async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload) {
@@ -80,25 +88,32 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
orgSlug,
chatId,
chatName,
- model: requestedModel,
+ model: rawRequestedModel,
} = data
- const messagesValidation = await safeValidateUIMessages({ messages: rawMessages })
+ const requestedModel: AssistantModelId | undefined =
+ rawRequestedModel && isKnownAssistantModelId(rawRequestedModel) ? rawRequestedModel : undefined
+
+ const messagesValidation = await safeValidateUIMessages({
+ messages: rawMessages,
+ })
if (!messagesValidation.success) {
- return res
- .status(400)
- .json({ error: 'Invalid request body', message: messagesValidation.error.message })
+ return res.status(400).json({
+ error: 'Invalid request body',
+ message: messagesValidation.error.message,
+ })
}
const messages = messagesValidation.data
let aiOptInLevel: AiOptInLevel = 'disabled'
- let isLimited = false
+ let hasAccessToAdvanceModel = false
let isHipaaEnabled = false
let orgId: number | undefined
let planId: string | undefined
if (!IS_PLATFORM) {
aiOptInLevel = 'schema'
+ hasAccessToAdvanceModel = true
}
if (IS_PLATFORM && orgSlug && authorization && projectRef) {
@@ -106,7 +121,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
// Get organizations and compute opt in level server-side
const {
aiOptInLevel: orgAIOptInLevel,
- isLimited: orgAILimited,
+ hasAccessToAdvanceModel: orgHasAccessToAdvanceModel,
isHipaaEnabled: orgIsHipaaEnabled,
orgId: fetchedOrgId,
planId: fetchedPlanId,
@@ -117,7 +132,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
})
aiOptInLevel = orgAIOptInLevel
- isLimited = orgAILimited
+ hasAccessToAdvanceModel = orgHasAccessToAdvanceModel
isHipaaEnabled = orgIsHipaaEnabled
orgId = fetchedOrgId
planId = fetchedPlanId
@@ -128,16 +143,20 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
}
}
+ const envThrottled = process.env.IS_THROTTLED !== 'false'
+
+ let effectiveModel: AssistantModelId = requestedModel ?? DEFAULT_ASSISTANT_ADVANCE_MODEL_ID
+ if (!hasAccessToAdvanceModel || (envThrottled && !isAssistantBaseModelId(effectiveModel))) {
+ effectiveModel = DEFAULT_ASSISTANT_BASE_MODEL_ID
+ }
+
const {
- model,
+ modelParams,
error: modelError,
promptProviderOptions,
- providerOptions,
} = await getModel({
provider: 'openai',
- model: requestedModel ?? 'gpt-5',
- routingKey: projectRef,
- isLimited,
+ modelEntry: getAssistantModelEntry(effectiveModel),
})
if (modelError) {
@@ -184,7 +203,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
const result = await generateAssistantResponse({
messages,
- model,
+ ...modelParams,
tools,
aiOptInLevel,
getSchemas: aiOptInLevel !== 'disabled' ? getSchemas : undefined,
@@ -197,7 +216,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
planId,
requestedModel,
promptProviderOptions,
- providerOptions,
abortSignal: abortController.signal,
onSpanCreated: (spanId) => {
res.setHeader('x-braintrust-span-id', spanId)
@@ -208,6 +226,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
sendReasoning: true,
headers: { 'Content-Encoding': 'none' },
onError: (error) => {
+ console.error('Assistant stream error:', error)
+
if (error == null) {
return 'unknown error'
}
diff --git a/apps/studio/pages/api/ai/sql/policy.ts b/apps/studio/pages/api/ai/sql/policy.ts
index 8bf086cd2d8..379674bbe41 100644
--- a/apps/studio/pages/api/ai/sql/policy.ts
+++ b/apps/studio/pages/api/ai/sql/policy.ts
@@ -3,6 +3,7 @@ import { IS_PLATFORM } from 'common'
import { source } from 'common-tags'
import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import { getOrgAIDetails } from 'lib/ai/org-ai-details'
import { RLS_PROMPT } from 'lib/ai/prompts'
import { getTools } from 'lib/ai/tools'
@@ -90,13 +91,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'sql-policy',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -112,8 +109,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
})
const { experimental_output } = await generateText({
- model,
- providerOptions,
+ ...modelParams,
stopWhen: stepCountIs(5),
prompt: source`
You are a Postgres RLS (Row Level Security) expert.
diff --git a/apps/studio/pages/api/ai/sql/title-v2.ts b/apps/studio/pages/api/ai/sql/title-v2.ts
index f415eeb1da7..b391251bf4b 100644
--- a/apps/studio/pages/api/ai/sql/title-v2.ts
+++ b/apps/studio/pages/api/ai/sql/title-v2.ts
@@ -1,6 +1,7 @@
import { generateText, Output } from 'ai'
import { source } from 'common-tags'
import { getModel } from 'lib/ai/model'
+import { DEFAULT_COMPLETION_MODEL } from 'lib/ai/model.utils'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
@@ -38,13 +39,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
try {
- const {
- model,
- error: modelError,
- providerOptions,
- } = await getModel({
+ const { modelParams, error: modelError } = await getModel({
provider: 'openai',
- routingKey: 'sql',
+ modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -52,8 +49,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
const result = await generateText({
- model,
- providerOptions,
+ ...modelParams,
output: Output.object({ schema: titleSchema }),
prompt: source`
Generate a short title and summarized description for this Postgres SQL snippet:
diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx
index 44999807e8b..6cb44ef7197 100644
--- a/apps/studio/state/ai-assistant-state.tsx
+++ b/apps/studio/state/ai-assistant-state.tsx
@@ -8,6 +8,7 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
import { constructHeaders } from 'data/fetchers'
import { prepareMessagesForAPI } from 'lib/ai/message-utils'
+import type { AssistantModelId } from 'lib/ai/model.utils'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { LOCAL_STORAGE_KEYS } from 'common'
@@ -22,7 +23,7 @@ export type AssistantMessageType = MessageType
export type SqlSnippet = string | { label: string; content: string }
-export type AssistantModel = 'gpt-5' | 'gpt-5-mini'
+export type AssistantModel = AssistantModelId
type ChatSession = {
id: string
@@ -287,7 +288,7 @@ function createChatInstance(
const messages = chatInstance.messages
const chat = state.chats[options.id]
if (chat) {
- chat.messages = messages as AssistantMessageType[]
+ chat.messages = messages
chat.updatedAt = new Date()
}
@@ -452,7 +453,7 @@ export const createAiAssistantState = (): AiAssistantState => {
if (index !== -1) {
state.updateMessage(msg)
} else {
- messagesToAdd.push(msg as AssistantMessageType)
+ messagesToAdd.push(msg)
}
})
@@ -468,7 +469,7 @@ export const createAiAssistantState = (): AiAssistantState => {
const messageIndex = chat.messages.findIndex((msg) => msg.id === updatedMessage.id)
if (messageIndex !== -1) {
- chat.messages[messageIndex] = updatedMessage as AssistantMessageType
+ chat.messages[messageIndex] = updatedMessage
chat.updatedAt = new Date()
}
},