diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts index 7b0c5ea3..93973777 100644 --- a/src/cliproxy/catalog-routing.ts +++ b/src/cliproxy/catalog-routing.ts @@ -20,7 +20,7 @@ export interface CatalogRoutingSnapshot { export async function getCatalogRoutingSnapshot(): Promise { const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); - const modelsResponse = await fetchCliproxyModels(); + const modelsResponse = snapshot.source === 'live' ? await fetchCliproxyModels() : null; const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); return { diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts index 2ab33041..54a97e6d 100644 --- a/src/cliproxy/managed-model-prefixes.ts +++ b/src/cliproxy/managed-model-prefixes.ts @@ -3,6 +3,8 @@ import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-t import { mapExternalProviderName } from './provider-capabilities'; import type { CLIProxyProvider } from './types'; +const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000; + interface ManagementAuthFileRecord { account_type?: string; name: string; @@ -25,11 +27,24 @@ function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | return providerName ? mapExternalProviderName(providerName) : null; } -async function listAuthFiles(): Promise { +async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise { const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { - headers: buildManagementHeaders(target), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGED_PREFIX_REQUEST_TIMEOUT_MS); + + try { + return await fetch(buildProxyUrl(target, path), { + ...init, + headers: buildManagementHeaders(target, init.headers as Record | undefined), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +async function listAuthFiles(): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files'); if (!response.ok) { throw new Error(`auth file listing failed with status ${response.status}`); @@ -40,12 +55,11 @@ async function listAuthFiles(): Promise { } async function patchAuthFilePrefix(name: string, prefix: string): Promise { - const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files/fields'), { + const response = await fetchManagementEndpoint('/v0/management/auth-files/fields', { method: 'PATCH', - headers: buildManagementHeaders(target, { + headers: { 'Content-Type': 'application/json', - }), + }, body: JSON.stringify({ name, prefix }), }); @@ -55,14 +69,9 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise } async function readAuthFileMetadata(name: string): Promise { - const target = getProxyTarget(); - const url = buildProxyUrl( - target, + const response = await fetchManagementEndpoint( `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` ); - const response = await fetch(url, { - headers: buildManagementHeaders(target), - }); if (!response.ok) { throw new Error(`auth file download failed for ${name} with status ${response.status}`); @@ -89,8 +98,17 @@ async function readAuthFileMetadata(name: string): Promise { export async function ensureManagedModelPrefixes( providers?: CLIProxyProvider[] ): Promise { + const allowedProviders = new Set( + (providers ?? []) + .map((provider) => provider.trim()) + .filter((provider) => getManagedModelPrefix(provider)) + ); + + if (providers && allowedProviders.size === 0) { + return { checked: 0, updated: 0 }; + } + const files = await listAuthFiles(); - const allowedProviders = new Set((providers ?? []).map((provider) => provider.trim())); let checked = 0; let updated = 0; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fd43ac93..b83c3453 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -125,7 +125,7 @@ function getSelectableModelId( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; } function getBackendLabel(backend: CLIProxyBackend): string { diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index 86c4e1ca..b6194ac4 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -162,9 +162,7 @@ export function buildCliproxyRoutingHints( .map((candidate) => candidate.id) .sort((left, right) => left.localeCompare(right)); const managedPinnedId = `${prefix}/${model.id}`; - const recommendedModelId = pinnedCandidates.includes(managedPinnedId) - ? managedPinnedId - : (pinnedCandidates[0] ?? managedPinnedId); + const pinnedAvailable = pinnedCandidates.includes(managedPinnedId); const mergedModel = mergedModelMap.get(normalize(model.id)); const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; const effectiveDisplayName = @@ -188,8 +186,8 @@ export function buildCliproxyRoutingHints( modelName: model.name?.trim() || model.id, prefix, pinnedModelId: managedPinnedId, - recommendedModelId, - pinnedAvailable: pinnedCandidates.length > 0, + recommendedModelId: managedPinnedId, + pinnedAvailable, unprefixedStatus, effectiveProvider, effectiveDisplayName, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 686278df..0b2bdf01 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -1017,7 +1017,6 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { expect(downloadedNames).toEqual(['gemini-main']); }); + it('returns immediately when called for providers without managed prefixes', async () => { + const fetchMock = mock(() => Promise.reject(new Error('should not fetch'))); + global.fetch = fetchMock as typeof fetch; + + const result = await ensureManagedModelPrefixes(['codex']); + + expect(result).toEqual({ checked: 0, updated: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('skips files that already have the managed prefix or a different custom prefix', async () => { const requests = installFetchMock({ files: [ diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 07734dcb..2d1a21f3 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -64,4 +64,24 @@ describe('cliproxy model routing hints', () => { effectiveProvider: null, }); }); + + it('does not promote custom auth-file prefixes as managed pinned model ids', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'team-a/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + }); + }); }); diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 2116a44d..ec21f4c4 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -28,7 +28,7 @@ function normalizePresetValues( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; }; return { diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index eb62216e..9549ab18 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -60,9 +60,7 @@ export function ModelConfigSection({ [routing] ); const toPreferredModelId = (modelId: string): string => - routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable - ? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId) - : modelId; + routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; const extendedContextModels = useMemo(() => { if (!catalog) return []; diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 0fa58b82..be7502bc 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -310,10 +310,7 @@ function getPreferredOptionValue( modelId: string, routingHint: CliproxyProviderRoutingHints['models'][number] | undefined ): string { - if (!routingHint?.pinnedAvailable) { - return modelId; - } - return routingHint.recommendedModelId; + return routingHint?.recommendedModelId ?? modelId; } export function FlexibleModelSelector({ @@ -338,6 +335,15 @@ export function FlexibleModelSelector({ new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), [routing] ); + const recommendedOptionValues = useMemo( + () => + new Set( + resolvedCatalogModels.map((model) => + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ), + [resolvedCatalogModels, routingHints] + ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] @@ -384,6 +390,12 @@ export function FlexibleModelSelector({ const allModelOptions = allModels .filter((model) => !catalogModelIds.has(model.id)) + .filter( + (model) => + !recommendedOptionValues.has( + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ) .map((model) => ({ value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'all', diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 1ac8199f..a49d0c25 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -47,6 +47,11 @@ const MAX_POLL_DURATION = 5 * 60 * 1000; /** Fail visibly after repeated poll transport errors instead of retrying forever */ const MAX_POLL_FAILURES = 3; +function invalidateCliproxyRoutingData(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + async function parseResponseBody(response: Response): Promise> { const text = await response.text(); if (!text) return {}; @@ -167,6 +172,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${provider} authentication successful`); openedAuthUrlRef.current = false; setState(INITIAL_STATE); @@ -301,6 +307,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts openedAuthUrlRef.current = false; @@ -467,6 +474,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${currentProvider} authentication successful`); setState(INITIAL_STATE); } else { diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d88e2b82..46d48a4e 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -14,6 +14,17 @@ import { } from '@/lib/api-client'; import { toast } from 'sonner'; +function invalidateCliproxyRoutingQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + +function invalidateCliproxyAccountQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyRoutingQueries(queryClient); +} + export function useCliproxy() { return useQuery({ queryKey: ['cliproxy'], @@ -128,8 +139,7 @@ export function useSetDefaultAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.setDefault(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Default account updated'); }, onError: (error: Error) => { @@ -145,8 +155,7 @@ export function useRemoveAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.remove(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Account removed'); }, onError: (error: Error) => { @@ -162,8 +171,7 @@ export function usePauseAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.pause(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account paused'); }, @@ -180,8 +188,7 @@ export function useResumeAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.resume(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account resumed'); }, @@ -198,8 +205,7 @@ export function useSoloAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.solo(provider, accountId), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); const pausedCount = data.paused.length; toast.success( @@ -219,8 +225,7 @@ export function useBulkPauseAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkPause(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -244,8 +249,7 @@ export function useBulkResumeAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkResume(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -270,8 +274,7 @@ export function useStartAuth() { mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) => api.cliproxy.auth.start(provider, nickname), onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success(`Account added for ${variables.provider}`); }, onError: (error: Error) => { @@ -297,8 +300,7 @@ export function useKiroImport() { return useMutation({ mutationFn: () => api.cliproxy.auth.kiroImport(), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); if (data.account) { toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`); } else { diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 7c5b1e8f..6aba8344 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -306,6 +306,7 @@ export function CliproxyPage() { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); }; const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {