Skip to content

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

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

Xingkai98 wants to merge 7 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 都没有,可先合;#266 合入后 requests_for() 可直接吃这张表。
Independent of #266: imports nothing from it, so it can merge first.

为什么键必须是 (operation, profile)

  • 不是 route:/v1/audio/speech 一条路径挂 3 个 profile(配音 / 音乐 / 音效)。上游自己的测试里 MOSS-SoundEffect 在 speech 上只声明 caption_to_audio、把 text_to_speech 标成 unsupported(tests/config/test_serving_capabilities.py:251,269)——同一路径两种答案,按路径匹配会把「只做音效」的部署读成会配音。
  • 不是 stage:stages[] 只有 id/type/depends_on,是拓扑;每个 model 拿到的是同一份全量列表(entrypoints/openai/serving_capabilities.py:400),用它判定所有模型必然同状态。

路由仍然有用,但只做一致性校验:OPERATION_ROUTES 记录 operation 对路径的所有权,profile 引用了别的 operation 的路径就报 route_mismatch 且不产生能力。

上游词表(vllm-omni PR #10102,按 d1104f8 取证;现已 43ff7b9,词表未变)

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 POST /v1/audio/generate

音乐走 /v1/audio/speech,不是 /v1/audio/generate:音乐不是独立 operation,它是 speech 下的 lyrics_to_music(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:633-662)POST 的正是 /v1/audio/speech 且必带歌词与 instructions。

改了什么

文件 说明
packages/core/src/opendirector/domain/capability_contract.py OPERATION_PROFILES(12 行,键 (operation, profile))、OPERATION_ROUTES(6 个 operation × 路由,含 /v1/videos/sync、WEBSOCKET /v1/realtime/video)、UNROUTED、capabilities_for()、declarations()、resolve()、classified_capabilities()、route_path()
tests/fixtures/omni-capabilities/{full,partial,absent}.json 三档真实响应形状:六操作齐全、必填字段齐全、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 路径)
tests/test_capability_contract.py 69 个用例:12 行 required_inputs/outputs 字面量表、与文档表格和全部样本的比对、fixture 构造不变量、路由级 exposure/transport/method 用例、5 条防「假覆盖」回归
docs/architecture/capability-contract.md 双语:上游词表取证、为什么键是成对、八种无法翻译的声明、状态与 reason 映射、unrouted、ASR、与 #266 的关系
docs/README.md 文档索引那一行

5 条防「假覆盖」回归:12 个成对声明非空;同一路径 3 个 profile 解析出 3 个不同答案;两个 model 的 stage_ids 相同但状态各自独立;8 个无上游 profile 的 id 恒 unknown;profile 缺 support 只报 unknown。

状态判定

  • available:profile support=supported,operation 是 supported + exposure=enabled,且该 profile 至少一条自己的路由 exposure=enabled;
  • missing:profile 或 operation 正面说不(support=unsupported / exposure=disabled / 自己的路由全 disabled);
  • unknown:其余全部。

missing 的两类正面说不分开记: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 区分「没答」与「答了但没声明」。

/v1/images/segmentations、/v1/videos/segmentations、/v1/videos/depth 都注册了但没有任何 operation 拥有它们 → image.segment / video.matting / video.motion_capture 恒 unknown(no_upstream_profile),不是 missing。

ASR 是上游缺口:上游词表没有识别 operation,omni router 也不注册 /v1/audio/transcriptions,请求侧写 asr 得 unmapped_stage,audio.score 恒 unknown。

怎么验

# 本 PR 的契约测试:69 passed
PYTHONPATH=packages/core/src:apps/api/src python -m pytest tests/test_capability_contract.py -q

# 能力相关子集:133 passed / 1 skipped
PYTHONPATH=packages/core/src:apps/api/src python -m pytest tests -k "capab or constraint or input_rule or availab" -q

python scripts/generate_contracts.py --check

全量 pytest tests 在本机不干净(缺真实 agent CLI,test_native_runtime.py 等有既有失败),与本改动无关,故不拿它当证据。

与 #266 的关系

互补,非 stacked(#266 来自 fork,GitHub 不允许跨仓 base)。接线两条路:

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

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

想确认的三点

  1. stage_ids 怎么处理? 上游每个 model 拿到的都是同一份全量列表,用它判定会让所有模型共享状态,所以这里只当拓扑。
  2. caption_to_audio 要不要补 id? 上游确实发布这个 profile(音效/环境声),本仓没有对应 capability id,现在如实报 no_local_capability 并保持 unknown。补 audio.effect 还是长期留白?
  3. asr 按上游缺口处理,接受吗? 现在报 unmapped_stage + audio.score 恒 unknown,等上游发布识别 operation 再补行。

已知限制

  • malformed_route 与 no_route_declared 可能同时出现(capability_contract.py:476-478),文档映射表只写了前者。
  • 文档表的 operation 列不受门禁:把某行 operation 改错不会红。
  • 省略 profile 的填充循环、method 缺省、profile id 大小写三条防御分支没有用例(上游会补齐 profile,method 缺省被 pydantic 拒收)。
  • fixture 的 expected 是生成后冻结的,没有 reviewedBy 标记,重跑生成器会把回归静默吸收。
  • test_fixtures_are_schema_valid 里的 seen_body >= 5 没跟随实际 body 数,掉一个 case 不会被发现。
  • 多模型部署下 available 仍是部署级 OR,不回答「哪个 model」。

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
@FayeSpica FayeSpica added type:feat New capability or behaviour area:provider Model providers and generation protocols labels Nov 11, 2026
…erts it

`/v1/realtime/video` is the one route whose exposure upstream rewrites:
`entrypoints/openai/serving_capabilities.py:342-347` forces `disabled` when
the profile's `required_inputs` intersect `{video, audio}` and otherwise keeps
the route's own value. Three fixture rows disagreed with that rule:

- full/`image_to_video` (`text`,`image`) carried `disabled` -> `enabled`
- partial/`mixed-operations`/`image_to_video` same
- partial/`unknown-enums`/`video_to_video` (`video`) carried `unknown` ->
  `disabled` (the override is unconditional on the route's own value)

`test_fixtures_obey_upstream_construction_invariants` claimed in its docstring
to pin this rule but only checked route-key sets, so the drift went unseen. It
now asserts the rule itself plus same-path exposure consistency inside one
operation; reverting any of the three rows turns it red (verified).

Resolution output is unchanged by these rows: no `expected` block moved.
Docs/PR body refreshed: upstream is now `43ff7b9` (vocabulary byte-identical to
`d1104f8`), test/doc counts and line references re-measured.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider Model providers and generation protocols type:feat New capability or behaviour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants