From c17217875c4393fe7ba9187b655180154e8218fa Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 28 Dec 2025 18:10:21 +0800 Subject: [PATCH] fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS --- src/pages/AiProvidersPage.tsx | 80 +++++++++++++++++--------------- src/services/api/apiCall.ts | 86 +++++++++++++++++++++++++++++++++++ src/services/api/index.ts | 1 + src/services/api/models.ts | 31 +++++++++++++ 4 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 src/services/api/apiCall.ts diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index df27cb47..bdb52028 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -11,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconCheck, IconX } from '@/components/ui/icons'; 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 iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; @@ -91,18 +98,25 @@ const parseExcludedModels = (text: string): string[] => const excludedModelsToText = (models?: string[]) => 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 trimmed = String(baseUrl || '') - .trim() - .replace(/\/+$/g, ''); + const trimmed = normalizeOpenAIBaseUrl(baseUrl); if (!trimmed) return ''; return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; }; const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { - const trimmed = String(baseUrl || '') - .trim() - .replace(/\/+$/g, ''); + const trimmed = normalizeOpenAIBaseUrl(baseUrl); if (!trimmed) return ''; if (trimmed.endsWith('/chat/completions')) { return trimmed; @@ -483,7 +497,7 @@ export function AiProvidersPage() { .find((entry) => entry.apiKey?.trim()) ?.apiKey?.trim(); const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']); - const list = await modelsApi.fetchModels( + const list = await modelsApi.fetchModelsViaApiCall( baseUrl, hasAuthHeader ? undefined : firstKey, headers @@ -492,7 +506,7 @@ export function AiProvidersPage() { } catch (err: any) { if (allowFallback) { try { - const list = await modelsApi.fetchModels(baseUrl); + const list = await modelsApi.fetchModelsViaApiCall(baseUrl); setOpenaiDiscoveryModels(list); return; } catch (fallbackErr: any) { @@ -645,48 +659,40 @@ export function AiProvidersPage() { setOpenaiTestStatus('loading'); setOpenaiTestMessage(t('ai_providers.openai_test_running')); - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS); try { - const response = await fetch(endpoint, { - method: 'POST', - headers, - signal: controller.signal, - body: JSON.stringify({ - model: modelName, - messages: [{ role: 'user', content: 'Hi' }], - stream: false, - max_tokens: 5, - }), - }); - const rawText = await response.text(); + const result = await apiCallApi.request( + { + method: 'POST', + url: endpoint, + header: Object.keys(headers).length ? headers : undefined, + data: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: 'Hi' }], + stream: false, + max_tokens: 5, + }), + }, + { timeout: OPENAI_TEST_TIMEOUT_MS } + ); - if (!response.ok) { - let errorMessage = `${response.status} ${response.statusText}`; - try { - const parsed = rawText ? JSON.parse(rawText) : null; - errorMessage = parsed?.error?.message || parsed?.message || errorMessage; - } catch { - if (rawText) { - errorMessage = rawText; - } - } - throw new Error(errorMessage); + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(getApiCallErrorMessage(result)); } setOpenaiTestStatus('success'); setOpenaiTestMessage(t('ai_providers.openai_test_success')); } catch (err: any) { setOpenaiTestStatus('error'); - if (err?.name === 'AbortError') { + const isTimeout = + err?.code === 'ECONNABORTED' || + String(err?.message || '').toLowerCase().includes('timeout'); + if (isTimeout) { setOpenaiTestMessage( t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }) ); } else { setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`); } - } finally { - window.clearTimeout(timeoutId); } }; diff --git a/src/services/api/apiCall.ts b/src/services/api/apiCall.ts new file mode 100644 index 00000000..8bd7d20d --- /dev/null +++ b/src/services/api/apiCall.ts @@ -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; + data?: string; +} + +export interface ApiCallResult { + statusCode: number; + header: Record; + 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 => { + 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; + const { bodyText, body } = normalizeBody(response?.body); + + return { + statusCode, + header, + bodyText, + body + }; + } +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 470eaada..990db81e 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,4 +1,5 @@ export * from './client'; +export * from './apiCall'; export * from './config'; export * from './configFile'; export * from './apiKeys'; diff --git a/src/services/api/models.ts b/src/services/api/models.ts index 4e872c41..b6d9dd62 100644 --- a/src/services/api/models.ts +++ b/src/services/api/models.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { normalizeModelList } from '@/utils/models'; +import { apiCallApi, getApiCallErrorMessage } from './apiCall'; const normalizeBaseUrl = (baseUrl: string): string => { let normalized = String(baseUrl || '').trim(); @@ -39,5 +40,35 @@ export const modelsApi = { }); const payload = response.data?.data ?? response.data?.models ?? response.data; return normalizeModelList(payload, { dedupe: true }); + }, + + async fetchModelsViaApiCall( + baseUrl: string, + apiKey?: string, + headers: Record = {} + ) { + 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 }); } };