mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-25 18:03: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"})
|
||||
Reference in New Issue
Block a user