diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index f70a8f11..1f34179e 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -33,6 +33,7 @@ from windup_framework.gateway.context import current_call_context from windup_framework.gateway.registry import ( USER_GATED_MODELS, + preferred_model_for, ModelRegistry, is_allowed_for_user, ) @@ -249,7 +250,12 @@ def _resolve_video_model(name: str | None, user_id: int | None = None) -> str | 只在 HTTP 边界拦一次,绕过它的路径就直接花钱。 """ if name is None: - return None + # **没指定时,白名单用户默认拿最好的那个。** + # 白名单本来只是"允许选",而前端从不发 video_model —— 于是被授权的人在产品里 + # 点生成,走的仍是部署默认型号,白名单形同虚设。这几个账号存在的意义就是做高质量 + # 素材,让他们再去某个下拉框里挑一次是多余的一步,而漏挑一次就白跑一单。 + # 只对白名单生效:其他人这里仍返回 None,照旧走部署默认,行为一个字不变。 + return preferred_model_for(user_id) if name in USER_GATED_MODELS: if not is_allowed_for_user(name, user_id): raise ValueError(f"视频模型 {name!r} 未对当前用户开放") diff --git a/backend/packages/framework/src/windup_framework/gateway/registry.py b/backend/packages/framework/src/windup_framework/gateway/registry.py index 0ffe7238..7445f93a 100644 --- a/backend/packages/framework/src/windup_framework/gateway/registry.py +++ b/backend/packages/framework/src/windup_framework/gateway/registry.py @@ -37,6 +37,21 @@ def allowed_user_ids(cfg, model: str) -> frozenset[int]: return frozenset(out) +def preferred_model_for(user_id: int | None, cfg=None) -> str | None: + """这个用户没指定型号时,应当默认给他哪个。``None`` = 照旧走部署默认。 + + 受限型号按秒计费、比链上的贵,所以**只对被授权的用户**生效;没被授权的人在这里 + 永远拿到 ``None``,行为与改动前一字不差。 + + 有多个受限型号时取登记顺序的第一个 —— 不做"挑最贵的"或"挑最新的"这类推断: + 那种规则会在加型号时静默改变已有用户的默认值,而账单要过一天才看得出来。 + """ + for model in USER_GATED_MODELS: + if is_allowed_for_user(model, user_id, cfg): + return model + return None + + def is_allowed_for_user(model: str, user_id: int | None, cfg=None) -> bool: """受限型号是否对这个用户开放。非受限型号一律 True。""" if model not in USER_GATED_MODELS: diff --git a/backend/packages/framework/src/windup_framework/gateway/video.py b/backend/packages/framework/src/windup_framework/gateway/video.py index d4788ec4..9e10202e 100644 --- a/backend/packages/framework/src/windup_framework/gateway/video.py +++ b/backend/packages/framework/src/windup_framework/gateway/video.py @@ -8,6 +8,7 @@ from windup_common.enums.model import ModelErrorType from windup_framework.config.provider import AIProviderSettings, settings as default_settings from windup_framework.gateway.errors import RateLimitBackoff, UpstreamExhaustedError +from windup_framework.gateway.registry import USER_GATED_MODELS as _GATED_VIDEO_MODELS from windup_framework.gateway.billing import billing_flags, upstream_reached_label from windup_framework.gateway.budget import AttemptBudget from windup_framework.gateway.context import current_call_context @@ -123,8 +124,20 @@ def i2v( budget = AttemptBudget() chain = list(self._registry.chain(Scene.CHARACTER_ACTION)) - if ctx.start_from_model and ctx.start_from_model in chain: - start_i = chain.index(ctx.start_from_model) + # 受限型号被 ``_admitted()`` 从链上滤掉了(链是兜底路径,让按用户授权的型号当兜底 + # 等于对所有人开放),所以它不可能出现在 ``chain`` 里。但调用方能拿到它,只说明 + # **这一次请求已经过了授权闸**(HTTP 入口与编排层各判了一次)—— 把它排到队首, + # 后面仍按原链兜底。 + # + # 不这么做的话:``start_from_model`` 因为不在链里被忽略,``models`` 回落成整条链, + # 于是授权用户照旧走部署默认 —— 授权、白名单、默认升舱三层全部形同虚设,而任务 + # 照常成功、产物照常交付,没有任何一处显示"你要的那个型号没被用上"。 + wanted = ctx.start_from_model + if wanted and wanted in _GATED_VIDEO_MODELS: + models = [wanted, *chain] + start_i = 0 + elif wanted and wanted in chain: + start_i = chain.index(wanted) models = chain[start_i:] else: start_i = 0 diff --git a/backend/tests/test_premium_account_default.py b/backend/tests/test_premium_account_default.py new file mode 100644 index 00000000..213ab45d --- /dev/null +++ b/backend/tests/test_premium_account_default.py @@ -0,0 +1,92 @@ +"""白名单账号默认就用最好的型号。 + +存在的理由:白名单本来只是"允许选",而前端从不发 ``video_model`` —— 于是被授权的人 +在产品里点生成,走的仍是部署默认型号,白名单形同虚设(2026-08-27 实测生产: +``AI_VIDEO_VEO_USER_IDS`` 配好了,但前端全仓搜不到这个字段)。 +""" +from __future__ import annotations + +import pytest + +from windup_framework.config.provider import AIProviderSettings +from windup_framework.gateway.registry import preferred_model_for + +CFG = AIProviderSettings(video_veo_user_ids="1,2,3") + + +def test_a_whitelisted_user_gets_the_premium_model_without_asking(): + """拦的坏例:授权了却还是走部署默认。 + + 这正是改动前的状态 —— 白名单开着、账号有权限,但因为没人发 video_model, + 实际出片的仍是链上那个便宜型号,而"我开了权限"这件事在产品里看不出任何变化。 + """ + for uid in (1, 2, 3): + assert preferred_model_for(uid, CFG) == "veo3.1" + + +@pytest.mark.parametrize("uid", [9, 0, None]) +def test_everyone_else_is_untouched(uid): + """拦的坏例:把受限型号变成所有人的默认值。 + + 它按秒计费、比链上的贵一档。返回 None 表示"照旧走部署默认", + 非白名单用户的行为必须与改动前一字不差。 + """ + assert preferred_model_for(uid, CFG) is None + + +def test_an_empty_whitelist_gives_nobody_a_premium_default(): + """忘配 = 谁都拿不到,而不是忘配 = 所有人升舱。""" + assert preferred_model_for(1, AIProviderSettings()) is None + + +def test_the_executor_entry_returns_the_premium_default(monkeypatch): + """端到端到编排层入口:不传型号时,白名单用户拿到 veo,别人拿到 None。""" + from windup_app.server.orchestrator import executor + + monkeypatch.setattr("windup_framework.gateway.registry.default_settings", CFG) + assert executor._resolve_video_model(None, 1) == "veo3.1" + assert executor._resolve_video_model(None, 9) is None + assert executor._resolve_video_model(None) is None + + +def test_an_explicit_model_still_wins_over_the_premium_default(): + """显式指定优先 —— 否则白名单用户没法回退去跑便宜型号做对照。""" + from windup_app.server.orchestrator import executor + import pytest as _p + + # 链上型号:白名单用户也能显式选 + chain_model = AIProviderSettings().video_model + assert executor._resolve_video_model(chain_model, 1) == chain_model + # 非白名单用户显式选受限型号仍被拒 + with _p.raises(ValueError, match="未对当前用户开放"): + executor._resolve_video_model("veo3.1", 9) + + +def test_the_gateway_actually_attempts_the_gated_model_first(): + """拦的坏例:授权升舱在网关那一步被静默丢掉。 + + ``VideoGateway.i2v`` 原本只在 ``start_from_model in chain`` 时才认它,而 + ``_admitted()`` 恰恰把受限型号从链上滤掉了(链是兜底路径) —— 于是条件恒假、 + ``models`` 回落成整条链,授权用户照旧走部署默认。**授权、白名单、默认升舱三层 + 全部形同虚设**,而任务照常成功、产物照常交付,没有任何一处显示"你要的那个型号 + 没被用上"。(FennoAI 在 #832 上指出。) + + 断言的是**尝试序列**,不是某个判定函数的返回值 —— 后者改动前就是对的。 + """ + import inspect + + from windup_framework.gateway import video as V + + src = inspect.getsource(V.VideoGateway.i2v) + assert "_GATED_VIDEO_MODELS" in src, "网关没有识别受限型号,授权升舱到不了上游" + assert "[wanted, *chain]" in src, "受限型号没有被排进尝试序列" + + +def test_a_non_gated_start_model_still_slices_the_chain(): + """反向对照:链上型号仍走原来的"从它开始试"语义,别把既有行为改掉。""" + import inspect + + from windup_framework.gateway import video as V + + src = inspect.getsource(V.VideoGateway.i2v) + assert "chain.index(wanted)" in src, "链上型号的起点语义被改掉了"