Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2298818
feat(gateway): 按状态码分类 UNREACHED/MAYBE_BILLED
xiaocheny214 Aug 16, 2026
e25fbb8
feat(gateway): 增加 fallback 与单价配置键
xiaocheny214 Aug 16, 2026
28fa46a
feat(gateway): 按 family 校验 scene 型号链
xiaocheny214 Aug 16, 2026
3a55aa1
feat(gateway): 522 只再试一次并按入口熔断
xiaocheny214 Aug 16, 2026
d5d5211
feat(gateway): 记录耗时与估算成本的 attempt trace
xiaocheny214 Aug 16, 2026
26992d7
refactor(providers): 图像 adapter 一次 POST 返回结构化结果
xiaocheny214 Aug 16, 2026
23fb730
feat(gateway): 图像调用按策略重试与 Fallback
xiaocheny214 Aug 16, 2026
ef2363a
feat(gateway): 视频建单与跟单分离并按策略 Fallback
xiaocheny214 Aug 16, 2026
7cef41f
fix(gateway): 已有 job_id 时禁止 429 再开第二单
xiaocheny214 Aug 16, 2026
4a9fe82
feat(gateway): 生成任务经 Gateway 装配并回写 request_id
xiaocheny214 Aug 16, 2026
19fce6a
fix(gateway): 熔断加锁并禁止无 job_id 时换型号
xiaocheny214 Aug 16, 2026
b782d7d
feat(gateway): 支持入口路由与台账落库
xiaocheny214 Aug 19, 2026
c47a057
feat(gateway): 同入口多 key 切换并拆分 attempt 冷热字段
xiaocheny214 Aug 19, 2026
fca747d
Merge branch 'main' into feat/llm-gateway
xiaocheny214 Aug 19, 2026
6474cab
fix(gateway): 去掉合入后重复的 generate 调用
xiaocheny214 Aug 19, 2026
ce2de8c
fix(gateway): 对齐合入后的 chat 工厂与预付费结算
xiaocheny214 Aug 19, 2026
7dde457
Merge branch 'main' into feat/llm-gateway
xiaocheny214 Aug 19, 2026
9e3f766
Merge branch 'main' into feat/llm-gateway
xiaocheny214 Aug 20, 2026
8874c87
fix(gateway): 拆开合入后揉在一起的出图与判官请求
xiaocheny214 Aug 20, 2026
88c6d7e
test(judge): 钉住 429/52x 重试与 500 不重发
xiaocheny214 Aug 20, 2026
180696d
test(gateway): 补 ChatGateway 熔断跳过与 adapter 错误分类
xiaocheny214 Aug 20, 2026
3cacb25
Merge branch 'main' into feat/llm-gateway
xiaocheny214 Aug 20, 2026
0088e9e
feat(gateway): ChatGateway 补齐 astream/bind_tools 以兼容 /ai/chat
xiaocheny214 Aug 20, 2026
b40a890
feat(gateway): 无响应断连收成 UNREACHED 以便同路重试
xiaocheny214 Aug 20, 2026
50d4d86
Merge branch 'main' into feat/llm-gateway
xiaocheny214 Aug 21, 2026
19c3cfb
fix(gateway): ChatGateway 补 ainvoke 对齐 /ai/chat
xiaocheny214 Aug 21, 2026
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
27 changes: 24 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,32 @@ QINIU_PRIVATE_SPACE=false
# 键名前缀必须是 AI_,由 framework/config/provider.py 的 env_prefix 决定。
AI_BASE_URL=https://api.qnaigc.com/v1
AI_API_KEY=your-ai-api-key
# 各能力分开配型号:同时在用不同模型,共用一个字段会换一个连带换全部。
# 取值即默认值——不写这几行时跑的就是它们。
AI_CHAT_MODEL=gpt-4o-mini
AI_MODEL=your-chat-model
# 各能力分开配型号:三条能力同时在用不同模型,共用一个字段会换一个连带换全部。
# 取值即默认值——不写这两行时跑的就是它们。
AI_CHAT_FALLBACKS=
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 级故障切换。
# 同入口多个 key 用逗号分隔:429 换下一个 key,522/525 跳过该入口剩余 key。
AI_ROUTE_PRIMARY_NAME=qnaigc-primary
AI_ROUTE_PRIMARY_BASE_URL=
AI_ROUTE_PRIMARY_API_KEY=
AI_ROUTE_PRIMARY_API_KEYS=
AI_ROUTE_FALLBACK_NAME=
AI_ROUTE_FALLBACK_BASE_URL=
AI_ROUTE_FALLBACK_API_KEY=
AI_ROUTE_FALLBACK_API_KEYS=
# 示例: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
# 判官(看图问答)。必须填一个**能读图的聊天模型**,不是出图模型:它要回 JSON 不是回图。
AI_JUDGE_MODEL=gemini-2.5-flash

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_ai_engine.impl.character_namer import LangChainCharacterNamer
Expand Down
101 changes: 52 additions & 49 deletions backend/packages/app/src/windup_app/server/orchestrator/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
from windup_ai_engine.slicing.quality import subject_blobs
from windup_common.directions import direction_prompt
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_framework.config.quality_gate import settings as gate_settings

