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:
Eugene
2026-07-24 17:50:14 +03:00
committed by GitHub
parent f4d1c9f8b2
commit 6b11f62391
23 changed files with 793 additions and 16 deletions

View File

@@ -0,0 +1,194 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""AIMLAPI agent-authorization endpoints (OAuth 2.0 Device Authorization Grant).
Lets a signed-in RAGFlow user obtain an AIMLAPI key without leaving the provider
dialog. The user's AIMLAPI login credentials never touch RAGFlow: it creates a
device-authorization request, the user approves it on the AIMLAPI consent page
(opened by the browser in a popup), and RAGFlow polls the token endpoint until
the key is issued. The device code is held server-side (Redis); only the issued
API key is returned to the browser, to be filled into the provider form.
Configuration (all via environment):
AIMLAPI_APP_URL agent-auth host (default https://app.aimlapi.com)
AIMLAPI_PARTNER_ID partner id (defaults to RAGFlow's; override to change)
AIMLAPI_PARTNER_NAME display name shown on the consent page
AIMLAPI_VERIFICATION_BASE_URL base of the consent page (default https://aimlapi.com)
AIMLAPI_REQUESTED_USD_LIMIT_MINOR requested spend cap in USD minor units (default 1000)
"""
import asyncio
import json
import logging
import os
import requests
from api.apps import login_required, current_user
from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, server_error_response, validate_request
from rag.utils.redis_conn import REDIS_CONN
LOGGER = logging.getLogger(__name__)
# OAuth 2.0 Device Authorization Grant (RFC 8628)
_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
_REDIS_KEY_PREFIX = "aimlapi_authz:"
_HTTP_TIMEOUT = 15
def _app_url() -> str:
return os.environ.get("AIMLAPI_APP_URL", "https://app.aimlapi.com").rstrip("/")
def _partner_id() -> str:
return os.environ.get("AIMLAPI_PARTNER_ID", "part_yNkcOvbGLtgxWLjy4sRysaer").strip()
def _partner_name() -> str:
return os.environ.get("AIMLAPI_PARTNER_NAME", "RAGFlow").strip()
def _verification_base_url() -> str:
return os.environ.get("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com").rstrip("/")
def _requested_usd_limit_minor() -> int:
try:
return int(os.environ.get("AIMLAPI_REQUESTED_USD_LIMIT_MINOR", "1000"))
except (TypeError, ValueError):
return 1000
@manager.route("/llm/aimlapi/authorize/start", methods=["POST"]) # noqa: F821
@login_required
async def aimlapi_authorize_start():
"""Create an AIMLAPI device-authorization request and return the consent URL."""
partner_id = _partner_id()
if not partner_id:
return get_data_error_result(message="AIMLAPI partner id is not configured. Set AIMLAPI_PARTNER_ID.")
# returnUrl only controls where AIMLAPI sends the browser after consent. This
# flow never depends on it — the key arrives via authenticated polling and the
# popup is closed on success — so we always use the trusted verification host
# rather than a client-supplied value, leaving no open-redirect surface.
return_url = _verification_base_url()
payload = {
"partnerId": partner_id,
"partnerName": _partner_name(),
"agentName": "RAGFlow",
"returnUrl": return_url,
"requestedUsdLimitMinor": _requested_usd_limit_minor(),
}
try:
resp = await asyncio.to_thread(
requests.post,
f"{_app_url()}/v3/agent-auth/authorizations",
json=payload,
timeout=_HTTP_TIMEOUT,
)
except Exception as e:
return server_error_response(e)
if resp.status_code not in (200, 201):
LOGGER.warning("AIMLAPI authorize start failed: status=%s body=%s", resp.status_code, resp.text[:300])
return get_data_error_result(message=f"AIMLAPI authorization request failed (HTTP {resp.status_code}).")
data = resp.json()
request_id = data.get("requestId")
device_code = data.get("deviceCode")
if not request_id or not device_code:
return get_data_error_result(message="AIMLAPI authorization response is missing requestId/deviceCode.")
try:
interval = int(data.get("interval", 5))
except (TypeError, ValueError):
interval = 5
try:
expires_in = int(data.get("expiresIn", 900))
except (TypeError, ValueError):
expires_in = 900
# Hold the device code server-side, scoped to this user; it never reaches the browser.
REDIS_CONN.set_obj(
f"{_REDIS_KEY_PREFIX}{request_id}",
{"device_code": device_code, "user_id": current_user.id},
expires_in,
)
LOGGER.info("AIMLAPI authorize start ok: request_id=%s", request_id)
# Rebuild the consent URL from AIMLAPI_VERIFICATION_BASE_URL so an env override applies; the create response returns an absolute URL.
verification_uri = f"{_verification_base_url()}/agent/authorize?request={request_id}"
return get_json_result(
data={
"request_id": request_id,
"verification_uri": verification_uri,
"interval": interval,
"expires_in": expires_in,
}
)
@manager.route("/llm/aimlapi/authorize/poll", methods=["POST"]) # noqa: F821
@login_required
@validate_request("request_id")
async def aimlapi_authorize_poll():
"""Poll the AIMLAPI token endpoint; return the issued key once the user approves."""
req = await get_request_json()
request_id = req["request_id"]
raw = REDIS_CONN.get(f"{_REDIS_KEY_PREFIX}{request_id}")
if not raw:
return get_json_result(data={"status": "expired"})
try:
cached = json.loads(raw)
except (TypeError, ValueError):
cached = None
if not cached or cached.get("user_id") != current_user.id:
return get_data_error_result(message="Authorization request not found for the current user.")
payload = {
"partnerId": _partner_id(),
"deviceCode": cached["device_code"],
"grant_type": _DEVICE_CODE_GRANT,
}
try:
resp = await asyncio.to_thread(
requests.post,
f"{_app_url()}/v3/agent-auth/token",
json=payload,
timeout=_HTTP_TIMEOUT,
)
except Exception as e:
return server_error_response(e)
if resp.status_code not in (200, 201):
LOGGER.warning("AIMLAPI authorize poll failed: status=%s body=%s", resp.status_code, resp.text[:300])
return get_data_error_result(message=f"AIMLAPI token poll failed (HTTP {resp.status_code}).")
data = resp.json()
status = str(data.get("status", "")).lower()
# The success field name is not contractually fixed yet; accept the common variants.
api_key = data.get("apiKey") or data.get("api_key") or data.get("access_token") or data.get("key")
if api_key:
REDIS_CONN.delete(f"{_REDIS_KEY_PREFIX}{request_id}")
LOGGER.info("AIMLAPI authorize poll ready: request_id=%s", request_id)
return get_json_result(data={"status": "ready", "api_key": api_key})
if status in ("denied", "expired", "cancelled", "canceled", "rejected"):
REDIS_CONN.delete(f"{_REDIS_KEY_PREFIX}{request_id}")
return get_json_result(data={"status": status})
return get_json_result(data={"status": status or "pending"})

View File

@@ -1,5 +1,84 @@
{
"factory_llm_infos": [
{
"name": "aimlapi.com",
"logo": "",
"tags": "LLM,TEXT EMBEDDING,IMAGE2TEXT",
"status": "1",
"rank": "1",
"url": "https://api.aimlapi.com/v1",
"llm": [
{
"llm_name": "openai/gpt-4o",
"tags": "LLM,CHAT,128k,IMAGE2TEXT",
"max_tokens": 128000,
"model_type": [
"chat",
"image2text"
],
"is_tools": true
},
{
"llm_name": "openai/gpt-4o-mini",
"tags": "LLM,CHAT,128k,IMAGE2TEXT",
"max_tokens": 128000,
"model_type": [
"chat",
"image2text"
],
"is_tools": true
},
{
"llm_name": "anthropic/claude-opus-4-8",
"tags": "LLM,CHAT,200k",
"max_tokens": 200000,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "anthropic/claude-haiku-4.5",
"tags": "LLM,CHAT,200k",
"max_tokens": 200000,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "google/gemini-2.5-pro",
"tags": "LLM,CHAT,1M",
"max_tokens": 1000000,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "google/gemini-2.5-flash",
"tags": "LLM,CHAT,1M",
"max_tokens": 1000000,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "deepseek/deepseek-chat",
"tags": "LLM,CHAT,64k",
"max_tokens": 65536,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "openai/text-embedding-3-small",
"tags": "TEXT EMBEDDING,8K",
"max_tokens": 8191,
"model_type": "embedding",
"is_tools": false
},
{
"llm_name": "openai/text-embedding-3-large",
"tags": "TEXT EMBEDDING,8K",
"max_tokens": 8191,
"model_type": "embedding",
"is_tools": false
}
]
},
{
"name": "OpenAI",
"logo": "",

View File

@@ -17,6 +17,7 @@ A complete list of model providers supported by RAGFlow, which will continue to
| Provider | URL |
| --------------------- | ----------------------------------------------- |
| aimlapi.com | `https://aimlapi.com` |
| Anthropic | `https://www.anthropic.com` |
| Astraflow | `https://astraflow.ucloud-global.com/en-us` |
| Astraflow-CN | `https://astraflow.ucloud.cn/` |

View File

@@ -62,6 +62,7 @@ class SupportedLiteLLMProvider(StrEnum):
Astraflow = "Astraflow"
Astraflow_CN = "Astraflow-CN"
FuturMix = "FuturMix"
AIMLAPI = "aimlapi.com"
FACTORY_DEFAULT_BASE_URL = {
@@ -94,6 +95,7 @@ FACTORY_DEFAULT_BASE_URL = {
SupportedLiteLLMProvider.Astraflow: "https://api-us-ca.umodelverse.ai/v1",
SupportedLiteLLMProvider.Astraflow_CN: "https://api.modelverse.cn/v1",
SupportedLiteLLMProvider.FuturMix: "https://futurmix.ai/v1",
SupportedLiteLLMProvider.AIMLAPI: "https://api.aimlapi.com/v1",
}
@@ -137,6 +139,7 @@ LITELLM_PROVIDER_PREFIX = {
SupportedLiteLLMProvider.Astraflow: "openai/",
SupportedLiteLLMProvider.Astraflow_CN: "openai/",
SupportedLiteLLMProvider.FuturMix: "openai/",
SupportedLiteLLMProvider.AIMLAPI: "openai/",
}
ChatModel = globals().get("ChatModel", {})

View File

@@ -1554,6 +1554,15 @@ class FuturMixChat(Base):
logging.info("[FuturMix] Chat initialized with model %s", model_name)
class AIMLAPIChat(Base):
_FACTORY_NAME = "aimlapi.com"
def __init__(self, key, model_name, base_url="", **kwargs):
base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1")
super().__init__(key, model_name, base_url, **kwargs)
logging.info("[aimlapi.com] Chat initialized with model %s", model_name)
class LiteLLMBase(ABC):
_FACTORY_NAME = [
"Tongyi-Qianwen",

View File

@@ -1398,6 +1398,15 @@ class FuturMixCV(GptV4):
logging.info("[FuturMix] CV initialized with model %s", model_name)
class AIMLAPICV(GptV4):
_FACTORY_NAME = "aimlapi.com"
def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs):
base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1")
super().__init__(key, model_name, lang=lang, base_url=base_url, **kwargs)
logging.info("[aimlapi.com] CV initialized with model %s", model_name)
class RAGconCV(GptV4):
"""
RAGcon CV Provider - routes through LiteLLM proxy

View File

@@ -356,6 +356,15 @@ class FuturMixEmbed(OpenAIEmbed):
logging.info("[FuturMix] Embedding initialized with model %s", model_name)
class AIMLAPIEmbed(OpenAIEmbed):
_FACTORY_NAME = "aimlapi.com"
def __init__(self, key, model_name="text-embedding-3-small", base_url=""):
base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1")
super().__init__(key, model_name, base_url)
logging.info("[aimlapi.com] Embedding initialized with model %s", model_name)
class BaiChuanEmbed(OpenAIEmbed):
_FACTORY_NAME = "BaiChuan"

View File

@@ -504,3 +504,94 @@ class NewAPI(OpenAIAPICompatible):
class RAGcon(OpenAIAPICompatible):
_FACTORY_NAME = "RAGcon"
class AIMLAPI(Base):
"""AIMLAPI (aimlapi.com) aggregates 700+ models behind an OpenAI-compatible
API. ``GET /v1/models`` returns one record per model *and* endpoint, so a
single model id repeats under different ``type`` values (e.g.
``openai/chat-completions`` and ``openai/responses/submit``); records are
de-duplicated by id and their RAGFlow model types unioned.
The ``type`` (endpoint family) field drives classification. Families RAGFlow
cannot consume — image/video/audio generation, batch, OCR — are intentionally
left out of the map, so those models are skipped. The listing carries no
modality flag, so image-capable chat models are detected from the id, the
same way OpenAIAPICompatible does.
"""
_FACTORY_NAME = "aimlapi.com"
_TYPE_TO_MODEL_TYPE = {
"openai/chat-completions": LLMType.CHAT.value,
"openai/responses/submit": LLMType.CHAT.value,
"anthropic/messages": LLMType.CHAT.value,
"openai/embeddings": LLMType.EMBEDDING.value,
"internal/text-to-speech": LLMType.TTS.value,
"internal/speech-to-text/submit": LLMType.ASR.value,
}
# Chat models whose id hints at image input also serve VISION (VLM).
# Heuristic: the /v1/models listing exposes no structured modality field.
_VISION_HINTS = (
"gpt-4o",
"gpt-4.1",
"gpt-4-turbo",
"gpt-5",
"chatgpt-4o",
"claude-3",
"claude-opus-4",
"claude-sonnet-4",
"claude-haiku-4",
"gemini",
"qwen-vl",
"qwen2-vl",
"qwen2.5-vl",
"qwen3-vl",
"internvl",
"llava",
"pixtral",
"minicpm-v",
"glm-4v",
"glm-4.1v",
"llama-3.2",
"llama-4",
"grok-2-vision",
"grok-4",
"vision",
"-vl",
)
def _format_model_list(self, raw_model_list):
models = raw_model_list.get("data") if isinstance(raw_model_list, dict) else raw_model_list
if not isinstance(models, list):
return []
merged = {}
for model in models:
if not isinstance(model, dict):
continue
model_id = model.get("id")
if not model_id:
continue
model_type = self._TYPE_TO_MODEL_TYPE.get(model.get("type"))
if not model_type:
continue
entry = merged.get(model_id)
if entry is None:
info = model.get("info") or {}
entry = {
"name": model_id,
"model_types": [],
"features": [],
"max_tokens": info.get("contextLength") or 8192,
}
merged[model_id] = entry
if model_type not in entry["model_types"]:
entry["model_types"].append(model_type)
if model_type == LLMType.CHAT.value and LLMType.VISION.value not in entry["model_types"] and any(hint in model_id.lower() for hint in self._VISION_HINTS):
entry["model_types"].append(LLMType.VISION.value)
return list(merged.values())

View 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

View File

@@ -108,6 +108,7 @@ const svgIcons = [
LLMFactory.Qiniu,
LLMFactory.TokenHub,
LLMFactory.FunASR,
LLMFactory.AIMLAPI,
];
export const LlmIcon = ({

View File

@@ -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',

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

View File

@@ -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',

View File

@@ -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',

View File

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

View File

@@ -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')}

View File

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

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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',

View File

@@ -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);

View File

@@ -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 }) =>

View File

@@ -43,6 +43,7 @@ export const formatNumberWithThousandsSeparator = (numberStr: string) => {
};
const orderFactoryList = [
LLMFactory.AIMLAPI,
LLMFactory.OpenAI,
LLMFactory.Moonshot,
LLMFactory.PPIO,