{MODEL_SLOTS.map((slot) => {
- const labelKey = slot === 'main'
- ? 'settings.providers.mainModel'
- : slot === 'haiku'
- ? 'settings.providers.haikuModel'
- : slot === 'sonnet'
- ? 'settings.providers.sonnetModel'
- : 'settings.providers.opusModel'
- const label = t(labelKey)
+ const label = t(MODEL_SLOT_LABEL_KEYS[slot])
const pickLabel = t('settings.providers.fetchModelsPick', { label })
return (
handleModelChange(slot, value)}
- placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
+ placeholder={
+ // Fable is the one slot that does not fall back to the
+ // main model: an empty value leaves the choice to the
+ // runtime, which resolves it to the provider's Opus-tier
+ // model (third-party) or a real Fable model (official).
+ slot === 'main'
+ ? t('settings.providers.modelIdPlaceholder')
+ : slot === 'fable'
+ ? t('settings.providers.fableModelPlaceholder')
+ : t('settings.providers.sameAsMain')
+ }
groups={modelPickerGroups}
pickerLabel={pickLabel}
noMatchesLabel={t('model.noMatches')}
@@ -2167,6 +2181,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
const mergedModels = { ...prev, ...newModels }
const nextModel1mSupport = {
main: hasModel1mMarker(mergedModels.main),
+ fable: mergedModels.fable ? hasModel1mMarker(mergedModels.fable) : false,
haiku: hasModel1mMarker(mergedModels.haiku),
sonnet: hasModel1mMarker(mergedModels.sonnet),
opus: hasModel1mMarker(mergedModels.opus),
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index dd467a78..ff8f6ea5 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -5265,7 +5265,7 @@ describe('chatStore history mapping', () => {
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
models: { main: model, haiku: '', sonnet: '', opus: '' },
- model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: false },
+ model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: false },
}]
const staleSelection = { providerId: 'provider-1', modelId: `${model}${enabled ? '' : '[1m]'}`, effortLevel: 'high' as const }
const expectedSelection = { ...staleSelection, modelId: `${model}${enabled ? '[1m]' : ''}` }
@@ -5290,7 +5290,7 @@ describe('chatStore history mapping', () => {
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
models: { main: 'deepseek-v4.1', haiku: '', sonnet: '', opus: '' },
- model1mSupport: { main: true, haiku: false, sonnet: false, opus: false },
+ model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false },
}]
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue')
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts
index 151feb15..1b1c2837 100644
--- a/desktop/src/stores/providerStore.test.ts
+++ b/desktop/src/stores/providerStore.test.ts
@@ -152,7 +152,7 @@ describe('providerStore runtime refresh', () => {
})
it.each([true, false])('refreshes saved model capabilities for idle, disconnected and draft selections (1m=%s)', async (enabled) => {
- const provider = makeProvider({ model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: enabled } })
+ const provider = makeProvider({ model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: enabled } })
providersApiMock.update.mockResolvedValue({ provider })
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
chatStoreState.sessions = {
@@ -197,7 +197,7 @@ describe('providerStore runtime refresh', () => {
})
it('reconciles restored selections when provider capabilities arrive after connection', async () => {
- const provider = makeProvider({ model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } })
+ const provider = makeProvider({ model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false } })
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
chatStoreState.sessions = { restored: { connectionState: 'connected', chatState: 'idle' } }
runtimeStoreState.selections = { restored: { providerId: provider.id, modelId: 'model-main' } }
diff --git a/desktop/src/stores/teamStore.test.ts b/desktop/src/stores/teamStore.test.ts
index a33b7fe5..0718c6eb 100644
--- a/desktop/src/stores/teamStore.test.ts
+++ b/desktop/src/stores/teamStore.test.ts
@@ -1387,6 +1387,49 @@ describe('teamStore workbench timeline', () => {
useTeamStore.getState().clearTeam()
})
+ it('keeps each member model through the roster read instead of dropping it', async () => {
+ const snapshot = workbench('v1', 'in_progress')
+ getWorkbenchForSessionMock.mockResolvedValue({
+ sessionId: 'lead-session',
+ teamName: 'team-workbench',
+ snapshots: [{
+ ...snapshot,
+ team: {
+ ...snapshot.team,
+ members: [
+ { ...snapshot.team.members[0]!, model: 'claude-opus-4-8' },
+ // A mapped provider id is not a known family — it must still survive
+ // verbatim rather than being normalised away.
+ { ...snapshot.team.members[1]!, model: 'qwen3.7-plus[1m]' },
+ ],
+ },
+ }],
+ source: 'live',
+ })
+
+ await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
+
+ const members = useTeamStore.getState()
+ .workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
+ expect(members.map(member => member.model)).toEqual(['claude-opus-4-8', 'qwen3.7-plus[1m]'])
+ })
+
+ it('reports no model rather than inventing one when the roster omits it', async () => {
+ const snapshot = workbench('v1', 'in_progress')
+ getWorkbenchForSessionMock.mockResolvedValue({
+ sessionId: 'lead-session',
+ teamName: 'team-workbench',
+ snapshots: [snapshot],
+ source: 'live',
+ })
+
+ await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
+
+ const members = useTeamStore.getState()
+ .workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
+ expect(members.map(member => member.model)).toEqual([undefined, undefined])
+ })
+
it('keeps unknown watcher identities out of each roster and refreshes authoritative data', () => {
getTeamMock.mockReturnValue(new Promise(() => {}))
getWorkbenchMock.mockReturnValue(new Promise(() => {}))
diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts
index b3f15775..7da51a79 100644
--- a/desktop/src/stores/teamStore.ts
+++ b/desktop/src/stores/teamStore.ts
@@ -144,6 +144,7 @@ function toTeamMember(raw: Record): TeamMember {
currentTask: raw.currentTask as string | undefined,
color: raw.color as AgentColor | undefined,
sessionId: raw.sessionId as string | undefined,
+ model: typeof raw.model === 'string' ? raw.model : undefined,
}
}
diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts
index 0e4f39f7..dd50a9b1 100644
--- a/desktop/src/types/provider.ts
+++ b/desktop/src/types/provider.ts
@@ -21,6 +21,7 @@ export type ModelMapping = {
export type Model1mSupport = {
main: boolean
+ fable: boolean
haiku: boolean
sonnet: boolean
opus: boolean
diff --git a/desktop/src/types/team.ts b/desktop/src/types/team.ts
index b61c5a1a..c4650178 100644
--- a/desktop/src/types/team.ts
+++ b/desktop/src/types/team.ts
@@ -25,6 +25,7 @@ export type TeamMember = {
currentTask?: string
color?: AgentColor
sessionId?: string
+ model?: string
}
export type TeamDetail = {
diff --git a/src/server/index.ts b/src/server/index.ts
index 8ab0e65c..77b0373e 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -10,7 +10,7 @@ import { handleSessionCollaborationApi } from './api/sessionCollaboration.js'
import { handleApiRequest } from './router.js'
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
-import { resolveCors, type CorsResolution } from './middleware/cors.js'
+import { isLoopbackBrowserOrigin, resolveCors, type CorsResolution } from './middleware/cors.js'
import { requireAuth, requireH5Token } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
@@ -377,6 +377,23 @@ export function startServer(port = PORT, host = HOST) {
})
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
+ // A *loopback* renderer's CORS preflight is answered before the
+ // capability gates. A preflight never carries credentials, so a
+ // browser-hosted renderer (the Vite dev server on
+ // http://localhost:) could never reach the API once H5 access was
+ // disabled: the preflight was rejected with a CORS-less 403, the
+ // browser refused to send the real request, and the app could only
+ // report an opaque `TypeError: Failed to fetch`. This grants nothing —
+ // the actual request is still gated below and still has to present the
+ // process token. Remote origins deliberately skip this block and keep
+ // hitting the strict rejection path beneath it.
+ if (req.method === 'OPTIONS' && origin && isLoopbackBrowserOrigin(origin)) {
+ if (cors.rejected) {
+ return corsRejectedResponse(cors)
+ }
+ return new Response(null, { status: 204, headers: cors.headers })
+ }
+
if (h5AccessControlBlocked) {
return isLocalCredentialOnlyPath(url.pathname)
? localCredentialRejectedResponse()
diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts
index 9d946438..a22236b0 100644
--- a/src/server/middleware/cors.ts
+++ b/src/server/middleware/cors.ts
@@ -45,7 +45,7 @@ function isLocalOrigin(origin?: string | null): boolean {
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
}
-function isLoopbackBrowserOrigin(origin: string): boolean {
+export function isLoopbackBrowserOrigin(origin: string): boolean {
let parsed: URL
try {
parsed = new URL(origin)
diff --git a/src/server/services/ccSwitchImport.test.ts b/src/server/services/ccSwitchImport.test.ts
index 206ea09e..ce531905 100644
--- a/src/server/services/ccSwitchImport.test.ts
+++ b/src/server/services/ccSwitchImport.test.ts
@@ -1062,6 +1062,7 @@ describe('cc-switch candidate mapping', () => {
})
expect(candidate.model1mSupport).toEqual({
main: false,
+ fable: false,
haiku: false,
sonnet: false,
opus: false,
@@ -1093,6 +1094,7 @@ describe('cc-switch candidate mapping', () => {
ANTHROPIC_BASE_URL: 'https://api.example.com',
ANTHROPIC_AUTH_TOKEN: FULL_KEY,
ANTHROPIC_MODEL: 'gemini-3.6-flash-high[1M]',
+ ANTHROPIC_DEFAULT_FABLE_MODEL: 'gemini-3.6-pro:1m',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3.6-flash-lite',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.6-flash-high:1m',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.6-pro[1m]',
@@ -1103,12 +1105,14 @@ describe('cc-switch candidate mapping', () => {
expect(candidate.models).toEqual({
main: 'gemini-3.6-flash-high',
+ fable: 'gemini-3.6-pro',
haiku: 'gemini-3.6-flash-lite',
sonnet: 'gemini-3.6-flash-high',
opus: 'gemini-3.6-pro',
})
expect(candidate.model1mSupport).toEqual({
main: true,
+ fable: true,
haiku: false,
sonnet: true,
opus: true,
diff --git a/src/server/services/ccSwitchImport.ts b/src/server/services/ccSwitchImport.ts
index 7c4cece4..b37b3108 100644
--- a/src/server/services/ccSwitchImport.ts
+++ b/src/server/services/ccSwitchImport.ts
@@ -624,6 +624,7 @@ function stripModel1mMarkers(models: ModelMapping): ModelMapping {
function readModel1mSupport(models: ModelMapping): Model1mSupport {
return {
main: hasModel1mMarker(models.main),
+ fable: models.fable ? hasModel1mMarker(models.fable) : false,
haiku: hasModel1mMarker(models.haiku),
sonnet: hasModel1mMarker(models.sonnet),
opus: hasModel1mMarker(models.opus),
diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts
index c199c902..1f73c795 100644
--- a/src/server/services/providerRuntimeEnv.ts
+++ b/src/server/services/providerRuntimeEnv.ts
@@ -176,6 +176,7 @@ function normalizeModel1mSupport(
if (!model1mSupport) return undefined
const normalized = {
main: model1mSupport.main === true,
+ fable: model1mSupport.fable === true,
haiku: model1mSupport.haiku === true,
sonnet: model1mSupport.sonnet === true,
opus: model1mSupport.opus === true,
@@ -209,7 +210,9 @@ function applyModel1mSupportMapping(
): SavedProvider['models'] {
return {
main: applyModel1mSupport(models.main, model1mSupport?.main),
- ...(models.fable ? { fable: models.fable.trim() } : {}),
+ ...(models.fable !== undefined
+ ? { fable: applyModel1mSupport(models.fable, model1mSupport?.fable) }
+ : {}),
haiku: applyModel1mSupport(models.haiku, model1mSupport?.haiku),
sonnet: applyModel1mSupport(models.sonnet, model1mSupport?.sonnet),
opus: applyModel1mSupport(models.opus, model1mSupport?.opus),
diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts
index f4f3bb90..95a61f88 100644
--- a/src/server/services/teamService.ts
+++ b/src/server/services/teamService.ts
@@ -269,7 +269,15 @@ function carryForwardArchivedMembers(
const identity = memberArchiveIdentity(historicalMember)
historicalIdentities.add(identity)
const currentMember = currentByIdentity.get(identity)
- if (currentMember) return { ...historicalMember, ...currentMember }
+ if (currentMember) {
+ return {
+ ...historicalMember,
+ ...currentMember,
+ ...(currentMember.model === undefined && historicalMember.model !== undefined
+ ? { model: historicalMember.model }
+ : {}),
+ }
+ }
return {
...historicalMember,
status: historicalMember.status === 'failed'
@@ -1515,7 +1523,7 @@ export class TeamService {
agentId: m.agentId,
name: m.name,
agentType: m.agentType,
- model: m.model,
+ ...(m.model ? { model: m.model } : {}),
color: m.color,
backendType: m.backendType,
status: this.deriveStatus(m.isActive),
diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts
index e6509611..ef401e2a 100644
--- a/src/server/types/provider.ts
+++ b/src/server/types/provider.ts
@@ -55,6 +55,9 @@ export const ModelMappingSchema = z.object({
export const Model1mSupportSchema = z.object({
main: z.boolean(),
+ // Defaulted rather than required: providers saved before the Fable slot
+ // existed carry no `fable` key, and a required field would reject them.
+ fable: z.boolean().default(false),
haiku: z.boolean(),
sonnet: z.boolean(),
opus: z.boolean(),
diff --git a/src/utils/teamDiscovery.ts b/src/utils/teamDiscovery.ts
index 454c142e..a23b3f5e 100644
--- a/src/utils/teamDiscovery.ts
+++ b/src/utils/teamDiscovery.ts
@@ -59,7 +59,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] {
name: member.name,
agentId: member.agentId,
agentType: member.agentType,
- model: member.model,
+ ...(member.model ? { model: member.model } : {}),
prompt: member.prompt,
status,
color: member.color,