Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 13 additions & 32 deletions apps/web/src/features/aiProviders/AiProvidersOpenAIModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ import { Input } from '@/components/ui/Input';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
import type { ModelInfo } from '@/utils/models';
import { normalizeAuthIndex } from '@/utils/authIndex';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import { discoverOpenAIModels } from './openAIModelDiscovery';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';

Expand Down Expand Up @@ -74,42 +72,25 @@ export function AiProvidersOpenAIModelsPage() {
);

const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
async () => {
const trimmedBaseUrl = form.baseUrl.trim();
if (!trimmedBaseUrl) return;

setFetching(true);
setError('');
try {
const headerObject = buildHeaderObject(form.headers);
const firstEntry = form.apiKeyEntries.find(
(entry) => entry.apiKey?.trim() || normalizeAuthIndex(entry.authIndex)
);
const firstKey = firstEntry?.apiKey?.trim();
const authIndex = normalizeAuthIndex(firstEntry?.authIndex) ?? undefined;
const hasAuthHeader = hasHeader(headerObject, 'authorization');
const list = await modelsApi.fetchModelsViaApiCall(
trimmedBaseUrl,
hasAuthHeader ? undefined : firstKey,
headerObject,
authIndex
);
const list = await discoverOpenAIModels({
baseUrl: trimmedBaseUrl,
headers: form.headers,
apiKeyEntries: form.apiKeyEntries,
proxyRequiresSavedEntryMessage: t(
'ai_providers.openai_models_proxy_requires_saved_entry'
),
});
setModels(list);
} catch (err: unknown) {
if (allowFallback) {
try {
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
setModels(list);
return;
} catch (fallbackErr: unknown) {
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
}
} else {
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
}
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
} finally {
setFetching(false);
}
Expand Down Expand Up @@ -243,7 +224,7 @@ export function AiProvidersOpenAIModelsPage() {
<Button
variant="secondary"
size="sm"
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
onClick={() => void fetchOpenaiModelDiscovery()}
loading={fetching}
disabled={disableControls || saving}
>
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/features/aiProviders/openAIModelDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';
import { discoverOpenAIModels } from './openAIModelDiscovery';

const options = {
baseUrl: 'https://api.example.com/v1',
headers: [],
proxyRequiresSavedEntryMessage: 'Save the provider first',
};

describe('discoverOpenAIModels', () => {
it('does not retry without the configured proxy route', async () => {
const fetchModels = vi.fn().mockRejectedValue(new Error('upstream blocked'));

await expect(
discoverOpenAIModels({
...options,
apiKeyEntries: [
{
apiKey: 'sk-test',
authIndex: 'auth-1',
proxyUrl: 'socks5://proxy.example.com:1080',
},
],
fetchModels,
})
).rejects.toThrow('upstream blocked');

expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).toHaveBeenCalledWith(
options.baseUrl,
'sk-test',
{},
'auth-1'
);
});

it('requires proxy-backed drafts to be saved before discovery', async () => {
const fetchModels = vi.fn();

await expect(
discoverOpenAIModels({
...options,
apiKeyEntries: [
{
apiKey: 'sk-test',
proxyUrl: 'socks5://proxy.example.com:1080',
},
],
fetchModels,
})
).rejects.toThrow(options.proxyRequiresSavedEntryMessage);

expect(fetchModels).not.toHaveBeenCalled();
});

it('preserves provider and entry headers on the routed request', async () => {
const fetchModels = vi.fn().mockResolvedValue([]);

await discoverOpenAIModels({
...options,
headers: [{ key: 'X-Provider', value: 'provider-value' }],
apiKeyEntries: [
{
apiKey: 'sk-test',
authIndex: 'auth-1',
proxyUrl: 'socks5://proxy.example.com:1080',
headers: { 'X-Entry': 'entry-value' },
},
],
fetchModels,
});

expect(fetchModels).toHaveBeenCalledWith(
options.baseUrl,
'sk-test',
{
'X-Provider': 'provider-value',
'X-Entry': 'entry-value',
},
'auth-1'
);
});

it('uses a single credentialless request for a public endpoint', async () => {
const fetchModels = vi.fn().mockResolvedValue([{ name: 'public-model' }]);

await discoverOpenAIModels({
...options,
apiKeyEntries: [],
fetchModels,
});

expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).toHaveBeenCalledWith(options.baseUrl, undefined, {}, undefined);
});
});
44 changes: 44 additions & 0 deletions apps/web/src/features/aiProviders/openAIModelDiscovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { modelsApi } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import { normalizeAuthIndex } from '@/utils/authIndex';
import { buildHeaderObject, hasHeader, type HeaderEntry } from '@/utils/headers';

