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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ AI_API_KEY=your-ai-api-key
# 取值即默认值——不写这两行时跑的就是它们。
AI_IMAGE_MODEL=gemini-2.5-flash-image
AI_VIDEO_MODEL=kling-v2-5-turbo
AI_IMAGE_FALLBACKS=
AI_VIDEO_FALLBACKS=kling-v2-6
AI_IMAGE_UNIT_COST=
AI_VIDEO_UNIT_COST_PER_SECOND=
AI_PRICE_VERSION=2026-08-16
# Gateway 路由试运行:primary 留空时复用 AI_BASE_URL / AI_API_KEY;
# fallback 三项都填才启用,用来验证 525 / SSL / 断连等 base_url 级故障切换。
AI_ROUTE_PRIMARY_NAME=qnaigc-primary
AI_ROUTE_PRIMARY_BASE_URL=
AI_ROUTE_PRIMARY_API_KEY=
AI_ROUTE_FALLBACK_NAME=
AI_ROUTE_FALLBACK_BASE_URL=
AI_ROUTE_FALLBACK_API_KEY=
# 示例:AI_ROUTE_FALLBACK_NAME=qnaigc-backup
# 示例:AI_ROUTE_FALLBACK_BASE_URL=https://backup.example.com/v1
# 示例:AI_ROUTE_FALLBACK_API_KEY=your-backup-ai-api-key
AI_GATEWAY_LEDGER_ENABLED=true

# ── 积分定价 ──
QUOTA_REGISTER_GIFT_AMOUNT=100
Expand Down
1 change: 1 addition & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from windup_framework.db import Base, engine
from windup_framework.gateway.models import AIGatewayAttempt, AIGatewayAttemptDetail # noqa: F401

# 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表
from windup_app.server.character.model import Character # noqa: F401
Expand Down
103 changes: 53 additions & 50 deletions backend/packages/app/src/windup_app/server/orchestrator/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
from sqlalchemy.orm import Session

from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard
from windup_framework.gateway import bind_call_context
from windup_framework.gateway.registry import ModelRegistry
from windup_framework.gateway.types import Scene

from windup_app.server.orchestrator import task_repo
from windup_app.server.orchestrator._fetch import fetch_own_media
Expand Down Expand Up @@ -140,27 +143,17 @@ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
logger.info("[gen] %s %s/%s %s", stage, i, total, note)


# 白名单而不是放开任意模型名:每个模型的入参形状不同(image_list / input_reference /
# Fal 队列 + `Authorization: Key`)。列进来却没适配它的协议,等于"看起来能选、点了必然
# 产生一个用不了的付费任务"。只列 SufyVideoProvider 真能建单的。Refs #239。
ALLOWED_VIDEO_MODELS: dict[str, str] = {
"kling-v2-5-turbo": "默认。稳,本地首帧即可",
"kling-v2-6": "有 motion-control",
}


def _resolve_video_model(name: str | None) -> str | None:
"""校验并返回视频模型名;``None`` 表示用部署默认值。

只允许是 CHARACTER_ACTION 链上的一员,含义是「这次从它开始试」。
非法取值在入口炸,不等到付费调用才失败。
"""
if name is None:
return None
if name not in ALLOWED_VIDEO_MODELS:
raise ValueError(
f"视频模型 {name!r} 不在本期开放列表内。可选:"
+ ";".join(f"{k}({v})" for k, v in ALLOWED_VIDEO_MODELS.items())
)
chain = ModelRegistry.from_settings().chain(Scene.CHARACTER_ACTION)
if name not in chain:
raise ValueError(f"视频模型 {name!r} 不在本期开放列表内。可选:" + ";".join(chain))
return name


Expand Down Expand Up @@ -188,11 +181,9 @@ def __init__(
fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None,
session_factory: Callable[[], Session] | None = None,
) -> None:
self._generator = generator # None → 懒加载真实装配
# 按视频模型名分桶的 generator 缓存(模型是 provider 的构造参数,不能事后换)
self._by_model: dict[str | None, CharacterGeneratorPort] = {}
# 抠图 / 图生图 provider 与视频模型无关,所有模型桶共用一份:每个抠图实例都会
# 各自惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。
self._generator = generator # None → 懒加载真实装配(一套共享 Gateway)
# 抠图 / 图生图与视频 Gateway 无关型号分桶:选哪个 kling 是 Gateway 读
# start_from_model 的事。每个抠图实例都会惰性加载一份 ONNX 会话,只装一次。
self._matte: MatteProvider | None = None
self._image: ImageProvider | None = None
# 本执行器是进程级单例,而每个请求起一个线程跑 run_action_task,上面几个缓存
Expand All @@ -216,26 +207,36 @@ def run_action_task(
先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session``
缺省时自开一个(后台场景);测试可传入自己的 session。
"""
request_id = f"act-{task_id}"
own = session is None
session = session or self._make_session()
reset = None
try:
task_repo.update_status(session, task_id, TaskStatus.RUNNING)
if own:
session.commit()

cons = (self._fetch_constraints or _load_constraints)(session, project_id)
reset = bind_call_context(
request_id=request_id,
task_id=str(task_id),
start_from_model=_resolve_video_model(input.video_model),
)
result = self._produce_action(input, cons)
task_repo.update_result(session, task_id, _ACTION_RESULT, result)
if own:
session.commit()
except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常
logger.exception("动作任务 %s 失败", task_id)
task_repo.update_status(
session, task_id, TaskStatus.FAILED, error_message=str(exc),
session, task_id, TaskStatus.FAILED,
error_message=f"{exc}; request_id={request_id}",
)
if own:
session.commit()
finally:
if reset is not None:
reset()
if own:
session.close()

Expand Down Expand Up @@ -281,7 +282,7 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints)
**extra,
)
progress: ProgressPort = _LogProgress()
generated = self._get_generator(_resolve_video_model(input.video_model)).generate(
generated = self._get_generator().generate(
card, action, master, progress, canvas=(cons.sprite_w, cons.sprite_h)
)

Expand All @@ -294,46 +295,37 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints)
]
return {"type": "character_action", "action_type": input.action_type.value, "frames": frames}

def _get_generator(self, video_model: str | None = None) -> CharacterGeneratorPort:
"""懒装配 CharacterGenerator,按模型名分桶
def _get_generator(self) -> CharacterGeneratorPort:
"""懒装配一套共享 CharacterGenerator(ImageGateway + VideoGateway + matte)

视频 provider 的模型是构造参数,不分桶的话第一个请求指定的模型会被后续所有请求
沿用,而调用方以为自己指定了。
选哪个 kling 不在装配时定,由 bind_call_context 的 start_from_model 交给 Gateway。
"""
if self._generator is not None:
return self._generator
# 命中缓存的快路径不进锁,否则每个请求都要在这里排一次队。只有装配新桶才上锁,
# 锁内重查一次:两个线程同时错过同一个桶时,后进来的那个要看见前一个的成果。
cached = self._by_model.get(video_model)
if cached is not None:
return cached
# 命中缓存的快路径不进锁,否则每个请求都要在这里排一次队。只有首次装配才上锁,
# 锁内重查一次:两个线程同时错过时,后进来的那个要看见前一个的成果。
with self._assembly_lock:
cached = self._by_model.get(video_model)
if cached is None:
cached = self._assemble(video_model)
self._by_model[video_model] = cached
return cached

def _assemble(self, video_model: str | None) -> CharacterGeneratorPort:
"""装一个模型桶。**调用方须持有 ``self._assembly_lock``**(会写共用 provider)。"""
if self._generator is None:
self._generator = self._assemble()
return self._generator

def _assemble(self) -> CharacterGeneratorPort:
"""装一套共享 Gateway。**调用方须持有 ``self._assembly_lock``**。"""
from windup_ai_engine.impl import CharacterGenerator
from windup_ai_engine.strategy.concrete import (
PerFrameStrategy,
VideoFrameStrategy,
)
from windup_common.models import GenRoute
from windup_framework.providers import (
OnnxU2NetMatteProvider,
SufyImageProvider,
SufyVideoProvider,
)
from windup_framework.gateway import build_image_gateway, build_video_gateway
from windup_framework.gateway.image import _CIRCUIT
from windup_framework.providers import OnnxU2NetMatteProvider

if self._matte is None:
self._matte = OnnxU2NetMatteProvider()
if self._image is None:
self._image = SufyImageProvider()
# 只有它随模型变 —— 模型是构造参数,换模型必须换实例。
video = SufyVideoProvider(model=video_model)
self._image = build_image_gateway(circuit=_CIRCUIT)
video = build_video_gateway(circuit=_CIRCUIT)
# 装配表必须与 GenRoute 对齐。下面那条断言让漏装在装配时暴露,而不是等到某个
# 动作第一次被请求时才炸——注入 generator 的测试走不到这条装配路径,漏了会测试
# 全绿而真实调用全崩。
Expand Down Expand Up @@ -385,7 +377,7 @@ class ImageTaskExecutor:
def __init__(
self,
*,
image=None, # None → 懒加载 SufyImageProvider
image=None, # None → 懒加载 ImageGateway
upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传
fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url
session_factory: Callable[[], Session] | None = None,
Expand All @@ -403,13 +395,19 @@ def run_image_task(
*,
session: Session | None = None,
) -> None:
request_id = f"img-{task_id}"
own = session is None
session = session or self._make_session()
reset = None
try:
task_repo.update_status(session, task_id, TaskStatus.RUNNING)
if own:
session.commit()
cons = _load_constraints(session, project_id) # 角色图也受项目约束
reset = bind_call_context(
request_id=request_id,
task_id=str(task_id),
)
urls = self._produce_image(input, cons)
task_repo.update_result(session, task_id, _IMAGE_RESULT, {
"type": "character_image",
Expand All @@ -419,10 +417,15 @@ def run_image_task(
session.commit()
except Exception as exc: # noqa: BLE001 —— 兜底
logger.exception("图片任务 %s 失败", task_id)
task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc))
task_repo.update_status(
session, task_id, TaskStatus.FAILED,
error_message=f"{exc}; request_id={request_id}",
)
if own:
session.commit()
finally:
if reset is not None:
reset()
if own:
session.close()

Expand Down Expand Up @@ -484,9 +487,9 @@ def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -

def _get_image(self):
if self._image is None:
from windup_framework.providers import SufyImageProvider
from windup_framework.gateway import build_image_gateway

self._image = SufyImageProvider()
self._image = build_image_gateway()
return self._image

def _download(self, url: str) -> bytes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ class CharacterActionInput:
# 这个动作是否循环播放。``None`` 原样往下传,由编排层兜成一次性:本层替调用方填默认值
# 的话,"没给"和"明确给了 False"从这里起就再也分不开了。
loop: bool | None = None
# 视频模型。``None`` = 用部署配置的默认值(kling-v2-5-turbo)。
# 取值域见 executor.ALLOWED_VIDEO_MODELS —— 只开放两个,因为每个模型的入参形状不同
# (image_list / input_reference / Fal 队列),全开等于把三套协议适配塞进一个改动
# 视频模型。``None`` = 用部署配置的默认值。取值域为
# ``ModelRegistry.chain(CHARACTER_ACTION)``(部署默认 + fallbacks);不在链上 → 入口
# 报错,不到付费调用才失败。选中的型号表示这次从它开始试,由 Gateway 读 start_from_model
video_model: str | None = None


Expand Down
4 changes: 2 additions & 2 deletions backend/packages/app/src/windup_app/web/api/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ class CharacterActionGenerateRequest(BaseModel):
# 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、
# 仍可用。而且猜错是静默的,帧数/时长/成色全部正常、没有任何一道会红。
loop: bool | None = None
# 视频模型。None = 用部署默认(kling-v2-5-turbo)。取值域见
# orchestrator.executor.ALLOWED_VIDEO_MODELS;非法值在入口就报错,不到付费调用才失败。
# 视频模型。None = 用部署默认。取值域见 ModelRegistry.chain(CHARACTER_ACTION);
# 非法值在入口就报错,不到付费调用才失败。选中的型号表示这次从它开始试
video_model: str | None = None

@model_validator(mode="after")
Expand Down
4 changes: 4 additions & 0 deletions backend/packages/common/src/windup_common/enums/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class ModelErrorType(str, Enum):
NETWORK = "network" # 网络错误(连接失败 / DNS),可重试
AUTH = "auth" # 鉴权失败(密钥错 / 失效),不可重试
INVALID_RESPONSE = "invalid_response" # 返回格式错误(如该出图却返回纯文本 / 空)
UNREACHED = "unreached" # 521/522/523/525,请求大概率未到上游
MAYBE_BILLED = "maybe_billed" # 520/524/其它可能已计费 5xx
UPSTREAM_FAILED = "upstream_failed" # 视频 job failed/cancelled
UNKNOWN = "unknown" # 未知错误

@property
Expand All @@ -24,4 +27,5 @@ def retryable(self) -> bool:
ModelErrorType.RATE_LIMIT,
ModelErrorType.TIMEOUT,
ModelErrorType.NETWORK,
ModelErrorType.UNREACHED,
}
41 changes: 41 additions & 0 deletions backend/packages/framework/src/windup_framework/config/provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""AI Provider 配置。"""

from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -34,9 +35,49 @@ class AIProviderSettings(BaseSettings):
video_model: str = "kling-v2-5-turbo"
image_model: str = "gemini-2.5-flash-image"

image_fallbacks: str = ""
video_fallbacks: str = ""
image_unit_cost: float | None = None
video_unit_cost_per_second: float | None = None
price_version: str = "2026-08-16"

# ── Gateway route spike: base_url / key route candidates ────────────────
# 第一版仍以 env 管理。primary 留空时复用上面的 AI_BASE_URL / AI_API_KEY;
# fallback 三个字段都填才表示启用一个备用入口。
route_primary_name: str = "primary"
route_primary_base_url: str = ""
route_primary_api_key: str = ""
route_fallback_name: str = ""
route_fallback_base_url: str = ""
route_fallback_api_key: str = ""
gateway_ledger_enabled: bool = True

@field_validator("image_unit_cost", "video_unit_cost_per_second", mode="before")
@classmethod
def _empty_cost_is_none(cls, v):
if v == "" or v is None:
return None
return v

@property
def normalized_base_url(self) -> str:
return self.base_url.rstrip("/")

@property
def effective_route_primary_base_url(self) -> str:
return (self.route_primary_base_url or self.base_url).rstrip("/")

@property
def effective_route_primary_api_key(self) -> str:
return self.route_primary_api_key or self.api_key

@property
def route_fallback_enabled(self) -> bool:
return all((
self.route_fallback_name.strip(),
self.route_fallback_base_url.strip(),
self.route_fallback_api_key.strip(),
))


settings = AIProviderSettings()
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from windup_framework.gateway.context import bind_call_context
from windup_framework.gateway.image import ImageGateway, build_image_gateway
from windup_framework.gateway.models import AIGatewayAttempt, AIGatewayAttemptDetail
from windup_framework.gateway.video import VideoGateway, build_video_gateway

__all__ = [
"AIGatewayAttempt",
"AIGatewayAttemptDetail",
"ImageGateway",
"VideoGateway",
"bind_call_context",
"build_image_gateway",
"build_video_gateway",
]
Loading