mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-25 09:53:29 +08:00
feat: add AIMLAPI (aimlapi.com) as a model provider (#17311)
### Summary This PR adds **aimlapi.com** as a model provider, so a RAGFlow user can enter one API key in the model settings and use AIMLAPI's models across the app. AIMLAPI ([aimlapi.com](https://aimlapi.com)) is an OpenAI-compatible aggregator that serves 700+ models (LLM, embedding, vision, TTS, ASR) from many providers behind a single API. The change mirrors the repo's existing "add provider" pattern (e.g. FuturMix / OpenRouter): provider logic lives in the same files those providers use, and shared / UI files get only registration entries. **Backend** - `conf/llm_factories.json` — the `aimlapi.com` factory entry. - `rag/llm/__init__.py`, `rag/llm/{chat,embedding,cv}_model.py` — LiteLLM adapters (chat, embedding, image2text) with a production base URL, overridable via `AIMLAPI_API_URL`. - `rag/llm/model_meta.py` — an `AIMLAPI` model-meta so the provider lists its full `/v1/models` catalog dynamically (classified by the endpoint `type`), the same way OpenRouter does. - `api/apps/restful_apis/aimlapi_api.py` — an optional "Get API key" flow using AIMLAPI's agent-authorization (OAuth 2.0 Device Authorization Grant, RFC 8628). The device code is kept server-side (Redis); only the issued key reaches the browser. **Frontend (`web/`)** - Provider registration (constant, icon allowlist, brand logo), the model picker (`LIST_MODEL_PROVIDERS` + a `buildLocalConfig` entry), and the "Get API key" button in the provider dialog. Locales added to `en` and `zh`. **Configuration** — production defaults are compiled in; endpoints and the partner id are overridable through `AIMLAPI_*` environment variables, so the same build works across environments. **Testing** — the `web` build passes; chat, embedding and dynamic model listing were smoke-tested against the live API.
This commit is contained in:
11
web/src/assets/svg/llm/aimlapi.svg
Normal file
11
web/src/assets/svg/llm/aimlapi.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" viewBox="0 0 2029 2029">
|
||||
<defs>
|
||||
<clipPath id="clip-AIMLAPI">
|
||||
<rect width="2029" height="2029"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="AIMLAPI" clip-path="url(#clip-AIMLAPI)">
|
||||
<circle id="Ellipse_5" data-name="Ellipse 5" cx="1014.5" cy="1014.5" r="1014.5"/>
|
||||
<path id="Subtraction_430" data-name="Subtraction 430" d="M-20415.064,4029h-655.867a156.035,156.035,0,0,1-40.705-5.4,156.112,156.112,0,0,1-37.3-15.5,156.067,156.067,0,0,1-32.07-24.55,155.938,155.938,0,0,1-25.027-32.548l-327.936-568a155.812,155.812,0,0,1-15.674-37.948,156.027,156.027,0,0,1-5.225-40.05,156.027,156.027,0,0,1,5.225-40.05,155.809,155.809,0,0,1,15.674-37.949l327.936-568a155.915,155.915,0,0,1,25.027-32.552,156.072,156.072,0,0,1,32.07-24.551,156.111,156.111,0,0,1,37.3-15.5,156.045,156.045,0,0,1,40.705-5.4h655.867a156,156,0,0,1,40.7,5.4,156.071,156.071,0,0,1,37.295,15.5,156.047,156.047,0,0,1,32.072,24.551,156.011,156.011,0,0,1,25.029,32.551l327.936,568a155.952,155.952,0,0,1,15.672,37.949,156.027,156.027,0,0,1,5.225,40.05,156.027,156.027,0,0,1-5.225,40.05,155.951,155.951,0,0,1-15.672,37.948l-327.936,568a156.03,156.03,0,0,1-25.029,32.548,156.1,156.1,0,0,1-32.072,24.55,156.052,156.052,0,0,1-37.295,15.5A155.994,155.994,0,0,1-20415.064,4029Zm-439.025-904.879a70.149,70.149,0,0,0-13.3,1.274,69.656,69.656,0,0,0-12.9,3.823,69.189,69.189,0,0,0-23.033,15.292l-262.551,262.557a69.216,69.216,0,0,0-15.295,23.031,69.4,69.4,0,0,0-3.822,12.9,70.024,70.024,0,0,0-1.275,13.3,70.02,70.02,0,0,0,1.275,13.3,69.394,69.394,0,0,0,3.822,12.9,69.166,69.166,0,0,0,15.295,23.03,69.165,69.165,0,0,0,23.029,15.292,69.7,69.7,0,0,0,12.9,3.823,70.093,70.093,0,0,0,13.295,1.274,70.149,70.149,0,0,0,13.3-1.274,69.7,69.7,0,0,0,12.9-3.823,69.182,69.182,0,0,0,23.031-15.292l213.324-213.324L-20680.7,3465.6a69.214,69.214,0,0,0,23.031,15.292,69.666,69.666,0,0,0,12.9,3.823,70.113,70.113,0,0,0,13.3,1.274,70.113,70.113,0,0,0,13.3-1.274,69.692,69.692,0,0,0,12.9-3.823,69.2,69.2,0,0,0,23.029-15.292l262.557-262.557a69.193,69.193,0,0,0,15.293-23.03,69.665,69.665,0,0,0,3.822-12.9,70.178,70.178,0,0,0,1.275-13.3,70.176,70.176,0,0,0-1.275-13.3,69.661,69.661,0,0,0-3.822-12.9,69.217,69.217,0,0,0-15.293-23.033,69.164,69.164,0,0,0-23.029-15.292,69.6,69.6,0,0,0-12.9-3.823,70.094,70.094,0,0,0-13.3-1.275,70.094,70.094,0,0,0-13.3,1.275,69.665,69.665,0,0,0-12.9,3.823,69.216,69.216,0,0,0-23.031,15.292l-213.324,213.33-173.4-173.4a69.155,69.155,0,0,0-23.029-15.292,69.656,69.656,0,0,0-12.9-3.823A70.113,70.113,0,0,0-20854.09,3124.121Z" transform="translate(21757.5 -2290.499)" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -108,6 +108,7 @@ const svgIcons = [
|
||||
LLMFactory.Qiniu,
|
||||
LLMFactory.TokenHub,
|
||||
LLMFactory.FunASR,
|
||||
LLMFactory.AIMLAPI,
|
||||
];
|
||||
|
||||
export const LlmIcon = ({
|
||||
|
||||
@@ -87,6 +87,7 @@ export enum LLMFactory {
|
||||
TokenHub = 'TokenHub',
|
||||
NewAPI = 'New API',
|
||||
FunASR = 'FunASR',
|
||||
AIMLAPI = 'aimlapi.com',
|
||||
}
|
||||
|
||||
// Please lowercase the file name
|
||||
@@ -173,6 +174,7 @@ export const IconMap = {
|
||||
[LLMFactory.SoMark]: 'somark',
|
||||
[LLMFactory.NewAPI]: 'new-api',
|
||||
[LLMFactory.FunASR]: 'funasr',
|
||||
[LLMFactory.AIMLAPI]: 'aimlapi',
|
||||
};
|
||||
|
||||
export const ModelTypeToField: Record<string, string> = {
|
||||
@@ -195,6 +197,7 @@ export const FieldToModelType: Record<string, string> = {
|
||||
|
||||
export const APIMapUrl = {
|
||||
[LLMFactory.OpenAI]: 'https://platform.openai.com/api-keys',
|
||||
[LLMFactory.AIMLAPI]: 'https://aimlapi.com/app/keys',
|
||||
[LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys',
|
||||
[LLMFactory.Gemini]: 'https://aistudio.google.com/app/apikey',
|
||||
[LLMFactory.DeepSeek]: 'https://platform.deepseek.com/api_keys',
|
||||
|
||||
186
web/src/hooks/use-aimlapi-authorize.ts
Normal file
186
web/src/hooks/use-aimlapi-authorize.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import llmService from '@/services/llm-service';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Drives the AIMLAPI agent-authorization (OAuth 2.0 device grant) flow from the
|
||||
* provider dialog: start → open the AIMLAPI consent page in a popup → poll the
|
||||
* RAGFlow backend until the key is issued. RAGFlow never sees the user's AIMLAPI
|
||||
* credentials; the popup handles sign-in/consent on aimlapi.com.
|
||||
*
|
||||
* Mirrors the data-source connector OAuth pattern
|
||||
* (`data-source/component/google-drive-token-field.tsx`): a popup opened
|
||||
* synchronously on click, a self-scheduling `setTimeout` poll loop, a manual
|
||||
* "check status" fallback, and cleanup on unmount.
|
||||
*/
|
||||
export type AimlapiAuthorizeStatus =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'awaiting_consent'
|
||||
| 'success'
|
||||
| 'denied'
|
||||
| 'expired'
|
||||
| 'error';
|
||||
|
||||
const TERMINAL_DENIED = new Set(['denied', 'rejected']);
|
||||
const TERMINAL_EXPIRED = new Set(['expired', 'cancelled', 'canceled']);
|
||||
const POPUP_NAME = 'ragflow-aimlapi-oauth';
|
||||
const POPUP_FEATURES = 'width=600,height=760';
|
||||
|
||||
interface UseAimlapiAuthorizeOptions {
|
||||
onKey: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
export function useAimlapiAuthorize({ onKey }: UseAimlapiAuthorizeOptions) {
|
||||
const [status, setStatus] = useState<AimlapiAuthorizeStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const deadlineRef = useRef<number>(0);
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const stoppedRef = useRef<boolean>(false);
|
||||
const intervalMsRef = useRef<number>(5000);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stoppedRef.current = true;
|
||||
clearTimer();
|
||||
};
|
||||
}, [clearTimer]);
|
||||
|
||||
const poll = useCallback(
|
||||
async (requestId: string, intervalMs: number) => {
|
||||
if (stoppedRef.current) return;
|
||||
if (Date.now() > deadlineRef.current) {
|
||||
setStatus('expired');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await llmService.aimlapiAuthorizePoll({
|
||||
request_id: requestId,
|
||||
});
|
||||
if (stoppedRef.current) return;
|
||||
if (data?.code !== 0) {
|
||||
setStatus('error');
|
||||
setError(data?.message ?? null);
|
||||
return;
|
||||
}
|
||||
const result = data.data ?? {};
|
||||
const state: string = result.status ?? 'pending';
|
||||
if (state === 'ready' && result.api_key) {
|
||||
setStatus('success');
|
||||
try {
|
||||
popupRef.current?.close();
|
||||
} catch {
|
||||
/* popup may be cross-origin/closed */
|
||||
}
|
||||
onKey(result.api_key);
|
||||
return;
|
||||
}
|
||||
if (TERMINAL_DENIED.has(state)) {
|
||||
setStatus('denied');
|
||||
return;
|
||||
}
|
||||
if (TERMINAL_EXPIRED.has(state)) {
|
||||
setStatus('expired');
|
||||
return;
|
||||
}
|
||||
// still pending → schedule the next poll
|
||||
pollTimerRef.current = setTimeout(
|
||||
() => poll(requestId, intervalMs),
|
||||
intervalMs,
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (stoppedRef.current) return;
|
||||
setStatus('error');
|
||||
setError(e?.message ?? null);
|
||||
}
|
||||
},
|
||||
[onKey],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
stoppedRef.current = false;
|
||||
setError(null);
|
||||
setStatus('starting');
|
||||
clearTimer();
|
||||
|
||||
// Open the popup synchronously within the click handler so it is not blocked;
|
||||
// navigate it once the backend returns the consent URL.
|
||||
const popup = window.open('', POPUP_NAME, POPUP_FEATURES);
|
||||
popupRef.current = popup;
|
||||
|
||||
try {
|
||||
const { data } = await llmService.aimlapiAuthorizeStart();
|
||||
if (data?.code !== 0) {
|
||||
setStatus('error');
|
||||
setError(data?.message ?? null);
|
||||
try {
|
||||
popup?.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const {
|
||||
request_id: requestId,
|
||||
verification_uri: verificationUri,
|
||||
interval,
|
||||
expires_in: expiresIn,
|
||||
} = data.data ?? {};
|
||||
|
||||
if (!requestId || !verificationUri) {
|
||||
setStatus('error');
|
||||
setError('AIMLAPI authorization response is missing required fields.');
|
||||
try {
|
||||
popup?.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
requestIdRef.current = requestId;
|
||||
deadlineRef.current = Date.now() + (expiresIn ?? 900) * 1000;
|
||||
|
||||
if (popup && verificationUri) {
|
||||
popup.location.href = verificationUri;
|
||||
} else if (verificationUri) {
|
||||
window.open(verificationUri, POPUP_NAME, POPUP_FEATURES);
|
||||
}
|
||||
|
||||
setStatus('awaiting_consent');
|
||||
const intervalMs = Math.max(interval ?? 5, 1) * 1000;
|
||||
intervalMsRef.current = intervalMs;
|
||||
pollTimerRef.current = setTimeout(
|
||||
() => poll(requestId, intervalMs),
|
||||
intervalMs,
|
||||
);
|
||||
} catch (e: any) {
|
||||
setStatus('error');
|
||||
setError(e?.message ?? null);
|
||||
try {
|
||||
popup?.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}, [clearTimer, poll]);
|
||||
|
||||
// Manual "check now" fallback (e.g. if the popup's postMessage/redirect is missed).
|
||||
const checkNow = useCallback(() => {
|
||||
const requestId = requestIdRef.current;
|
||||
if (!requestId || stoppedRef.current) return;
|
||||
clearTimer();
|
||||
poll(requestId, intervalMsRef.current);
|
||||
}, [clearTimer, poll]);
|
||||
|
||||
return { status, error, start, checkNow };
|
||||
}
|
||||
@@ -1935,6 +1935,7 @@ Example: Virtual Hosted Style`,
|
||||
save: 'Save',
|
||||
search: 'Search',
|
||||
availableModels: 'Available models',
|
||||
recommended: 'Recommended',
|
||||
profile: 'Profile',
|
||||
avatar: 'Avatar',
|
||||
avatarTip: 'This will be displayed on your profile.',
|
||||
@@ -1990,6 +1991,13 @@ Example: Virtual Hosted Style`,
|
||||
apiKeyMessage: 'Please enter the API Key',
|
||||
apiKeyTip:
|
||||
'The API Key can be obtained by registering the corresponding LLM supplier.',
|
||||
aimlapiGetKey: 'Get API key',
|
||||
aimlapiCheckStatus: 'Check status',
|
||||
aimlapiAwaitingConsent: 'Waiting for approval in the AI/ML API window…',
|
||||
aimlapiKeyAdded: 'Your AI/ML API key has been generated and added above.',
|
||||
aimlapiAuthDenied: 'Authorization was denied. Please try again.',
|
||||
aimlapiAuthExpired: 'The request expired. Please try again.',
|
||||
aimlapiAuthFailed: 'Sign-in failed. Please try again.',
|
||||
showMoreModels: 'View models',
|
||||
hideModels: 'Hide models',
|
||||
baseUrl: 'Base URL',
|
||||
|
||||
@@ -1628,6 +1628,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
save: '保存',
|
||||
search: '搜索',
|
||||
availableModels: '可选模型',
|
||||
recommended: '推荐',
|
||||
profile: '概要',
|
||||
avatar: '头像',
|
||||
avatarTip: '这会在你的个人主页展示',
|
||||
@@ -1679,6 +1680,13 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
apiKey: 'API Key',
|
||||
apiKeyMessage: '请输入API Key',
|
||||
apiKeyTip: 'API Key可以通过注册相应的LLM供应商来获取。',
|
||||
aimlapiGetKey: '获取 API Key',
|
||||
aimlapiCheckStatus: '检查状态',
|
||||
aimlapiAwaitingConsent: '正在等待 AI/ML API 窗口中的授权…',
|
||||
aimlapiKeyAdded: '您的 AI/ML API Key 已生成并已填入上方。',
|
||||
aimlapiAuthDenied: '授权被拒绝,请重试。',
|
||||
aimlapiAuthExpired: '请求已过期,请重试。',
|
||||
aimlapiAuthFailed: '登录失败,请重试。',
|
||||
showMoreModels: '展示更多模型',
|
||||
hideModels: '隐藏模型',
|
||||
baseUrl: 'Base URL',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AimlapiAuthorizeStatus,
|
||||
useAimlapiAuthorize,
|
||||
} from '@/hooks/use-aimlapi-authorize';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { KeyRound, Loader2 } from 'lucide-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
const AimlapiStatusTextKey: Partial<Record<AimlapiAuthorizeStatus, string>> = {
|
||||
awaiting_consent: 'aimlapiAwaitingConsent',
|
||||
success: 'aimlapiKeyAdded',
|
||||
denied: 'aimlapiAuthDenied',
|
||||
expired: 'aimlapiAuthExpired',
|
||||
error: 'aimlapiAuthFailed',
|
||||
};
|
||||
|
||||
/**
|
||||
* "Get API key" control for the AIMLAPI provider dialog. Runs the AIMLAPI
|
||||
* agent-authorization (OAuth device grant) flow and, on success, hands the
|
||||
* issued key back to the modal via `onKey` so it can be filled into the form.
|
||||
*/
|
||||
export const AimlapiGetKeyButton: FC<{ onKey: (apiKey: string) => void }> = ({
|
||||
onKey,
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'setting' });
|
||||
const { status, error, start, checkNow } = useAimlapiAuthorize({ onKey });
|
||||
|
||||
const busy = status === 'starting' || status === 'awaiting_consent';
|
||||
const statusKey = AimlapiStatusTextKey[status];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
) : (
|
||||
<KeyRound size={14} />
|
||||
)}
|
||||
{t('aimlapiGetKey')}
|
||||
</Button>
|
||||
{status === 'awaiting_consent' && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={checkNow}>
|
||||
{t('aimlapiCheckStatus')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{statusKey && (
|
||||
<span
|
||||
className={cn('text-xs', {
|
||||
'text-state-success': status === 'success',
|
||||
'text-text-secondary': status === 'awaiting_consent',
|
||||
'text-state-error':
|
||||
status === 'denied' || status === 'expired' || status === 'error',
|
||||
})}
|
||||
>
|
||||
{t(statusKey)}
|
||||
{status === 'error' && error ? `: ${error}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AimlapiGetKeyButton;
|
||||
@@ -18,9 +18,10 @@
|
||||
import { LlmIcon } from '@/components/svg-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchInput } from '@/components/ui/input';
|
||||
import { APIMapUrl } from '@/constants/llm';
|
||||
import { APIMapUrl, LLMFactory } from '@/constants/llm';
|
||||
import { useFetchAvailableProviders } from '@/hooks/use-llm-request';
|
||||
import { ArrowUpRight, Plus } from 'lucide-react';
|
||||
import { sortLLmFactoryListBySpecifiedOrder } from '@/utils/common-util';
|
||||
import { ArrowUpRight, Plus, Star } from 'lucide-react';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -82,12 +83,16 @@ export const AvailableModels: FC<{
|
||||
}, [factoryList, searchTerm]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (selectedTag === null) {
|
||||
return searchedModels;
|
||||
}
|
||||
return searchedModels.filter((model) =>
|
||||
model.model_types.some((type) => type === selectedTag),
|
||||
);
|
||||
const filtered =
|
||||
selectedTag === null
|
||||
? searchedModels
|
||||
: searchedModels.filter((model) =>
|
||||
model.model_types.some((type) => type === selectedTag),
|
||||
);
|
||||
// AIMLAPI first (then RAGFlow's curated order) in the available list
|
||||
return sortLLmFactoryListBySpecifiedOrder(
|
||||
filtered as any,
|
||||
) as unknown as typeof filtered;
|
||||
}, [searchedModels, selectedTag]);
|
||||
|
||||
// Number of providers matching each tag, respecting the current search term so
|
||||
@@ -172,7 +177,7 @@ export const AvailableModels: FC<{
|
||||
</header>
|
||||
|
||||
{/* Models List */}
|
||||
<div className="p-4 pt-0 flex flex-col gap-4 overflow-auto h-full scrollbar-auto">
|
||||
<div className="@container p-4 pt-0 flex flex-col gap-4 overflow-auto h-full scrollbar-auto">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
@@ -181,9 +186,12 @@ export const AvailableModels: FC<{
|
||||
className="group border border-border-button rounded-lg p-3 hover:bg-bg-input transition-colors"
|
||||
onClick={() => handleAddModel(model.name)}
|
||||
>
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<LlmIcon name={model.name} imgClass="h-8 w-8 text-text-primary" />
|
||||
<div className="flex flex-1 gap-1.5 items-center">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<LlmIcon
|
||||
name={model.name}
|
||||
imgClass="h-8 w-8 shrink-0 text-text-primary"
|
||||
/>
|
||||
<div className="flex flex-1 min-w-0 gap-1.5 items-center">
|
||||
<div className="font-normal text-base truncate">
|
||||
{model.name}
|
||||
</div>
|
||||
@@ -192,7 +200,7 @@ export const AvailableModels: FC<{
|
||||
asLink
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-4"
|
||||
className="size-4 shrink-0"
|
||||
to={APIMapUrl[model.name as keyof typeof APIMapUrl]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -203,11 +211,27 @@ export const AvailableModels: FC<{
|
||||
<ArrowUpRight size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{model.name === LLMFactory.AIMLAPI && (
|
||||
// Recommended badge: the star always shows; the label appears
|
||||
// only when the card (container) is wide enough to fit it
|
||||
// alongside the Add button — otherwise it collapses to just
|
||||
// the star.
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center gap-1 text-state-success"
|
||||
title={t('setting.recommended')}
|
||||
aria-label={t('setting.recommended')}
|
||||
>
|
||||
<Star size={14} className="shrink-0 fill-current" />
|
||||
<span className="hidden @[20rem]:inline text-xs font-medium whitespace-nowrap">
|
||||
{t('setting.recommended')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
className="px-2 opacity-0 transition-all group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
className="shrink-0 px-2 opacity-0 transition-all group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('setting.addTheModel')}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
|
||||
import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form';
|
||||
import { RefObject } from 'react';
|
||||
import { LLMFactory } from '@/constants/llm';
|
||||
import { useCallback, type RefObject } from 'react';
|
||||
import { AimlapiGetKeyButton } from '../aimlapi-get-key-button';
|
||||
import { DRAFT_INSTANCE_SENTINEL, DraftModeCardProps } from '../interface';
|
||||
import { ModelsSection } from '../models-section';
|
||||
import VerifyButton from '../verify-button';
|
||||
@@ -45,6 +47,16 @@ export function DraftModeCard({
|
||||
draftName,
|
||||
setDraftName,
|
||||
}: DraftModeCardProps) {
|
||||
// On success, fold the OAuth-issued key into the current form values so it
|
||||
// lands in the (editable) api_key field without clobbering other inputs.
|
||||
const handleAimlapiKey = useCallback(
|
||||
(key: string) => {
|
||||
const current = formRef.current?.getValues() ?? {};
|
||||
formRef.current?.reset({ ...current, api_key: key });
|
||||
},
|
||||
[formRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-2 py-3 flex flex-col gap-4">
|
||||
<InstanceNameSection
|
||||
@@ -62,6 +74,10 @@ export function DraftModeCard({
|
||||
labelClassName="font-normal"
|
||||
/>
|
||||
|
||||
{providerName === LLMFactory.AIMLAPI && (
|
||||
<AimlapiGetKeyButton onKey={handleAimlapiKey} />
|
||||
)}
|
||||
|
||||
<div className="pt-3">
|
||||
<VerifyButton
|
||||
onVerify={handleVerify}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import { DynamicFormRef } from '@/components/dynamic-form';
|
||||
import message from '@/components/ui/message';
|
||||
import { IModelInfo } from '@/interfaces/request/llm';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LIST_MODEL_PROVIDERS } from '../provider-schema/constants';
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
@@ -88,6 +91,7 @@ const GenericProviderInstanceCard = forwardRef<
|
||||
const formRef = useRef<DynamicFormRef>(null);
|
||||
// Mirror of the per-instance model list - written by ModelsSection
|
||||
// via `setModelInfo`, read by the payload builder.
|
||||
const { t } = useTranslation();
|
||||
const modelInfoRef = useRef<IModelInfo[]>([]);
|
||||
|
||||
// Provider-specific config: carries `verifyTransform` / `submitTransform`
|
||||
@@ -189,13 +193,21 @@ const GenericProviderInstanceCard = forwardRef<
|
||||
// catch it). For both drafts and saved cards, run the form's
|
||||
// own validation so errors surface in the UI.
|
||||
if (isDraft && !draftName.trim()) return false;
|
||||
// List-model providers (list picker) require at least one selected model.
|
||||
if (
|
||||
LIST_MODEL_PROVIDERS.has(providerName) &&
|
||||
modelInfoRef.current.length === 0
|
||||
) {
|
||||
message.error(t('setting.selectModelBeforeVerify'));
|
||||
return false;
|
||||
}
|
||||
const isValid = await formRef.current?.trigger();
|
||||
return !!isValid;
|
||||
},
|
||||
getSavePayload,
|
||||
markSaved,
|
||||
}),
|
||||
[isDraft, draftName, getSavePayload, markSaved],
|
||||
[isDraft, draftName, getSavePayload, markSaved, providerName, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,10 @@ import { LLMFactory } from '@/constants/llm';
|
||||
* 4 model_* fields directly.
|
||||
*/
|
||||
export const LIST_MODEL_PROVIDERS = new Set<string>([
|
||||
// aimlapi.com serves its full /v1/models catalog (700+) via the AIMLAPI
|
||||
// ModelMeta on the backend, so the dialog uses the list picker (mirror of
|
||||
// that registry) instead of a single manual model_name field.
|
||||
LLMFactory.AIMLAPI,
|
||||
LLMFactory.Ollama,
|
||||
LLMFactory.OpenRouter,
|
||||
LLMFactory.VLLM,
|
||||
|
||||
@@ -24,6 +24,28 @@ import { buildModelInfoFromValues } from './utils';
|
||||
* Used for scenarios after OllamaModal merge
|
||||
*/
|
||||
export const LocalLlmConfigs: Record<string, ProviderConfig> = {
|
||||
// aimlapi.com is a cloud OpenAI-compatible aggregator (like OpenRouter): it
|
||||
// uses the list picker, so it needs a config whose submitTransform forwards
|
||||
// `model_info` (the picker selection). Falling back to GenericApiKeyConfig
|
||||
// dropped that field, so selected models were never persisted.
|
||||
[LLMFactory.AIMLAPI]: buildLocalConfig(
|
||||
LLMFactory.AIMLAPI,
|
||||
'aimlapi.com',
|
||||
['chat', 'embedding', 'image2text', 'tts', 'speech2text'],
|
||||
undefined,
|
||||
false,
|
||||
[
|
||||
{
|
||||
name: 'base_url',
|
||||
label: 'addLlmBaseUrl',
|
||||
type: 'inputSelect',
|
||||
required: false,
|
||||
placeholder: 'baseUrlNameMessage',
|
||||
shouldRender: 'hideWhenInstanceExists',
|
||||
},
|
||||
],
|
||||
'https://docs.aimlapi.com/quickstart/simple-model',
|
||||
),
|
||||
[LLMFactory.Ollama]: buildLocalConfig(
|
||||
LLMFactory.Ollama,
|
||||
'Ollama',
|
||||
|
||||
@@ -19,6 +19,8 @@ const {
|
||||
patchInstanceModel,
|
||||
deleteInstanceModels,
|
||||
updateProviderInstance,
|
||||
aimlapiAuthorizeStart,
|
||||
aimlapiAuthorizePoll,
|
||||
} = api;
|
||||
|
||||
const methods = {
|
||||
@@ -94,6 +96,14 @@ const methods = {
|
||||
url: updateProviderInstance,
|
||||
method: 'put',
|
||||
},
|
||||
aimlapiAuthorizeStart: {
|
||||
url: aimlapiAuthorizeStart,
|
||||
method: 'post',
|
||||
},
|
||||
aimlapiAuthorizePoll: {
|
||||
url: aimlapiAuthorizePoll,
|
||||
method: 'post',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const llmService = registerNextServer<keyof typeof methods>(methods);
|
||||
|
||||
@@ -26,6 +26,9 @@ export default {
|
||||
// llm model
|
||||
listAllAddedModels: `${restAPIv1}/models`,
|
||||
defaultModel: `${restAPIv1}/models/default`,
|
||||
// AIMLAPI agent-authorization (OAuth device grant) — obtain a key from the provider dialog
|
||||
aimlapiAuthorizeStart: `${restAPIv1}/llm/aimlapi/authorize/start`,
|
||||
aimlapiAuthorizePoll: `${restAPIv1}/llm/aimlapi/authorize/poll`,
|
||||
listProviders: `${restAPIv1}/providers`,
|
||||
addProvider: `${restAPIv1}/providers`,
|
||||
addProviderInstance: ({ llm_factory }: { llm_factory: string }) =>
|
||||
|
||||
@@ -43,6 +43,7 @@ export const formatNumberWithThousandsSeparator = (numberStr: string) => {
|
||||
};
|
||||
|
||||
const orderFactoryList = [
|
||||
LLMFactory.AIMLAPI,
|
||||
LLMFactory.OpenAI,
|
||||
LLMFactory.Moonshot,
|
||||
LLMFactory.PPIO,
|
||||
|
||||
Reference in New Issue
Block a user