fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS

This commit is contained in:
Supra4E8C
2025-12-28 18:10:21 +08:00
parent 981f7ac9b2
commit c17217875c
4 changed files with 161 additions and 37 deletions

View File

@@ -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);
} }
}; };

View 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
};
}
};

View File

@@ -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';

View File

@@ -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 });
} }
}; };