Files
yingqing/core/backend/airshelf/settings/base.py
T

362 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
def env(name: str, default: str | None = None) -> str | None:
return os.getenv(name, default)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(name: str, default: str = "") -> list[str]:
value = os.getenv(name, default)
return [item.strip() for item in value.split(",") if item.strip()]
def env_int(name: str, default: int) -> int:
"""读取整数环境变量;格式错误时给出可直接定位配置项的中文错误。"""
value = os.getenv(name)
if value is None or not value.strip():
return default
try:
return int(value)
except ValueError as exc:
raise ValueError(f"环境变量 {name} 必须是整数,当前值:{value!r}") from exc
def env_float(name: str, default: float) -> float:
"""读取小数环境变量;格式错误时给出可直接定位配置项的中文错误。"""
value = os.getenv(name)
if value is None or not value.strip():
return default
try:
return float(value)
except ValueError as exc:
raise ValueError(f"环境变量 {name} 必须是数字,当前值:{value!r}") from exc
def env_int_list(name: str, default: tuple[int, ...]) -> list[int]:
"""读取逗号分隔的整数列表;空字符串表示不重试。"""
value = os.getenv(name)
if value is None:
return list(default)
if not value.strip():
return []
try:
return [int(item.strip()) for item in value.split(",") if item.strip()]
except ValueError as exc:
raise ValueError(f"环境变量 {name} 必须是逗号分隔的整数列表,当前值:{value!r}") from exc
SECRET_KEY = env("DJANGO_SECRET_KEY", "airshelf-dev-insecure-key")
DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"apps.common",
"apps.accounts",
"apps.assets",
"apps.products",
"apps.projects",
"apps.ai",
"apps.billing",
"apps.ops",
"apps.adminpanel",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "airshelf.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "airshelf.wsgi.application"
ASGI_APPLICATION = "airshelf.asgi.application"
if env("DB_ENGINE", "sqlite") == "mysql":
mysql_options = {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
}
if env("DB_BIND_ADDRESS"):
mysql_options["bind_address"] = env("DB_BIND_ADDRESS")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": env("DB_NAME", "airshelf"),
"USER": env("DB_USER", "airshelf"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST", "127.0.0.1"),
"PORT": env("DB_PORT", "3306"),
"OPTIONS": mysql_options,
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# 连接复用(development + production 共用):连远程 MySQL 时,默认每请求重开连接
# (CONN_MAX_AGE=0),一次 TCP 握手 ~1.6s,登录时前端并发十几个接口逐个重握手 = 卡几十秒。
# 复用连接 + 健康检查 + 连接超时,放在 base 让两套环境都生效(sqlite 测试库不受影响)。
if DATABASES["default"]["ENGINE"].endswith("mysql"):
DATABASES["default"]["CONN_MAX_AGE"] = 300
DATABASES["default"]["CONN_HEALTH_CHECKS"] = True
DATABASES["default"].setdefault("OPTIONS", {})["connect_timeout"] = 5
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "apps.common.pagination.DefaultPagination",
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"PAGE_SIZE": 20,
}
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"),
}
}
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
# 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。
# 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。
CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true"
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TIMEZONE = TIME_ZONE
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
TOS = {
"endpoint": env("TOS_ENDPOINT"),
"bucket": env("TOS_BUCKET"),
"access_key_id": env("TOS_ACCESS_KEY_ID"),
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
}
VOLCANO = {
"ark_api_key": env("VOLCANO_ARK_API_KEY"),
"ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
}
# 豆包语音合成(旁白配音 TTS)。与 ARK 不是同一套钥匙:
# 火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用,取 APPID 和 Access Token
VOLC_TTS = {
"appid": env("VOLC_TTS_APPID", ""),
"access_token": env("VOLC_TTS_ACCESS_TOKEN", ""),
"cluster": env("VOLC_TTS_CLUSTER", "volcano_tts"),
"base_url": env("VOLC_TTS_BASE_URL", "https://openspeech.bytedance.com/api/v1/tts"),
}
YUNQI = {
"api_key": env("YUNQI_API_KEY"),
"base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
}
# tokenssr 中转站:一把 key 通吃 ~90 模型(gpt-image-2 参考图 / gpt-5.x / 全套 Gemini 文本&图像)。
TOKENSSR = {
"api_key": env("TOKENSSR_API_KEY", ""),
"base_url": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
# 中转站凭证回退表(provider.name → .env)。可插拔解析顺序见 services.resolve_provider_credentials:
# DB 的 ModelProvider.base_url/api_key 优先,留空才回退到这里。密钥只在 .env,不写死、不强制进库。
PROVIDER_BASE_URLS = {
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
# YunQi 对话(脚本助手):gpt-5.5 与 gemini-3.1-pro-preview 在 YunQi 同一地址、各有独立计费 key,
# 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。
"yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
PROVIDER_KEYS = {
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
"yunqi": env("YUNQI_API_KEY", ""),
"yunqi_gpt": env("YUNQI_GPT_API_KEY", ""),
"yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""),
"tokenssr": env("TOKENSSR_API_KEY", ""),
}
# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下,
# Seedance 引用 asset:// 素材必须同账号才解析得到(否则 asset not found)。只覆盖 video,图像/文本仍用自有 key。
# 待 AirShelf 自有账号开通人像素材库后,清空此项即回落默认 VOLCANO_ARK_API_KEY。
VIDEO_ARK_API_KEY = env("VIDEO_ARK_API_KEY", "")
# 中转站生图/改图端点的 Azure 风格 api-version(yunqi 的 images/edits 必须带,否则 404)。
# DB 的 ModelProvider.metadata.api_version 优先,留空回退到这里。
PROVIDER_API_VERSIONS = {
"yunqi": env("YUNQI_API_VERSION", "2025-04-01-preview"),
}
# 自由创作视频:团队在途任务并发上限(视频长时高价,限并发同时压 web 慢调用敞口)
FREE_VIDEO_MAX_CONCURRENT = int(env("FREE_VIDEO_MAX_CONCURRENT", "3"))
# 模型失败重试与动态 Fallback 的统一策略配置,已由全部 AI 业务入口与调用审计共用。
#
# 生效方式:修改环境变量后,需要同步重启 Django API 与 Celery Worker,确保两端读取同一策略。
# 时间单位:除 jitter_ratio 外,所有 timeout、cap 和 retry_delays 均为“秒”。
# 次数口径:max_calls 包含首次调用、原模型重试和所有候选模型调用;max_models 包含主模型。
MODEL_ROUTING_POLICY = {
# 单个用户逻辑任务最多尝试的不同模型数量;包含用户选择或系统默认的主模型。
# 合法范围 1~10。调大可提高成功率,但会增加等待时间和平台真实成本。
"max_models": env_int("MODEL_ROUTING_MAX_MODELS", 3),
# 单个用户逻辑任务最多发出的真实模型请求总数;包含首次调用、重试和 Fallback。
# 合法范围 120,且不得小于 max_models。用户侧仍只允许一次预留和一次最终结算。
"max_calls": env_int("MODEL_ROUTING_MAX_CALLS", 5),
# 退避时间随机抖动比例;0.20 表示在基础等待时间上随机浮动 ±20%。
# 合法范围 0~1。调大可减轻并发任务同时重试,但会让实际等待时间波动更明显。
"jitter_ratio": env_float("MODEL_ROUTING_JITTER_RATIO", 0.20),
"text": {
# 文本主模型失败后的重试等待时间;列表长度就是重试次数,默认 1 秒、3 秒(最多重试 2 次)。
"retry_delays": env_int_list("MODEL_ROUTING_TEXT_RETRY_DELAYS", (1, 3)),
# 普通非流式文本请求的单次最长等待时间。
"request_timeout": env_int("MODEL_ROUTING_TEXT_REQUEST_TIMEOUT", 120),
# 流式文本请求的单次最长等待时间;脚本 SSE 正常生成可能明显慢于普通文本。
"stream_timeout": env_int("MODEL_ROUTING_TEXT_STREAM_TIMEOUT", 300),
# 单个文本逻辑任务从首次调用 Provider 起允许的总执行时间,默认 8 分钟。
"total_timeout": env_int("MODEL_ROUTING_TEXT_TOTAL_TIMEOUT", 480),
# 429 响应 Retry-After 的最长接受时间;超过该值时不继续长时间等待,转入 Fallback 或最终失败。
"retry_after_cap": env_int("MODEL_ROUTING_TEXT_RETRY_AFTER_CAP", 15),
},
"image": {
# 图片主模型失败后的重试等待时间;默认等待 3 秒后重试 1 次。
"retry_delays": env_int_list("MODEL_ROUTING_IMAGE_RETRY_DELAYS", (3,)),
# 单次图片生成或编辑请求的最长等待时间;中转站真实出图可能超过 75 秒。
"request_timeout": env_int("MODEL_ROUTING_IMAGE_REQUEST_TIMEOUT", 300),
# 单张图片逻辑任务的总执行时间,默认 15 分钟。
"total_timeout": env_int("MODEL_ROUTING_IMAGE_TOTAL_TIMEOUT", 900),
# 图片 429 Retry-After 的最长接受时间。
"retry_after_cap": env_int("MODEL_ROUTING_IMAGE_RETRY_AFTER_CAP", 15),
},
"audio": {
# 配音主模型失败后的重试等待时间;默认等待 2 秒后重试 1 次。
"retry_delays": env_int_list("MODEL_ROUTING_AUDIO_RETRY_DELAYS", (2,)),
# 单次配音请求的最长等待时间。
"request_timeout": env_int("MODEL_ROUTING_AUDIO_REQUEST_TIMEOUT", 60),
# 单个配音逻辑任务的总执行时间,默认 3 分钟。
"total_timeout": env_int("MODEL_ROUTING_AUDIO_TOTAL_TIMEOUT", 180),
# 配音 429 Retry-After 的最长接受时间。
"retry_after_cap": env_int("MODEL_ROUTING_AUDIO_RETRY_AFTER_CAP", 15),
},
"video": {
# 视频尚未拿到 Provider 任务 ID 时的提交重试等待时间;默认等待 3 秒后重试 1 次。
"submit_retry_delays": env_int_list("MODEL_ROUTING_VIDEO_SUBMIT_RETRY_DELAYS", (3,)),
# 单次创建视频 Provider 任务的最长等待时间。
"submit_timeout": env_int("MODEL_ROUTING_VIDEO_SUBMIT_TIMEOUT", 120),
# 视频“提交与 Fallback”阶段总时限,默认 5 分钟;成功拿到 Provider 任务 ID 后结束该阶段。
"submit_total_timeout": env_int("MODEL_ROUTING_VIDEO_SUBMIT_TOTAL_TIMEOUT", 300),
# 单次查询视频任务状态的最长等待时间。
"poll_request_timeout": env_int("MODEL_ROUTING_VIDEO_POLL_REQUEST_TIMEOUT", 60),
# 视频提交遇到 429 时,Retry-After 的最长接受时间。
"retry_after_cap": env_int("MODEL_ROUTING_VIDEO_RETRY_AFTER_CAP", 30),
},
"postprocess": {
# 模型成功后的下载、上传和资产保存重试等待时间;不增加模型调用次数,也不触发模型 Fallback。
"retry_delays": env_int_list("MODEL_ROUTING_POSTPROCESS_RETRY_DELAYS", (2, 5, 10)),
},
}
# 模特库三视图真实生成与计费开关:必须在 API 与 Celery worker 同步部署后才显式开启。
# 关闭时提交接口直接拒绝;开启后允许预留积分并把生成任务投递给 worker。
MODEL_TRIVIEW_GENERATION_ENABLED = env_bool("MODEL_TRIVIEW_GENERATION_ENABLED", False)
# 模特上身图结构化提示词 V2.2 全量开关:
# - true:所有团队的 mode=model 商品上身图使用 V2.2 提示词;不替用户切换 Seedream/GPT,不改变数量、比例或扣费;
# - false:仅 CANARY_TEAM_IDS 命中的团队使用 V2.2;白名单也为空时全部沿用旧提示词;
# 修改后必须同步重启 API 与 Celery Worker;紧急回退设为 false 并清空白名单。
MODEL_TRYON_PROMPT_V2_ENABLED = env_bool("MODEL_TRYON_PROMPT_V2_ENABLED", False)
# Step 5.2 单团队试点:全局开关关闭时,仅命中该白名单的团队使用 V2;空值与无效值均不会误开全量。
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS = frozenset(
item.lower() for item in env_list("MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS")
)
# 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有
ASSETS_API = {
"access_key": env("ASSETS_API_ACCESS_KEY", ""),
"secret_key": env("ASSETS_API_SECRET_KEY", ""),
"enabled": str(env("ASSETS_API_ENABLED", "false")).lower() == "true",
"project_name": env("ASSETS_API_PROJECT_NAME", "int_dev_Airlabs"),
}
# 开户赠送额度(积分)。商业决策(2026-07-03):不赠送(=0);genesis 流水仅 trial>0 才落(I8 天然满足)。
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "0")