from windup_app.server.orchestrator import billing, quality_gate, task_repo
Expand Down Expand Up @@ -159,27 +162,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 @@ -223,17 +216,14 @@ def __init__(
| None = None,
session_factory: Callable[[], Session] | None = None,
) -> None:
self._generator = generator # None → 懒加载真实装配
# 按视频模型名分桶的 generator 缓存(模型是 provider 的构造参数,不能事后换)
# 三渲二的渲染方向数属于项目约束;同一视频模型在 4 向和 8 向项目中
# 不是同一个缓存实例,否则先请求的项目会把后续项目的方向数锁死。
# directions=1 保留旧的 model/None 键,避免已有注入测试和调用方失效;
# 多方向项目用二元组分桶,防止同一模型的方向配置互相污染。
self._by_model: dict[
str | None | tuple[str | None, int], CharacterGeneratorPort
] = {}
# 抠图 / 图生图 provider 与视频模型无关,所有模型桶共用一份:每个抠图实例都会
# 各自惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。
self._generator = generator # None → 懒加载真实装配(一套共享 Gateway)
# 选哪个 kling 是 Gateway 读 start_from_model 的事,不分模型桶。
# 三渲二的渲染方向数仍属项目约束:4 向和 8 向的相机表不同,必须分桶,
# 否则先请求的项目会把后续项目的方向数锁死。directions=1 用整数 1
# 做键,已有 `_get_generator()` 调用仍走同一份。
self._by_model: dict[int, CharacterGeneratorPort] = {}
# 抠图 / 图生图与视频 Gateway 无关方向分桶,所有桶共用一份:每个抠图实例
# 都会惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。
self._matte: MatteProvider | None = None
self._image: ImageProvider | None = None
# 判官同样与视频模型无关,故不分桶。缺省 None 时**不建**实例:建了就意味着每个
Expand Down Expand Up @@ -261,14 +251,21 @@ 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)
_settle_credit(session, task_id, success=True)
Expand Down Expand Up @@ -301,6 +298,8 @@ def run_action_task(
if own:
session.commit()
finally:
if reset is not None:
reset()
if own:
session.close()

Expand Down Expand Up @@ -369,6 +368,8 @@ def _produce_action(
# **不静默回退。** 拿到了 model_3d_url 却下载不下来 / 渲不出来,就报错,不改走
# i2v —— 两条路线的画风、成本、多朝向能力都不同,悄悄换一条等于让调用方拿着
# 错误的前提做后续决定,而帧数、时长、成色全都正常,没有任何一道会红。
#
# 选哪个 kling 不在这里传:run_action_task 已经 bind_call_context(start_from_model)。
model_url = (input.model_3d_url or "").strip()
# 三渲二那支不取母版,而出口的判官闸口要拿它当参照 —— 不先置 None 的话那支会
# 撞 UnboundLocalError,而它只在有 3D 资产的造型上触发。
Expand Down Expand Up @@ -433,49 +434,43 @@ def _get_generator(
video_model: str | None = None,
directions: int = 1,
) -> CharacterGeneratorPort:
"""懒装配 CharacterGenerator,按模型名分桶
"""懒装配 CharacterGenerator(ImageGateway + VideoGateway + matte)

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

def _assemble(
self, video_model: str | None, directions: int
) -> CharacterGeneratorPort:
"""装一个模型桶。**调用方须持有 ``self._assembly_lock``**(会写共用 provider)。"""
def _assemble(self, directions: int) -> CharacterGeneratorPort:
"""装一个方向桶。**调用方须持有 ``self._assembly_lock``**(会写共用 provider)。"""
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 @@ -574,7 +569,7 @@ class ImageTaskExecutor:
def __init__(
self,
*,
image=None, # None → 懒加载 SufyImageProvider
image=None, # None → 懒加载 ImageGateway
matte: MatteProvider | None = None, # None → 懒加载 OnnxU2NetMatteProvider
upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传
fetch_ref: Callable[[str], bytes]
Expand All @@ -595,13 +590,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, quality = self._produce_image(input, cons)
task_repo.update_result(
session,
Expand All @@ -627,6 +628,8 @@ def run_image_task(
if own:
session.commit()
finally:
if reset is not None:
reset()
if own:
session.close()

Expand Down Expand Up @@ -711,9 +714,9 @@ def _produce_image(

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 _get_matte(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,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
# ── 三渲二(#192)────────────────────────────────────────────────────
#
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 @@ -198,8 +198,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
# 这次动作属于哪个造型。给了才可能走三渲二 —— 3D 资产挂在造型一级(#121)。
# 不给则照旧走 i2v(向后兼容:前端接上之前所有调用都是这样)。
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,
}
45 changes: 45 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 @@ -40,9 +41,53 @@ class AIProviderSettings(BaseSettings):
# ``GET /models`` 去核对。
judge_model: str = "gemini-2.5-flash"

chat_fallbacks: str = ""
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 三个字段都填才表示启用一个备用入口。
# *_API_KEYS 是同入口额外 key(逗号分隔):429 换 key,UNREACHED 跳过剩余 key。
route_primary_name: str = "primary"
route_primary_base_url: str = ""
route_primary_api_key: str = ""
route_primary_api_keys: str = ""
route_fallback_name: str = ""
route_fallback_base_url: str = ""
route_fallback_api_key: str = ""
route_fallback_api_keys: 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,17 @@
from windup_framework.gateway.chat import ChatGateway, build_chat_gateway
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",
"ChatGateway",
"ImageGateway",
"VideoGateway",
"bind_call_context",
"build_chat_gateway",
"build_image_gateway",
"build_video_gateway",
]
Loading
Loading