mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 14:55:34 +08:00
Provider model selection had outgrown a single hardcoded service. The old service mixed shared caching with provider catalogs and CLI lookup details. That made stale model lists more likely as providers changed on separate schedules. Move model discovery behind each provider so lookup lives next to the integration. The shared service now focuses on provider resolution, caching, persistence, and dedupe. Return cache metadata and add bypassCache because model availability changes outside the app. The UI and /models command can show freshness and let users force a provider refresh. Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
|
|
import type {
|
|
ProjectSession,
|
|
LLMProvider,
|
|
Project,
|
|
ProviderModelsCacheInfo,
|
|
ProviderModelsDefinition,
|
|
} from '../../../types/app';
|
|
|
|
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
|
claude: 'opus',
|
|
cursor: 'gpt-5.3-codex',
|
|
codex: 'gpt-5.4',
|
|
gemini: 'gemini-3.1-pro-preview',
|
|
opencode: 'anthropic/claude-sonnet-4-5',
|
|
};
|
|
|
|
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
|
if (provider === 'codex') {
|
|
return ['default', 'acceptEdits', 'bypassPermissions'];
|
|
}
|
|
if (provider === 'claude') {
|
|
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
}
|
|
if (provider === 'opencode') {
|
|
return ['default'];
|
|
}
|
|
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
};
|
|
|
|
interface UseChatProviderStateArgs {
|
|
selectedSession: ProjectSession | null;
|
|
selectedProject: Project | null;
|
|
}
|
|
|
|
type ProviderModelsApiResponse = {
|
|
success?: boolean;
|
|
data?: {
|
|
models?: ProviderModelsDefinition;
|
|
cache?: ProviderModelsCacheInfo;
|
|
};
|
|
};
|
|
|
|
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
|
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
|
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
|
const [provider, setProvider] = useState<LLMProvider>(() => {
|
|
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
|
});
|
|
const [cursorModel, setCursorModel] = useState<string>(() => {
|
|
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
|
|
});
|
|
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
|
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
|
|
});
|
|
const [codexModel, setCodexModel] = useState<string>(() => {
|
|
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
|
});
|
|
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
|
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
|
});
|
|
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
|
|
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
|
});
|
|
|
|
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
|
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
|
>({});
|
|
const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState<
|
|
Partial<Record<LLMProvider, ProviderModelsCacheInfo>>
|
|
>({});
|
|
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
|
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
|
|
|
const lastProviderRef = useRef(provider);
|
|
const providerModelsRequestIdRef = useRef(0);
|
|
|
|
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
|
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
|
const requestId = providerModelsRequestIdRef.current + 1;
|
|
providerModelsRequestIdRef.current = requestId;
|
|
const isHardRefresh = options.bypassCache === true;
|
|
|
|
if (isHardRefresh) {
|
|
setProviderModelsRefreshing(true);
|
|
} else {
|
|
setProviderModelsLoading(true);
|
|
}
|
|
|
|
try {
|
|
const results = await Promise.all(
|
|
providers.map(async (p) => {
|
|
const params = new URLSearchParams();
|
|
if (options.bypassCache) {
|
|
params.set('bypassCache', 'true');
|
|
}
|
|
|
|
const queryString = params.toString();
|
|
const response = await authenticatedFetch(`/api/providers/${p}/models${queryString ? `?${queryString}` : ''}`);
|
|
const body = (await response.json()) as ProviderModelsApiResponse;
|
|
if (!body.success || !body.data?.models || !body.data?.cache) {
|
|
return null;
|
|
}
|
|
|
|
return body.data;
|
|
}),
|
|
);
|
|
|
|
if (providerModelsRequestIdRef.current !== requestId) {
|
|
return;
|
|
}
|
|
|
|
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
|
|
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
|
|
|
|
providers.forEach((p, i) => {
|
|
const entry = results[i];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
nextCatalog[p] = entry.models;
|
|
nextCacheCatalog[p] = entry.cache;
|
|
});
|
|
|
|
setProviderModelCatalog(nextCatalog);
|
|
setProviderModelCacheCatalog(nextCacheCatalog);
|
|
} catch (error) {
|
|
console.error('Error loading provider models:', error);
|
|
} finally {
|
|
if (providerModelsRequestIdRef.current === requestId) {
|
|
setProviderModelsLoading(false);
|
|
setProviderModelsRefreshing(false);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadProviderModels();
|
|
}, [loadProviderModels]);
|
|
|
|
const pickStoredOrCurrent = (
|
|
storageKey: string,
|
|
current: string,
|
|
def: ProviderModelsDefinition,
|
|
): string => {
|
|
const stored = localStorage.getItem(storageKey);
|
|
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
|
|
return stored;
|
|
}
|
|
if (current && def.OPTIONS.some((o) => o.value === current)) {
|
|
return current;
|
|
}
|
|
return def.DEFAULT;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const claude = providerModelCatalog.claude;
|
|
if (claude) {
|
|
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
|
|
if (next !== claudeModel) {
|
|
setClaudeModel(next);
|
|
}
|
|
if (localStorage.getItem('claude-model') !== next) {
|
|
localStorage.setItem('claude-model', next);
|
|
}
|
|
}
|
|
}, [providerModelCatalog.claude, claudeModel]);
|
|
|
|
useEffect(() => {
|
|
const cursor = providerModelCatalog.cursor;
|
|
if (cursor) {
|
|
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
|
|
if (next !== cursorModel) {
|
|
setCursorModel(next);
|
|
}
|
|
if (localStorage.getItem('cursor-model') !== next) {
|
|
localStorage.setItem('cursor-model', next);
|
|
}
|
|
}
|
|
}, [providerModelCatalog.cursor, cursorModel]);
|
|
|
|
useEffect(() => {
|
|
const codex = providerModelCatalog.codex;
|
|
if (codex) {
|
|
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
|
|
if (next !== codexModel) {
|
|
setCodexModel(next);
|
|
}
|
|
if (localStorage.getItem('codex-model') !== next) {
|
|
localStorage.setItem('codex-model', next);
|
|
}
|
|
}
|
|
}, [providerModelCatalog.codex, codexModel]);
|
|
|
|
useEffect(() => {
|
|
const gemini = providerModelCatalog.gemini;
|
|
if (gemini) {
|
|
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
|
|
if (next !== geminiModel) {
|
|
setGeminiModel(next);
|
|
}
|
|
if (localStorage.getItem('gemini-model') !== next) {
|
|
localStorage.setItem('gemini-model', next);
|
|
}
|
|
}
|
|
}, [providerModelCatalog.gemini, geminiModel]);
|
|
|
|
useEffect(() => {
|
|
const opencode = providerModelCatalog.opencode;
|
|
if (opencode) {
|
|
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
|
|
if (next !== opencodeModel) {
|
|
setOpenCodeModel(next);
|
|
}
|
|
if (localStorage.getItem('opencode-model') !== next) {
|
|
localStorage.setItem('opencode-model', next);
|
|
}
|
|
}
|
|
}, [providerModelCatalog.opencode, opencodeModel]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedSession?.id) {
|
|
return;
|
|
}
|
|
|
|
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
|
const validModes = getPermissionModesForProvider(provider);
|
|
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
|
}, [selectedSession?.id, provider]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
|
return;
|
|
}
|
|
|
|
setProvider(selectedSession.__provider);
|
|
localStorage.setItem('selected-provider', selectedSession.__provider);
|
|
}, [provider, selectedSession]);
|
|
|
|
useEffect(() => {
|
|
if (lastProviderRef.current === provider) {
|
|
return;
|
|
}
|
|
setPendingPermissionRequests([]);
|
|
lastProviderRef.current = provider;
|
|
}, [provider]);
|
|
|
|
useEffect(() => {
|
|
setPendingPermissionRequests((previous) =>
|
|
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
|
);
|
|
}, [selectedSession?.id]);
|
|
|
|
useEffect(() => {
|
|
if (provider !== 'cursor') {
|
|
return;
|
|
}
|
|
|
|
authenticatedFetch('/api/cursor/config')
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
if (!data.success || !data.config?.model?.modelId) {
|
|
return;
|
|
}
|
|
|
|
const modelId = data.config.model.modelId as string;
|
|
if (!localStorage.getItem('cursor-model')) {
|
|
setCursorModel(modelId);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error loading Cursor config:', error);
|
|
});
|
|
}, [provider]);
|
|
|
|
const cyclePermissionMode = useCallback(() => {
|
|
const modes = getPermissionModesForProvider(provider);
|
|
|
|
const currentIndex = modes.indexOf(permissionMode);
|
|
const nextIndex = (currentIndex + 1) % modes.length;
|
|
const nextMode = modes[nextIndex];
|
|
setPermissionMode(nextMode);
|
|
|
|
if (selectedSession?.id) {
|
|
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
|
}
|
|
}, [permissionMode, provider, selectedSession?.id]);
|
|
|
|
return {
|
|
provider,
|
|
setProvider,
|
|
cursorModel,
|
|
setCursorModel,
|
|
claudeModel,
|
|
setClaudeModel,
|
|
codexModel,
|
|
setCodexModel,
|
|
geminiModel,
|
|
setGeminiModel,
|
|
opencodeModel,
|
|
setOpenCodeModel,
|
|
permissionMode,
|
|
setPermissionMode,
|
|
pendingPermissionRequests,
|
|
setPendingPermissionRequests,
|
|
cyclePermissionMode,
|
|
providerModelCatalog,
|
|
providerModelCacheCatalog,
|
|
providerModelsLoading,
|
|
providerModelsRefreshing,
|
|
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
|
};
|
|
}
|