fix(cliproxy): harden pinned routing follow-up

This commit is contained in:
Tam Nhu Tran
2026-04-10 01:16:31 -04:00
parent b6aa6fb0d4
commit 40c4816acf
13 changed files with 124 additions and 50 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export interface CatalogRoutingSnapshot {
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
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 {
+33 -15
View File
@@ -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<ManagementAuthFileRecord[]> {
async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise<Response> {
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<string, string> | undefined),
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}
async function listAuthFiles(): Promise<ManagementAuthFileRecord[]> {
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<ManagementAuthFileRecord[]> {
}
async function patchAuthFilePrefix(name: string, prefix: string): Promise<void> {
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<void>
}
async function readAuthFileMetadata(name: string): Promise<AuthFileMetadata> {
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<AuthFileMetadata> {
export async function ensureManagedModelPrefixes(
providers?: CLIProxyProvider[]
): Promise<ManagedPrefixSyncResult> {
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;
+1 -1
View File
@@ -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 {
+3 -5
View File
@@ -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,
@@ -1017,7 +1017,6 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
return;
}
pendingManualAuthState.delete(state);
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
@@ -1033,6 +1032,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
isDefault: account.isDefault,
},
});
pendingManualAuthState.delete(state);
return;
}
@@ -1167,7 +1167,11 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
}
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
}
res.json({
@@ -1180,6 +1184,9 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
isDefault: account.isDefault,
},
});
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
return;
}
@@ -111,6 +111,16 @@ describe('ensureManagedModelPrefixes', () => {
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: [
@@ -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',
});
});
});
@@ -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 {
@@ -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 [];
@@ -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',
+8
View File
@@ -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<typeof useQueryClient>): void {
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
}
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
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 {
+20 -18
View File
@@ -14,6 +14,17 @@ import {
} from '@/lib/api-client';
import { toast } from 'sonner';
function invalidateCliproxyRoutingQueries(queryClient: ReturnType<typeof useQueryClient>): void {
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
}
function invalidateCliproxyAccountQueries(queryClient: ReturnType<typeof useQueryClient>): 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 {
+1
View File
@@ -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) => {