Skip to content
Closed
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
35 changes: 24 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
# DeepSeek provider: one or more bearer tokens, comma-separated
# TOKENS ==========================
# Open Developer -> Network settings; make sure logged in; find a '/chat/completion' URL call and get the Authorization: Bearer XXXXXXXXXX)
# DeepSeek/Qwen Bearer tokens (multiple = comma-separated)
DEEPSEEK_TOKENS=

# Qwen provider: one or more bearer tokens, comma-separated
QWEN_TOKENS=

# address the API server binds to

# CONNECTION ======================
# address the API server binds (alt HOST, PORT, PROXY, API_KEY)
DANYAPI_HOST=0.0.0.0
# port the API server listens on
DANYAPI_PORT=8000
# (optional) outgoing proxy for upstream communication
# (e.g. socks5://127.0.0.1:1080 or http://127.0.0.1:8080, docker: socks5://host.docker.internal:1080)
DANYAPI_PROXY=
# (optional) if enforcing USER Bearer token on user-api calls
DANYAPI_API_KEY=


# SETTINGS ========================
# upstream request timeout in seconds
DANYAPI_TIMEOUT=60
# human-like delay before each upstream request (seconds)
DANYAPI_HUMAN_DELAY_MIN=0.5
DANYAPI_HUMAN_DELAY_MAX=3.0
# seconds to wait for a free account before returning 429 (empty = wait forever)
DANYAPI_ACQUIRE_TIMEOUT=
# max server-side chats cached per provider (LRU) for stateless session reuse
DANYAPI_ACQUIRE_TIMEOUT=600

# SESSION: session_id -> maps to arbitrary user value (ex. session_id: "george")
# max server-side session-maps chats cached per provider (LRU) for stateless session reuse
DANYAPI_SESSION_CACHE_SIZE=128
# seconds an unused session/context stays reusable (0 = never expire)
DANYAPI_SESSION_TTL_SECONDS=3600
# seconds an unused session/context stays reusable (0 = never expire, currently 7 days)
DANYAPI_SESSION_TTL_SECONDS=604800
# directory for on-disk session cache (default = system temp dir, e.g. %TEMP%\danyapi)
DANYAPI_CACHE_DIR=
# set to 1/true/yes to disable on-disk cache entirely
DANYAPI_CACHE_DISABLED=
# log level: DEBUG, INFO, WARNING, ERROR

# LOGGING: level -> DEBUG, INFO, WARNING, ERROR
DANYAPI_LOG_LEVEL=INFO
# file path for persistent logs (empty = console only)
DANYAPI_LOG_FILE=
# max log file size in bytes before rotation
DANYAPI_LOG_MAX_BYTES=10485760
# number of rotated log files to keep
DANYAPI_LOG_BACKUP_COUNT=3

# set to 0/false/no/off to disable usage tracking (token counter stats)
DANYAPI_USAGE_ENABLED=1
# max recent usage records kept in memory for /v1/usage
DANYAPI_USAGE_MAX_RECORDS=1000
# auto-update to the latest GitHub release on each start (0 disables)
DANYAPI_AUTO_UPDATE=1
DANYAPI_AUTO_UPDATE=1
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY danyapi ./danyapi

RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libc6-dev nodejs \
&& apt-get install -y --no-install-recommends gcc libc6-dev nodejs curl \
&& gcc -O3 -pthread -funroll-loops -flto -fomit-frame-pointer -o danyapi/deepseek/pow_solver danyapi/deepseek/pow_solver.c \
&& apt-get purge -y gcc libc6-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

ENV PYTHONUNBUFFERED=1
ENV DANYAPI_HOST=0.0.0.0
ENV DANYAPI_PORT=8000

Expand Down
3 changes: 2 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ def main() -> None:
import uvicorn

from danyapi.config import settings
from danyapi.logging import uvicorn_log_config
from danyapi.logging import log_startup_info, uvicorn_log_config

print(f"DanyAPI starting on {settings.host}:{settings.port}")
log_startup_info()
uvicorn.run(
"danyapi.api.openai:app",
host=settings.host,
Expand Down
4 changes: 3 additions & 1 deletion danyapi/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ def main() -> None:
)
sys.exit(1)
from danyapi.config import settings
from danyapi.logging import uvicorn_log_config
from danyapi.logging import log_startup_info, uvicorn_log_config

log_startup_info()

uvicorn.run(
"danyapi.api.openai:app",
Expand Down
78 changes: 70 additions & 8 deletions danyapi/api/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import random
import re
import secrets
import time
import uuid
from contextlib import asynccontextmanager
Expand All @@ -17,7 +18,7 @@
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator

Expand Down Expand Up @@ -125,7 +126,7 @@ class FileSpec(BaseModel):


class ChatCompletionRequest(BaseModel):
model: str = Field(default="deepseek-v4-flash")
model: str | None = None
messages: list[ChatMessage] = Field(default_factory=list)
stream: bool = False
temperature: float | None = None
Expand Down Expand Up @@ -271,6 +272,8 @@ async def lifespan(app: FastAPI):
app.state.qwen_models = []
if not accounts and not qwen_accounts:
raise RuntimeError("no valid credentials: set DEEPSEEK_TOKENS or QWEN_TOKENS")
app.state.default_model = _determine_default_model(app)
log.info("default model: %s", app.state.default_model)
yield
finally:
for ds_acct in accounts:
Expand Down Expand Up @@ -318,6 +321,22 @@ async def _fetch_qwen_models(client: QwenClient) -> list[dict]:
return models


def _determine_default_model(app_obj: FastAPI | None = None) -> str:
target = app_obj or app
pool = getattr(target.state, "pool", None)
qwen_pool = getattr(target.state, "qwen_pool", None)
qwen_models: list[dict] = getattr(target.state, "qwen_models", [])

if pool and pool.accounts:
return next(iter(MODEL_TYPE_BY_NAME), "deepseek-v4-flash")
if qwen_pool and qwen_pool.accounts and qwen_models:
chat_models = [m["id"] for m in qwen_models if m.get("model_type") == "chat"]
if chat_models:
return chat_models[0]
return qwen_models[0]["id"]
return "deepseek-v4-flash"


app = FastAPI(title="DanyAPI", lifespan=lifespan)

app.add_middleware(
Expand Down Expand Up @@ -490,6 +509,9 @@ async def add_tokens(tokens: dict) -> dict:
_write_env_tokens(merged_ds, merged_qw)
settings.deepseek_tokens = merged_ds
settings.qwen_tokens = merged_qw
if added_ds or added_qw:
app.state.default_model = _determine_default_model(app)
log.info("default model updated: %s", app.state.default_model)

parts = []
if added_ds:
Expand Down Expand Up @@ -522,8 +544,11 @@ async def _extract_request_model(request: Request) -> str | None:
return None
if isinstance(payload, dict):
model = payload.get("model")
if isinstance(model, str):
return model
if isinstance(model, str) and model.strip():
return model.strip()
default_m = getattr(app.state, "default_model", None)
if default_m:
return f"{default_m} (default)"
return None


Expand All @@ -543,11 +568,13 @@ def _log_request_failure(request: Request, model: str | None, duration: float, s
)


def _log_request_success(request: Request, duration: float) -> None:
def _log_request_success(request: Request, duration: float, model: str | None = None) -> None:
model_part = f"model={model} " if model else ""
log.info(
"%s %s success (%.0fms)",
"%s %s %ssuccess (%.0fms)",
request.method,
request.url.path,
model_part,
duration,
)

Expand All @@ -573,10 +600,43 @@ async def _log_request_failures(request: Request, call_next):
status=response.status_code,
)
elif request.url.path == "/v1/chat/completions":
_log_request_success(request, (time.monotonic() - started) * 1000)
_log_request_success(request, (time.monotonic() - started) * 1000, await _extract_request_model(request))
return response


PUBLIC_PATHS = {"/", "/favicon.ico", "/health"}


@app.middleware("http")
async def _authenticate_request(request: Request, call_next):
if settings.api_key and request.method != "OPTIONS":
path = request.url.path.rstrip("/") or "/"
if path not in PUBLIC_PATHS and not path.startswith("/docs"):
auth_header = request.headers.get("Authorization", "")
token = ""
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
elif auth_header:
token = auth_header.strip()

if not token:
token = request.headers.get("x-api-key", "").strip()

if not token or not secrets.compare_digest(token, settings.api_key):
return JSONResponse(
status_code=401,
content={
"error": {
"message": "Incorrect API key provided or missing authorization token.",
"type": "invalid_request_error",
"param": None,
"code": "invalid_api_key",
}
},
)
return await call_next(request)


MAX_FILES_PER_REQUEST = 50
MAX_FILE_SIZE = 100 * 1024 * 1024

Expand Down Expand Up @@ -813,6 +873,8 @@ def _resolve_provider(model: str) -> str:

@app.post("/v1/chat/completions")
async def chat_completions(req: ChatCompletionRequest) -> Any:
if not req.model:
req.model = getattr(app.state, "default_model", "deepseek-v4-flash")
provider = _resolve_provider(req.model)
if provider == "qwen":
return await _chat_completions_qwen(req)
Expand Down Expand Up @@ -850,7 +912,7 @@ async def image_generations(req: ImageGenerationRequest) -> dict:
data.append({"url": url})
continue
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as hc:
async with httpx.AsyncClient(follow_redirects=True, timeout=30, proxy=settings.proxy) as hc:
img_resp = await hc.get(url)
if img_resp.status_code != 200:
log.warning("image download failed (%s) for %s, returning url", img_resp.status_code, url)
Expand Down
10 changes: 6 additions & 4 deletions danyapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ def _env_str(key: str, default: str = "") -> str:

class Settings:
def __init__(self) -> None:
self.host = os.environ.get("DANYAPI_HOST", "0.0.0.0")
self.port = _env_int("DANYAPI_PORT", 8000)
self.host = _env_str("DANYAPI_HOST") or _env_str("HOST") or "0.0.0.0"
self.port = _env_int("DANYAPI_PORT", 0) or _env_int("PORT", 0) or 8000
self.proxy = _env_str("DANYAPI_PROXY") or _env_str("PROXY") or None
self.api_key = _env_str("DANYAPI_API_KEY") or _env_str("API_KEY") or None
tokens = [t.strip() for t in os.environ.get("DEEPSEEK_TOKENS", "").split(",") if t.strip()]
self.deepseek_tokens = tokens
qwen_tokens = [t.strip() for t in os.environ.get("QWEN_TOKENS", "").split(",") if t.strip()]
Expand All @@ -55,8 +57,8 @@ def __init__(self) -> None:
self.acquire_timeout = _env_float_opt("DANYAPI_ACQUIRE_TIMEOUT")
self.session_cache_size = _env_int("DANYAPI_SESSION_CACHE_SIZE", 128)
self.session_ttl = _env_float("DANYAPI_SESSION_TTL_SECONDS", 3600.0)
self.log_level = _env_str("DANYAPI_LOG_LEVEL", "INFO") or "INFO"
self.log_file = _env_str("DANYAPI_LOG_FILE")
self.log_level = _env_str("DANYAPI_LOG_LEVEL") or _env_str("LOG_LEVEL") or "INFO"
self.log_file = _env_str("DANYAPI_LOG_FILE") or _env_str("LOG_FILE")
self.log_max_bytes = _env_int("DANYAPI_LOG_MAX_BYTES", 10 * 1024 * 1024)
self.log_backup_count = _env_int("DANYAPI_LOG_BACKUP_COUNT", 3)
self.cache_dir = _env_str("DANYAPI_CACHE_DIR")
Expand Down
7 changes: 7 additions & 0 deletions danyapi/deepseek/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import httpx

from ..config import settings

log = logging.getLogger("danyapi.deepseek")

BASE_URL = "https://chat.deepseek.com"
Expand Down Expand Up @@ -48,6 +50,7 @@ def __init__(
token: str | None = None,
device_id: str | None = None,
timeout: float = 60.0,
proxy: str | None = None,
) -> None:
self.token = token
self.device_id = device_id or new_device_id()
Expand All @@ -65,6 +68,7 @@ def __init__(
headers=headers,
timeout=httpx.Timeout(timeout),
follow_redirects=True,
proxy=proxy or settings.proxy,
)

async def aclose(self) -> None:
Expand Down Expand Up @@ -101,6 +105,9 @@ async def check_auth(self) -> bool:
params={"did": self.device_id, "scope": "main"},
)
return resp.json().get("code") == 0
except (httpx.ConnectError, httpx.ProxyError, httpx.TimeoutException) as exc:
log.error("upstream connection failed (%s): check network/proxy settings", exc)
return False
except (httpx.HTTPError, ValueError):
return False

Expand Down
Loading