mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 14:50:30 +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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user