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:
194
api/apps/restful_apis/aimlapi_api.py
Normal file
194
api/apps/restful_apis/aimlapi_api.py
Normal 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"})
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
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