Files
ragflow/web/src/hooks/use-aimlapi-authorize.ts
Eugene 6b11f62391 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.
2026-07-24 22:50:14 +08:00

187 lines
5.5 KiB
TypeScript

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