From 01d86916735a8e20c7e5b7382285f675d387a813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 13 Jun 2026 08:07:36 +0800 Subject: [PATCH] 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 --- desktop/bun.lock | 11 + desktop/package.json | 3 + .../src/__tests__/generalSettings.test.tsx | 17 + desktop/src/api/providers.ts | 6 +- .../src/constants/openaiOfficialProvider.ts | 5 + desktop/src/pages/Settings.tsx | 472 +++++++++++------- desktop/src/stores/providerStore.test.ts | 24 +- desktop/src/stores/providerStore.ts | 109 +++- .../__tests__/persistence-upgrade.test.ts | 6 + src/server/__tests__/providers.test.ts | 34 +- src/server/api/providers.ts | 4 +- src/server/services/openaiOfficialProvider.ts | 7 +- .../services/persistentStorageMigrations.ts | 42 +- src/server/services/providerRuntimeEnv.ts | 44 +- src/server/services/providerService.ts | 112 ++++- src/server/types/provider.ts | 11 +- 16 files changed, 688 insertions(+), 219 deletions(-) diff --git a/desktop/bun.lock b/desktop/bun.lock index e66645f8..34e0162b 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -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=="], diff --git a/desktop/package.json b/desktop/package.json index 1aec0e44..b96e1384 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 57e0b981..a0cf84d2 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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() diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 08292399..35e50413 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -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('/api/providers') + return api.get('/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('/api/providers/reorder', { orderedIds }) }, test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) { diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts index d38947e1..eabb8bb6 100644 --- a/desktop/src/constants/openaiOfficialProvider.ts +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -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' diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 6d806406..83bf956c 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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(defaultProviderOrder(providers)) + const seen = new Set() + 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([ + [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(null) const [isDeletingProvider, setIsDeletingProvider] = useState(false) const [testResults, setTestResults] = useState>({}) - const [draggingId, setDraggingId] = useState(null) - const [dragOverId, setDragOverId] = useState(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() { - {/* Official provider — always visible at top */} -
-
!isClaudeOfficialActive && handleActivateOfficial()} + item.id)} + strategy={verticalListSortingStrategy} > - -
-
- {t('settings.providers.officialName')} - {isClaudeOfficialActive && ( - {t('settings.providers.default')} - )} -
-
{t('settings.providers.officialDesc')}
-
-
+
+ {providerItems.map((item) => { + if (item.kind === 'claude-official') { + return ( + {t('settings.providers.default')} + ) : null} + details={isClaudeOfficialActive ? ( +
+ +
+ ) : null} + /> + ) + } - {isClaudeOfficialActive && ( -
- -
- )} -
+ if (item.kind === 'openai-official') { + return ( + handleActivate(OPENAI_OFFICIAL_PROVIDER_ID) : undefined} + title={t('settings.providers.openaiOfficialName')} + subtitle={t('settings.providers.openaiOfficialDesc')} + badges={isOpenAIOfficialActive ? ( + {t('settings.providers.default')} + ) : null} + details={isOpenAIOfficialActive ? ( +
+ +
+ ) : null} + /> + ) + } -
-
!isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)} - > - -
-
- {t('settings.providers.openaiOfficialName')} - {isOpenAIOfficialActive && ( - {t('settings.providers.default')} - )} -
-
{t('settings.providers.openaiOfficialDesc')}
-
-
+ const provider = item.provider + const isActive = activeId === provider.id + const test = testResults[provider.id] + const preset = presetMap.get(provider.presetId) - {isOpenAIOfficialActive && ( -
- -
- )} -
- - {/* Saved providers */} - {isLoading && providers.length === 0 ? ( -
-
-
- ) : ( -
- {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), })