diff --git a/config.conf.example b/config.conf.example old mode 100755 new mode 100644 index f7e1bb6a..af95cd1a --- a/config.conf.example +++ b/config.conf.example @@ -43,4 +43,22 @@ gemini = true # Its usefull to fix errors like 403 or restricted connections # Example: http_proxy = http://127.0.0.1:2334 [Proxy] -http_proxy = \ No newline at end of file +http_proxy = + +# --- Astraflow Provider Settings --- +# Astraflow by UCloud — OpenAI-compatible platform supporting 200+ models (global endpoint) +# Sign up / get API key: https://astraflow.ucloud-global.com +# China endpoint sign up: https://astraflow.ucloud.cn +# +# You may supply your key here OR via environment variables: +# Global endpoint: ASTRAFLOW_API_KEY +# China endpoint: ASTRAFLOW_CN_API_KEY +[Astraflow] +# Global endpoint API key (used when use_cn_endpoint = false) +api_key = +# China endpoint API key (used when use_cn_endpoint = true) +cn_api_key = +# Set to true to use the China endpoint (https://api.modelverse.cn/v1) +use_cn_endpoint = false +# Default model to use when none is specified in the request +default_model = gpt-4o \ No newline at end of file diff --git a/src/app/config.py b/src/app/config.py index 602ba1a0..9e020f97 100644 --- a/src/app/config.py +++ b/src/app/config.py @@ -27,6 +27,13 @@ def load_config(config_file: str = "config.conf") -> configparser.ConfigParser: config["AI"] = {"default_model_gemini": "gemini-3-flash"} if "Proxy" not in config: config["Proxy"] = {"http_proxy": ""} + if "Astraflow" not in config: + config["Astraflow"] = { + "api_key": "", + "cn_api_key": "", + "use_cn_endpoint": "false", + "default_model": "gpt-4o", + } # Save changes to the configuration file, also with UTF-8 encoding. try: diff --git a/src/app/endpoints/astraflow.py b/src/app/endpoints/astraflow.py new file mode 100644 index 00000000..e4623ac9 --- /dev/null +++ b/src/app/endpoints/astraflow.py @@ -0,0 +1,78 @@ +# src/app/endpoints/astraflow.py +""" +FastAPI router for the Astraflow provider. + +Exposes two endpoints that mirror the OpenAI API surface: + GET /astraflow/v1/models + POST /astraflow/v1/chat/completions + +The router simply proxies requests to the Astraflow REST API via +`app.services.astraflow_client`, adding authentication transparently. +""" + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from app.logger import logger +from app.services.astraflow_client import ( + AstraflowClientNotConfiguredError, + _default_model, + chat_completions, + chat_completions_stream, + list_models, +) +from schemas.request import OpenAIChatRequest + +router = APIRouter(prefix="/astraflow") + + +@router.get("/v1/models", summary="List Astraflow models") +async def astraflow_list_models(): + """ + Returns the list of models available through the Astraflow endpoint. + Proxies GET /v1/models from the upstream Astraflow API. + """ + try: + return await list_models() + except AstraflowClientNotConfiguredError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + logger.error(f"[Astraflow] Error listing models: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error listing Astraflow models: {str(e)}") + + +@router.post("/v1/chat/completions", summary="Astraflow chat completions") +async def astraflow_chat_completions(request: OpenAIChatRequest): + """ + OpenAI-compatible chat completions endpoint backed by Astraflow. + + Accepts the same request schema as POST /v1/chat/completions and + transparently proxies it to the Astraflow API, including SSE streaming. + """ + if not request.messages: + raise HTTPException(status_code=400, detail="No messages provided.") + + model = request.model or _default_model() + + payload = { + "model": model, + "messages": request.messages, + "stream": bool(request.stream), + } + if request.tools: + payload["tools"] = request.tools + if request.tool_choice is not None: + payload["tool_choice"] = request.tool_choice + + try: + if request.stream: + return StreamingResponse( + chat_completions_stream(payload), + media_type="text/event-stream", + ) + return await chat_completions(payload) + except AstraflowClientNotConfiguredError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + logger.error(f"[Astraflow] Error in chat completions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error processing Astraflow chat completion: {str(e)}") diff --git a/src/app/main.py b/src/app/main.py index 7ce2f769..41e53332 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -8,7 +8,7 @@ from app.logger import logger # Import endpoint routers -from app.endpoints import gemini, chat, google_generative +from app.endpoints import gemini, chat, google_generative, astraflow @asynccontextmanager async def lifespan(app: FastAPI): @@ -64,3 +64,4 @@ async def lifespan(app: FastAPI): app.include_router(gemini.router) app.include_router(chat.router) app.include_router(google_generative.router) +app.include_router(astraflow.router) diff --git a/src/app/services/astraflow_client.py b/src/app/services/astraflow_client.py new file mode 100644 index 00000000..db0c2b96 --- /dev/null +++ b/src/app/services/astraflow_client.py @@ -0,0 +1,106 @@ +# src/app/services/astraflow_client.py +""" +Thin async client for the Astraflow OpenAI-compatible REST API. + +Astraflow (by UCloud / 优刻得) exposes an OpenAI-compatible interface, so no +special SDK is required — only a base_url switch and an API key. + +Endpoints: + Global : https://api-us-ca.umodelverse.ai/v1 (env: ASTRAFLOW_API_KEY) + China : https://api.modelverse.cn/v1 (env: ASTRAFLOW_CN_API_KEY) +""" + +import os +import logging +from typing import AsyncIterator, List, Optional + +import httpx + +from app.config import CONFIG + +logger = logging.getLogger(__name__) + +_GLOBAL_BASE_URL = "https://api-us-ca.umodelverse.ai/v1" +_CN_BASE_URL = "https://api.modelverse.cn/v1" + + +class AstraflowClientNotConfiguredError(Exception): + """Raised when no API key is available for Astraflow.""" + + +def _get_base_url() -> str: + use_cn = CONFIG.get("Astraflow", "use_cn_endpoint", fallback="false").strip().lower() + return _CN_BASE_URL if use_cn == "true" else _GLOBAL_BASE_URL + + +def _get_api_key() -> str: + """ + Resolve the API key with the following priority: + 1. Environment variable (ASTRAFLOW_CN_API_KEY / ASTRAFLOW_API_KEY) + 2. config.conf [Astraflow] section + """ + use_cn = CONFIG.get("Astraflow", "use_cn_endpoint", fallback="false").strip().lower() + if use_cn == "true": + key = os.environ.get("ASTRAFLOW_CN_API_KEY") or CONFIG.get("Astraflow", "cn_api_key", fallback="") + else: + key = os.environ.get("ASTRAFLOW_API_KEY") or CONFIG.get("Astraflow", "api_key", fallback="") + if not key: + raise AstraflowClientNotConfiguredError( + "Astraflow API key not set. Provide ASTRAFLOW_API_KEY (or ASTRAFLOW_CN_API_KEY) " + "as an environment variable or set api_key in the [Astraflow] section of config.conf." + ) + return key + + +def _default_model() -> str: + return CONFIG.get("Astraflow", "default_model", fallback="gpt-4o") + + +async def list_models() -> dict: + """Return the list of models available on the Astraflow endpoint.""" + api_key = _get_api_key() + base_url = _get_base_url() + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{base_url}/models", + headers={"Authorization": f"Bearer {api_key}"}, + ) + resp.raise_for_status() + return resp.json() + + +async def chat_completions(payload: dict) -> dict: + """Non-streaming chat completion — returns the full response dict.""" + api_key = _get_api_key() + base_url = _get_base_url() + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + resp.raise_for_status() + return resp.json() + + +async def chat_completions_stream(payload: dict) -> AsyncIterator[bytes]: + """Streaming chat completion — yields raw SSE bytes from the upstream.""" + api_key = _get_api_key() + base_url = _get_base_url() + stream_payload = {**payload, "stream": True} + async with httpx.AsyncClient(timeout=120) as client: + async with client.stream( + "POST", + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=stream_payload, + ) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(): + yield chunk