From bd8a8510fe04980b11a2a0d9928be99bf1229e64 Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Mon, 2 Nov 2026 23:34:48 +0800 Subject: [PATCH] [feat] models: configure agent input modalities Signed-off-by: Weiming Liao --- apps/api/src/opendirector_api/main.py | 7 +- apps/api/src/opendirector_api/models.py | 3 + .../src/opendirector/domain/capabilities.py | 15 ++ .../opendirector/domain/creation/models.py | 6 +- .../infrastructure/model_configs.py | 12 +- src/backend/models.ts | 1 + src/backend/rules.ts | 1 + src/canvas/ModelServiceForm.tsx | 8 + src/canvas/model-settings.css | 5 + src/contracts/creation.schema.json | 155 ++++++++++++++++++ src/contracts/generated.ts | 4 + tests/test_model_configs.py | 32 +++- 12 files changed, 240 insertions(+), 9 deletions(-) diff --git a/apps/api/src/opendirector_api/main.py b/apps/api/src/opendirector_api/main.py index 2b24bd40..77372260 100644 --- a/apps/api/src/opendirector_api/main.py +++ b/apps/api/src/opendirector_api/main.py @@ -116,7 +116,8 @@ def save_model_batch(value: ModelBatchInput): return model_configs.save_many(value.kind, value.url, value.modelIds, value.apiKey.get_secret_value(), str(value.configId) if value.configId else None, connection_type=value.connectionType, protocol=value.protocol, auth_mode=value.authMode, video_adapter=value.videoAdapter, - video_capabilities=value.videoCapabilities.model_dump(exclude_none=True) if value.videoCapabilities else None) + video_capabilities=value.videoCapabilities.model_dump(exclude_none=True) if value.videoCapabilities else None, + input_modalities=value.inputModalities) @app.post('/api/model-configs/discover') async def discover_models(value: ModelDiscoveryRequest): @@ -158,7 +159,7 @@ async def test_agent_model(value: AgentModelTest): def create_model_config(value: ModelConfigInput): return model_configs.save(value.kind, value.url, value.modelId, value.apiKey.get_secret_value(), connection_type=value.connectionType, protocol=value.protocol, auth_mode=value.authMode, video_adapter=value.videoAdapter, video_capabilities=value.videoCapabilities.model_dump(exclude_none=True) if value.videoCapabilities else None, - capabilities_supplied='videoCapabilities' in value.model_fields_set) + capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities) @app.post('/api/model-configs/capabilities') async def model_capabilities(value: ModelCapabilityRequest): @@ -179,7 +180,7 @@ async def model_capabilities(value: ModelCapabilityRequest): def update_model_config(config_id: UUID, value: ModelConfigInput): return model_configs.save(value.kind, value.url, value.modelId, value.apiKey.get_secret_value(), str(config_id), connection_type=value.connectionType, protocol=value.protocol, auth_mode=value.authMode, video_adapter=value.videoAdapter, video_capabilities=value.videoCapabilities.model_dump(exclude_none=True) if value.videoCapabilities else None, - capabilities_supplied='videoCapabilities' in value.model_fields_set) + capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities) @app.delete("/api/model-configs/{config_id}", status_code=204) def delete_model_config(config_id: UUID): diff --git a/apps/api/src/opendirector_api/models.py b/apps/api/src/opendirector_api/models.py index 581d6d9e..85ba3bf7 100644 --- a/apps/api/src/opendirector_api/models.py +++ b/apps/api/src/opendirector_api/models.py @@ -3,6 +3,7 @@ from pydantic import model_validator, BaseModel, ConfigDict, Field, SecretStr from opendirector.domain.creation.editors import ToolDraftBinding +from opendirector.domain.capabilities import AgentInputModality from opendirector.domain.video_capabilities import VideoAdapter, VideoCapabilities @@ -25,6 +26,7 @@ class ModelConfigInput(ModelConnection): url: str = Field(max_length=2048) modelId: str = Field(max_length=200) apiKey: SecretStr = SecretStr("") + inputModalities: list[AgentInputModality] | None = Field(default=None, min_length=1, max_length=2) videoAdapter: VideoAdapter | None = None videoCapabilities: VideoCapabilities | None = None @@ -54,6 +56,7 @@ class ModelConfigPublic(ModelConnection): providerId: UUID | None = Field(default=None, exclude_if=lambda value: value is None) videoAdapter: VideoAdapter = 'auto' videoCapabilities: VideoCapabilities | None = None + inputModalities: list[AgentInputModality] | None = None inputRules: dict = Field(default_factory=dict) id: UUID kind: Literal["agent", "image", "video", "audio", "vision"] diff --git a/packages/core/src/opendirector/domain/capabilities.py b/packages/core/src/opendirector/domain/capabilities.py index a1c8cc04..730a4101 100644 --- a/packages/core/src/opendirector/domain/capabilities.py +++ b/packages/core/src/opendirector/domain/capabilities.py @@ -8,6 +8,8 @@ from dataclasses import dataclass from typing import Literal +from opendirector.domain.projects import ProjectError + Capability = Literal[ 'text.chat', 'image.generate', 'image.edit', 'image.cutout', 'video.generate', 'video.first_frame', 'video.edit', 'audio.speech', 'audio.music', 'image.segment', 'video.matting', @@ -151,3 +153,16 @@ class ChatRequest: class MediaResult: data: bytes suffix: str + + +AgentInputModality = Literal['text', 'image'] + + +def agent_input_modalities(kind, value): + """Declared agent inputs are separate from media-generation reference rules.""" + if value is None: + return ['text'] if kind == 'agent' else None + if (kind != 'agent' or not isinstance(value, (list, tuple)) or 'text' not in value + or len(value) != len(set(value)) or any(item not in {'text', 'image'} for item in value)): + raise ProjectError('对话模型输入模态必须包含文本,可选图片,且不能重复。') + return ['text', *(['image'] if 'image' in value else [])] diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index 9364b955..2216cc79 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -5,6 +5,7 @@ from pydantic import Field, SecretStr, field_validator, model_validator +from opendirector.domain.capabilities import agent_input_modalities from opendirector.domain.video_capabilities import VideoCapabilities as ExistingVideoCapabilities from opendirector.domain.video_capabilities import VideoAdapter, check_video_config from opendirector.domain.projects import ProjectError @@ -63,6 +64,7 @@ class ModelConnection(Contract): connectionType: Literal['api', 'self_hosted'] protocol: Protocol authMode: Literal['none', 'bearer'] + inputModalities: tuple[Literal['text', 'image'], ...] | None = None videoAdapter: VideoAdapter = 'auto' videoCapabilities: VideoCapabilities | None = None @@ -96,6 +98,7 @@ def connection(self): if self.protocol not in allowed[self.kind]: raise ValueError('Protocol is incompatible with model category') try: + agent_input_modalities(self.kind, self.inputModalities) check_video_config(self.kind, self.protocol, self.videoAdapter, self.videoCapabilities.model_dump(exclude_none=True) if self.videoCapabilities else None) except ProjectError as error: @@ -129,6 +132,7 @@ class ModelSnapshot(Contract): url: str = Field(min_length=1, max_length=2048) protocol: Protocol connectionType: Literal['api', 'self_hosted'] + inputModalities: tuple[Literal['text', 'image'], ...] | None = Field(default=None, exclude_if=lambda value: value is None) videoAdapter: VideoAdapter = 'auto' inputRules: InputRules | None = None @@ -146,5 +150,5 @@ def model_name(cls, value): def connection(self): ModelConnection(kind=self.kind, url=self.url, modelId=self.modelId, connectionType=self.connectionType, protocol=self.protocol, - authMode='none', videoAdapter=self.videoAdapter) + authMode='none', videoAdapter=self.videoAdapter, inputModalities=self.inputModalities) return self diff --git a/packages/core/src/opendirector/infrastructure/model_configs.py b/packages/core/src/opendirector/infrastructure/model_configs.py index aab687c5..e2134349 100644 --- a/packages/core/src/opendirector/infrastructure/model_configs.py +++ b/packages/core/src/opendirector/infrastructure/model_configs.py @@ -11,6 +11,7 @@ from opendirector.domain.projects import ProjectError, identifier from opendirector.domain.input_rules import input_rules +from opendirector.domain.capabilities import agent_input_modalities from opendirector.domain.video_capabilities import check_video_config from opendirector.infrastructure.local_projects import child, read_json, write_json @@ -78,7 +79,7 @@ def _read(self) -> list[dict]: item.update(services[item['providerId']]) seen = set() for item in value["models"]: - if not {'id', 'kind', 'url', 'modelId', 'apiKey'} <= set(item) or set(item) - {'id', 'kind', 'url', 'modelId', 'apiKey', 'connectionType', 'protocol', 'authMode', 'videoAdapter', 'videoCapabilities', 'providerId'}: + if not {'id', 'kind', 'url', 'modelId', 'apiKey'} <= set(item) or set(item) - {'id', 'kind', 'url', 'modelId', 'apiKey', 'connectionType', 'protocol', 'authMode', 'videoAdapter', 'videoCapabilities', 'providerId', 'inputModalities'}: raise ValueError() identifier(item["id"]) if item["id"] in seen or item["kind"] not in MODEL_KINDS: @@ -91,6 +92,7 @@ def _read(self) -> list[dict]: if 'videoAdapter' not in item: item['videoAdapter'] = ('minimax_h3' if item['modelId'].rsplit('/', 1)[-1].lower() == 'minimax-h3' else 'fasth3') if item['kind'] == 'video' and item['protocol'] == 'vllm_omni' else 'auto' + item['inputModalities'] = agent_input_modalities(item['kind'], item.get('inputModalities')) item['videoCapabilities'] = check_video_config(item['kind'], item['protocol'], item['videoAdapter'], item.get('videoCapabilities')) if item['authMode'] == 'bearer' and not fields['apiKey']: raise ValueError() @@ -100,7 +102,7 @@ def _read(self) -> list[dict]: @staticmethod def _public(item: dict) -> dict: - return {**({'providerId': item['providerId']} if 'providerId' in item else {}), **{key: item[key] for key in ("id", "kind", "url", "modelId", "connectionType", "protocol", "authMode", "videoAdapter", "videoCapabilities")}, 'inputRules': input_rules(item)} + return {**({'providerId': item['providerId']} if 'providerId' in item else {}), **{key: item[key] for key in ("id", "kind", "url", "modelId", "connectionType", "protocol", "authMode", "videoAdapter", "videoCapabilities", "inputModalities")}, 'inputRules': input_rules(item)} def list(self) -> list[dict]: with self.lock: @@ -117,7 +119,7 @@ def resolve(self, config_id: str, kind: str) -> dict: raise ProjectError("所选模型不存在或类别不符,请在设置中配置并重新选择。", 409) return dict(item) - def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str | None = None, *, connection_type='api', protocol=None, auth_mode=None, video_adapter=None, video_capabilities=None, capabilities_supplied=False) -> dict: + def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str | None = None, *, connection_type='api', protocol=None, auth_mode=None, video_adapter=None, video_capabilities=None, capabilities_supplied=False, input_modalities=None) -> dict: if kind not in MODEL_KINDS: raise ProjectError("无效的模型类别。") fields = model_fields(url, model_id, api_key) @@ -139,6 +141,7 @@ def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str raise ProjectError('请填写 API Key,或将鉴权方式设为无需鉴权。') fields['apiKey'] = existing['apiKey'] item = {'id': config_id or str(uuid4()), 'kind': kind, **fields, **connection} + item['inputModalities'] = agent_input_modalities(kind, input_modalities if input_modalities is not None else (existing or {}).get('inputModalities')) item['videoAdapter'] = video_adapter if video_adapter is not None else (existing or {}).get('videoAdapter', 'auto') raw = video_capabilities if capabilities_supplied or video_capabilities is not None else (existing or {}).get('videoCapabilities') item['videoCapabilities'] = check_video_config(kind, connection['protocol'], item['videoAdapter'], raw) @@ -177,7 +180,7 @@ def _write(self, models: list[dict]) -> None: def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, config_id: str | None = None, *, connection_type='api', protocol=None, - auth_mode=None, video_adapter=None, video_capabilities=None) -> list[dict]: + auth_mode=None, video_adapter=None, video_capabilities=None, input_modalities=None) -> list[dict]: if kind not in MODEL_KINDS or not 1 <= len(model_ids) <= 200: raise ProjectError('请选择 1—200 个模型。') connection = connection_fields(kind, connection_type, protocol, auth_mode) @@ -209,6 +212,7 @@ def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, old = existing item = {'id': old['id'] if old else str(uuid4()), 'kind': kind, **field, **connection, 'apiKey': key, 'providerId': provider_id, 'videoAdapter': video_adapter or 'auto'} + item['inputModalities'] = agent_input_modalities(kind, input_modalities if input_modalities is not None else (old or {}).get('inputModalities')) item['videoCapabilities'] = check_video_config(kind, connection['protocol'], item['videoAdapter'], video_capabilities) profile = input_rules(item)['profile'] if item['videoCapabilities'] is not None and profile in {'fasth3-omni', 'vllm-omni-h3'}: diff --git a/src/backend/models.ts b/src/backend/models.ts index 0651de12..72a134b2 100644 --- a/src/backend/models.ts +++ b/src/backend/models.ts @@ -25,6 +25,7 @@ export interface ModelConfigInput { connectionType: "api" | "self_hosted"; protocol: NonNullable; authMode: "none" | "bearer"; + inputModalities?: ("text" | "image")[] | null; videoAdapter?: VideoAdapter; videoCapabilities?: VideoCapabilities | null; } diff --git a/src/backend/rules.ts b/src/backend/rules.ts index b3c7c9ac..acedb701 100644 --- a/src/backend/rules.ts +++ b/src/backend/rules.ts @@ -53,6 +53,7 @@ export interface ModelConfig { | null; authMode: "none" | "bearer" | null; inputRules: InputRules; + inputModalities?: ("text" | "image")[] | null; videoAdapter?: VideoAdapter; videoCapabilities?: VideoCapabilities | null; } diff --git a/src/canvas/ModelServiceForm.tsx b/src/canvas/ModelServiceForm.tsx index 0a72e291..a37c733f 100644 --- a/src/canvas/ModelServiceForm.tsx +++ b/src/canvas/ModelServiceForm.tsx @@ -79,6 +79,7 @@ export function ModelServiceForm({ : (existing?.protocol ?? PROTOCOLS[kind][0][0]), authMode: existing?.authMode ?? "none", videoAdapter: existing?.videoAdapter ?? "auto", + inputModalities: kind === "agent" ? existing?.inputModalities ?? ["text"] : undefined, }); const [selectedModels, setSelectedModels] = useState(() => existing @@ -370,6 +371,13 @@ export function ModelServiceForm({

+ {kind === "agent" &&
+ 输入能力 + + +

应用于本次选中的模型。仅在模型及接口支持图片理解时启用。

+
} summary svg { transform: rotate(180deg); } + +.cw-model-modalities { border: 0; border-top: 1px solid var(--settings-border); margin: 0 0 20px; padding: 16px 0 0; display: flex; flex-wrap: wrap; gap: 12px 24px; } +.cw-model-modalities legend { padding: 0 8px 0 0; font-size: var(--settings-label-size); } +.cw-model-modalities label { display: flex; align-items: center; gap: 6px; font-size: var(--settings-control-size); } +.cw-model-modalities .cw-model-note { flex-basis: 100%; margin: 0; } diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index 86d291e7..1fab0c76 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -6597,6 +6597,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -6708,6 +6727,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -6810,6 +6848,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -6894,6 +6951,25 @@ "title": "Connectiontype", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -18838,6 +18914,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -18876,6 +18971,7 @@ "connectionType", "protocol", "authMode", + "inputModalities", "videoAdapter", "videoCapabilities", "id", @@ -18938,6 +19034,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -18983,6 +19098,7 @@ "connectionType", "protocol", "authMode", + "inputModalities", "videoAdapter", "videoCapabilities", "apiKey" @@ -19043,6 +19159,25 @@ "title": "Authmode", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ @@ -19073,6 +19208,7 @@ "connectionType", "protocol", "authMode", + "inputModalities", "videoAdapter", "videoCapabilities" ], @@ -19129,6 +19265,25 @@ "title": "Connectiontype", "type": "string" }, + "inputModalities": { + "anyOf": [ + { + "items": { + "enum": [ + "text", + "image" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputmodalities" + }, "videoAdapter": { "default": "auto", "enum": [ diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index 6ce3f22e..3d6674b7 100644 --- a/src/contracts/generated.ts +++ b/src/contracts/generated.ts @@ -756,6 +756,7 @@ export interface ModelConnectionInput extends ContractInput { readonly connectionType: "api" | "self_hosted"; readonly protocol: "openai" | "volcengine" | "vllm_omni" | "vllm_omni_segmentation" | "vllm_omni_music"; readonly authMode: "none" | "bearer"; + readonly inputModalities?: ReadonlyArray<"text" | "image"> | null; readonly videoAdapter?: "auto" | "fasth3" | "minimax_h3" | "standard"; readonly videoCapabilities?: VideoCapabilitiesInput | null; } @@ -767,6 +768,7 @@ export interface ModelSnapshotInput extends ContractInput { readonly url: string; readonly protocol: "openai" | "volcengine" | "vllm_omni" | "vllm_omni_segmentation" | "vllm_omni_music"; readonly connectionType: "api" | "self_hosted"; + readonly inputModalities?: ReadonlyArray<"text" | "image"> | null; readonly videoAdapter?: "auto" | "fasth3" | "minimax_h3" | "standard"; readonly inputRules?: InputRulesInput | null; } @@ -2124,6 +2126,7 @@ export interface ModelConnection extends Contract { readonly connectionType: "api" | "self_hosted"; readonly protocol: "openai" | "volcengine" | "vllm_omni" | "vllm_omni_segmentation" | "vllm_omni_music"; readonly authMode: "none" | "bearer"; + readonly inputModalities: ReadonlyArray<"text" | "image"> | null; readonly videoAdapter: "auto" | "fasth3" | "minimax_h3" | "standard"; readonly videoCapabilities: VideoCapabilities | null; } @@ -2135,6 +2138,7 @@ export interface ModelSnapshot extends Contract { readonly url: string; readonly protocol: "openai" | "volcengine" | "vllm_omni" | "vllm_omni_segmentation" | "vllm_omni_music"; readonly connectionType: "api" | "self_hosted"; + readonly inputModalities?: ReadonlyArray<"text" | "image"> | null; readonly videoAdapter: "auto" | "fasth3" | "minimax_h3" | "standard"; readonly inputRules: InputRules | null; } diff --git a/tests/test_model_configs.py b/tests/test_model_configs.py index 8a583135..3c9a5054 100644 --- a/tests/test_model_configs.py +++ b/tests/test_model_configs.py @@ -30,7 +30,7 @@ def test_four_categories_survive_restart_without_exposing_secrets(api): response = client.post("/api/model-configs", json=payload(kind)) assert response.status_code == 200 assert "test-secret" not in response.text - assert set(response.json()) == {"id", "kind", "url", "modelId", "inputRules", "connectionType", "protocol", "authMode", "videoAdapter", "videoCapabilities"} + assert set(response.json()) == {"id", "kind", "url", "modelId", "inputRules", "connectionType", "protocol", "authMode", "videoAdapter", "videoCapabilities", "inputModalities"} configs.append(response.json()) assert len({item["id"] for item in configs}) == 5 assert client.get("/api/model-configs").json() == configs @@ -322,3 +322,33 @@ def test_batch_edit_legacy_segmentation_preserves_identity(api): assert response.status_code == 200, response.text assert response.json()[0]['id'] == saved['id'] assert len(client.get('/api/model-configs').json()) == 2 + + +def test_agent_image_input_persists_through_batch_edits_and_restart(api): + client, store = api + saved = client.post('/api/model-configs/batch', json=payload(modelIds=['vision-agent'], + inputModalities=['text', 'image'])).json()[0] + assert saved['inputModalities'] == ['text', 'image'] + resolved = ModelConfigs(store.repository).resolve(saved['id'], 'agent') + assert resolved['inputModalities'] == ['text', 'image'] + unchanged = client.patch('/api/model-configs/' + saved['id'], json=payload(modelId='vision-agent', apiKey='')) + assert unchanged.status_code == 200 + assert unchanged.json()['inputModalities'] == ['text', 'image'] + disabled = client.post('/api/model-configs/batch', json=payload(configId=saved['id'], + modelIds=['vision-agent'], apiKey='', inputModalities=['text'])) + assert disabled.status_code == 200 + assert disabled.json()[0]['inputModalities'] == ['text'] + + +@pytest.mark.parametrize('modalities', [[], ['image'], ['text', 'text'], ['text', 'video']]) +def test_invalid_agent_input_modalities_do_not_write(api, modalities): + client, store = api + response = client.post('/api/model-configs', json=payload(inputModalities=modalities)) + assert response.status_code in {400, 422} + assert ModelConfigs(store.repository).list() == [] + + +def test_media_generation_model_cannot_declare_agent_modalities(api): + client, _ = api + response = client.post('/api/model-configs', json=payload(kind='image', inputModalities=['text', 'image'])) + assert response.status_code == 400