mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS
This commit is contained in:
@@ -11,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u
|
|||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
import {
|
||||||
|
ampcodeApi,
|
||||||
|
apiCallApi,
|
||||||
|
getApiCallErrorMessage,
|
||||||
|
modelsApi,
|
||||||
|
providersApi,
|
||||||
|
usageApi
|
||||||
|
} from '@/services/api';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
@@ -91,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
|
|||||||
const excludedModelsToText = (models?: string[]) =>
|
const excludedModelsToText = (models?: string[]) =>
|
||||||
Array.isArray(models) ? models.join('\n') : '';
|
Array.isArray(models) ? models.join('\n') : '';
|
||||||
|
|
||||||
|
const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||||
|
let trimmed = String(baseUrl || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||||
|
trimmed = trimmed.replace(/\/+$/g, '');
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||||||
|
trimmed = `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||||
const trimmed = String(baseUrl || '')
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
.trim()
|
|
||||||
.replace(/\/+$/g, '');
|
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||||
const trimmed = String(baseUrl || '')
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
.trim()
|
|
||||||
.replace(/\/+$/g, '');
|
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
if (trimmed.endsWith('/chat/completions')) {
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -483,7 +497,7 @@ export function AiProvidersPage() {
|
|||||||
.find((entry) => entry.apiKey?.trim())
|
.find((entry) => entry.apiKey?.trim())
|
||||||
?.apiKey?.trim();
|
?.apiKey?.trim();
|
||||||
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
|
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
|
||||||
const list = await modelsApi.fetchModels(
|
const list = await modelsApi.fetchModelsViaApiCall(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasAuthHeader ? undefined : firstKey,
|
hasAuthHeader ? undefined : firstKey,
|
||||||
headers
|
headers
|
||||||
@@ -492,7 +506,7 @@ export function AiProvidersPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (allowFallback) {
|
if (allowFallback) {
|
||||||
try {
|
try {
|
||||||
const list = await modelsApi.fetchModels(baseUrl);
|
const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
|
||||||
setOpenaiDiscoveryModels(list);
|
setOpenaiDiscoveryModels(list);
|
||||||
return;
|
return;
|
||||||
} catch (fallbackErr: any) {
|
} catch (fallbackErr: any) {
|
||||||
@@ -645,48 +659,40 @@ export function AiProvidersPage() {
|
|||||||
setOpenaiTestStatus('loading');
|
setOpenaiTestStatus('loading');
|
||||||
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
|
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const result = await apiCallApi.request(
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
url: endpoint,
|
||||||
signal: controller.signal,
|
header: Object.keys(headers).length ? headers : undefined,
|
||||||
body: JSON.stringify({
|
data: JSON.stringify({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
messages: [{ role: 'user', content: 'Hi' }],
|
||||||
stream: false,
|
stream: false,
|
||||||
max_tokens: 5,
|
max_tokens: 5,
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
const rawText = await response.text();
|
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
let errorMessage = `${response.status} ${response.statusText}`;
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
try {
|
|
||||||
const parsed = rawText ? JSON.parse(rawText) : null;
|
|
||||||
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
|
|
||||||
} catch {
|
|
||||||
if (rawText) {
|
|
||||||
errorMessage = rawText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenaiTestStatus('success');
|
setOpenaiTestStatus('success');
|
||||||
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
|
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setOpenaiTestStatus('error');
|
setOpenaiTestStatus('error');
|
||||||
if (err?.name === 'AbortError') {
|
const isTimeout =
|
||||||
|
err?.code === 'ECONNABORTED' ||
|
||||||
|
String(err?.message || '').toLowerCase().includes('timeout');
|
||||||
|
if (isTimeout) {
|
||||||
setOpenaiTestMessage(
|
setOpenaiTestMessage(
|
||||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
|
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
86
src/services/api/apiCall.ts
Normal file
86
src/services/api/apiCall.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Generic API call helper (proxied via management API).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ApiCallRequest {
|
||||||
|
authIndex?: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCallResult<T = any> {
|
||||||
|
statusCode: number;
|
||||||
|
header: Record<string, string[]>;
|
||||||
|
bodyText: string;
|
||||||
|
body: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||||
|
if (input === undefined || input === null) {
|
||||||
|
return { bodyText: '', body: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const text = input;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { bodyText: text, body: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { bodyText: text, body: JSON.parse(trimmed) };
|
||||||
|
} catch {
|
||||||
|
return { bodyText: text, body: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { bodyText: JSON.stringify(input), body: input };
|
||||||
|
} catch {
|
||||||
|
return { bodyText: String(input), body: input };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||||
|
const status = result.statusCode;
|
||||||
|
const body = result.body;
|
||||||
|
const bodyText = result.bodyText;
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
message = body?.error?.message || body?.error || body?.message || '';
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
message = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message && bodyText) {
|
||||||
|
message = bodyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && message) return `${status} ${message}`.trim();
|
||||||
|
if (status) return `HTTP ${status}`;
|
||||||
|
return message || 'Request failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiCallApi = {
|
||||||
|
request: async (
|
||||||
|
payload: ApiCallRequest,
|
||||||
|
config?: AxiosRequestConfig
|
||||||
|
): Promise<ApiCallResult> => {
|
||||||
|
const response = await apiClient.post('/api-call', payload, config);
|
||||||
|
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||||
|
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||||
|
const { bodyText, body } = normalizeBody(response?.body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
header,
|
||||||
|
bodyText,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './client';
|
export * from './client';
|
||||||
|
export * from './apiCall';
|
||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './configFile';
|
export * from './configFile';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { normalizeModelList } from '@/utils/models';
|
import { normalizeModelList } from '@/utils/models';
|
||||||
|
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||||
|
|
||||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||||
let normalized = String(baseUrl || '').trim();
|
let normalized = String(baseUrl || '').trim();
|
||||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
|||||||
});
|
});
|
||||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||||
return normalizeModelList(payload, { dedupe: true });
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchModelsViaApiCall(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey?: string,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
) {
|
||||||
|
const endpoint = buildModelsEndpoint(baseUrl);
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('Invalid base url');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedHeaders = { ...headers };
|
||||||
|
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||||
|
if (apiKey && !hasAuthHeader) {
|
||||||
|
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: endpoint,
|
||||||
|
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result.body ?? result.bodyText;
|
||||||
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user