Skip to content

feat(contract): (operation, profile) capability mapping table / 能力映射表契约 - #278

Open
Xingkai98 wants to merge 5 commits into
ThinkFlowLab:mainfrom
Xingkai98:feat/capability-contract
Open

Xingkai98 wants to merge 5 commits into
ThinkFlowLab:mainfrom
Xingkai98:feat/capability-contract

Conversation

@Xingkai98

@Xingkai98 Xingkai98 commented Nov 10, 2026 •

Copy link
Copy Markdown

feat(contract): (operation, profile) capability mapping table / 能力映射表契约

独立于 #266:本模块零依赖(不 import omni_capabilities.py 任何符号),可先合。与 #266 是互补关系 —— #266 提供发现与匹配,本 PR 提供它缺的「上游 operation/profile 词表 → capability id」这一层;#266 合入后 requests_for() 可直接吃这张表。
Independent of #266: this module imports nothing from it, so it can merge first. #266 owns discovery and matching; this PR adds the missing (operation, profile) → capability layer that requests_for() can consume once #266 lands.

这一版改了什么 / What changed in this revision

上一版(66ed78c)把键定成 (stage, route)。按上游 head d1104f8 取证后,这个键不成立,已整体替换:

上一版 这一版 依据
STAGE_ROUTES,键 (stage, route) OPERATION_PROFILES,键 (operation, profile),覆盖上游全部 12 个 profile vllm_omni/serving_capabilities.py:43-54
stage 名(text/image/tts/music/score)参与能力判定 stages[] 只作拓扑(topology),不参与任何能力判定 stages[] 只有 id/type/depends_on,且每个 model 拿到同一份全量列表(entrypoints/openai/serving_capabilities.py:400)
/v1/audio/transcriptions 在 score 下读成 audio.score 上游根本没有识别 operation,asr 报 unmapped_stage,audio.score 恒 unknown api_server.py 无该路由;recipes/.../Ming-3.0-audio-flash.md:331
有路由无 operation 的三项报 parameter_only 报 no_upstream_profile,状态 unknown(不是 missing) docs/serving/capabilities.md:41-43
fixture 是自拟的 stages:[{name, routes}] fixture 按真实响应形状写(stages[]=id/type/depends_on、models[].operations 六个齐全、profile 带 routes[]) protocol/capabilities.py:36-79

上游真实词表(取证) / The evidenced vocabulary

vllm-project/vllm-omni PR #10102,head d1104f8:

operation profile 本仓 id 路由
chat text_response text.chat POST /v1/chat/completions
chat image_response / audio_response (无)输出分别是图像/音频,本仓 text.chat 的消费者是纯文本适配器 POST /v1/chat/completions
image_generation text_to_image image.generate POST /v1/images/generations
image_edit image_to_image image.edit POST /v1/images/edits
video_generation text_to_video / image_to_video / video_to_video video.generate POST /v1/videos、POST /v1/videos/sync、WS /v1/realtime/video
speech text_to_speech audio.speech POST /v1/audio/speech
speech lyrics_to_music audio.music POST /v1/audio/speech
speech caption_to_audio (无) POST /v1/audio/speech
audio_generation text_to_audio (无)扩散侧 /v1/audio/generate 契约,无 lyrics/instructions,不是本仓 audio.music POST /v1/audio/generate

为什么键是 (operation, profile) / Why the key is the pair

不是 route:/v1/audio/speech 一条路径挂 3 个 profile —— 配音、音乐、音效。上游自己的测试里,MOSS-SoundEffect 在 speech 上只声明 caption_to_audio,同时把 text_to_speech 标成 unsupported(tests/config/test_serving_capabilities.py:233,251):同一路径,两种答案。只按路径匹配,「只做音效」的部署会看起来会配音。路由仍然有用,但只作为一致性校验:OPERATION_ROUTES 记录 operation 对路径的所有权,profile 引用了别的 operation 的路径就报 route_mismatch 且不产生能力。

不是 stage:stages[] 是部署拓扑,id 是数字字符串,每个 model 都拿到同一份全量列表。用它判定,所有模型共享一个状态、既不能区分也不能诊断。本模块把 stages[] 只放进 topology 供展示。

音乐走 /v1/audio/speech,不是 /v1/audio/generate:音乐生成不是独立 operation,它是 speech 下的 lyrics_to_music profile(MiniMax Music3 / YuE2 用它,model_executor/models/minimax_music3/pipeline.py:50-55)。/v1/audio/generate 属 audio_generation 的 text_to_audio,是扩散侧另一条请求契约,没有 voice / lyrics / instructions 映射(docs/serving/capabilities.md:59-60):它既不能映射到 audio.speech,也不能映射到 audio.music —— 本仓 audio.music 的唯一适配器 VllmOmniMusic(integrations/model_adapters.py:510-537)POST 的正是 /v1/audio/speech,且必带歌词与 instructions,而这条 profile 恰恰没有这两样。所以 text_to_audio 留空,support=supported 时报 no_local_capability,audio.music 只由 lyrics_to_music 决定(无证据即 unknown)。

改了什么 / What changed

文件 说明
packages/core/src/opendirector/domain/capability_contract.py OPERATION_PROFILES(12 行,键 (operation, profile))、OPERATION_ROUTES(6 个 operation,键是 (transport, method, path),含 /v1/videos/sync 与 websocket WEBSOCKET /v1/realtime/video)、UNROUTED、capabilities_for()、declarations()、resolve()、classified_capabilities()、route_path()
tests/fixtures/omni-capabilities/{full,partial,absent}.json 三档真实形状样本(六操作齐全、必填字段齐全、profiles 为 [] 而非缺席;exclude_none 只能省掉 request 这类 None 字段;required_inputs/outputs 照上游 STANDARD_PROFILES 含顺序);partial 覆盖 unsupported / exposure=disabled / profile 路由全 disabled / no_local_capability / 全 unknown 枚举;absent 覆盖没答、只答拓扑、profiles: []
tests/fixtures/omni-capabilities/malformed.json 只用于防御输入:合法 pydantic、但上游投影发不出的 body(profile 引用别的 operation 的路由;自有路由全 disabled 旁边挂着一条本表不认识的 enabled 路径)。只被防御性测试读取,不进样本 oracle
tests/test_capability_contract.py 64 个用例,含 5 条防「假覆盖」回归、12 行上游 required_inputs/outputs 字面量表与两份文档表格/全部样本 fixture 的比对、fixture 结构不变量校验(observed_at/reason_code/execution/route 三元组)、路由级 exposure 与 transport/method 用例
docs/architecture/capability-contract.md 双语:上游词表取证、为什么键是成对、八种无法翻译的声明、状态与 reason 映射表、unrouted、ASR、与 #266 的关系
docs/README.md 文档索引那一行同步

状态判定:只有正面证据才是 missing

  • available:某个 model 的 profile support=supported,其 operation 是 supported + exposure=enabled,且该 profile 至少一条自己的路由 exposure=enabled;
  • missing:profile 或 operation 正面说不(support=unsupported / exposure=disabled / 自己的路由全 disabled);
  • unknown:其余全部 —— 404、字段缺席、枚举为 unknown、路由可达性无法确认、声明无法翻译、能力不可路由。

missing 的两类「正面说不」不再坍缩成一个标签:resolve()['reasons'] 给出 unsupported(模型不行)/ endpoint_disabled(部署把 operation 的路由关了)/ route_disabled(profile 自己的路由全关)。unknown 的原因在 unresolved(no_route_declared、malformed_route、unknown_route、route_mismatch、no_local_capability 等);endpoint 三值 absent / empty / present 区分「没答」与「答了但没声明」。

