diff --git a/harness-tests/core/model-attachments.test.ts b/harness-tests/core/model-attachments.test.ts
index 12009730e..abf93d00b 100644
--- a/harness-tests/core/model-attachments.test.ts
+++ b/harness-tests/core/model-attachments.test.ts
@@ -96,6 +96,44 @@ beforeEach(() => {
store.reads = 0
})
describe('model attachment boundary', () => {
+ it.each(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'])(
+ 'prepares images and PDFs for %s while preserving gateway and custom endpoint limits',
+ async (model) => {
+ const image = await file('png bytes', 'image/png')
+ const pdf = await file('%PDF-1.7 synthetic', 'application/pdf')
+ const projected = await prepareAttachmentMessages(
+ [message([image, pdf])],
+ { env, scope, connection: connection('openai', model) },
+ )
+ expect(
+ projected[0].parts.filter((part) => part.type !== 'text'),
+ ).toMatchObject([
+ { type: 'image', source: { type: 'data', mimeType: 'image/png' } },
+ {
+ type: 'document',
+ source: { type: 'data', mimeType: 'application/pdf' },
+ },
+ ])
+ for (const selected of [
+ connection('openrouter', `openai/${model}`),
+ connection('vercel', `openai/${model}`),
+ ]) {
+ expect(modelAttachmentSupport(selected)).toMatchObject({
+ imageTypes: ['image/png', 'image/jpeg'],
+ pdf: false,
+ })
+ expect(() =>
+ validateModelAttachments([pdf], { connection: selected }),
+ ).toThrow(/verified support/)
+ }
+ expect(modelAttachmentSupport(connection('compatible', model))).toEqual({
+ model,
+ imageTypes: [],
+ pdf: false,
+ })
+ },
+ )
+
it('reads exact same-bot, other-bot and legacy sources independently of the receiving conversation scope', async () => {
const sameBot = await file('Same bot source')
sameBot.conversationId = 'current:source-thread'
diff --git a/harness-tests/core/run-model-picker.test.ts b/harness-tests/core/run-model-picker.test.ts
new file mode 100644
index 000000000..296306d76
--- /dev/null
+++ b/harness-tests/core/run-model-picker.test.ts
@@ -0,0 +1,67 @@
+import { createElement, type ComponentProps } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { RunModelPicker } from '../../src/chat/components/RunModelPicker'
+import type { RunModelCatalog } from '../../src/chat/core/run-model'
+
+// Render the popup inline so assertions cover the picker's actual choices.
+vi.mock('@base-ui/react/popover', async () => {
+ const { createElement } = await import('react')
+ const children = (props: ComponentProps<'div'>) => props.children
+ return {
+ Popover: {
+ Root: children,
+ Portal: children,
+ Positioner: children,
+ Popup: children,
+ Title: children,
+ Trigger: (props: ComponentProps<'button'>) =>
+ createElement('button', props),
+ },
+ }
+})
+
+const catalog: RunModelCatalog = {
+ defaultSelection: { provider: 'openai', model: 'gpt-5.6-terra' },
+ choices: ['gpt-5-mini', 'o3', 'gpt-5.6-terra', 'gpt-5.1', 'o3-mini'].map(
+ (model) => ({
+ selection: { provider: 'openai', model },
+ label: model,
+ providerLabel: 'OpenAI',
+ attachments: [],
+ reasoning: [{ value: 'default', label: 'Default' }],
+ retiring: model === 'gpt-5-mini' || model === 'o3',
+ }),
+ ),
+}
+
+describe('retiring model picker choices', () => {
+ it.each(['gpt-5.6-terra', 'gpt-5-mini'])(
+ 'keeps %s selected while hiding other retiring choices',
+ (model) => {
+ const html = renderToStaticMarkup(
+ createElement(RunModelPicker, {
+ model: {
+ selection: { provider: 'openai', model },
+ catalog,
+ catalogReady: true,
+ loading: false,
+ refreshing: false,
+ error: '',
+ refetch: vi.fn(),
+ select: vi.fn(),
+ },
+ }),
+ )
+ expect(html).toContain('gpt-5.6-terra')
+ expect(html).toContain('gpt-5.1')
+ expect(html).toContain('o3-mini')
+ expect(html).not.toContain('o3')
+ expect(html.includes('gpt-5-mini')).toBe(
+ model === 'gpt-5-mini',
+ )
+ expect(html).toContain(`aria-label="Model: ${model}"`)
+ expect(html).toContain('aria-pressed="true"')
+ },
+ )
+})
diff --git a/harness-tests/core/run-models.test.ts b/harness-tests/core/run-models.test.ts
index a15f71e19..73e7f2d25 100644
--- a/harness-tests/core/run-models.test.ts
+++ b/harness-tests/core/run-models.test.ts
@@ -16,6 +16,7 @@ import {
type Provider,
} from '../../src/chat/core/types'
import type { RunModelSelection } from '../../src/chat/core/run-model'
+import { resolveRunModelChoice } from '../../src/chat/components/useRunModel'
const state = vi.hoisted(() => ({
credentials: null as Credentials | null,
@@ -428,6 +429,149 @@ describe('model catalog and policy', () => {
})
})
+describe('OpenAI December 2026 retirement', () => {
+ const retiringModels = [
+ 'gpt-5',
+ 'gpt-5-2025-08-07',
+ 'gpt-5-mini',
+ 'gpt-5-mini-2025-08-07',
+ 'gpt-5-nano',
+ 'gpt-5-nano-2025-08-07',
+ 'gpt-5-pro',
+ 'gpt-5-pro-2025-10-06',
+ 'o3',
+ 'o3-2025-04-16',
+ 'o3-pro',
+ 'o3-pro-2025-06-10',
+ ]
+ const modelProviders: Provider[] = ['openai', 'openrouter', 'vercel']
+
+ it.each(modelProviders)(
+ 'marks retiring %s models without marking other GPT-5 or o-series models',
+ async (provider) => {
+ const prefix = provider === 'openai' ? '' : 'openai/'
+ configured(provider, `${prefix}gpt-5.6-sol`)
+ const catalog = await getRunModelCatalog(env, scope)
+ const choices = catalog.choices
+ .filter(
+ (choice) =>
+ choice.selection.provider === provider && !choice.retiring,
+ )
+ .map((choice) => choice.selection.model)
+ for (const model of retiringModels)
+ expect(choices).not.toContain(`${prefix}${model}`)
+ for (const model of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'])
+ expect(choices).toContain(`${prefix}${model}`)
+ const installed =
+ provider === 'openai'
+ ? OPENAI_CHAT_MODELS
+ : provider === 'openrouter'
+ ? OPENROUTER_CHAT_MODELS
+ : VERCEL_GATEWAY_CHAT_MODELS
+ for (const model of installed)
+ if (
+ !model.startsWith('~') &&
+ !model.endsWith(':batch') &&
+ !retiringModels.includes(model.replace(/^openai\//, ''))
+ )
+ expect(choices).toContain(model)
+ },
+ )
+
+ it.each(modelProviders)(
+ 'keeps configured and pinned %s selections without silently migrating them',
+ async (provider) => {
+ const prefix = provider === 'openai' ? '' : 'openai/'
+ for (const model of retiringModels) {
+ const selection = select(provider, `${prefix}${model}`)
+ configured(provider, selection.model)
+ const catalog = await getRunModelCatalog(env, scope)
+ expect(catalog.defaultSelection).toEqual(selection)
+ expect(
+ catalog.choices.find(
+ (choice) => choice.selection.model === selection.model,
+ )?.label,
+ ).toBe(`${selection.model} (retires December 11, 2026)`)
+ if (
+ provider === 'openai' &&
+ !OPENAI_CHAT_MODELS.some((candidate) => candidate === model)
+ )
+ await expect(resolveRunModel(env, scope)).rejects.toMatchObject({
+ status: 409,
+ })
+ else {
+ expect((await resolveRunModel(env, scope)).selection).toEqual(
+ selection,
+ )
+ expect(resolveRunModelChoice(selection, catalog).error).toBe('')
+ }
+ }
+ const pinned = select(provider, `${prefix}gpt-5-mini`)
+ configured(provider, `${prefix}gpt-5.6-terra`)
+ expect(
+ (await resolveRunModel(env, { ...scope, selection: pinned })).selection,
+ ).toEqual(pinned)
+ const catalog = await getRunModelCatalog(env, scope)
+ expect(resolveRunModelChoice(pinned, catalog)).toMatchObject({
+ choice: { selection: pinned, retiring: true },
+ error: '',
+ })
+ },
+ )
+
+ it.each(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'])(
+ 'exposes reviewed reasoning and attachments for %s',
+ async (model) => {
+ configured('openai', model)
+ const catalog = await getRunModelCatalog(env, scope)
+ const choice = catalog.choices.find(
+ (choice) => choice.selection.model === model,
+ )
+ const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max']
+ expect(choice?.reasoning.map((option) => option.value)).toEqual([
+ 'default',
+ ...efforts,
+ ])
+ expect(choice?.attachments).toEqual(['image', 'pdf'])
+ expect((await resolveRunModel(env, scope)).modelOptions).toEqual({
+ max_output_tokens: 2048,
+ })
+ for (const effort of efforts)
+ expect(
+ (
+ await resolveRunModel(env, {
+ ...scope,
+ selection: select('openai', model, effort),
+ })
+ ).modelOptions,
+ ).toEqual({
+ max_output_tokens: 2048,
+ reasoning: { effort },
+ })
+ await expect(
+ resolveRunModel(env, {
+ ...scope,
+ selection: select('openai', model, 'minimal'),
+ }),
+ ).rejects.toMatchObject({ status: 400 })
+ },
+ )
+
+ it('does not infer retirement for a custom compatible endpoint', async () => {
+ configured('compatible', 'gpt-5', {
+ baseUrl: 'https://models.example/v1',
+ })
+ const catalog = await getRunModelCatalog(env, scope)
+ const selected = resolveRunModelChoice(catalog.defaultSelection, catalog)
+ expect(selected.error).toBe('')
+ expect(selected.choice?.retiring).toBeUndefined()
+ expect(selected.choice?.label).toBe('gpt-5')
+ expect((await resolveRunModel(env, scope)).selection).toEqual(
+ catalog.defaultSelection,
+ )
+ })
+})
+
describe('reviewed reasoning options', () => {
it('keeps Kimi default off and exposes a genuine on switch without invented effort levels', async () => {
const choice = (await getRunModelCatalog(env, scope)).choices[0]
diff --git a/src/chat/components/RunModelPicker.tsx b/src/chat/components/RunModelPicker.tsx
index 68683dc54..5ca5f0e8c 100644
--- a/src/chat/components/RunModelPicker.tsx
+++ b/src/chat/components/RunModelPicker.tsx
@@ -52,6 +52,12 @@ export function RunModelPicker({
NonNullable['choices']
>()
for (const choice of model.catalog?.choices ?? []) {
+ if (
+ choice.retiring &&
+ (choice.selection.provider !== selection?.provider ||
+ choice.selection.model !== selection.model)
+ )
+ continue
if (
!`${choice.label} ${choice.providerLabel} ${choice.selection.model}`
.toLowerCase()
diff --git a/src/chat/core/run-model.ts b/src/chat/core/run-model.ts
index 0a4bc7564..fb5dea0ac 100644
--- a/src/chat/core/run-model.ts
+++ b/src/chat/core/run-model.ts
@@ -17,6 +17,7 @@ export interface RunModelChoice {
providerLabel: string
attachments: Array<'image' | 'pdf'>
reasoning: Array<{ value: string; label: string }>
+ retiring?: boolean
unavailableReason?: string
}
export interface RunModelCatalog {
diff --git a/src/chat/server/model-attachments.ts b/src/chat/server/model-attachments.ts
index 96757825c..9df577678 100644
--- a/src/chat/server/model-attachments.ts
+++ b/src/chat/server/model-attachments.ts
@@ -56,6 +56,9 @@ const openaiVision = new Set([
'gpt-5.2',
'gpt-5.4',
'gpt-5.5',
+ 'gpt-5.6-sol',
+ 'gpt-5.6-terra',
+ 'gpt-5.6-luna',
'o3',
'o4-mini',
])
diff --git a/src/chat/server/run-models.ts b/src/chat/server/run-models.ts
index 727469ea9..06bf4d067 100644
--- a/src/chat/server/run-models.ts
+++ b/src/chat/server/run-models.ts
@@ -79,9 +79,29 @@ const maxCatalogModels = 2048
const isNative = (provider: Provider, model: string) =>
nativeModels[provider]?.includes(model) === true
+// https://developers.openai.com/api/docs/deprecations#2026-06-11-gpt-5-and-o3-model-deprecations
+// Hide these from new picker choices, but preserve configured and pinned selections.
+const retiringOpenaiModels = new Set([
+ 'gpt-5',
+ 'gpt-5-2025-08-07',
+ 'gpt-5-mini',
+ 'gpt-5-mini-2025-08-07',
+ 'gpt-5-nano',
+ 'gpt-5-nano-2025-08-07',
+ 'gpt-5-pro',
+ 'gpt-5-pro-2025-10-06',
+ 'o3',
+ 'o3-2025-04-16',
+ 'o3-pro',
+ 'o3-pro-2025-06-10',
+])
+
/** Exact reviewed model IDs only. An adapter update does not enable new controls. */
const openaiEfforts: Record = {
'gpt-6-astra': ['low', 'medium', 'high'],
+ 'gpt-5.6-sol': ['none', 'low', 'medium', 'high', 'xhigh', 'max'],
+ 'gpt-5.6-terra': ['none', 'low', 'medium', 'high', 'xhigh', 'max'],
+ 'gpt-5.6-luna': ['none', 'low', 'medium', 'high', 'xhigh', 'max'],
'gpt-5.5': ['none', 'low', 'medium', 'high'],
'gpt-5.2': ['none', 'low', 'medium', 'high'],
'gpt-5.1': ['none', 'low', 'medium', 'high'],
@@ -331,6 +351,11 @@ export async function getRunModelCatalog(
503,
)
for (const model of models) {
+ const retiring =
+ (provider === 'openai' && retiringOpenaiModels.has(model)) ||
+ ((provider === 'openrouter' || provider === 'vercel') &&
+ model.startsWith('openai/') &&
+ retiringOpenaiModels.has(model.slice('openai/'.length)))
const selected = { ...connection, model }
const support = modelAttachmentSupport(selected, env.INCLUDED_MODEL)
const unavailableReason = availability(scope.policy, selected)?.message
@@ -339,7 +364,9 @@ export async function getRunModelCatalog(
label:
scope.fixture && provider === 'included'
? 'Local preview'
- : (modelLabels[model] ?? model),
+ : retiring
+ ? `${model} (retires December 11, 2026)`
+ : (modelLabels[model] ?? model),
providerLabel: providerLabels[provider],
attachments: [
...(support.imageTypes.length ? ['image' as const] : []),
@@ -348,6 +375,7 @@ export async function getRunModelCatalog(
reasoning: ['default', ...reasoningValues(provider, model)].map(
(value) => ({ value, label: reasoningLabels[value] ?? value }),
),
+ ...(retiring ? { retiring: true } : {}),
...(unavailableReason ? { unavailableReason } : {}),
})
}