chore(studio): default assistant to GPT-5.6 Luna (#49749)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature / chore: hide assistant model selection in the UI and default
chats to GPT-5.6 Luna.

## What is the current behavior?

The assistant composer exposes a model picker. Paid orgs default to
`gpt-5.3-codex`; everyone else defaults to `gpt-5.4-nano`.

## What is the new behavior?

- The model picker is hidden in the assistant composer and Explorer
home.
- Chats default to `gpt-5.6-luna` with `reasoningEffort: medium`.
- Model selection plumbing is kept (registry, entitlements, `setModel`,
generate-v4 request body) so a requested model can still be honored when
provided.
- Other completion endpoints still use `gpt-5.4-nano`.

## Additional context

Model selector UI can be re-enabled by passing `selectedModel` /
`onSelectModel` to `AssistantChatForm`.


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

## Summary by CodeRabbit

* **New Features**
* Added support for the GPT-5.6 Luna model with medium reasoning
capability.
  * Made GPT-5.6 Luna the default assistant model.
* **Improvements**
* Simplified assistant chat by removing model selection from the primary
chat experience.
  * Updated model fallback behavior to use the standard assistant model.
* Chat forms can now optionally display model selection when configured.
* **Tests**
* Updated model coverage and assistant chat tests for the new defaults
and behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Saxon FletcherandCursor authored and GitHub committed 2026-09-02 10:24:37 +10:00
1 parent 4d2bd0eacf
commit 9b17ce8f2c
10 files changed
+46 -74

No files matched your search

@@ -5,7 +5,6 @@ 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()
@@ -13,7 +12,6 @@ export const ExplorerHomeTab = () => {
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">
@@ -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">
@@ -9,7 +9,6 @@ const {
mockPush,
mockSelectChat,
mockSetContext,
mockSetModel,
mockWhenInitialized,
} = vi.hoisted(() => ({
mockCreateChat: vi.fn(() => 'chat-2'),
@@ -17,7 +16,6 @@ const {
mockPush: vi.fn(),
mockSelectChat: vi.fn(),
mockSetContext: vi.fn(),
mockSetModel: vi.fn(),
mockWhenInitialized: vi.fn(() => Promise.resolve()),
}))
@@ -41,7 +39,6 @@ vi.mock('@/state/ai-assistant-state', () => ({
createChat: mockCreateChat,
selectChat: mockSelectChat,
setContext: mockSetContext,
setModel: mockSetModel,
}),
whenAiAssistantInitialized: () => mockWhenInitialized(),
}))
@@ -59,7 +56,6 @@ describe('useCreateChat', () => {
await result.current.createChat({
name: 'Investigate errors',
initialMessage: 'What happened?',
model: 'gpt-5.4-nano',
})
})
@@ -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()
@@ -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 = () => {}
@@ -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 () => {
@@ -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')
})
})
@@ -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'
@@ -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
@@ -652,7 +625,6 @@ export const AssistantChat = ({
</div>
</div>
)}
{disablePrompts && (
<Admonition
showIcon={false}
@@ -706,8 +678,6 @@ export const AssistantChat = ({
composerContext?.onSetSqlSnippets?.(newSnippets)
}}
includeSnippetsInMessage={includeSnippetsInMessage}
selectedModel={selectedModel}
onSelectModel={(model) => snap.setModel(model)}
/>
</div>
</div>
@@ -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>(
@@ -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">
@@ -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 ? (
+1 -2
View File
@@ -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',
+13 -10
View File
@@ -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', () => {
@@ -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)
@@ -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()
})
})
+13 -7
View File
@@ -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
@@ -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,
@@ -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 {
@@ -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 },
},
+1 -2
View File
@@ -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,
@@ -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
}
@@ -53,7 +53,6 @@ describe('useAiAssistantChatList', () => {
state.loadPersistedState({
projectRef: 'default',
activeChatId: 'persisted-chat',
model: state.model,
chats: {
'persisted-chat': {
id: 'persisted-chat',
@@ -146,7 +146,6 @@ describe('whenAiAssistantInitialized', () => {
state.loadPersistedState({
projectRef: 'default',
activeChatId: 'persisted-chat',
model: state.model,
chats: {
'persisted-chat': {
id: 'persisted-chat',