八种无法翻译的声明分别进 unresolved(不丢弃):unknown_route、route_mismatch、unmapped_stage、unknown_profile、operation_mismatch、no_local_capability、no_route_declared、malformed_route,每条带 operation/profile/route/reason。

有路由、没有 operation 的能力

/v1/images/segmentations、/v1/videos/segmentations、/v1/videos/depth 都注册了,但没有任何 operation 拥有它们。image.segment / video.matting / video.motion_capture 恒为 unknown,reason no_upstream_profile —— 端点不发布该能力,无法判定,不能写成 missing。

ASR 是上游缺口,不是本表漏行

上游词表没有识别 operation,omni router 也不注册 /v1/audio/transcriptions(上游 recipe 明写该端点不可用)。请求侧写 asr 得到 unmapped_stage,audio.score 恒 unknown(no_upstream_operation)。等上游发布识别 operation 再谈映射。

防「假覆盖」的 5 条回归 / Five regressions against false coverage

测试 钉住什么
test_real_shaped_body_resolves_non_empty_pairs 真实形状 body 解析出 12 个成对声明,能力不是空表
test_route_alone_cannot_distinguish_speech_profiles 同一路径 3 个 profile 解析出 3 个不同答案;只做音效的部署不会被读成会配音
test_stage_ids_do_not_collapse_capabilities 两个 model 的 stage_ids 相同,能力状态仍各自独立;数字 stage id 不再冒成翻译失败
test_capabilities_without_an_upstream_profile_are_unknown_not_missing 8 个无上游 profile 的 id 恒 unknown(字面量表,不从被测常量现算)
test_a_profile_without_support_is_unknown_even_when_its_operation_is_supported profile 级字段缺席是 unknown;写清 unsupported 才是 missing

不碰什么 / Out of scope

测试怎么跑 / How to verify

# 本 PR 的契约测试
PYTHONPATH=packages/core/src:apps/api/src python -m pytest tests/test_capability_contract.py -q
# 58 passed(上一版 40 passed)

# 相关子集
PYTHONPATH=packages/core/src:apps/api/src python -m pytest tests -k "capab or constraint or input_rule or availab" -q
# 本分支(基于 main):122 passed / 1 skipped(上一版 104 passed / 1 skipped)
# 与 #266 合并后:271 passed / 1 skipped(上一版 261 passed / 1 skipped)

python scripts/generate_contracts.py --check   # Creation contracts verified

全量 pytest tests:1602 passed / 98 skipped / 18 failed,18 个失败全部在 test_native_runtime.py、test_deepseek_runtime.py、test_jiuwenswarm_runtime.py、test_timeline_packages.py(容器里没有真实 agent CLI),把这些文件放到不含本改动的树上跑,失败集合完全相同。

(本机 python 3.13,pydantic 2.14.3 / pytest 9.2.0。generate_contracts.py 只覆盖 creation 契约,本模块不产生生成物。)

与 #266 的关系 / Relationship to #266