type OpenAIModelDiscoveryOptions = {
baseUrl: string;
headers: HeaderEntry[];
apiKeyEntries: ApiKeyEntry[];
proxyRequiresSavedEntryMessage: string;
fetchModels?: typeof modelsApi.fetchModelsViaApiCall;
};

export const discoverOpenAIModels = async ({
baseUrl,
headers,
apiKeyEntries,
proxyRequiresSavedEntryMessage,
fetchModels = modelsApi.fetchModelsViaApiCall,
}: OpenAIModelDiscoveryOptions) => {
const headerObject = buildHeaderObject(headers);
const firstEntry = apiKeyEntries.find(
(entry) =>
entry.apiKey?.trim() ||
normalizeAuthIndex(entry.authIndex) ||
entry.proxyUrl?.trim() ||
Object.keys(entry.headers ?? {}).length > 0
);
const firstKey = firstEntry?.apiKey?.trim();
const authIndex = normalizeAuthIndex(firstEntry?.authIndex) ?? undefined;
const resolvedHeaders = { ...headerObject, ...(firstEntry?.headers ?? {}) };
Comment on lines +21 to +31

if (firstEntry?.proxyUrl?.trim() && !authIndex) {
throw new Error(proxyRequiresSavedEntryMessage);
Comment on lines +33 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow saved proxy entries without auth-index

When a proxy-backed OpenAI entry is created in the UI, the form/save path only includes an authIndex if one was already present; otherwise the saved config can be an api-key plus proxy-url entry. In environments where /openai-compatibility echoes that shape instead of injecting an auth-index, this guard keeps throwing the “save provider” error even after the user saves, so model discovery never issues a request for that provider. The save-first check needs to distinguish truly unsaved drafts from saved proxy entries or obtain a usable route before blocking.

Useful? React with 👍 / 👎.

}

const hasAuthHeader = hasHeader(resolvedHeaders, 'authorization');
return fetchModels(
baseUrl,
hasAuthHeader ? undefined : firstKey,
resolvedHeaders,
authIndex
);
};
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@
"openai_models_fetch_loading": "Fetching models from /models...",
"openai_models_fetch_empty": "No models returned. Check the address, key, or headers.",
"openai_models_fetch_error": "Failed to fetch models",
"openai_models_proxy_requires_saved_entry": "Save the provider before fetching models through its configured proxy.",
"openai_models_fetch_back": "Back to edit",
"openai_models_fetch_apply": "Add selected models",
"openai_models_search_label": "Search models",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@
"openai_models_fetch_loading": "Получение моделей из /models...",
"openai_models_fetch_empty": "Модели не вернулись. Проверьте адрес, ключ или заголовки.",
"openai_models_fetch_error": "Не удалось получить модели",
"openai_models_proxy_requires_saved_entry": "Сохраните провайдера перед получением моделей через настроенный прокси.",
"openai_models_fetch_back": "Вернуться к редактированию",
"openai_models_fetch_apply": "Добавить выбранные модели",
"openai_models_search_label": "Поиск моделей",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@
"openai_models_fetch_loading": "正在从 /models 获取模型列表...",
"openai_models_fetch_empty": "未获取到模型,请检查地址、密钥或请求头。",
"openai_models_fetch_error": "获取模型失败",
"openai_models_proxy_requires_saved_entry": "请先保存渠道,再通过已配置的代理获取模型。",
"openai_models_fetch_back": "返回编辑",
"openai_models_fetch_apply": "添加所选模型",
"openai_models_search_label": "搜索模型",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@
"openai_models_fetch_loading": "正在從 /models 取得模型清單...",
"openai_models_fetch_empty": "未取得模型,請檢查位址、金鑰或請求標頭。",
"openai_models_fetch_error": "取得模型失敗",
"openai_models_proxy_requires_saved_entry": "請先儲存渠道,再透過已設定的代理取得模型。",
"openai_models_fetch_back": "返回編輯",
"openai_models_fetch_apply": "新增已選模型",
"openai_models_search_label": "搜尋模型",
Expand Down