fix(providers): make provider list sortable (#753)

Render provider settings through dnd-kit sortable rows, include official providers in the same order model, and persist providerOrder across server and desktop state.

Tested:

- bun test src/server/__tests__/providers.test.ts src/server/__tests__/persistence-upgrade.test.ts

- cd desktop && bun run test -- --run src/stores/providerStore.test.ts src/__tests__/generalSettings.test.tsx -t "providerStore reorderProviders|Settings > Providers tab"

- cd desktop && bun run lint

- cd desktop && bun run build

- bun run check:persistence-upgrade

Not-tested:

- bun run check:server (broad suite hit environment failures during this run: MCP stdio zshrc timeout, adapter dependency gap at the time, and e2e cascade)

Confidence: high

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-13 08:07:36 +08:00
parent 0eee5a73b3
commit 01d8691673
16 changed files with 688 additions and 219 deletions

View File

@ -5,6 +5,9 @@
"": {
"name": "claude-code-desktop",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
@ -105,6 +108,14 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@electron/asar": ["@electron/asar@3.4.1", "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],

View File

@ -94,6 +94,9 @@
"lint": "node ./node_modules/typescript/bin/tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",

View File

@ -35,6 +35,7 @@ const tauriProcessMock = vi.hoisted(() => ({
}))
const providerStoreState = {
providers: [] as SavedProvider[],
providerOrder: [] as string[],
activeId: null as string | null,
hasLoadedProviders: true,
presets: [] as ProviderPreset[],
@ -189,6 +190,7 @@ describe('Settings > General tab', () => {
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
providerStoreState.providers = []
providerStoreState.providerOrder = []
providerStoreState.activeId = null
providerStoreState.hasLoadedProviders = true
providerStoreState.presets = []
@ -1482,6 +1484,7 @@ describe('Settings > Providers tab', () => {
notes: '',
},
]
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official']
providerStoreState.activeId = null
providerStoreState.hasLoadedProviders = true
})
@ -1530,6 +1533,20 @@ describe('Settings > Providers tab', () => {
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
})
it('renders saved and official providers in the stored sortable order', () => {
providerStoreState.providerOrder = ['provider-1', 'openai-official', 'claude-official']
render(<Settings />)
const rows = screen.getAllByRole('button', { name: 'Drag to reorder' })
.map((handle) => handle.closest('[data-testid]')?.getAttribute('data-testid'))
expect(rows).toEqual([
'provider-provider-1',
'openai-official-provider',
'claude-official-provider',
])
})
it('requires confirmation before deleting a provider', async () => {
render(<Settings />)

View File

@ -11,6 +11,8 @@ import type {
import type { ProviderPreset } from '../types/providerPreset'
type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null }
type ProvidersListResponse = ProvidersResponse & { providerOrder?: string[] }
type ProvidersReorderResponse = { providers: SavedProvider[]; providerOrder?: string[] }
type ProviderResponse = { provider: SavedProvider }
type PresetsResponse = { presets: ProviderPreset[] }
type TestResultResponse = { result: ProviderTestResult }
@ -22,7 +24,7 @@ type AuthStatusResponse = {
export const providersApi = {
list() {
return api.get<ProvidersResponse>('/api/providers')
return api.get<ProvidersListResponse>('/api/providers')
},
presets() {
@ -62,7 +64,7 @@ export const providersApi = {
},
reorder(orderedIds: string[]) {
return api.put<{ providers: SavedProvider[] }>('/api/providers/reorder', { orderedIds })
return api.put<ProvidersReorderResponse>('/api/providers/reorder', { orderedIds })
},
test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) {

View File

@ -1,6 +1,11 @@
import type { ModelInfo } from '../types/settings'
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 OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex'
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'

View File

@ -1,6 +1,23 @@
import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react'
import QRCode from 'qrcode'
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Copy, Eye, EyeOff, GripVertical, PowerOff, QrCode, RotateCw } from 'lucide-react'
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation, type TranslationKey } from '../i18n'
@ -34,7 +51,11 @@ import { MemorySettings } from './MemorySettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
import {
BUILT_IN_PROVIDER_IDS,
CLAUDE_OFFICIAL_PROVIDER_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../constants/openaiOfficialProvider'
import { useUpdateStore } from '../stores/updateStore'
import { getBaseUrl } from '../api/client'
import { formatBytes } from '../lib/formatBytes'
@ -237,9 +258,74 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
// ─── Provider Settings ──────────────────────────────────────
type ProviderListItem =
| { id: typeof CLAUDE_OFFICIAL_PROVIDER_ID; kind: 'claude-official' }
| { id: typeof OPENAI_OFFICIAL_PROVIDER_ID; kind: 'openai-official' }
| { id: string; kind: 'saved'; provider: SavedProvider }
function defaultProviderOrder(providers: SavedProvider[]): string[] {
return [
...providers.map((provider) => provider.id),
...BUILT_IN_PROVIDER_IDS,
]
}
function normalizeProviderOrder(providerOrder: string[], providers: SavedProvider[]): string[] {
const knownIds = new Set<string>(defaultProviderOrder(providers))
const seen = new Set<string>()
const order: string[] = []
for (const id of providerOrder.length > 0 ? providerOrder : defaultProviderOrder(providers)) {
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 buildProviderListItems(
providers: SavedProvider[],
providerOrder: string[],
): ProviderListItem[] {
const savedItems = new Map(
providers.map((provider) => [
provider.id,
{ id: provider.id, kind: 'saved', provider } satisfies ProviderListItem,
]),
)
const items = new Map<string, ProviderListItem>([
[CLAUDE_OFFICIAL_PROVIDER_ID, { id: CLAUDE_OFFICIAL_PROVIDER_ID, kind: 'claude-official' }],
[OPENAI_OFFICIAL_PROVIDER_ID, { id: OPENAI_OFFICIAL_PROVIDER_ID, kind: 'openai-official' }],
...savedItems,
])
return normalizeProviderOrder(providerOrder, providers)
.map((id) => items.get(id))
.filter((item): item is ProviderListItem => item !== undefined)
}
function providerItemTestId(item: ProviderListItem): string {
switch (item.kind) {
case 'claude-official':
return 'claude-official-provider'
case 'openai-official':
return 'openai-official-provider'
case 'saved':
return `provider-${item.provider.id}`
}
}
function ProviderSettings() {
const {
providers,
providerOrder,
activeId,
hasLoadedProviders,
presets,
@ -260,8 +346,14 @@ function ProviderSettings() {
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
useEffect(() => {
void fetchProviders()
@ -311,44 +403,21 @@ function ProviderSettings() {
await fetchSettings()
}
const handleProviderDragStart = (event: React.DragEvent, id: string) => {
setDraggingId(id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', id)
}
const providerItems = useMemo(
() => buildProviderListItems(providers, providerOrder),
[providerOrder, providers],
)
const handleProviderDragOver = (event: React.DragEvent, overId: string) => {
if (!draggingId || draggingId === overId) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
if (dragOverId !== overId) setDragOverId(overId)
}
const handleProviderDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const clearProviderDragState = () => {
setDraggingId(null)
setDragOverId(null)
}
const ids = providerItems.map((item) => item.id)
const oldIndex = ids.indexOf(String(active.id))
const newIndex = ids.indexOf(String(over.id))
if (oldIndex === -1 || newIndex === -1) return
const handleProviderDrop = (event: React.DragEvent, targetId: string) => {
event.preventDefault()
const sourceId = draggingId || event.dataTransfer.getData('text/plain')
if (!sourceId || sourceId === targetId) {
clearProviderDragState()
return
}
const ids = providers.map((p) => p.id)
const from = ids.indexOf(sourceId)
const to = ids.indexOf(targetId)
if (from === -1 || to === -1) {
clearProviderDragState()
return
}
ids.splice(from, 1)
ids.splice(to, 0, sourceId)
clearProviderDragState()
void reorderProviders(ids)
void reorderProviders(arrayMove(ids, oldIndex, newIndex))
}
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
@ -367,126 +436,92 @@ function ProviderSettings() {
</Button>
</div>
{/* Official provider — always visible at top */}
<div
data-testid="claude-official-provider"
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isClaudeOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleProviderDragEnd}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isClaudeOfficialActive && handleActivateOfficial()}
<SortableContext
items={providerItems.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isClaudeOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isClaudeOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
</div>
</div>
<div className="flex flex-col gap-2">
{providerItems.map((item) => {
if (item.kind === 'claude-official') {
return (
<SortableProviderCard
key={item.id}
item={item}
isActive={isClaudeOfficialActive}
dragLabel={t('settings.providers.dragToReorder')}
onActivate={!isClaudeOfficialActive ? handleActivateOfficial : undefined}
title={t('settings.providers.officialName')}
subtitle={t('settings.providers.officialDesc')}
badges={isClaudeOfficialActive ? (
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
) : null}
details={isClaudeOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<ClaudeOfficialLogin />
</div>
) : null}
/>
)
}
{isClaudeOfficialActive && (
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
<ClaudeOfficialLogin />
</div>
)}
</div>
if (item.kind === 'openai-official') {
return (
<SortableProviderCard
key={item.id}
item={item}
isActive={isOpenAIOfficialActive}
dragLabel={t('settings.providers.dragToReorder')}
onActivate={!isOpenAIOfficialActive ? () => handleActivate(OPENAI_OFFICIAL_PROVIDER_ID) : undefined}
title={t('settings.providers.openaiOfficialName')}
subtitle={t('settings.providers.openaiOfficialDesc')}
badges={isOpenAIOfficialActive ? (
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
) : null}
details={isOpenAIOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<ChatGPTOfficialLogin />
</div>
) : null}
/>
)
}
<div
data-testid="openai-official-provider"
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOpenAIOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOpenAIOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.openaiOfficialName')}</span>
{isOpenAIOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.openaiOfficialDesc')}</div>
</div>
</div>
const provider = item.provider
const isActive = activeId === provider.id
const test = testResults[provider.id]
const preset = presetMap.get(provider.presetId)
{isOpenAIOfficialActive && (
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
<ChatGPTOfficialLogin />
</div>
)}
</div>
{/* Saved providers */}
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const isActive = activeId === provider.id
const test = testResults[provider.id]
const preset = presetMap.get(provider.presetId)
return (
<div
key={provider.id}
onDragOver={(e) => 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' : ''
}`}
>
<span
draggable
onDragStart={(e) => 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
</span>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
{preset && preset.id !== 'custom' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
)}
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{provider.baseUrl} &middot; {provider.models.main}
</div>
{test && !test.loading && test.result && (
<div className="text-xs mt-1 flex flex-col gap-0.5">
return (
<SortableProviderCard
key={item.id}
item={item}
isActive={isActive}
dragLabel={t('settings.providers.dragToReorder')}
onActivate={!isActive ? () => handleActivate(provider.id) : undefined}
title={provider.name}
subtitle={`${provider.baseUrl} · ${provider.models.main}`}
badges={(
<>
{preset && preset.id !== 'custom' && (
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-text-tertiary)]">{preset.name}</span>
)}
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-warning)]">
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
</span>
)}
{isActive && (
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
)}
</>
)}
result={test && !test.loading && test.result ? (
<div className="mt-1 flex flex-col gap-0.5 text-xs">
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
@ -500,23 +535,31 @@ function ProviderSettings() {
</span>
)}
</div>
) : null}
actions={(
<>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
)}
</>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
)}
</div>
</div>
)
})}
/>
)
})}
</div>
</SortableContext>
</DndContext>
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
)}
) : 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 (
<div
ref={setNodeRef}
style={style}
data-testid={providerItemTestId(item)}
className={`group relative flex flex-col rounded-[8px] border transition-colors ${
isActive
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-container-low)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
} ${isDragging ? 'shadow-[var(--shadow-dropdown)] opacity-90' : ''}`}
>
<div className="flex items-center gap-2 px-3 py-3">
<button
type="button"
{...attributes}
{...listeners}
aria-label={dragLabel}
title={dragLabel}
className="flex h-8 w-8 shrink-0 cursor-grab items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:cursor-grabbing"
style={{ touchAction: 'none' }}
>
<GripVertical className="h-4 w-4" />
</button>
<button
type="button"
onClick={onActivate}
aria-disabled={!onActivate}
className={`flex min-w-0 flex-1 items-center gap-3 rounded-[6px] text-left focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
onActivate ? 'cursor-pointer' : 'cursor-default'
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
{badges}
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">{subtitle}</span>
{result}
</span>
</button>
{actions && (
<div className="flex shrink-0 items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
{actions}
</div>
)}
</div>
{details}
</div>
)
}
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {

View File

@ -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' })

View File

@ -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<ProviderTestResult>
}
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<string>(defaultProviderOrder(providers))
const source = providerOrder && providerOrder.length > 0
? providerOrder
: defaultProviderOrder(providers)
const seen = new Set<string>()
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<string> {
return new Set(
Object.values(provider.models)
@ -98,6 +157,7 @@ function refreshConnectedSessionsForProvider(provider: SavedProvider, activeId:
export const useProviderStore = create<ProviderStore>((set, get) => ({
providers: [],
providerOrder: [...BUILT_IN_PROVIDER_IDS],
activeId: null,
hasLoadedProviders: false,
presets: [],
@ -108,8 +168,14 @@ export const useProviderStore = create<ProviderStore>((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<ProviderStore>((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),
})
}
},

View File

@ -62,11 +62,13 @@ describe('persistent storage upgrade migrations', () => {
schemaVersion?: number
activeId?: string | null
activeProviderId?: string
providerOrder?: string[]
rootFutureField?: unknown
providers?: Array<Record<string, unknown>>
}
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([])
})

View File

@ -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 () => {

View File

@ -172,8 +172,8 @@ async function handleReorder(req: Request): Promise<Response> {
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

View File

@ -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'

View File

@ -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<string>(defaultProviderOrder(providers))
const source = Array.isArray(value) ? value : defaultProviderOrder(providers)
const seen = new Set<string>()
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),
}
}

View File

@ -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<string>([
...providerIds,
...BUILT_IN_PROVIDER_IDS,
])
const source = Array.isArray(value)
? value
: defaultProviderOrder(providers)
const seen = new Set<string>()
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),
}
}

View File

@ -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<SavedProvider> {
@ -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<SavedProvider[]> {
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 ---

View File

@ -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),
})