Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion config.conf.example
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
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
7 changes: 7 additions & 0 deletions src/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
78 changes: 78 additions & 0 deletions src/app/endpoints/astraflow.py
Original file line number Diff line number Diff line change
@@ -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)}")
3 changes: 2 additions & 1 deletion src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
106 changes: 106 additions & 0 deletions src/app/services/astraflow_client.py
Original file line number Diff line number Diff line change
@@ -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