- {providers.map((provider) => {
- const isActive = activeId === provider.id
- const test = testResults[provider.id]
- const preset = presetMap.get(provider.presetId)
- return (
-
handleProviderDragOver(e, provider.id)}
- onDrop={(e) => handleProviderDrop(e, provider.id)}
- onDragLeave={() => setDragOverId((cur) => (cur === provider.id ? null : cur))}
- className={`relative flex items-center gap-3 px-4 py-3.5 rounded-xl border transition-all group ${
- isActive
- ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
- : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
- } ${draggingId === provider.id ? 'opacity-50' : ''} ${
- dragOverId === provider.id ? 'border-[var(--color-brand)] border-dashed' : ''
- }`}
- >
-
handleProviderDragStart(e, provider.id)}
- onDragEnd={clearProviderDragState}
- role="button"
- aria-label={t('settings.providers.dragToReorder')}
- title={t('settings.providers.dragToReorder')}
- className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] flex-shrink-0 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity hover:text-[var(--color-text-secondary)]"
- >
- drag_indicator
-
-
-
-
- {provider.name}
- {preset && preset.id !== 'custom' && (
- {preset.name}
- )}
- {provider.apiFormat && provider.apiFormat !== 'anthropic' && (
-
- {provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
-
- )}
- {isActive && (
- {t('settings.providers.default')}
- )}
-
-
- {provider.baseUrl} · {provider.models.main}
-
- {test && !test.loading && test.result && (
-
+ return (
+
handleActivate(provider.id) : undefined}
+ title={provider.name}
+ subtitle={`${provider.baseUrl} · ${provider.models.main}`}
+ badges={(
+ <>
+ {preset && preset.id !== 'custom' && (
+ {preset.name}
+ )}
+ {provider.apiFormat && provider.apiFormat !== 'anthropic' && (
+
+ {provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
+
+ )}
+ {isActive && (
+ {t('settings.providers.default')}
+ )}
+ >
+ )}
+ result={test && !test.loading && test.result ? (
+
{test.result.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
@@ -500,23 +535,31 @@ function ProviderSettings() {
)}
+ ) : null}
+ actions={(
+ <>
+ {!isActive && (
+
+ )}
+
+
+ {!isActive && (
+
+ )}
+ >
)}
-
-
- {!isActive && (
-
- )}
-
-
- {!isActive && (
-
- )}
-
-
- )
- })}
+ />
+ )
+ })}
+
+
+
+
+ {isLoading && providers.length === 0 ? (
+
- )}
+ ) : null}
{/* Create Modal — conditionally rendered so state resets on close */}
{showCreateModal && (
@@ -546,6 +589,97 @@ function ProviderSettings() {
)
}
+type SortableProviderCardProps = {
+ item: ProviderListItem
+ isActive: boolean
+ dragLabel: string
+ title: ReactNode
+ subtitle: ReactNode
+ badges?: ReactNode
+ result?: ReactNode
+ actions?: ReactNode
+ details?: ReactNode
+ onActivate?: () => void
+}
+
+function SortableProviderCard({
+ item,
+ isActive,
+ dragLabel,
+ title,
+ subtitle,
+ badges,
+ result,
+ actions,
+ details,
+ onActivate,
+}: SortableProviderCardProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: item.id })
+ const style: CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 20 : undefined,
+ }
+
+ return (
+
+
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ {details}
+
+ )
+}
+
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {
diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts
index 32ef9ac1..e66149da 100644
--- a/desktop/src/stores/providerStore.test.ts
+++ b/desktop/src/stores/providerStore.test.ts
@@ -196,7 +196,7 @@ describe('providerStore reorderProviders', () => {
const b = makeProvider({ id: 'b', name: 'B' })
const c = makeProvider({ id: 'c', name: 'C' })
- let resolveReorder: (value: { providers: SavedProvider[] }) => void = () => {}
+ let resolveReorder: (value: { providers: SavedProvider[]; providerOrder?: string[] }) => void = () => {}
providersApiMock.reorder.mockReturnValue(
new Promise((resolve) => {
resolveReorder = resolve
@@ -218,6 +218,28 @@ describe('providerStore reorderProviders', () => {
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b'])
})
+ it('optimistically applies full display order including built-in providers', async () => {
+ const a = makeProvider({ id: 'a', name: 'A' })
+ const b = makeProvider({ id: 'b', name: 'B' })
+ providersApiMock.reorder.mockResolvedValue({
+ providers: [b, a],
+ providerOrder: ['openai-official', 'b', 'claude-official', 'a'],
+ })
+
+ const { useProviderStore } = await import('./providerStore')
+ useProviderStore.setState({
+ providers: [a, b],
+ providerOrder: ['a', 'b', 'claude-official', 'openai-official'],
+ activeId: null,
+ })
+
+ await useProviderStore.getState().reorderProviders(['openai-official', 'b', 'claude-official', 'a'])
+
+ expect(providersApiMock.reorder).toHaveBeenCalledWith(['openai-official', 'b', 'claude-official', 'a'])
+ expect(useProviderStore.getState().providerOrder).toEqual(['openai-official', 'b', 'claude-official', 'a'])
+ expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['b', 'a'])
+ })
+
it('rolls back to the previous order when the request fails', async () => {
const a = makeProvider({ id: 'a', name: 'A' })
const b = makeProvider({ id: 'b', name: 'B' })
diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts
index 55afb3f2..b1912a8a 100644
--- a/desktop/src/stores/providerStore.ts
+++ b/desktop/src/stores/providerStore.ts
@@ -7,6 +7,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useSettingsStore } from './settingsStore'
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
import {
+ BUILT_IN_PROVIDER_IDS,
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../constants/openaiOfficialProvider'
@@ -22,6 +23,7 @@ import type { RuntimeSelection } from '../types/runtime'
type ProviderStore = {
providers: SavedProvider[]
+ providerOrder: string[]
activeId: string | null
hasLoadedProviders: boolean
presets: ProviderPreset[]
@@ -41,6 +43,63 @@ type ProviderStore = {
testConfig: (input: TestProviderConfigInput) => Promise
}
+function defaultProviderOrder(providers: SavedProvider[]): string[] {
+ return [
+ ...providers.map((provider) => provider.id),
+ ...BUILT_IN_PROVIDER_IDS,
+ ]
+}
+
+function normalizeProviderOrder(providerOrder: string[] | undefined, providers: SavedProvider[]): string[] {
+ const knownIds = new Set(defaultProviderOrder(providers))
+ const source = providerOrder && providerOrder.length > 0
+ ? providerOrder
+ : defaultProviderOrder(providers)
+ const seen = new Set()
+ const order: string[] = []
+
+ for (const id of source) {
+ if (!knownIds.has(id) || seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ for (const id of defaultProviderOrder(providers)) {
+ if (seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ return order
+}
+
+function isPermutation(candidateIds: string[], expectedIds: string[]): boolean {
+ const expectedSet = new Set(expectedIds)
+ const candidateSet = new Set(candidateIds)
+ return (
+ candidateIds.length === expectedIds.length &&
+ candidateSet.size === candidateIds.length &&
+ expectedIds.every((id) => candidateSet.has(id)) &&
+ candidateIds.every((id) => expectedSet.has(id))
+ )
+}
+
+function sortSavedProvidersByOrder(providers: SavedProvider[], providerOrder: string[]): SavedProvider[] {
+ const byId = new Map(providers.map((provider) => [provider.id, provider]))
+ return providerOrder
+ .map((id) => byId.get(id))
+ .filter((provider): provider is SavedProvider => provider !== undefined)
+}
+
+function mergeSavedOrderIntoProviderOrder(providerOrder: string[], savedOrder: string[]): string[] {
+ const savedSet = new Set(savedOrder)
+ const queue = [...savedOrder]
+ return providerOrder.map((id) => {
+ if (!savedSet.has(id)) return id
+ return queue.shift() ?? id
+ })
+}
+
function providerModelIds(provider: SavedProvider): Set {
return new Set(
Object.values(provider.models)
@@ -98,6 +157,7 @@ function refreshConnectedSessionsForProvider(provider: SavedProvider, activeId:
export const useProviderStore = create((set, get) => ({
providers: [],
+ providerOrder: [...BUILT_IN_PROVIDER_IDS],
activeId: null,
hasLoadedProviders: false,
presets: [],
@@ -108,8 +168,14 @@ export const useProviderStore = create((set, get) => ({
fetchProviders: async () => {
set({ isLoading: true, error: null })
try {
- const { providers, activeId } = await providersApi.list()
- set({ providers, activeId, hasLoadedProviders: true, isLoading: false })
+ const { providers, activeId, providerOrder } = await providersApi.list()
+ set({
+ providers,
+ providerOrder: normalizeProviderOrder(providerOrder, providers),
+ activeId,
+ hasLoadedProviders: true,
+ isLoading: false,
+ })
} catch (err) {
set({
isLoading: false,
@@ -148,24 +214,37 @@ export const useProviderStore = create((set, get) => ({
reorderProviders: async (orderedIds) => {
const previous = get().providers
- // Optimistically reorder locally so the drag feels instant…
- const byId = new Map(previous.map((p) => [p.id, p]))
- const next = orderedIds
- .map((id) => byId.get(id))
- .filter((p): p is SavedProvider => p !== undefined)
- // Bail out if the id set is stale (e.g. a provider was deleted mid-drag).
- if (next.length !== previous.length) {
+ const previousOrder = normalizeProviderOrder(get().providerOrder, previous)
+ const savedIds = previous.map((provider) => provider.id)
+ const nextOrder = isPermutation(orderedIds, previousOrder)
+ ? orderedIds
+ : isPermutation(orderedIds, savedIds)
+ ? mergeSavedOrderIntoProviderOrder(previousOrder, orderedIds)
+ : null
+
+ if (!nextOrder) {
await get().fetchProviders()
return
}
- set({ providers: next })
+
+ // Optimistically reorder locally so the drag feels instant.
+ set({
+ providers: sortSavedProvidersByOrder(previous, nextOrder),
+ providerOrder: nextOrder,
+ })
try {
- const { providers } = await providersApi.reorder(orderedIds)
- set({ providers })
+ const { providers, providerOrder } = await providersApi.reorder(orderedIds)
+ set({
+ providers,
+ providerOrder: normalizeProviderOrder(providerOrder, providers),
+ })
} catch (err) {
- // …and roll back to the pre-drag order if persistence fails. `previous`
- // is the server's last-known truth, so no refetch is needed.
- set({ providers: previous, error: err instanceof Error ? err.message : String(err) })
+ // Roll back to the server's last-known truth if persistence fails.
+ set({
+ providers: previous,
+ providerOrder: previousOrder,
+ error: err instanceof Error ? err.message : String(err),
+ })
}
},
diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts
index 4ccf6c08..f678865b 100644
--- a/src/server/__tests__/persistence-upgrade.test.ts
+++ b/src/server/__tests__/persistence-upgrade.test.ts
@@ -62,11 +62,13 @@ describe('persistent storage upgrade migrations', () => {
schemaVersion?: number
activeId?: string | null
activeProviderId?: string
+ providerOrder?: string[]
rootFutureField?: unknown
providers?: Array>
}
expect(migrated.schemaVersion).toBe(CURRENT_PROVIDER_INDEX_SCHEMA_VERSION)
expect(migrated.activeId).toBe('provider-1')
+ expect(migrated.providerOrder).toEqual(['provider-1', 'claude-official', 'openai-official'])
expect(migrated.activeProviderId).toBeUndefined()
expect(migrated.rootFutureField).toEqual({ keep: true })
expect(migrated.providers?.[0]?.extraFutureField).toBe('keep-me')
@@ -124,6 +126,7 @@ describe('persistent storage upgrade migrations', () => {
const migrated = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'providers.json'), 'utf-8')) as {
activeId?: string | null
+ providerOrder?: string[]
providers?: Array<{
id?: string
presetId?: string
@@ -133,6 +136,7 @@ describe('persistent storage upgrade migrations', () => {
}>
}
expect(migrated.activeId).toBe('legacy-provider')
+ expect(migrated.providerOrder).toEqual(['legacy-provider', 'claude-official', 'openai-official'])
expect(migrated.providers?.[0]).toMatchObject({
id: 'legacy-provider',
presetId: 'custom',
@@ -196,9 +200,11 @@ describe('persistent storage upgrade migrations', () => {
expect(report.migratedEntries).not.toContain('providers.json -> cc-haha/providers.json')
const current = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as {
activeId?: string | null
+ providerOrder?: string[]
providers?: unknown[]
}
expect(current.activeId).toBeNull()
+ expect(current.providerOrder).toEqual(['claude-official', 'openai-official'])
expect(current.providers).toEqual([])
})
diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts
index f775c793..7c63f252 100644
--- a/src/server/__tests__/providers.test.ts
+++ b/src/server/__tests__/providers.test.ts
@@ -95,7 +95,11 @@ describe('ProviderService', () => {
test('should return empty array when no providers exist', async () => {
const svc = new ProviderService()
const result = await svc.listProviders()
- expect(result).toEqual({ providers: [], activeId: null })
+ expect(result).toEqual({
+ providers: [],
+ activeId: null,
+ providerOrder: ['claude-official', 'openai-official'],
+ })
})
test('should recover from a malformed providers index after an upgrade', async () => {
@@ -106,7 +110,11 @@ describe('ProviderService', () => {
const result = await svc.listProviders()
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
- expect(result).toEqual({ providers: [], activeId: null })
+ expect(result).toEqual({
+ providers: [],
+ activeId: null,
+ providerOrder: ['claude-official', 'openai-official'],
+ })
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
})
@@ -635,7 +643,7 @@ describe('ProviderService', () => {
const c = await svc.addProvider(sampleInput({ name: 'C' }))
const result = await svc.reorderProviders([c.id, a.id, b.id])
- expect(result.map((p) => p.name)).toEqual(['C', 'A', 'B'])
+ expect(result.providers.map((p) => p.name)).toEqual(['C', 'A', 'B'])
// Persisted order survives a fresh read
const { providers } = await svc.listProviders()
@@ -645,6 +653,23 @@ describe('ProviderService', () => {
expect((config.providers as Array<{ name: string }>).map((p) => p.name)).toEqual(['C', 'A', 'B'])
})
+ test('should persist display order including built-in official providers', async () => {
+ const svc = new ProviderService()
+ const a = await svc.addProvider(sampleInput({ name: 'A' }))
+ const b = await svc.addProvider(sampleInput({ name: 'B' }))
+
+ const result = await svc.reorderProviders(['openai-official', b.id, 'claude-official', a.id])
+
+ expect(result.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
+ expect(result.providers.map((p) => p.id)).toEqual([b.id, a.id])
+
+ const listed = await svc.listProviders()
+ expect(listed.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
+
+ const config = await readProvidersConfig()
+ expect(config.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
+ })
+
test('should not change activeId when reordering', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
@@ -1606,8 +1631,9 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
- const body = (await res.json()) as { providers: { name: string }[] }
+ const body = (await res.json()) as { providers: { name: string }[]; providerOrder: string[] }
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
+ expect(body.providerOrder).toEqual([b.id, a.id, 'claude-official', 'openai-official'])
})
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {
diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts
index d046c0f1..c3457dfa 100644
--- a/src/server/api/providers.ts
+++ b/src/server/api/providers.ts
@@ -172,8 +172,8 @@ async function handleReorder(req: Request): Promise {
const body = await parseJsonBody(req)
try {
const input = ReorderProvidersSchema.parse(body)
- const providers = await providerService.reorderProviders(input.orderedIds)
- return Response.json({ providers })
+ const { providers, providerOrder } = await providerService.reorderProviders(input.orderedIds)
+ return Response.json({ providers, providerOrder })
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
diff --git a/src/server/services/openaiOfficialProvider.ts b/src/server/services/openaiOfficialProvider.ts
index bfc05a06..d251b1ff 100644
--- a/src/server/services/openaiOfficialProvider.ts
+++ b/src/server/services/openaiOfficialProvider.ts
@@ -8,9 +8,12 @@ import {
} from '../../services/openaiAuth/models.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import { getHahaOpenAIOAuthFilePath } from './hahaOpenAIOAuthService.js'
-import type { SavedProvider } from '../types/provider.js'
+import {
+ OPENAI_OFFICIAL_PROVIDER_ID,
+ type SavedProvider,
+} from '../types/provider.js'
-export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
+export { OPENAI_OFFICIAL_PROVIDER_ID }
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
export const OPENAI_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_OPENAI_OAUTH_PROVIDER'
export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE'
diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts
index 2a956a6a..d7119631 100644
--- a/src/server/services/persistentStorageMigrations.ts
+++ b/src/server/services/persistentStorageMigrations.ts
@@ -4,8 +4,9 @@ import * as path from 'path'
import { randomBytes } from 'node:crypto'
import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js'
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
+import { BUILT_IN_PROVIDER_IDS } from '../types/provider.js'
-export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1
+export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 2
type MigrationReport = {
migratedEntries: string[]
@@ -76,6 +77,36 @@ function isLegacyRootProvider(value: unknown): value is LegacyRootProvider {
)
}
+function defaultProviderOrder(providers: JsonObject[]): string[] {
+ return [
+ ...providers
+ .map((provider) => provider.id)
+ .filter((id): id is string => typeof id === 'string'),
+ ...BUILT_IN_PROVIDER_IDS,
+ ]
+}
+
+function normalizeProviderOrder(value: unknown, providers: JsonObject[]): string[] {
+ const knownIds = new Set(defaultProviderOrder(providers))
+ const source = Array.isArray(value) ? value : defaultProviderOrder(providers)
+ const seen = new Set()
+ const order: string[] = []
+
+ for (const id of source) {
+ if (typeof id !== 'string' || !knownIds.has(id) || seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ for (const id of defaultProviderOrder(providers)) {
+ if (seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ return order
+}
+
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
@@ -126,10 +157,15 @@ function migrateProvidersIndex(value: unknown): JsonObject {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId: null,
providers: [],
+ providerOrder: [...BUILT_IN_PROVIDER_IDS],
}
}
- const { activeProviderId: _legacyActiveProviderId, ...rest } = value
+ const {
+ activeProviderId: _legacyActiveProviderId,
+ providerOrder: rawProviderOrder,
+ ...rest
+ } = value
const providers = value.providers.filter(isSavedProvider)
const rawActiveId =
typeof value.activeId === 'string'
@@ -149,6 +185,7 @@ function migrateProvidersIndex(value: unknown): JsonObject {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
+ providerOrder: normalizeProviderOrder(rawProviderOrder, providers),
}
}
@@ -257,6 +294,7 @@ function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
+ providerOrder: normalizeProviderOrder(undefined, providers),
}
}
diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts
index 979d27f5..d9c671c9 100644
--- a/src/server/services/providerRuntimeEnv.ts
+++ b/src/server/services/providerRuntimeEnv.ts
@@ -9,6 +9,9 @@ import type {
ProvidersIndex,
SavedProvider,
} from '../types/provider.js'
+import {
+ BUILT_IN_PROVIDER_IDS,
+} from '../types/provider.js'
import {
ATTRIBUTION_HEADER_ENV_KEY,
attributionHeaderEnvForModel,
@@ -92,12 +95,50 @@ export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
}
}
+function defaultProviderOrder(providers: SavedProvider[]): string[] {
+ return [
+ ...providers.map((provider) => provider.id),
+ ...BUILT_IN_PROVIDER_IDS,
+ ]
+}
+
+function normalizeProviderOrder(value: unknown, providers: SavedProvider[]): string[] {
+ const providerIds = providers.map((provider) => provider.id)
+ const knownIds = new Set([
+ ...providerIds,
+ ...BUILT_IN_PROVIDER_IDS,
+ ])
+ const source = Array.isArray(value)
+ ? value
+ : defaultProviderOrder(providers)
+ const seen = new Set()
+ const order: string[] = []
+
+ for (const id of source) {
+ if (typeof id !== 'string' || !knownIds.has(id) || seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ for (const id of defaultProviderOrder(providers)) {
+ if (seen.has(id)) continue
+ seen.add(id)
+ order.push(id)
+ }
+
+ return order
+}
+
export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return null
}
- const { activeProviderId: legacyActiveProviderId, ...rest } = value
+ const {
+ activeProviderId: legacyActiveProviderId,
+ providerOrder: rawProviderOrder,
+ ...rest
+ } = value
const providers = value.providers
.filter(isSavedProvider)
.map((provider) => normalizeSavedProvider(provider))
@@ -119,6 +160,7 @@ export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
schemaVersion: typeof value.schemaVersion === 'number' ? value.schemaVersion : 1,
activeId,
providers,
+ providerOrder: normalizeProviderOrder(rawProviderOrder, providers),
}
}
diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts
index 8f0aa773..54ad8d45 100644
--- a/src/server/services/providerService.ts
+++ b/src/server/services/providerService.ts
@@ -53,11 +53,76 @@ import type {
ApiFormat,
ProviderAuthStrategy,
} from '../types/provider.js'
+import {
+ BUILT_IN_PROVIDER_IDS,
+} from '../types/provider.js'
const DEFAULT_INDEX: ProvidersIndex = {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId: null,
providers: [],
+ providerOrder: [...BUILT_IN_PROVIDER_IDS],
+}
+
+function isPermutation(candidateIds: string[], expectedIds: string[]): boolean {
+ const expectedSet = new Set(expectedIds)
+ const candidateSet = new Set(candidateIds)
+ return (
+ candidateIds.length === expectedIds.length &&
+ candidateSet.size === candidateIds.length &&
+ expectedIds.every((id) => candidateSet.has(id)) &&
+ candidateIds.every((id) => expectedSet.has(id))
+ )
+}
+
+function savedProviderIds(providers: SavedProvider[]): string[] {
+ return providers.map((provider) => provider.id)
+}
+
+function fullProviderOrderIds(providers: SavedProvider[]): string[] {
+ return [
+ ...savedProviderIds(providers),
+ ...BUILT_IN_PROVIDER_IDS,
+ ]
+}
+
+function sortSavedProvidersByOrder(providers: SavedProvider[], providerOrder: string[]): SavedProvider[] {
+ const byId = new Map(providers.map((provider) => [provider.id, provider]))
+ return providerOrder
+ .map((id) => byId.get(id))
+ .filter((provider): provider is SavedProvider => provider !== undefined)
+}
+
+function mergeSavedOrderIntoDisplayOrder(providerOrder: string[], savedOrder: string[]): string[] {
+ const savedSet = new Set(savedOrder)
+ const queue = [...savedOrder]
+ return providerOrder.map((id) => {
+ if (!savedSet.has(id)) return id
+ return queue.shift() ?? id
+ })
+}
+
+function appendNewProviderToOrder(providerOrder: string[], providerId: string, existingProviders: SavedProvider[]): string[] {
+ const existingProviderIds = new Set(existingProviders.map((provider) => provider.id))
+ const lastSavedIndex = providerOrder.reduce(
+ (latest, id, index) => existingProviderIds.has(id) ? index : latest,
+ -1,
+ )
+ if (lastSavedIndex !== -1) {
+ return [
+ ...providerOrder.slice(0, lastSavedIndex + 1),
+ providerId,
+ ...providerOrder.slice(lastSavedIndex + 1),
+ ]
+ }
+
+ const firstBuiltInIndex = providerOrder.findIndex((id) => BUILT_IN_PROVIDER_IDS.includes(id as typeof BUILT_IN_PROVIDER_IDS[number]))
+ if (firstBuiltInIndex === -1) return [...providerOrder, providerId]
+ return [
+ ...providerOrder.slice(0, firstBuiltInIndex),
+ providerId,
+ ...providerOrder.slice(firstBuiltInIndex),
+ ]
}
export class ProviderService {
@@ -125,9 +190,13 @@ export class ProviderService {
// --- CRUD ---
- async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
+ async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null; providerOrder: string[] }> {
const index = await this.readIndex()
- return { providers: index.providers, activeId: index.activeId }
+ return {
+ providers: index.providers,
+ activeId: index.activeId,
+ providerOrder: index.providerOrder,
+ }
}
async getProvider(id: string): Promise {
@@ -159,6 +228,7 @@ export class ProviderService {
...(input.notes !== undefined && { notes: input.notes }),
}
+ index.providerOrder = appendNewProviderToOrder(index.providerOrder, provider.id, index.providers)
index.providers.push(provider)
await this.writeIndex(index)
return provider
@@ -210,37 +280,39 @@ export class ProviderService {
}
index.providers.splice(idx, 1)
+ index.providerOrder = index.providerOrder.filter((providerId) => providerId !== id)
await this.writeIndex(index)
}
/**
- * Reorder saved providers to match the given id order.
+ * Reorder providers to match the given display id order.
*
- * The display order is simply the array order in providers.json, so reordering
- * persists by rewriting the array — no separate `order` field is needed.
- * `orderedIds` must be a permutation of the existing provider ids (same members,
- * no duplicates, none missing); otherwise the request is rejected so a stale
- * client can't silently drop or duplicate a provider.
+ * New clients send a full display-order permutation including the built-in
+ * official providers. Legacy clients may still send only saved provider ids;
+ * in that case the saved providers are reordered inside the current display
+ * order without moving the built-in official rows.
*/
- async reorderProviders(orderedIds: string[]): Promise {
+ async reorderProviders(orderedIds: string[]): Promise<{ providers: SavedProvider[]; providerOrder: string[] }> {
const index = await this.readIndex()
- const currentIds = index.providers.map((p) => p.id)
- const orderedSet = new Set(orderedIds)
- const isPermutation =
- orderedIds.length === currentIds.length &&
- orderedSet.size === orderedIds.length &&
- currentIds.every((id) => orderedSet.has(id))
+ const currentSavedIds = savedProviderIds(index.providers)
+ const isFullDisplayPermutation = isPermutation(orderedIds, fullProviderOrderIds(index.providers))
+ const isLegacySavedPermutation = isPermutation(orderedIds, currentSavedIds)
- if (!isPermutation) {
- throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids')
+ if (!isFullDisplayPermutation && !isLegacySavedPermutation) {
+ throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids and built-in provider ids')
}
- const byId = new Map(index.providers.map((p) => [p.id, p]))
- index.providers = orderedIds.map((id) => byId.get(id) as SavedProvider)
+ index.providerOrder = isFullDisplayPermutation
+ ? orderedIds
+ : mergeSavedOrderIntoDisplayOrder(index.providerOrder, orderedIds)
+ index.providers = sortSavedProvidersByOrder(index.providers, index.providerOrder)
await this.writeIndex(index)
- return index.providers
+ return {
+ providers: index.providers,
+ providerOrder: index.providerOrder,
+ }
}
// --- Activation ---
diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts
index 97579298..7db3d103 100644
--- a/src/server/types/provider.ts
+++ b/src/server/types/provider.ts
@@ -7,6 +7,13 @@
import { z } from 'zod'
+export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
+export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
+export const BUILT_IN_PROVIDER_IDS = [
+ CLAUDE_OFFICIAL_PROVIDER_ID,
+ OPENAI_OFFICIAL_PROVIDER_ID,
+] as const
+
export const ApiFormatSchema = z.enum([
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
@@ -61,6 +68,7 @@ export const ProvidersIndexSchema = z.object({
schemaVersion: z.number().int().positive().optional(),
activeId: z.string().nullable(),
providers: z.array(SavedProviderSchema),
+ providerOrder: z.array(z.string()).default([]),
})
export const CreateProviderSchema = z.object({
@@ -99,7 +107,8 @@ export const TestProviderSchema = z.object({
})
export const ReorderProvidersSchema = z.object({
- // A permutation of the existing saved provider ids, in the desired display order.
+ // A permutation of the display provider ids, including built-in official providers.
+ // The legacy saved-provider-only permutation is still accepted by ProviderService.
orderedIds: z.array(z.string().min(1)).min(1),
})