互补 PR(非 stacked,因 #266 来自 fork,GitHub 不允许跨仓 base):#266 定义发现文档与匹配,本 PR 提供 (operation, profile) → capability 这一层,两者可独立合入。合并路径 ——

  1. feat(providers): discover capabilities and validate generation inputs #266 直接吃 capabilities_for(operation, profile),requests_for() 从「按 kind+protocol 猜 profile 名」变成表驱动;
  2. 或先只把 resolve() 的逐能力状态接给展示层,匹配仍留在 feat(providers): discover capabilities and validate generation inputs #266 里。

本 PR 不再消费 Model.stage_ids:它是拓扑,不是能力键。

给 @bread-lxy 的三个问题 / Three questions

  1. stage_ids 怎么处理? 上游每个 model 拿到的都是同一份全量列表,用它判定能力会让所有模型共享一个状态,所以本 PR 只把它当拓扑。若 feat(providers): discover capabilities and validate generation inputs #266 希望 stage 参与逐模型判定,需要上游先做到按模型差异化 —— 否则只能得到「全员同状态」。
  2. caption_to_audio 要不要补 id? 上游确实发布这个 profile(音效/环境声),本仓没有对应 capability id,现在如实报 no_local_capability 并保持 unknown。(text_to_audio 同样留空:它是扩散侧 /v1/audio/generate 契约,没有 lyrics/instructions,喂不了本仓 audio.music 需要的 /v1/audio/speech 请求。)是补 audio.effect,还是长期留白?后者更保守,但会看不见「只做音效」的部署。
  3. asr 按上游缺口处理,接受吗? 上游词表里没有识别 operation,/v1/audio/transcriptions 在 omni 下不注册。本 PR 的答案是 unmapped_stage + audio.score 恒 unknown,等上游发布后再补行。

后续(本 PR 不做) / Follow-ups

  • 上游发布识别 operation(或本仓补 audio.transcribe)后,OPERATION_PROFILES 加一行即可,unmapped_stage 自然消失;
  • docs/architecture/test-contracts.md 的「一个语义只有一个所有者」要求:表一旦合并,requests_for() 里那 30 行 (kind, protocol) 分支应当删除,而不是与本表并存;
  • 多模型部署下 available 仍是部署级 OR(不回答「哪个 model」):本模块不读 model id,如需要 served_by 得由 feat(providers): discover capabilities and validate generation inputs #266 一侧决定脱敏口径。

已知限制(第 3 轮窄修后仍存) / Known limits after this pass

本轮只修 5 条接口面问题(full 档 caption_to_audio 一致性、fixture 构造不变量 + 守门测试、unrecognised 路由的 unknown 半边用例、两处同义反复断言、malformed.json 纳入 required_inputs 行校验)。以下仍是已知限制,未在本轮改动:

  • malformed_route 与 no_route_declared 同时出现:一条读不出 path 的路由条目会报两条 unresolved,文档映射表只写了前者(capability_contract.py:476-478)。
  • 文档表第 2 列(operation)不受门禁:test_documented_required_inputs_match_fixtures 只比 required_inputs → outputs 单元格,把某行 operation 改错不会红。
  • 两处上游行号引用不够精确:model_adapters.py:547 应为 :555(POST /audio/transcriptions);serving_capabilities.py:341-343 应用全路径 entrypoints/openai/serving_capabilities.py,短名会与 vllm_omni/serving_capabilities.py(176 行)撞行号。
  • 省略 profile 的填充循环、method 缺省、profile id 大小写三条防御分支仍无用例(上轮记 M10 medium / M12 low / M13 low):上游 declare_capabilities 会补齐 profile,method 缺省被 pydantic 拒收,id 大小写只会「少给能力」。
  • expected 是脚本生成后冻结的:fixture 里没有 reviewedBy 一类标记,重跑生成器会把回归静默吸收。
  • test_fixtures_are_schema_valid 的 seen_body >= 5 没有跟随实际 body 数(现为 5),掉一个 case 不会被发现。
  • 仍有一处冗余断言:test_a_profile_without_support_is_unknown_even_when_its_operation_is_supported 里的 != AVAILABLE 与紧邻的 == UNKNOWN 是同一件事(本轮只清了新增的两处)。

Xingkai98 and others added 4 commits November 11, 2026 00:24
…abulary

vllm-omni#10102 publishes six operations and twelve profiles; `stages[]` is
deployment topology (`id`/`type`/`depends_on`, numeric strings, the same full
list for every model) and a route cannot separate the three profiles that share
`/v1/audio/speech`.  The table is therefore keyed by (operation, profile), with
OPERATION_ROUTES kept as the ownership check that reports `route_mismatch`.

- OPERATION_PROFILES replaces STAGE_ROUTES, covering all twelve profiles
- capabilities are available only on a supported profile in an enabled
  operation; `missing` needs positive evidence, everything else stays `unknown`
- segmentations/depth keep a route but no operation, so image.segment,
  video.matting and video.motion_capture are `unknown` with `no_upstream_profile`
- `asr` reports `unmapped_stage`: upstream publishes no recognition operation
- fixtures follow the real response shape; five regressions pin the pairs,
  the shared speech path, topology-only stages and absent fields
…res schema-valid

Round-2 fixes for the three independent reviews of ThinkFlowLab#278.

Product decisions (P1-P5):
- P1: image_response / audio_response no longer map to text.chat; they stay
  unknown with no_local_capability, since text.chat is served by a text-only
  adapter and an image-only deployment must not read as able to chat.
- P2: text_to_audio (audio_generation, /v1/audio/generate) maps to audio.music
  alongside lyrics_to_music; the StableAudio/LTX2-t2a shape no longer reads
  audio.music as missing.
- P3: support=unsupported and exposure=disabled stay "missing" but are told
  apart: resolve() now returns a `reasons` map (unsupported /
  endpoint_disabled / route_disabled) instead of dropping reason_code.
- P4: profiles[*].routes[*].exposure is read: all routes disabled -> missing +
  route_disabled, all unknown or routes: [] -> unknown, one enabled -> available.
- P5: one unrecognised route no longer voids a profile that answered; only when
  nothing usable remains does it fall to unknown.

Contract and implementation (I1-I3):
- I1: OPERATION_ROUTES is (transport, method, path); _check_route compares all
  three, so DELETE /v1/chat/completions and a websocket-declared http route are
  route_mismatch.
- I2: endpoint distinguishes absent (no answer: None, no models, models not a
  list) from empty (answered, no operation declared) from present.
- I3: the unreachable `item in UNROUTED` branch is deleted; unrouted ids never
  collect evidence, and the disjointness invariant is enforced by
  test_every_capability_id_is_routed_or_explained instead of dead runtime code.
  Malformed routes and supported profiles with no route are now reported
  (malformed_route / no_route_declared) instead of silently unknown.

Fixtures (F1-F3):
- All three fixtures are upstream-shaped: six operations per model, support and
  exposure on every operation, profile support present, profiles: [] instead of
  a missing key, image_response/audio_response outputs ["image"]/["audio"].
- Defensive shapes (asr key, misplaced profile, absent required support) moved
  out of the fixtures into the tests that defend against malformed input.
- test_fixtures_are_schema_valid asserts upstream's structural invariants only.
- exclude_none is described correctly (it drops None-valued optional fields such
  as `request`; default_factory lists serialize as []).

Tests (T1-T5):
- The tautological order assertion is now a literal vocabulary list, and the
  twelve pairs are literal too instead of an echo of OPERATION_PROFILES.
- test_a_profile_without_support_is_unknown_even_when_its_operation_is_supported
  covers profile-level absence (old N4 mutant now dies).
- test_a_profile_that_declares_no_route_is_unknown covers `supported + routes: []`
  (old M7/P3 mutant now dies).
- New cases: profile routes all disabled / all unknown, one unknown route does
  not void a profile, DELETE and websocket mismatches, image-only chat, text_to_audio
  -> audio.music, absent vs empty endpoints, reason separation, non-dict operation
  values, profile order independence.
- UNROUTED expectations are a literal table, not recomputed from the constant.

Docs (D1-D2):
- Corrected the exclude_none claim and the audio.score / /v1/audio/transcriptions
  statement (the capabilities body never publishes that path, so audio.score can
  only be unknown).
- Added the status/reason mapping table (CN + EN) covering every declaration
  shape, plus the profile/route/profile-output tables.

Tests: 60 passed (was 40); related subset 122 passed, 1 skipped. Nine mutations
of the new rules (including removing route_disabled) all fail the suite.
@Xingkai98 Xingkai98 changed the title feat(contract): stage/route capability mapping table / 能力映射表契约 feat(contract): (operation, profile) capability mapping table / 能力映射表契约 Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant