Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ import { useCreateChat, useCreateNotebook, useCreateQuery } from './hooks'
import { CHAT_TEMPLATES, NOTEBOOK_TEMPLATES } from './templates'
import { ActionCard } from '@/components/layouts/Tabs/ActionCard'
import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm'
import type { AssistantModel } from '@/state/ai-assistant-state'

export const ExplorerHomeTab = () => {
const { createNotebook } = useCreateNotebook()
const { createQuery } = useCreateQuery()
const { createChat } = useCreateChat()

const [value, setValue] = useState<string>('')
const [selectedModel, setSelectedModal] = useState<AssistantModel>('gpt-5.4-nano')

return (
<div className="bg-surface-100 h-full flex flex-col items-center justify-center">
Expand All @@ -31,9 +29,7 @@ export const ExplorerHomeTab = () => {
placeholder="Ask anything about your project"
value={value}
onValueChange={(e) => setValue(e.target.value)}
selectedModel={selectedModel}
onSelectModel={setSelectedModal}
onSubmit={(message) => createChat({ initialMessage: message, model: selectedModel })}
onSubmit={(message) => createChat({ initialMessage: message })}
/>

<section className="mt-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ const {
mockPush,
mockSelectChat,
mockSetContext,
mockSetModel,
mockWhenInitialized,
} = vi.hoisted(() => ({
mockCreateChat: vi.fn(() => 'chat-2'),
mockCreateDraft: vi.fn(),
mockPush: vi.fn(),
mockSelectChat: vi.fn(),
mockSetContext: vi.fn(),
mockSetModel: vi.fn(),
mockWhenInitialized: vi.fn(() => Promise.resolve()),
}))

Expand All @@ -41,7 +39,6 @@ vi.mock('@/state/ai-assistant-state', () => ({
createChat: mockCreateChat,
selectChat: mockSelectChat,
setContext: mockSetContext,
setModel: mockSetModel,
}),
whenAiAssistantInitialized: () => mockWhenInitialized(),
}))
Expand All @@ -59,7 +56,6 @@ describe('useCreateChat', () => {
await result.current.createChat({
name: 'Investigate errors',
initialMessage: 'What happened?',
model: 'gpt-5.4-nano',
})
})

Expand All @@ -72,7 +68,6 @@ describe('useCreateChat', () => {
name: 'Investigate errors',
initialMessage: 'What happened?',
})
expect(mockSetModel).toHaveBeenCalledWith('gpt-5.4-nano')
expect(mockPush).toHaveBeenCalledWith('/project/default/explorer/chat/chat-2')
expect(mockSelectChat).not.toHaveBeenCalled()

Expand All @@ -82,7 +77,7 @@ describe('useCreateChat', () => {
expect(mockSelectChat).not.toHaveBeenCalled()
})

// Hydration replaces the chat map and the model wholesale, so a chat created mid-load would be
// Hydration replaces the chat map wholesale, so a chat created mid-load would be
// dropped the moment the persisted state lands
it('waits for the assistant state to hydrate before creating the chat', async () => {
let resolveHydration = () => {}
Expand All @@ -97,11 +92,10 @@ describe('useCreateChat', () => {

let created: Promise<string | undefined> | undefined
await act(async () => {
created = result.current.createChat({ name: 'Investigate errors', model: 'gpt-5.4-nano' })
created = result.current.createChat({ name: 'Investigate errors' })
})

expect(mockCreateChat).not.toHaveBeenCalled()
expect(mockSetModel).not.toHaveBeenCalled()
expect(mockPush).not.toHaveBeenCalled()

await act(async () => {
Expand All @@ -113,7 +107,6 @@ describe('useCreateChat', () => {
name: 'Investigate errors',
initialMessage: undefined,
})
expect(mockSetModel).toHaveBeenCalledWith('gpt-5.4-nano')
expect(mockPush).toHaveBeenCalledWith('/project/default/explorer/chat/chat-2')
})
})
Expand Down
32 changes: 1 addition & 31 deletions apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,16 @@ import { Markdown } from '@/components/interfaces/Markdown'
import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query'
import { useRateMessageMutation } from '@/data/ai/rate-message-mutation'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import type { AssistantMessageMetadata } from '@/lib/ai/assistant-message-metadata'
import { getParallelApprovalIdsToReject } from '@/lib/ai/message-utils'
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 { useTrack } from '@/lib/telemetry/track'
import type { AssistantModel, SqlSnippet } from '@/state/ai-assistant-state'
import type { SqlSnippet } from '@/state/ai-assistant-state'
import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
Expand Down Expand Up @@ -109,26 +102,6 @@ export const AssistantChat = ({
enabled: shortcutsEnabled,
})

const { hasAccess: hasAccessToAdvanceModel, isLoading: isLoadingEntitlements } =
useCheckEntitlements('assistant.advance_model')

const selectedModel = useMemo<AssistantModel>(() => {
// While entitlements are loading, use the stored model without enforcing access
if (isLoadingEntitlements) {
return snap.model ?? DEFAULT_ASSISTANT_BASE_MODEL_ID
}

const defaultModel = defaultAssistantModelId(hasAccessToAdvanceModel)
const model = snap.model ?? defaultModel

if (!isKnownAssistantModelId(model)) return defaultModel
if (!hasAccessToAdvanceModel && !isAssistantBaseModelId(model)) {
return DEFAULT_ASSISTANT_BASE_MODEL_ID
}

return model
}, [isLoadingEntitlements, hasAccessToAdvanceModel, snap.model])

const [updatedOptInSinceMCP] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
false
Expand Down Expand Up @@ -652,7 +625,6 @@ export const AssistantChat = ({
</div>
</div>
)}

{disablePrompts && (
<Admonition
showIcon={false}
Expand Down Expand Up @@ -706,8 +678,6 @@ export const AssistantChat = ({
composerContext?.onSetSqlSnippets?.(newSnippets)
}}
includeSnippetsInMessage={includeSnippetsInMessage}
selectedModel={selectedModel}
onSelectModel={(model) => snap.setModel(model)}
/>
</div>
</div>
Expand Down
20 changes: 14 additions & 6 deletions apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export interface FormProps {
/* If currently editing an existing message */
isEditing?: boolean
/* The currently selected AI model */
selectedModel: AssistantModelId
selectedModel?: AssistantModelId
/* Callback when a model is chosen */
onSelectModel: (model: AssistantModelId) => void
onSelectModel?: (model: AssistantModelId) => void
}

const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
Expand Down Expand Up @@ -99,6 +99,7 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
}

const canSubmit = !disabled && !loading && !!value
const showModelSelector = selectedModel !== undefined && onSelectModel !== undefined

return (
<div className="w-full">
Expand Down Expand Up @@ -131,10 +132,17 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
onChange={(event) => onValueChange(event)}
onKeyDown={handleKeyDown}
/>
<div className="absolute inset-x-1.5 bottom-1.5 flex items-center justify-between pointer-events-none">
<div className="pointer-events-auto">
<ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
</div>
<div
className={cn(
'absolute inset-x-1.5 bottom-1.5 flex items-center pointer-events-none',
showModelSelector ? 'justify-between' : 'justify-end'
)}
>
{showModelSelector && (
<div className="pointer-events-auto">
<ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
</div>
)}

<div className="flex gap-3 items-center pointer-events-auto">
{loading ? (
Expand Down
3 changes: 1 addition & 2 deletions apps/studio/lib/ai/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ describe('getModel', () => {
expect(error).toEqual(new Error('OPENAI_API_KEY not available'))
})

it('returns openai gpt-5.3-codex when hasAccessToAdvanceModel and not throttled', async () => {
it('returns openai gpt-5.3-codex with reasoning effort', async () => {
vi.stubEnv('OPENAI_API_KEY', 'test-key')
vi.stubEnv('IS_THROTTLED', 'false')

const { modelParams, error } = await getModel({
provider: 'openai',
Expand Down
23 changes: 13 additions & 10 deletions apps/studio/lib/ai/model.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ describe('model.utils', () => {
it('should have openai provider with models', () => {
expect(PROVIDERS.openai).toBeDefined()
expect(PROVIDERS.openai.models).toBeDefined()
expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.3-codex')
expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.6-luna')
expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.4-nano')
expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.3-codex')
})

it('should have exactly one default model per provider', () => {
Expand Down Expand Up @@ -111,20 +112,23 @@ describe('model.utils', () => {
})

it('defaults should satisfy unions', () => {
expect(DEFAULT_ASSISTANT_BASE_MODEL_ID).toBe('gpt-5.4-nano')
expect(DEFAULT_ASSISTANT_BASE_MODEL_ID).toBe('gpt-5.6-luna')
expect(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID).toBe('gpt-5.3-codex')
expect(defaultAssistantModelId(false)).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID)
expect(defaultAssistantModelId(true)).toBe(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID)
expect(defaultAssistantModelId(true)).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID)
})

it('isAssistantBaseModelId / isAdvanceOnlyModelId', () => {
expect(isAssistantBaseModelId('gpt-5.6-luna')).toBe(true)
expect(isAssistantBaseModelId('gpt-5.4-nano')).toBe(true)
expect(isAssistantBaseModelId('gpt-5.3-codex')).toBe(false)
expect(isAdvanceOnlyModelId('gpt-5.3-codex')).toBe(true)
expect(isAdvanceOnlyModelId('gpt-5.4-nano')).toBe(false)
expect(isAdvanceOnlyModelId('gpt-5.6-luna')).toBe(false)
})

it('isKnownAssistantModelId', () => {
expect(isKnownAssistantModelId('gpt-5.6-luna')).toBe(true)
expect(isKnownAssistantModelId('gpt-5.4-nano')).toBe(true)
expect(isKnownAssistantModelId('gpt-5.3-codex')).toBe(true)
expect(isKnownAssistantModelId('gpt-5')).toBe(false)
Expand All @@ -133,28 +137,27 @@ describe('model.utils', () => {
})

it('getAssistantModelEntry returns config for known ids', () => {
expect(getAssistantModelEntry('gpt-5.6-luna').reasoningEffort).toBe('medium')
expect(getAssistantModelEntry('gpt-5.4-nano').reasoningEffort).toBe('low')
expect(getAssistantModelEntry('gpt-5.3-codex').reasoningEffort).toBe('low')
expect(getAssistantModelEntry('gpt-5.4-nano')).toEqual(
ASSISTANT_MODELS.find((m) => m.id === 'gpt-5.4-nano')
expect(getAssistantModelEntry('gpt-5.6-luna')).toEqual(
ASSISTANT_MODELS.find((m) => m.id === 'gpt-5.6-luna')
)
})

it('DEFAULT_COMPLETION_MODEL is gpt-5.4-nano with no reasoning effort', () => {
expect(DEFAULT_COMPLETION_MODEL.id).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID)
expect(DEFAULT_COMPLETION_MODEL.id).toBe('gpt-5.4-nano')
expect(DEFAULT_COMPLETION_MODEL.reasoningEffort).toBe('none')
})

it('openaiModelEntry enforces valid reasoning effort at compile time', () => {
// Valid: supported effort level
const withEffort = openaiModelEntry({
id: 'gpt-5.4-nano',
id: 'gpt-5.6-luna',
reasoningEffort: 'low',
})
expect(withEffort.reasoningEffort).toBe('low')

// Valid: no effort
const withoutEffort = openaiModelEntry({ id: 'gpt-5.4-nano' })
const withoutEffort = openaiModelEntry({ id: 'gpt-5.6-luna' })
expect(withoutEffort.reasoningEffort).toBeUndefined()
})
})
Expand Down
20 changes: 13 additions & 7 deletions apps/studio/lib/ai/model.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ export type ProviderName = 'bedrock' | 'openai'

export type BedrockModel = 'anthropic.claude-3-7-sonnet-20250219-v1:0' | 'openai.gpt-oss-120b-1:0'

export type OpenAIModelId = 'gpt-5.4-nano' | 'gpt-5.3-codex'
export type OpenAIModelId = 'gpt-5.4-nano' | 'gpt-5.3-codex' | 'gpt-5.6-luna'

// Source: https://developers.openai.com/api/docs/guides/reasoning + per-model pages
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'

// Per-model reasoning effort compatibility.
// Sources: https://developers.openai.com/api/docs/models/gpt-5.4-nano
// https://developers.openai.com/api/docs/models/gpt-5.3-codex
// https://developers.openai.com/api/docs/models/gpt-5.6-luna
type ModelReasoningSupport = {
'gpt-5.4-nano': 'none' | 'low' | 'medium' | 'high' | 'xhigh'
'gpt-5.3-codex': 'low' | 'medium' | 'high' | 'xhigh'
'gpt-5.6-luna': 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
}

type ReasoningEffortFor<ModelId extends OpenAIModelId> = ModelId extends keyof ModelReasoningSupport
Expand Down Expand Up @@ -59,6 +61,11 @@ export const LOGS_REWRITE_MODEL = openaiModelEntry({
// 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.6-luna',
requiresAdvanceModelEntitlement: false,
reasoningEffort: 'medium',
}),
openaiModelEntry({
id: 'gpt-5.4-nano',
requiresAdvanceModelEntitlement: false,
Expand All @@ -82,14 +89,12 @@ const ASSISTANT_MODELS_MAP = Object.fromEntries(ASSISTANT_MODELS.map((m) => [m.i
(typeof ASSISTANT_MODELS)[number]
>

export const DEFAULT_ASSISTANT_BASE_MODEL_ID = 'gpt-5.4-nano' satisfies AssistantBaseModelId
export const DEFAULT_ASSISTANT_BASE_MODEL_ID = 'gpt-5.6-luna' satisfies AssistantBaseModelId

export const DEFAULT_ASSISTANT_ADVANCE_MODEL_ID = 'gpt-5.3-codex' satisfies AssistantModelId

export function defaultAssistantModelId(hasAccessToAdvanceModel: boolean): AssistantModelId {
return hasAccessToAdvanceModel
? DEFAULT_ASSISTANT_ADVANCE_MODEL_ID
: DEFAULT_ASSISTANT_BASE_MODEL_ID
export function defaultAssistantModelId(_hasAccessToAdvanceModel: boolean): AssistantModelId {
return DEFAULT_ASSISTANT_BASE_MODEL_ID
}

export function isKnownAssistantModelId(id: string): id is AssistantModelId {
Expand Down Expand Up @@ -153,6 +158,7 @@ export const PROVIDERS: ProviderRegistry = {
},
openai: {
models: {
'gpt-5.6-luna': { default: false },
'gpt-5.3-codex': { default: false },
'gpt-5.4-nano': { default: true },
},
Expand Down
3 changes: 1 addition & 2 deletions apps/studio/pages/api/ai/sql/generate-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ 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_ADVANCE_MODEL_ID,
DEFAULT_ASSISTANT_BASE_MODEL_ID,
getAssistantModelEntry,
isAssistantBaseModelId,
Expand Down Expand Up @@ -157,7 +156,7 @@ 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
let effectiveModel: AssistantModelId = requestedModel ?? DEFAULT_ASSISTANT_BASE_MODEL_ID
if (!hasAccessToAdvanceModel || (envThrottled && !isAssistantBaseModelId(effectiveModel))) {
effectiveModel = DEFAULT_ASSISTANT_BASE_MODEL_ID
}
Expand Down
1 change: 0 additions & 1 deletion apps/studio/state/ai-assistant-state.hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ describe('useAiAssistantChatList', () => {
state.loadPersistedState({
projectRef: 'default',
activeChatId: 'persisted-chat',
model: state.model,
chats: {
'persisted-chat': {
id: 'persisted-chat',
Expand Down
1 change: 0 additions & 1 deletion apps/studio/state/ai-assistant-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ describe('whenAiAssistantInitialized', () => {
state.loadPersistedState({
projectRef: 'default',
activeChatId: 'persisted-chat',
model: state.model,
chats: {
'persisted-chat': {
id: 'persisted-chat',
Expand Down
Loading