From aad694ac640e9d5e2413e33c415ff96ddf8aee34 Mon Sep 17 00:00:00 2001 From: bread-lxy <2022200058@ruc.edu.cn> Date: Mon, 9 Nov 2026 22:55:12 +0800 Subject: [PATCH 01/21] feat: enforce optional model capability admission --- apps/api/src/opendirector_api/agent.py | 18 +- apps/api/src/opendirector_api/localization.py | 3 + apps/api/src/opendirector_api/main.py | 40 +- apps/api/src/opendirector_api/models.py | 8 + docs/README.md | 1 + docs/guides/model-availability.md | 60 +++ packages/core/src/opendirector/agent/tools.py | 19 +- .../application/creation_generation.py | 16 +- .../application/model_availability.py | 195 ++++++++++ .../opendirector/application/model_catalog.py | 24 +- .../opendirector/application/tool_tasks.py | 53 ++- packages/core/src/opendirector/cli.py | 20 + .../opendirector/domain/creation/commands.py | 1 + .../opendirector/domain/creation/models.py | 36 ++ .../opendirector/domain/omni_capabilities.py | 89 +++++ .../infrastructure/model_configs.py | 40 +- .../integrations/capability_probe.py | 164 +++++++++ tests/conftest.py | 17 + tests/test_assistant_conversations.py | 4 +- tests/test_capability_acceptance.py | 292 +++++++++++++++ tests/test_model_availability.py | 348 ++++++++++++++++++ tests/test_model_configs.py | 7 +- 22 files changed, 1422 insertions(+), 33 deletions(-) create mode 100644 docs/guides/model-availability.md create mode 100644 packages/core/src/opendirector/application/model_availability.py create mode 100644 packages/core/src/opendirector/domain/omni_capabilities.py create mode 100644 packages/core/src/opendirector/integrations/capability_probe.py create mode 100644 tests/test_capability_acceptance.py create mode 100644 tests/test_model_availability.py diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 1a53ae4a..d3e9df7c 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -17,6 +17,7 @@ from opendirector.agent.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec, runtime_catalog, runtime_status from opendirector.agent.skills import skills from opendirector.domain.projects import ProjectError +from opendirector.application.model_availability import requests_for from opendirector.domain.rule_facts import MAX_REFERENCES, PROMPT_MAX_LENGTH from opendirector.infrastructure.creation_projects import durable_json from opendirector.infrastructure.local_projects import child, read_json @@ -71,7 +72,8 @@ def path(self, project_id: UUID, session: str | None = None) -> Path: return directory def update(self, project_id: UUID, session: str | None = None, *, draft: Draft | None = None, - begin: dict | None = None, finish: dict | None = None, conversation_id: UUID | None = None) -> dict: + begin: dict | None = None, finish: dict | None = None, conversation_id: UUID | None = None, + before_begin=None) -> dict: with self.projects.local.transaction() as db: self.projects._recover() directory, _, _ = self.projects._load(db, project_id) @@ -92,6 +94,8 @@ def update(self, project_id: UUID, session: str | None = None, *, draft: Draft | if begin is not None: if any(message['id'] == begin['id'] for message in value['messages']): raise ProjectError('这条消息已接受,请读取对话,不要重复发送。', 409) + if before_begin is not None: + before_begin() value['messages'].append(begin) value['messages'].append({'id': str(uuid5(UUID(begin['id']), 'assistant')), 'role': 'assistant', 'text': '', 'status': 'streaming', @@ -304,6 +308,11 @@ async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Hea if value.runtime == 'jiuwenswarm' and value.thinking != 'auto': raise ProjectError('JiuwenSwarm 当前使用原生默认推理强度。', 409) config = await asyncio.to_thread(configs.resolve, str(value.modelId), 'agent') if spec.modelSource == 'settings' else None + if config is not None: + prepared = await asyncio.to_thread(generation.availability.prepare, config) + current = await asyncio.to_thread(configs.resolve, str(value.modelId), 'agent') + generation.availability.require(current, requests_for(current)[0], prepared) + config = current available = await skills(projects.local.repository, directory) if spec.supportsSkills else [] if set(value.skills) - {item['name'] for item in available}: raise ProjectError('技能已失效,请重新选择。', 409) @@ -318,6 +327,10 @@ async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Hea preferences = {kind: {'modelConfigId': model_id, 'modelName': model_names.get(model_id)} for kind, model_id in model_selections.items() if model_id} events = RunEvents() + def validate_connection(): + if config is not None: + current = configs.resolve(str(value.modelId), 'agent') + generation.availability.require(current, requests_for(current)[0], prepared) async def produce() -> None: text, status, error, accepted = '', 'complete', None, False activities = [] @@ -330,7 +343,8 @@ async def produce() -> None: 'skills': value.skills, 'runtime': value.runtime, 'runtimeModel': value.runtimeModel, 'modelSelections': dict(value.modelSelections), 'model': model, 'status': 'complete'} - saved = await asyncio.to_thread(conversations.update, project_id, session, begin=user, conversation_id=value.conversationId) + saved = await asyncio.to_thread(conversations.update, project_id, session, begin=user, + conversation_id=value.conversationId, before_begin=validate_connection) accepted = True events.put({'type': 'start', 'conversation': saved}) context = [] diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py index 0586213f..33cd91ee 100644 --- a/apps/api/src/opendirector_api/localization.py +++ b/apps/api/src/opendirector_api/localization.py @@ -21,6 +21,9 @@ 'kling_aspect': 'Kling keyframe generation follows the image framing. Choose Auto for frames, or a supported ratio for text and image references.', 'kling_output': 'Choose an output resolution and integer duration supported by the selected Kling model.', 'kling_cancel': 'Remote Kling cancellation is not connected. The task may continue running and incur charges.', + 'capability_unavailable': 'The service explicitly does not support this request. Check model capabilities and your selection.', + 'capability_paused': 'The execution endpoint is paused. Try again shortly.', + 'capability_connection_changed': 'The model connection changed during the capability check. Submit again.', 'tool_concept_translation': 'Use a short English subject description, or add a chat model in Models to translate it automatically.', 'tool_no_video_match': 'The requested subject was not found in the video. Try a different description.', 'voice_upload_type': 'Upload the voice recording using multipart/form-data.', diff --git a/apps/api/src/opendirector_api/main.py b/apps/api/src/opendirector_api/main.py index 8bbc9af8..4372072e 100644 --- a/apps/api/src/opendirector_api/main.py +++ b/apps/api/src/opendirector_api/main.py @@ -16,6 +16,7 @@ from opendirector.domain.settings import GeneralSettings, GeneralSettingsPatch from opendirector.application.creation_generation import CreationGeneration from opendirector.application.model_catalog import ModelCatalog +from opendirector.application.model_availability import ModelAvailability from opendirector.application.preferences import Preferences from opendirector.application.tool_tasks import ToolTasks from opendirector.application.creation_tool_assets import CreationToolAssets @@ -30,7 +31,8 @@ from opendirector_api.localization import localized_error from opendirector_api.models import (ModelConfigInput, ModelConfigPublic, AgentModelTest, ModelCapabilityRequest, ModelDiscoveryRequest, ModelBatchInput, - ToolTaskCreate, ToolPlacement, ImageSelectionCreate) + ToolTaskCreate, ToolPlacement, ImageSelectionCreate, AvailabilityRefresh, AvailabilityCatalog, + ModelAvailability as ModelAvailabilityResponse) def video_capabilities(value): @@ -41,7 +43,7 @@ def model_options(value: ModelBatchInput): return {key: item.model_dump(exclude_unset=True) for key, item in value.modelOptions.items()} -def create_app(store: LocalProjects | None = None, chat_provider=None, generation_provider=None) -> FastAPI: +def create_app(store: LocalProjects | None = None, chat_provider=None, generation_provider=None, capability_probe=None) -> FastAPI: configure_runtime() repository = data_root() if store is None: @@ -49,9 +51,10 @@ def create_app(store: LocalProjects | None = None, chat_provider=None, generatio model_configs = ModelConfigs(store.repository) preferences = Preferences(store.repository) creation_projects = CreationProjects(store) - creation_generation = CreationGeneration(creation_projects, model_configs, generation_provider) - creation_tools = ToolTasks(store, model_configs, CreationToolAssets(creation_projects), projects=creation_projects) - catalog = ModelCatalog(model_configs, chat_provider or ChatCompletions()) + availability = ModelAvailability(model_configs, capability_probe) + creation_generation = CreationGeneration(creation_projects, model_configs, generation_provider, availability=availability) + creation_tools = ToolTasks(store, model_configs, CreationToolAssets(creation_projects), projects=creation_projects, availability=availability) + catalog = ModelCatalog(model_configs, chat_provider or ChatCompletions(), availability) @asynccontextmanager async def lifespan(app): @@ -64,6 +67,7 @@ async def lifespan(app): await creation_tools.close() finally: await creation_generation.close() + availability.probe.close() app = FastAPI(title="OpenDirector Local API", version="0.1.2", lifespan=lifespan) app.include_router(creation_routes(creation_projects, creation_generation)) @@ -192,6 +196,21 @@ def health(): def list_model_configs(): return catalog.list() + @app.get('/api/model-configs/availability', response_model=AvailabilityCatalog) + def model_availability(): + return availability.list() + + @app.post('/api/model-configs/availability', response_model=AvailabilityCatalog) + def refresh_model_availability(value: AvailabilityRefresh): + return availability.refresh([str(item) for item in value.configIds] if value.configIds is not None else None, manual=value.manual) + + @app.post('/api/model-configs/availability/check', response_model=ModelAvailabilityResponse) + async def check_model_availability(value: ModelDiscoveryRequest): + return await catalog.check_availability(value.kind, value.url, value.modelId, value.apiKey.get_secret_value(), + str(value.configId) if value.configId else None, connection_type=value.connectionType, protocol=value.protocol, + auth_mode=value.authMode, proxy_mode=value.proxyMode, video_adapter=value.videoAdapter, + capability_auto_check=value.capabilityAutoCheck) + @app.get('/api/settings', response_model=GeneralSettings) def get_settings(): return preferences.get() @@ -211,7 +230,7 @@ def save_model_batch(value: ModelBatchInput): protocol=value.protocol, auth_mode=value.authMode, proxy_mode=value.proxyMode, video_adapter=value.videoAdapter, video_capabilities=video_capabilities(value), input_modalities=value.inputModalities, model_options=model_options(value), - capabilities_supplied='videoCapabilities' in value.model_fields_set) + capabilities_supplied='videoCapabilities' in value.model_fields_set, capability_auto_check=value.capabilityAutoCheck) @app.post('/api/model-configs/discover') async def discover_models(value: ModelDiscoveryRequest): @@ -224,14 +243,16 @@ async def discover_models(value: ModelDiscoveryRequest): async def test_agent_model(value: AgentModelTest): elapsed = await catalog.test_agent(value.url, value.modelId, value.apiKey.get_secret_value(), str(value.configId) if value.configId else None, connection_type=value.connectionType, - protocol=value.protocol, auth_mode=value.authMode, proxy_mode=value.proxyMode) + protocol=value.protocol, auth_mode=value.authMode, proxy_mode=value.proxyMode, + capability_auto_check=value.capabilityAutoCheck) return {'elapsedMs': elapsed} @app.post("/api/model-configs", response_model=ModelConfigPublic) def create_model_config(value: ModelConfigInput): return catalog.save(value.kind, value.url, value.modelId, value.apiKey.get_secret_value(), connection_type=value.connectionType, protocol=value.protocol, auth_mode=value.authMode, proxy_mode=value.proxyMode, video_adapter=value.videoAdapter, video_capabilities=video_capabilities(value), - capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities) + capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities, + capability_auto_check=value.capabilityAutoCheck) @app.post('/api/model-configs/capabilities') async def model_capabilities(value: ModelCapabilityRequest): @@ -245,7 +266,8 @@ async def model_capabilities(value: ModelCapabilityRequest): def update_model_config(config_id: UUID, value: ModelConfigInput): return catalog.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, proxy_mode=value.proxyMode, video_adapter=value.videoAdapter, video_capabilities=video_capabilities(value), - capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities) + capabilities_supplied='videoCapabilities' in value.model_fields_set, input_modalities=value.inputModalities, + capability_auto_check=value.capabilityAutoCheck) @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 b45d4c82..3007d213 100644 --- a/apps/api/src/opendirector_api/models.py +++ b/apps/api/src/opendirector_api/models.py @@ -5,6 +5,7 @@ from opendirector.domain.creation.editors import ToolDraftBinding from opendirector.domain.capabilities import AgentInputModality from opendirector.domain.video_capabilities import VideoAdapter, VideoCapabilities +from opendirector.domain.creation.models import ModelAvailability, AvailabilityCatalog class Model(BaseModel): @@ -27,6 +28,12 @@ class ModelConnection(Model): protocol: Literal['openai', 'volcengine', 'kling_video', 'minimax_video_v2', 'vllm_omni', 'vllm_omni_segmentation', 'vllm_omni_music', 'vllm_omni_depth', 'vllm_omni_score'] | None = None authMode: Literal['none', 'bearer'] | None = None proxyMode: Literal['system', 'direct'] | None = None + capabilityAutoCheck: bool | None = Field(default=None, strict=True, exclude_if=lambda value: value is None) + + +class AvailabilityRefresh(Model): + configIds: list[UUID] | None = Field(default=None, min_length=1, max_length=200) + manual: bool = False class ModelConfigInput(ModelConnection): @@ -68,6 +75,7 @@ class ModelCapabilityRequest(ModelConfigInput): class ModelConfigPublic(ModelConnection): + availability: ModelAvailability | None = Field(default=None, exclude_if=lambda value: value is None) providerId: UUID | None = Field(default=None, exclude_if=lambda value: value is None) videoAdapter: VideoAdapter = 'auto' videoCapabilities: VideoCapabilities | None = None diff --git a/docs/README.md b/docs/README.md index a87ee89f..6e535fac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ This directory is the entry point for the repository's shared documentation. The | [Python installation and packaging](development/packaging.md) | Installation, platform wheel builds, and isolated verification | Packaging, resource paths, or build process changes | | [Business CLI](guides/cli.md) | Querying, editing, and generating through a terminal or Agent Shell | Commands, authorization, or receipt semantics change | | [Video model capabilities](guides/video-model-capabilities.md) | Declaring output specifications supported by a service | Configuration fields or capability integration changes | +| [Model availability / 模型可用状态](guides/model-availability.md) | Optional runtime declarations, automatic checks, shared admission and API/CLI/Agent queries | Capability protocol, refresh, or acceptance rules change | | [OpenAI Images](guides/openai-images.md) | Wiring gpt-image as an optional image service, request shape and failures | Integration fields, capability scope, or the OpenAI interface change | | [Kling video](guides/kling-video.md) ([中文](guides/kling-video.zh-CN.md)) | Optional Kling 4.0 and Flash setup, model-specific inputs, and recovery limits | Kling inputs, model declarations, or recovery behavior change | | [Agent runtime](architecture/agent-runtime.md) | Agent runtime architecture | Runtime architecture changes | diff --git a/docs/guides/model-availability.md b/docs/guides/model-availability.md new file mode 100644 index 00000000..47b5e387 --- /dev/null +++ b/docs/guides/model-availability.md @@ -0,0 +1,60 @@ +# Model availability / 模型可用状态 + +## 中文 + +模型接入可以读取服务的可选能力声明。自托管连接默认自动检查,其他连接默认关闭;服务级 `capabilityAutoCheck` 可以覆盖默认值。省略该字段会保留已有设置,旧配置无需转换。共享同一 `providerId` 的模型使用相同设置和查询缓存。独立服务及旧的独立配置保留自己的归属,相同 URL 和密钥不会合并它们。自动检查只查询能力接口,不生成内容、不唤醒模型。 + +“检查服务能力”可以检查未保存的表单。草稿检查不会保存配置,也不会更新已保存模型的状态。显式刷新已保存模型可以检查关闭自动检查的服务。 + +状态有三种:`available` 表示该请求具有匹配声明;`unavailable` 表示服务明确不支持或暂停当前执行入口;`unknown` 表示没有足够信息。旧服务没有接口、超时、断网、鉴权失败和无法理解的版本都保留手动配置与现有请求路径。错误原因分别显示,响应正文与凭据不进入状态数据。 + +声明按精确 Model ID、输入模式、输出类型和实际 HTTP 路由匹配。一个模型支持聊天协议,不能证明它会返回助手需要的文字。音乐适配器通过 `/audio/speech` 发送歌词 `input`、风格 `instructions` 和音频帧数 `max_new_tokens`;其 `lyrics_to_music` 模式独立于普通 TTS 的 `text_to_speech`。`/audio/generate` 的通用音频生成声明不会启用现有音乐适配器。 + +声明在内存中保留 30 秒;可信部署暂停状态最多保留 5 秒。内部使用本地单调时钟计算有效期,系统时钟调整和服务时钟偏差不改变到期规则;对外时间用于界面展示。查询和等待共享请求的总预算为 2 秒,失败可以短时间后重试。缓存有容量限制,进程重启后重新获得观察。目录读取不会访问模型服务,到期会改变目录版本。 + +生成、工具和使用设置模型的助手共享接受规则。服务查询位于项目存储事务之外,接受新任务时再次核对连接身份。明确拒绝不会创建生成任务,也不会调用生成接口。工具的本地操作和可选翻译保留原有行为。已接受任务的快照、恢复、下载、取消和同一命令重放继续使用原有规则;最新能力状态不会重新拒绝它们。能力状态不会清除音色或替换用户选择。 + +## English + +Model connections can read an optional service capability declaration. Self-hosted connections check automatically by default; other connections require explicit enablement or a manual check. The optional service-level `capabilityAutoCheck` preference and query cache are shared by models with the same `providerId`. Independent providers and legacy standalone configuration IDs stay isolated even at the same URL with the same credential. Checks query declarations without inference or wake-up requests. + +The unsaved form check uses an isolated draft identity. It saves neither configuration nor observations for saved connections. Explicit refresh can check selected saved connections even when automatic checks are disabled. + +`available`, `unavailable`, and `unknown` describe evidence for a specific request. Matching requires the exact model, operation profile, consumed output and actual HTTP route. Unknown or failed discovery preserves manual configuration and the existing request path. Authentication, connection, timeout, unsupported-version and invalid-response reasons are distinct; credentials and response bodies are never included in observations. + +Text responses and image responses on Chat Completions are distinct. Music uses the `lyrics_to_music` profile on `/audio/speech`, with lyrics, style and an audio-frame budget; ordinary TTS uses `text_to_speech`. Generic `/audio/generate` declarations do not create a consumer for that API in OpenDirector. + +Declarations expire after 30 seconds. A proven deployment-wide pause expires after at most 5 seconds. Internal expiration uses a local monotonic clock; service timestamps identify observations, and wall-clock timestamps are presentation data. Each query, including waiting for an existing query, has a two-second budget. The bounded process-local cache coalesces concurrent reads and permits short failure retries. Catalog reads never contact providers; expiration changes their revision. + +Generation, tools and settings-backed assistant turns share acceptance rules. Network reads occur outside project transactions; connection identity is checked again when accepting work. Explicit rejection creates no job and submits no inference. Local tools and optional translation retain their behavior. Accepted snapshots, recovery, downloads, cancellation and idempotent replay do not undergo new capability admission. Observations never clear voice selections or silently choose another model. + +## HTTP, CLI and Agent interfaces + +| Interface | Behavior | +| --- | --- | +| `GET /api/model-configs/availability` | Current observations without network access | +| `POST /api/model-configs/availability` | Refresh eligible services; accepts optional `configIds` and `manual` | +| `POST /api/model-configs/availability/check` | Check an unsaved model form with existing connection/authentication fields; optional `configId` reuses a key only at its saved URL | +| `GET /api/model-configs` | Saved models with an optional current `availability` projection | +| Agent `model_availability` | Same read/refresh service; accepts `refresh`, `configIds`, `manual` | + +Manual refresh requires explicit `configIds`. The existing `/api/model-configs/capabilities` endpoint retains its video-output specification behavior. + +```sh +opendirector model availability +opendirector model availability --refresh +opendirector model availability --refresh --manual --model MODEL_CONFIG_UUID +``` + +Responses include a `revision` and model observations containing `identity`, `status`, `reasonCode`, `checkedAt`, `expiresAt` and exact operation decisions. Times use Unix milliseconds. `configId` is null for drafts. `capabilityAutoCheck` and runtime observations are excluded from accepted job execution snapshots. + +The remote protocol is `GET /omni/capabilities`, with `object="omni.capabilities"` and `schema_version=1`. Missing models, profiles, routes or output information remain unknown. A worker-only pause does not prove that another request will use that worker. No result implies that every possible parameter combination or GPU resource requirement has been verified. + +## Development checks / 开发验证 + +```sh +.venv/bin/python -m pytest tests/test_model_availability.py tests/test_capability_acceptance.py -q +.venv/bin/python -m pytest tests/test_model_configs.py tests/test_model_catalog.py tests/test_creation_generation_service.py tests/test_tool_tasks.py tests/test_assistant_conversations.py tests/test_cli.py -q +``` + +These tests use isolated configuration/project directories, model doubles and a local HTTP server. They cover identity changes, draft isolation, operation matching, TTL expiration, coalescing, errors, zero-job rejection, local tools and replay. They do not validate real model outputs. See [development validation](../development/validation.md) for browser, contracts and import-layer checks. diff --git a/packages/core/src/opendirector/agent/tools.py b/packages/core/src/opendirector/agent/tools.py index d68dd547..5dd4e4e7 100644 --- a/packages/core/src/opendirector/agent/tools.py +++ b/packages/core/src/opendirector/agent/tools.py @@ -1,5 +1,6 @@ """Business commands shared by every agent runtime.""" import asyncio +from opendirector.application.model_availability import ModelAvailability import difflib import shutil from pathlib import Path @@ -231,6 +232,13 @@ def tools() -> list[dict]: {'type': 'object', 'properties': {}, 'additionalProperties': False}), tool_schema('read', 'Read a discovered skill file/resource, or text asset via asset:. No scripts.', {'type': 'object', 'properties': {'path': {'type': 'string'}}, 'required': ['path'], 'additionalProperties': False})] + result.append(tool_schema('model_availability', 'Read current model operation availability without network access. ' + 'Set refresh=true to query declarations without inference. Manual refresh requires explicit configIds. ' + 'Unknown preserves manual configuration; unavailable blocks only new matching operations.', + {'type': 'object', 'properties': {'refresh': {'type': 'boolean', 'default': False}, + 'manual': {'type': 'boolean', 'default': False}, + 'configIds': {'type': 'array', 'items': {'type': 'string', 'format': 'uuid'}, 'minItems': 1, 'maxItems': 200}}, + 'additionalProperties': False})) query_schema = TypeAdapter(Query).json_schema() query_schema['type'] = 'object' for definition in query_schema['$defs'].values(): @@ -281,6 +289,7 @@ def __init__(self, projects, generation, configs): self.projects = projects self.generation = generation self.configs = configs + self.availability = getattr(generation, 'availability', None) or ModelAvailability(configs) self.assets = CreationAssets(projects) self.voices = getattr(generation, 'voices', None) or VoiceLibrary(configs) @@ -320,8 +329,16 @@ def recording(): ref_text=arguments.get('refText'), speaker_description=arguments.get('speakerDescription')) if name == 'read_project': snapshot = await asyncio.to_thread(self.projects.snapshot, project_id) - return {'project': snapshot.model_dump(mode='json'), 'models': self.configs.list(), 'commands': list(COMMANDS), + return {'project': snapshot.model_dump(mode='json'), 'models': self.availability.catalog(), 'commands': list(COMMANDS), 'commandMetadata': {kind: {'description': model.__doc__} for kind, model in COMMANDS.items()}} + if name == 'model_availability': + refresh = TypeAdapter(bool).validate_python(arguments.get('refresh', False), strict=True) + manual = TypeAdapter(bool).validate_python(arguments.get('manual', False), strict=True) + config_ids = TypeAdapter(list[UUID]).validate_python(arguments['configIds']) if 'configIds' in arguments else None + if refresh: + return await asyncio.to_thread(self.availability.refresh, + [str(item) for item in config_ids] if config_ids is not None else None, manual=manual) + return self.availability.list() if name == 'query': if {'projectId', 'queryId'} & arguments.keys(): raise ProjectError('查询身份与工程范围由宿主提供。') diff --git a/packages/core/src/opendirector/application/creation_generation.py b/packages/core/src/opendirector/application/creation_generation.py index c94b6d23..8f7b24c3 100644 --- a/packages/core/src/opendirector/application/creation_generation.py +++ b/packages/core/src/opendirector/application/creation_generation.py @@ -15,6 +15,7 @@ from opendirector.application.creation_assets import CreationAssets from opendirector.application.creation_canvas import CreationCanvas, asset_ref, current_generation_inputs, visible_item_ids from opendirector.application.voice_library import VoiceLibrary +from opendirector.application.model_availability import ModelAvailability, generation_request from opendirector.application.generation_runtime import GenerationRuntime, _self_hosted_video from opendirector.domain.creation.assets import AssetProvenance from opendirector.domain.creation.canvas import TextCanvasItem, ImageCanvasItem, VideoCanvasItem, AudioCanvasItem @@ -80,7 +81,7 @@ def frozen_model(self, frozen): class CreationGeneration(GenerationRuntime): - def __init__(self, projects, configs, provider=None): + def __init__(self, projects, configs, provider=None, *, availability=None): super().__init__() self.projects, self.configs = projects, configs self.assets = CreationAssets(projects) @@ -88,6 +89,7 @@ def __init__(self, projects, configs, provider=None): self.settings = gateway().settings(projects.local.repository) self.provider = provider or GenerationProviders(self.store) self.voices = VoiceLibrary(configs) + self.availability = availability or ModelAvailability(configs) def directory(self, project_id): with self.projects.local.transaction() as db: @@ -294,6 +296,14 @@ def prepare(self, command, *, session=None, source='ui'): # again under commit so a slow/offline provider cannot freeze editing. initial = self.projects.snapshot(command.projectId) voice_spec = next((spec for spec in initial.generationSpecs if spec.id == command.payload.specId), None) + capability_identity = None + capability_failure = None + if voice_spec is not None and voice_spec.modelConfigId: + try: + capability_config = self.configs.resolve(str(voice_spec.modelConfigId), 'agent' if voice_spec.kind == 'text' else voice_spec.kind) + capability_identity = self.availability.prepare(capability_config) + except ProjectError as error: + capability_failure = error voice_config, voice_failure = None, None if voice_spec is not None and voice_spec.kind == 'audio' and voice_spec.parameters.creationType == 'audio': try: @@ -346,6 +356,10 @@ def accept(before): raise ContractError('revision_conflict', '音色验证期间生成配置或模型服务已变化,请重新提交。', spec.id) if voice_failure is not None: raise voice_failure + if capability_failure is not None: + raise capability_failure + if capability_identity is not None: + self.availability.require(config, generation_request(config, spec, resolved), capability_identity) except (ValueError, ProjectError) as error: raise ContractError.from_error(error, spec.id) from error for reference in resolved: diff --git a/packages/core/src/opendirector/application/model_availability.py b/packages/core/src/opendirector/application/model_availability.py new file mode 100644 index 00000000..6f202cd6 --- /dev/null +++ b/packages/core/src/opendirector/application/model_availability.py @@ -0,0 +1,195 @@ +"""Shared optional capability checks for new work; catalogs never perform I/O. + +Configured adapters still own request construction and manual input rules. +Remote observations can reject a precisely matched request, but cannot add +adapters, rewrite configuration, or change an already accepted execution. +""" +from dataclasses import dataclass +import hashlib +import json +from urllib.parse import urlsplit + +from opendirector.domain.omni_capabilities import timestamp +from opendirector.domain.projects import ProjectError +from opendirector.integrations.capability_probe import CapabilityProbe, PAUSE_SECONDS, automatic, identity +from opendirector.integrations.volcengine import endpoint + + +class AvailabilityError(ProjectError): + def __init__(self, code, message): + super().__init__(message, 409) + self.code = code + + +@dataclass(frozen=True) +class RequestOperation: + operation: str + profile: str + path: str + output: str + method: str = 'POST' + + +def requests_for(config): + """Only the protocols this application actually sends, including music.""" + kind, protocol = config['kind'], config.get('protocol', 'openai') + def request(operation, profile, suffix, output): + return RequestOperation(operation, profile, urlsplit(endpoint(config['url'], suffix)).path, output) + if kind == 'agent': + return [request('chat', 'text_response', '/chat/completions', 'text')] + if kind == 'image' and protocol == 'openai': + return [request('image_generation', 'text_to_image', '/images/generations', 'image'), + request('image_edit', 'image_to_image', '/images/edits', 'image')] + if kind == 'video' and protocol == 'vllm_omni': + return [request('video_generation', profile, '/videos', 'video') + for profile in ('text_to_video', 'image_to_video', 'video_to_video')] + if kind == 'audio' and protocol in {'openai', 'vllm_omni_music'}: + return [request('speech', 'lyrics_to_music' if protocol == 'vllm_omni_music' else 'text_to_speech', '/audio/speech', 'audio')] + # Ark/Minimax and specialized segmentation, depth, score protocols have + # different wire contracts. A shared media label proves no Omni operation. + return [] + + +def generation_request(config, spec, references): + candidates = requests_for(config) + if not candidates: + return None + transmitted = [item for item in references if item.transmission != 'context_only'] + kinds = {item.asset.kind for item in transmitted} + if spec.kind == 'image': + profile = 'image_to_image' if 'image' in kinds else 'text_to_image' + elif spec.kind == 'video': + profile = 'video_to_video' if 'video' in kinds else 'image_to_video' if 'image' in kinds else 'text_to_video' + elif spec.kind == 'audio': + profile = 'lyrics_to_music' if spec.parameters.creationType == 'music' else 'text_to_speech' + else: + profile = 'text_response' + return next((item for item in candidates if item.profile == profile), None) + + +def tool_request(config, capability): + profile = {'text.chat': 'text_response', 'image.generate': 'text_to_image', 'image.edit': 'image_to_image', 'image.cutout': 'image_to_image', + 'video.generate': 'text_to_video', 'video.first_frame': 'image_to_video', + 'video.edit': 'video_to_video', + 'audio.speech': 'text_to_speech', 'audio.music': 'lyrics_to_music'}.get(capability) + return next((item for item in requests_for(config) if item.profile == profile), None) + + +def _revision(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()[:24] + + +class ModelAvailability: + def __init__(self, configs, probe=None): + self.configs = configs + self.probe = probe or CapabilityProbe() + + def prepare(self, config, *, manual=False): + self.probe.query(config, manual=manual) + return identity(config) + + def assert_identity(self, config, prepared): + if identity(config) != prepared: + raise AvailabilityError('capability_connection_changed', '能力检查期间模型服务已变化,请重新提交。') + + def decision(self, config, request): + observation = self.probe.peek(config) + result = dict(operation=request.operation, profile=request.profile, method=request.method, + path=request.path, output=request.output, status='unknown', reasonCode='not_checked', + checkedAt=None, expiresAt=None) + if not observation: + return result + result.update(checkedAt=round(observation.checked * 1000), expiresAt=round(observation.expires * 1000), reasonCode=observation.reason) + document = observation.document + if document is None: + return result + model = next((item for item in document.models if item.id == config['modelId']), None) + operation = model.operations.get(request.operation) if model else None + if operation is None: + result['reasonCode'] = 'model_not_declared' if model is None else 'operation_not_declared' + return result + profile = next((item for item in operation.profiles if item.id == request.profile), None) + if operation.support == 'unsupported': + result.update(status='unavailable', reasonCode='operation_unsupported') + elif operation.exposure == 'disabled': + result.update(status='unavailable', reasonCode='operation_disabled') + elif profile is None: + result['reasonCode'] = 'profile_not_declared' + elif profile.support == 'unsupported': + result.update(status='unavailable', reasonCode='profile_unsupported') + else: + route = next((item for item in profile.routes if item.transport == 'http' + and item.method == request.method and item.path == request.path), None) + exposed = next((item for item in document.routes if item.transport == 'http' + and item.method == request.method and item.path == request.path), None) + if route is None: + result['reasonCode'] = 'route_not_declared' + elif route.exposure == 'disabled' or (exposed and exposed.exposure == 'disabled'): + result.update(status='unavailable', reasonCode='route_disabled') + elif operation.support == profile.support == 'supported' and profile.outputs and request.output not in profile.outputs: + result.update(status='unavailable', reasonCode='output_unsupported') + elif operation.support == profile.support == 'supported' and request.output in profile.outputs: + result.update(status='available', reasonCode='supported') + else: + result['reasonCode'] = 'declaration_incomplete' + # Only a route which serves this exact request can establish pause. + execution = operation.execution + if route and execution.state == 'paused' and execution.scope == 'deployment': + try: + timestamp(execution.observed_at) # Validate provenance, without comparing unrelated machine clocks. + if self.probe.clock() < observation.received + PAUSE_SECONDS: + result.update(status='unavailable', reasonCode='execution_paused', expiresAt=round((observation.checked + PAUSE_SECONDS) * 1000)) + except (ValueError, TypeError, AttributeError): + pass + return result + + def require(self, config, request, prepared=None): + if prepared is not None: + self.assert_identity(config, prepared) + if request is None: + return + decision = self.decision(config, request) + if decision['status'] == 'unavailable': + code = 'capability_paused' if decision['reasonCode'] == 'execution_paused' else 'capability_unavailable' + raise AvailabilityError(code, '模型执行入口已暂停,请稍后重试。' if code == 'capability_paused' + else '服务明确不支持当前请求,请查看模型能力并修改选择。') + + def model(self, config): + operations = [self.decision(config, request) for request in requests_for(config)] + states = {item['status'] for item in operations} + state = ('unavailable' if states == {'unavailable'} else 'available' if states == {'available'} else 'unknown') + observation = self.probe.peek(config) + value = dict(configId=config.get('id'), modelId=config['modelId'], identity=identity(config), autoCheck=automatic(config), + status=state, reasonCode=(operations[0]['reasonCode'] if operations and len(states) == 1 else 'mixed_operations' if operations else 'adapter_not_mapped'), + checkedAt=round(observation.checked * 1000) if observation else None, + expiresAt=min((item['expiresAt'] for item in operations if item['expiresAt'] is not None), default=None), operations=operations) + return {**value, 'revision': _revision(value)} + + def list(self): + values = [self.model(private) for _, private in self.configs.catalog_snapshot()] + return {'revision': _revision(values), 'models': values} + + def catalog(self): + return [{**public, 'availability': self.model(private)} for public, private in self.configs.catalog_snapshot()] + + def refresh(self, config_ids=None, *, manual=False): + if type(manual) is not bool or (config_ids is not None and not 1 <= len(config_ids) <= 200): + raise ProjectError('能力刷新参数无效,请指定 1–200 个模型配置。', 422) + if manual and not config_ids: + raise ProjectError('手动检查需要指定模型配置。', 422) + selected = set(config_ids) if config_ids is not None else None + values = self.configs.list() + if selected is not None and selected - {item['id'] for item in values}: + raise ProjectError('模型配置已被删除,请重新加载。', 404) + seen = set() + for item in values: + if selected is not None and item['id'] not in selected: + continue + config = self.configs.resolve(item['id'], item['kind']) + if not manual and not automatic(config): + continue + key = identity(config, service=True) + if key not in seen: + self.prepare(config, manual=manual) + seen.add(key) + return self.list() diff --git a/packages/core/src/opendirector/application/model_catalog.py b/packages/core/src/opendirector/application/model_catalog.py index d5df3a3c..c8ec2e7f 100644 --- a/packages/core/src/opendirector/application/model_catalog.py +++ b/packages/core/src/opendirector/application/model_catalog.py @@ -11,6 +11,7 @@ from opendirector.domain.projects import ProjectError from opendirector.integrations.chat_completions import ChatCompletions, chat_payload from opendirector.integrations.model_capabilities import read_service_models, read_video_capabilities +from opendirector.application.model_availability import ModelAvailability, requests_for _SAVED_KEY = '请填写该服务的 API Key;新地址不能复用原地址的密钥。' @@ -32,12 +33,23 @@ def _reuse_saved_key(config, existing, *, require_key): class ModelCatalog: """Saved model configuration plus read-only service probes.""" - def __init__(self, configs, tester=None): + def __init__(self, configs, tester=None, availability=None): self.configs = configs self.tester = tester or ChatCompletions() + self.availability = availability or ModelAvailability(configs) def list(self): - return self.configs.list() + return self.availability.catalog() + + async def check_availability(self, kind, url, model_id, api_key, config_id=None, *, connection_type='api', + protocol=None, auth_mode=None, proxy_mode=None, video_adapter=None, capability_auto_check=None): + config = _draft(kind, url, model_id, api_key, connection_type, protocol, auth_mode, proxy_mode, + videoAdapter=video_adapter or 'auto', _capability_namespace='draft') + if capability_auto_check is not None: + config['capabilityAutoCheck'] = capability_auto_check + config = await self._with_saved_key(config, config_id, kind, require_key=True, threaded=True) + await asyncio.to_thread(self.availability.prepare, config, manual=True) + return self.availability.model(config) def save(self, kind, url, model_id, api_key, config_id=None, **options): return self.configs.save(kind, url, model_id, api_key, config_id, **options) @@ -74,9 +86,13 @@ async def discover(self, kind, url, model_id, api_key, config_id=None, *, connec return list(dict.fromkeys(item['id'] for item in models)) async def test_agent(self, url, model_id, api_key, config_id=None, *, connection_type='api', protocol=None, - auth_mode=None, proxy_mode=None, timeout=AGENT_TEST_TIMEOUT_SECONDS): - config = _draft('agent', url, model_id, api_key, connection_type, protocol, auth_mode, proxy_mode) + auth_mode=None, proxy_mode=None, timeout=AGENT_TEST_TIMEOUT_SECONDS, capability_auto_check=None): + config = _draft('agent', url, model_id, api_key, connection_type, protocol, auth_mode, proxy_mode, _capability_namespace='draft') + if capability_auto_check is not None: + config['capabilityAutoCheck'] = capability_auto_check config = await self._with_saved_key(config, config_id, 'agent', require_key=True, threaded=True) + await asyncio.to_thread(self.availability.prepare, config) + self.availability.require(config, requests_for(config)[0]) payload = chat_payload(config, [{'role': 'user', 'content': 'Reply with OK only.'}], 'auto') payload['max_tokens'] = 1024 started = time.monotonic() diff --git a/packages/core/src/opendirector/application/tool_tasks.py b/packages/core/src/opendirector/application/tool_tasks.py index 4194d41d..dd14a965 100644 --- a/packages/core/src/opendirector/application/tool_tasks.py +++ b/packages/core/src/opendirector/application/tool_tasks.py @@ -13,6 +13,7 @@ from PIL import Image from opendirector.application.tool_operations import TOOLS, Source, ToolContext, select_image_objects +from opendirector.application.model_availability import ModelAvailability, tool_request from opendirector.domain.capabilities import LABELS from opendirector.domain.generation import GenerationError from opendirector.domain.projects import ProjectError, identifier @@ -47,12 +48,13 @@ def output_item_id(task, index, kind): class ToolTasks(ToolTaskLifecycle): - def __init__(self, store, configs, assets, *, transport=None, poll_interval=3, concurrency=2, projects): + def __init__(self, store, configs, assets, *, transport=None, poll_interval=3, concurrency=2, projects, availability=None): self.store, self.configs, self.assets = store, configs, assets self.transport, self.poll_interval = transport, poll_interval self.jobs: dict[tuple[str, str], asyncio.Task] = {} self.limit = asyncio.Semaphore(concurrency) self.projects = projects + self.availability = availability or ModelAvailability(configs) self.accepting = set() self._init_lifecycle() @@ -76,6 +78,15 @@ def catalog(self): 'available': spec.recipe is not None and not missing, 'parametersConfirmed': spec.recipe is not None, 'fields': list(spec.fields), 'note': spec.note, 'models': {item: [{'id': config['id'], 'label': _model_label(config)} for config in served.get(item, [])] for item in needs}} + for capability, choices in entry['models'].items(): + for choice in choices: + config = next(item for item in served[capability] if item['id'] == choice['id']) + private = self.configs.resolve(config['id'], config['kind']) + request = tool_request(private, capability) + if request: + choice['availability'] = self.availability.decision(private, request) + if choices and all(item.get('availability', {}).get('status') == 'unavailable' for item in choices): + entry.update(available=False, reason='服务明确不支持当前请求,请查看模型能力并修改选择。', reasonCode='capability_unavailable') if spec.recipe is None or missing: entry['reason'] = f"需要接入具备「{'、'.join(LABELS[item] for item in missing or needs)}」能力的模型。" tools.append(entry) @@ -83,7 +94,8 @@ def catalog(self): if not selection['available']: selection['reason'] = '接入图像分割模型后可自动识别选区;仍可手动画选区。' return {'capabilities': {item: [{'id': config['id'], 'label': _model_label(config)} for config in configs] - for item, configs in served.items()}, 'tools': tools, 'selectionPreview': selection} + for item, configs in served.items()}, 'tools': tools, 'selectionPreview': selection, + 'availabilityRevision': self.availability.list()['revision']} def _model(self, capability, chosen): candidates = self.providers().get(capability, []) @@ -117,6 +129,9 @@ async def progress(_): if self.providers().get('text.chat'): async def chat(request): chat_config, chat_adapter = self._model('text.chat', None) + prepared = await asyncio.to_thread(self.availability.prepare, chat_config) + current, _ = self._model('text.chat', chat_config['id']) + self.availability.require(current, tool_request(current, 'text.chat'), prepared) return await chat_adapter.chat(chat_config, request) context = ToolContext(sources=[], mask=None, boxes=[], parameters={}, image_model=None, video_model=None, @@ -217,9 +232,17 @@ def _prepare(self, project_id, value, session): # Snapshot validation and accepted ledger publication share the project # lock. A source cannot be replaced between checking and acceptance. with self.store.transaction(): - return self._prepare_locked(project_id, value, session) + initial = self._prepare_locked(project_id, value, session, inspect=True) + if isinstance(initial, tuple): + return initial # A replay does not query or reaccept its frozen job. + prepared = {} + for capability, config in initial.items(): + if tool_request(config, capability) is not None: + prepared[capability] = self.availability.prepare(config) + with self.store.transaction(): + return self._prepare_locked(project_id, value, session, prepared=prepared) - def _prepare_locked(self, project_id, value, session): + def _prepare_locked(self, project_id, value, session, *, inspect=False, prepared=None): if self.closing: raise ProjectError('工具服务正在关闭,请稍后查询原任务。', 503) self._ensure_owner() @@ -268,16 +291,30 @@ def _prepare_locked(self, project_id, value, session): canvas = value.get('canvas') if canvas and (len(canvas['sourceNodeIds']) != len(source_ids) or canvas['sourceNodeIds'][0] != canvas['nodeId']): raise ProjectError('工具任务的画布来源不匹配。', 422) - from .creation_tool_drafts import freeze_inputs - frozen = freeze_inputs(self, project_id, value) models = {capability: self._model(capability, chosen.get(capability))[0] for capability in needs} - if self.providers().get('text.chat'): + if needs and self.providers().get('text.chat'): models['text.chat'] = self._model('text.chat', chosen.get('text.chat'))[0] + if inspect: + return models + for capability, config in list(models.items()): + request = tool_request(config, capability) + if request is None: + continue + if capability not in (prepared or {}): + raise ProjectError('能力检查期间模型服务已变化,请重新提交。', 409, code='capability_connection_changed') + self.availability.assert_identity(config, prepared[capability]) + if capability == 'text.chat' and capability not in needs: + if self.availability.decision(config, request)['status'] == 'unavailable': + del models[capability] + else: + self.availability.require(config, request) + from .creation_tool_drafts import freeze_inputs + frozen = freeze_inputs(self, project_id, value) task = {'schemaVersion': 1, 'id': task_id, 'projectId': project_id, 'tool': spec.id, 'status': 'queued', 'phase': '排队中', 'sourceAssetIds': source_ids, 'maskAssetId': mask_id, 'annotationAssetId': annotation_id, 'boxes': value.get('boxes') or [], 'parameters': parameters, 'modelConfigIds': {item: config['id'] for item, config in models.items()}, - 'modelSnapshots': {item: {key: val for key, val in config.items() if key != 'apiKey'} for item, config in models.items()}, 'requestedModelConfigIds': chosen, 'requires': list(needs), + 'modelSnapshots': {item: {key: val for key, val in config.items() if key not in {'apiKey', 'capabilityAutoCheck', 'availability'}} for item, config in models.items()}, 'requestedModelConfigIds': chosen, 'requires': list(needs), 'outputs': [], 'error': None, 'createdAt': _now(), 'finishedAt': None, 'revision': 1, 'workerId': self.worker_id, 'localState': 'active', 'cancelRequested': False, 'modelCalls': [], diff --git a/packages/core/src/opendirector/cli.py b/packages/core/src/opendirector/cli.py index 2b66ec8a..9562a947 100644 --- a/packages/core/src/opendirector/cli.py +++ b/packages/core/src/opendirector/cli.py @@ -349,6 +349,26 @@ def select_model(models, selector): return matches[0] +@group('model').command('availability', help='Read model operation availability; optionally refresh declarations without running models.') +def model_availability(ctx: typer.Context, + refresh: Annotated[bool, typer.Option(help='Query configured services using automatic-check preferences.')] = False, + model: Annotated[list[UUID] | None, typer.Option(help='Restrict to saved model configuration UUIDs.')] = None, + manual: Annotated[bool, typer.Option(help='Explicitly check selected models even when automatic checks are disabled. Requires --refresh and --model.')] = False): + client = ctx.obj + client.command = ctx.command_path + if manual and (not refresh or not model): + raise typer.BadParameter('--manual requires --refresh and --model') + arguments = {'manual': manual} + if model: + arguments['configIds'] = [str(item) for item in model] + if client.live: + emit(client.call('model_availability', {**arguments, 'refresh': refresh})) + elif refresh: + emit(client.request('POST', '/api/model-configs/availability', json=arguments)) + else: + emit(client.request('GET', '/api/model-configs/availability')) + + @group("model").command("list", help="Find model configurations by media kind or model name.") def model_list(ctx: typer.Context, kind: Annotated[Literal["agent", "image", "video", "audio", "vision"] | None, diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index 7ca6b9a4..36ff2b2c 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -398,6 +398,7 @@ def command_fingerprint(command: Command) -> str: IssueCode = Literal[TimelineIssueCode, 'invalid_input', 'revision_conflict', 'role_restricted', 'missing_object', 'reference_invalid', 'kling_model', 'kling_mode', 'kling_image', 'kling_frames', 'kling_prompt', 'kling_aspect', 'kling_output', 'kling_cancel', + 'capability_unavailable', 'capability_paused', 'capability_connection_changed', 'capability_unsupported', 'preview_expired', 'storage_unavailable', 'submission_unknown', 'provider_error', 'moss_speech_text', 'moss_speech_voice', 'moss_speech_reference', 'moss_prompt_limit', 'moss_image_size', 'moss_image_reference', 'moss_layout_reference', diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index 937a2d0f..b3ab049f 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -141,16 +141,52 @@ def connection(self): return self +class OperationAvailability(Contract): + operation: str = Field(description='Canonical remote operation identifier.') + profile: str = Field(description='Input and output mode used by the configured adapter.') + method: str = Field(description='Actual HTTP method used for submission.') + path: str = Field(description='Actual HTTP submission path.') + output: str = Field(description='Output modality consumed by the adapter.') + status: Literal['available', 'unavailable', 'unknown'] = Field(description='Current evidence for this exact request.') + reasonCode: str = Field(description='Stable, localizable decision reason.') + checkedAt: int | None = Field(description='Observation completion time in Unix milliseconds.') + expiresAt: int | None = Field(description='Decision expiry in Unix milliseconds; recompute after expiry.') + + +class ModelAvailability(Contract): + configId: UUID | None = Field(description='Saved model identity; null for an unsaved draft.') + modelId: str = Field(description='Exact remote model identifier requested.') + identity: str = Field(description='Opaque identity of the checked connection and model.') + autoCheck: bool = Field(description='Effective service automatic-check preference.') + status: Literal['available', 'unavailable', 'unknown'] = Field(description='Summary across the configured adapter operations.') + reasonCode: str = Field(description='Stable, localizable summary reason.') + checkedAt: int | None = Field(description='Observation completion time in Unix milliseconds.') + expiresAt: int | None = Field(description='Earliest operation expiry in Unix milliseconds.') + revision: str = Field(description='Projection version; changes on observations, identity changes and expiration.') + operations: tuple[OperationAvailability, ...] = Field(description='Exact operations consumed by the configured adapter.') + + +class AvailabilityCatalog(Contract): + revision: str = Field(description='Version of the complete observation projection.') + models: tuple[ModelAvailability, ...] = Field(description='Current saved model observations; reading does not query providers.') + + class ModelConfigPublic(ModelConnection): """Application-level, never embedded in an editable project's credential data.""" id: UUID = Field(description='Stable object ID.') providerId: UUID | None = Field(default=None, exclude=True, description='Shared provider connection ID.') inputRules: InputRules = Field(description='Verified model input limits and output choices.') + capabilityAutoCheck: bool | None = Field(default=None, strict=True, exclude_if=lambda value: value is None, + description='Service preference; omitted defaults to automatic for self-hosted connections.') + availability: ModelAvailability | None = Field(default=None, exclude_if=lambda value: value is None, + description='Ephemeral observation, never persisted in project snapshots.') class ModelConfigWrite(ModelConnection): """Only the model-settings write boundary accepts a credential.""" apiKey: SecretStr | None = Field(default=None, max_length=8192, description='Provider credential; omitted from public model records.') + capabilityAutoCheck: bool | None = Field(default=None, strict=True, exclude_if=lambda value: value is None, + description='Service automatic-check override; omission preserves the existing preference.') @field_validator('apiKey') @classmethod diff --git a/packages/core/src/opendirector/domain/omni_capabilities.py b/packages/core/src/opendirector/domain/omni_capabilities.py new file mode 100644 index 00000000..15d1e135 --- /dev/null +++ b/packages/core/src/opendirector/domain/omni_capabilities.py @@ -0,0 +1,89 @@ +"""The optional Omni discovery document. Missing declarations remain unknown.""" +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +Support = Literal['supported', 'unsupported', 'unknown'] +Exposure = Literal['enabled', 'disabled', 'unknown'] + + +class Declaration(BaseModel): + model_config = ConfigDict(extra='ignore', strict=True) + + +class Route(Declaration): + method: str = Field(max_length=16) + path: str = Field(max_length=2048) + transport: str = 'http' + exposure: Exposure = 'unknown' + + +class Profile(Declaration): + id: str = Field(min_length=1, max_length=100) + support: Support = 'unknown' + required_inputs: list[str] = Field(default_factory=list, max_length=32) + outputs: list[str] = Field(default_factory=list, max_length=32) + routes: list[Route] = Field(default_factory=list, max_length=32) + reason_code: str = Field(default='declaration_incomplete', max_length=200) + + @model_validator(mode='after') + def unique_routes(self): + check_routes(self.routes) + return self + + +class Execution(Declaration): + state: Literal['unpaused', 'paused', 'unknown'] = 'unknown' + scope: Literal['deployment', 'worker'] | None = None + observed_at: str | None = Field(default=None, max_length=100) + + +class Operation(Declaration): + support: Support = 'unknown' + exposure: Exposure = 'unknown' + reason_code: str = Field(default='declaration_incomplete', max_length=200) + profiles: list[Profile] = Field(default_factory=list, max_length=64) + execution: Execution = Field(default_factory=Execution) + + @model_validator(mode='after') + def unique_profiles(self): + if len({item.id for item in self.profiles}) != len(self.profiles): + raise ValueError('duplicate profiles') + if self.support == 'unsupported' and any(item.support == 'supported' for item in self.profiles): + raise ValueError('unsupported operation has a supported profile') + return self + + +class Model(Declaration): + id: str = Field(min_length=1, max_length=200) + stage_ids: list[str] = Field(default_factory=list, max_length=256) + operations: dict[str, Operation] = Field(default_factory=dict, max_length=64) + + +class OmniCapabilities(Declaration): + object: Literal['omni.capabilities'] + schema_version: Literal[1] + observed_at: str = Field(max_length=100) + models: list[Model] = Field(max_length=512) + routes: list[Route] = Field(default_factory=list, max_length=256) + + @model_validator(mode='after') + def unique_models(self): + if len({item.id for item in self.models}) != len(self.models): + raise ValueError('duplicate models') + timestamp(self.observed_at) + check_routes(self.routes) + return self + + +def check_routes(routes): + if len({(item.transport, item.method, item.path) for item in routes}) != len(routes): + raise ValueError('duplicate routes') + + +def timestamp(value): + value = datetime.fromisoformat(value.replace('Z', '+00:00')) + if value.tzinfo is None: + raise ValueError('timestamp requires a timezone') + return value.timestamp() diff --git a/packages/core/src/opendirector/infrastructure/model_configs.py b/packages/core/src/opendirector/infrastructure/model_configs.py index c928272d..9494704c 100644 --- a/packages/core/src/opendirector/infrastructure/model_configs.py +++ b/packages/core/src/opendirector/infrastructure/model_configs.py @@ -39,7 +39,8 @@ def _read(self) -> list[dict]: if value["version"] != 1 or not isinstance(value["models"], list): raise ValueError() services = value.get('services', {}) - if not isinstance(services, dict) or any(set(service) != {'url', 'apiKey', 'connectionType', 'authMode'} for service in services.values()): + if not isinstance(services, dict) or any(not {'url', 'apiKey', 'connectionType', 'authMode'} <= set(service) + or set(service) - {'url', 'apiKey', 'connectionType', 'authMode', 'capabilityAutoCheck'} for service in services.values()): raise ValueError() for item in value['models']: if 'providerId' in item: @@ -47,7 +48,9 @@ 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', 'proxyMode', 'videoAdapter', 'videoCapabilities', 'providerId', 'inputModalities'}: + if not {'id', 'kind', 'url', 'modelId', 'apiKey'} <= set(item) or set(item) - {'id', 'kind', 'url', 'modelId', 'apiKey', 'connectionType', 'protocol', 'authMode', 'proxyMode', 'videoAdapter', 'videoCapabilities', 'providerId', 'inputModalities', 'capabilityAutoCheck'}: + raise ValueError() + if 'capabilityAutoCheck' in item and type(item['capabilityAutoCheck']) is not bool: raise ValueError() identifier(item["id"]) if item["id"] in seen or item["kind"] not in MODEL_KINDS: @@ -70,12 +73,23 @@ 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", "proxyMode", "videoAdapter", "videoCapabilities", "inputModalities")}, 'inputRules': input_rules(item)} + return {**({'providerId': item['providerId']} if 'providerId' in item else {}), + **({'capabilityAutoCheck': item['capabilityAutoCheck']} if 'capabilityAutoCheck' in item else {}), + **{key: item[key] for key in ("id", "kind", "url", "modelId", "connectionType", "protocol", "authMode", "proxyMode", "videoAdapter", "videoCapabilities", "inputModalities")}, 'inputRules': input_rules(item)} def list(self) -> list[dict]: with self.lock: return [self._public(item) for item in self._read()] + def catalog_snapshot(self) -> list[tuple[dict, dict]]: + """Public rows and their private identities from one locked read. + + Callers may project in-memory observations after releasing this lock; + no provider request belongs in the configuration transaction. + """ + with self.lock: + return [(self._public(item), dict(item)) for item in self._read()] + def agent(self, config_id: str) -> dict: """Resolve a private credential snapshot for a backend call only.""" return self.resolve(config_id, "agent") @@ -87,7 +101,9 @@ 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, proxy_mode=None, video_adapter=None, video_capabilities=None, capabilities_supplied=False, input_modalities=None) -> 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, proxy_mode=None, video_adapter=None, video_capabilities=None, capabilities_supplied=False, input_modalities=None, capability_auto_check=None) -> dict: + if capability_auto_check is not None and type(capability_auto_check) is not bool: + raise ProjectError('自动能力检查设置必须为布尔值。') if kind not in MODEL_KINDS: raise ProjectError("无效的模型类别。") fields = model_fields(url, model_id, api_key) @@ -111,6 +127,9 @@ 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} + auto_check = capability_auto_check if capability_auto_check is not None else (existing or {}).get('capabilityAutoCheck') + if auto_check is not None: + item['capabilityAutoCheck'] = auto_check 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') @@ -127,6 +146,8 @@ def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str for peer in models: if peer.get('providerId') == item['providerId']: peer.update({key: item[key] for key in ('url', 'apiKey', 'connectionType', 'authMode', 'proxyMode')}) + if auto_check is not None: + peer['capabilityAutoCheck'] = auto_check self._write(models) return self._public(item) @@ -145,12 +166,16 @@ def _write(self, models: list[dict]) -> None: item = dict(model) if 'providerId' in item: services[item['providerId']] = {key: item.pop(key) for key in ('url', 'apiKey', 'connectionType', 'authMode')} + if 'capabilityAutoCheck' in item: + services[item['providerId']]['capabilityAutoCheck'] = item.pop('capabilityAutoCheck') stored.append(item) write_json(self.path, {'version': 1, 'models': stored, 'services': services}) 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, proxy_mode=None, video_adapter=None, video_capabilities=None, input_modalities=None, model_options=None, capabilities_supplied=False) -> list[dict]: + auth_mode=None, proxy_mode=None, video_adapter=None, video_capabilities=None, input_modalities=None, model_options=None, capabilities_supplied=False, capability_auto_check=None) -> list[dict]: + if capability_auto_check is not None and type(capability_auto_check) is not bool: + raise ProjectError('自动能力检查设置必须为布尔值。') if kind not in MODEL_KINDS or not 1 <= len(model_ids) <= 200: raise ProjectError('请选择 1—200 个模型。') model_options = model_options or {} @@ -178,6 +203,7 @@ def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, raise ProjectError('请填写该服务的 API Key;新地址不能复用原地址的密钥。') key = existing['apiKey'] provider_id = existing.get('providerId', existing['id']) if existing else str(uuid4()) + auto_check = capability_auto_check if capability_auto_check is not None else (existing or {}).get('capabilityAutoCheck') previous = [item for item in models if item.get('providerId') == provider_id or item['id'] == config_id] peers = {item['modelId']: item for item in previous} saved = [] @@ -187,6 +213,8 @@ 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'} + if auto_check is not None: + item['capabilityAutoCheck'] = auto_check options = model_options.get(field['modelId'], {}) item['videoAdapter'] = options.get('videoAdapter') or video_adapter or (old or {}).get('videoAdapter', 'auto') modalities = options.get('inputModalities', input_modalities if input_modalities is not None else (old or {}).get('inputModalities')) @@ -201,6 +229,8 @@ def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, for item in remaining: if item.get('providerId') == provider_id: item.update(url=fields[0]['url'], apiKey=key, connectionType=connection['connectionType'], authMode=connection['authMode'], proxyMode=connection['proxyMode']) + if auto_check is not None: + item['capabilityAutoCheck'] = auto_check self._write(remaining + saved) return [self._public(item) for item in saved] diff --git a/packages/core/src/opendirector/integrations/capability_probe.py b/packages/core/src/opendirector/integrations/capability_probe.py new file mode 100644 index 00000000..9a3681a2 --- /dev/null +++ b/packages/core/src/opendirector/integrations/capability_probe.py @@ -0,0 +1,164 @@ +"""Bounded, process-local, coalesced reads of optional Omni declarations. + +This service never wakes a model and never submits inference. A worker owns +each HTTP request; callers share its future with a total two-second deadline. +""" +from collections import OrderedDict +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError +from dataclasses import dataclass +import hashlib +import json +import threading +import time + +import httpx + +from opendirector.domain.omni_capabilities import OmniCapabilities +from opendirector.integrations.http_settings import auth_headers, trust_environment +from opendirector.integrations.volcengine import endpoint + +DECLARATION_SECONDS = 30 +PAUSE_SECONDS = 5 +QUERY_SECONDS = 2 +FAILURE_SECONDS = 2 + + +def identity(config, *, service=False): + keys = ('url', 'apiKey', 'authMode', 'connectionType', 'proxyMode', '_capability_namespace') + if not service: + keys += ('id', 'providerId', 'kind', 'modelId', 'protocol', 'videoAdapter', 'inputModalities', + 'videoCapabilities', 'capabilityAutoCheck') + value = {key: config.get(key) for key in keys} + value['serviceId'] = config.get('providerId') or config.get('id') + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def automatic(config): + return config.get('capabilityAutoCheck', config.get('connectionType') == 'self_hosted') + + +@dataclass(frozen=True) +class Observation: + document: OmniCapabilities | None + reason: str + checked: float + expires: float + received: float + valid_until: float + + +class CapabilityProbe: + def __init__(self, *, transport=None, clock=time.monotonic, wall_clock=time.time, timeout=QUERY_SECONDS, capacity=128): + self.transport, self.clock, self.timeout, self.capacity = transport, clock, timeout, capacity + self.wall_clock = wall_clock + self._lock = threading.Lock() + self._cache = OrderedDict() + self._pending: dict[str, Future] = {} + self._workers = ThreadPoolExecutor(max_workers=4, thread_name_prefix='capabilities') + + def close(self): + self._workers.shutdown(wait=False, cancel_futures=True) + + def _observation(self, document, reason): + lifetime = DECLARATION_SECONDS if document else FAILURE_SECONDS + received, checked = self.clock(), self.wall_clock() + return Observation(document, reason, checked, checked + lifetime, received, received + lifetime) + + def peek(self, config): + key = identity(config, service=True) + with self._lock: + observation = self._cache.get(key) + return observation if observation and observation.valid_until > self.clock() else None + + def query(self, config, *, manual=False): + if not manual and not automatic(config): + return self.peek(config) + key = identity(config, service=True) + deadline = time.monotonic() + self.timeout + with self._lock: + observation = self._cache.get(key) + # Paused observations need another read after their shorter lifetime. + paused = observation and observation.document and any( + op.execution.state == 'paused' for model in observation.document.models for op in model.operations.values()) + if (not manual and observation and observation.valid_until > self.clock() + and (not paused or observation.received + PAUSE_SECONDS > self.clock())): + return observation + future = self._pending.get(key) + if future is None: + if len(self._pending) >= self.capacity: + return self._observation(None, 'query_busy') + future = self._workers.submit(self._read, dict(config), deadline) + self._pending[key] = future + # Register outside the lock: already-completed futures invoke callbacks inline. + register = True + else: + register = False + if register: + future.add_done_callback(lambda done: self._finish(key, done)) + try: + value = future.result(timeout=max(0, deadline - time.monotonic())) + # Future.result may wake before its completion callback publishes. + self._finish(key, future) + return value + except TimeoutError: + value = self._observation(None, 'query_timeout') + with self._lock: + if self._pending.get(key) is future: + self._cache[key] = value + while len(self._cache) > self.capacity: + self._cache.popitem(last=False) + return value + + def _finish(self, key, future): + try: + value = future.result() + except Exception: + value = self._observation(None, 'query_failed') + with self._lock: + if self._pending.get(key) is not future: + return # A waiter already published it; do not overwrite a newer refresh. + self._pending.pop(key, None) + self._cache[key] = value + self._cache.move_to_end(key) + while len(self._cache) > self.capacity: + self._cache.popitem(last=False) + + def _read(self, config, deadline): + reason, document = 'query_failed', None + try: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + with httpx.Client(transport=self.transport, trust_env=trust_environment(config['url'], config=config), + timeout=remaining, follow_redirects=False) as client: + with client.stream('GET', endpoint(config['url'], '/omni/capabilities'), headers=auth_headers(config)) as response: + if response.status_code != 200: + reason = ('authentication_failed' if response.status_code in {401, 403} + else 'protocol_unavailable' if response.status_code == 404 else 'query_failed') + else: + data = bytearray() + for chunk in response.iter_bytes(): + if time.monotonic() >= deadline: + raise TimeoutError() + data.extend(chunk) + if len(data) > 1024 * 1024: + raise ValueError('response too large') + decoded = json.loads(data) + if isinstance(decoded, dict) and decoded.get('object') != 'omni.capabilities': + reason = 'protocol_unavailable' + elif isinstance(decoded, dict) and type(decoded.get('schema_version')) is not int: + reason = 'invalid_response' + elif isinstance(decoded, dict) and decoded.get('schema_version') != 1: + reason = 'unsupported_version' + else: + document = OmniCapabilities.model_validate(decoded) + reason = 'observed' + if time.monotonic() > deadline: + raise TimeoutError() + except (httpx.TimeoutException, TimeoutError): + reason, document = 'query_timeout', None + except httpx.HTTPError: + reason, document = 'connection_failed', None + except (ValueError, TypeError, RecursionError): + reason, document = 'invalid_response', None + return self._observation(document, reason) diff --git a/tests/conftest.py b/tests/conftest.py index af1661df..a41c259e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,23 @@ configure_runtime() +@pytest.fixture(autouse=True) +def optional_capability_endpoint(monkeypatch): + """Existing provider doubles predate discovery: never query real hosts. + + Capability tests supply their own transport, including the explicit HTTP + transport used by the loopback integration test. + """ + import httpx + from opendirector.integrations.capability_probe import CapabilityProbe + original = CapabilityProbe.__init__ + def initialize(self, *args, **kwargs): + if kwargs.get('transport') is None: + kwargs['transport'] = httpx.MockTransport(lambda request: httpx.Response(404)) + original(self, *args, **kwargs) + monkeypatch.setattr(CapabilityProbe, '__init__', initialize) + + @pytest.fixture def public_cdn(monkeypatch): """Mocked downloads still validate DNS, without depending on public resolvers.""" diff --git a/tests/test_assistant_conversations.py b/tests/test_assistant_conversations.py index 111913c3..573c9275 100644 --- a/tests/test_assistant_conversations.py +++ b/tests/test_assistant_conversations.py @@ -35,7 +35,9 @@ async def stream(self, run): monkeypatch.setattr(agent, 'runtime_available', lambda _: True) monkeypatch.setattr(agent, 'skills', empty_skills) from opendirector.infrastructure.model_configs import ModelConfigs - monkeypatch.setattr(ModelConfigs, 'resolve', lambda *args: {'id': str(uuid4()), 'modelId': 'test-model'}) + config = {'id': str(uuid4()), 'modelId': 'test-model', 'kind': 'agent', 'url': 'http://models.test/v1', + 'connectionType': 'api', 'protocol': 'openai', 'authMode': 'none', 'apiKey': ''} + monkeypatch.setattr(ModelConfigs, 'resolve', lambda *args: dict(config)) with TestClient(create_app(LocalProjects(tmp_path)), headers={ 'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': str(uuid4())}) as client: pid = str(uuid4()) diff --git a/tests/test_capability_acceptance.py b/tests/test_capability_acceptance.py new file mode 100644 index 00000000..af8e42f6 --- /dev/null +++ b/tests/test_capability_acceptance.py @@ -0,0 +1,292 @@ +"""Shared acceptance, HTTP, CLI and Agent tests with isolated model doubles.""" +import asyncio +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import threading +from unittest.mock import Mock +from uuid import uuid4 + +from fastapi.testclient import TestClient +import httpx +import pytest +from typer.testing import CliRunner + +from opendirector import cli +from opendirector.agent.tools import AgentTools +from opendirector.application.creation_tool_assets import CreationToolAssets +from opendirector.application.model_availability import ModelAvailability +from opendirector.application.tool_tasks import ToolTasks +from opendirector.domain.creation.commands import parse_command +from opendirector.domain.projects import ProjectError +from opendirector.infrastructure.local_projects import LocalProjects +from opendirector.infrastructure.model_configs import ModelConfigs +from opendirector.integrations.capability_probe import CapabilityProbe +from opendirector_api.main import create_app +from opendirector_api import agent +from test_creation_canvas_service import project, ver +from test_creation_generation_service import Provider, runtime, configure +from test_model_availability import document, NOW +from test_tool_tasks import png, upload, images_server + + +def command_for(project, config): + _, project_id, _ = project + target, spec = configure(project, config) + return parse_command(dict(commandId=uuid4(), projectId=project_id, type='generation.submit', + expectedVersions=[ver('canvas_item', target), ver('generation_spec', spec)], + payload=dict(specId=spec.id, expectedSpecRevision=spec.revision, originItemId=target.id))) + + +def test_generation_rejection_creates_no_job_or_provider_call(project): + store, pid, session = project + provider = Provider() + runner, config = runtime(project, provider) + calls = [] + def respond(request): + calls.append(request) + return httpx.Response(200, json=document(model='test-agent', support='unsupported')) + probe = CapabilityProbe(transport=httpx.MockTransport(respond)) + runner.availability = ModelAvailability(runner.configs, probe) + command = command_for(project, config) + async def run(): + try: + receipt = await runner.execute(command, session=session) + assert receipt.status == 'rejected' + assert receipt.issues[0].code == 'capability_unavailable' + assert not store.snapshot(pid).jobs and provider.calls == [] + assert len(calls) == 1 and calls[0].method == 'GET' + finally: + await runner.close() + try: + asyncio.run(run()) + finally: + probe.close() + + +def test_generation_identity_change_during_query_is_rejected_under_commit(project): + store, pid, session = project + runner, config = runtime(project, Provider()) + def respond(request): + # This save must complete while the HTTP query is in progress. + runner.configs.save('agent', 'http://changed.test/v1', 'test-agent', '', config['id'], connection_type='self_hosted') + return httpx.Response(200, json=document(model='test-agent')) + probe = CapabilityProbe(transport=httpx.MockTransport(respond)) + runner.availability = ModelAvailability(runner.configs, probe) + try: + result = runner.prepare(command_for(project, config), session=session) + assert result.status == 'rejected' + assert result.issues[0].code == 'capability_connection_changed' + assert not store.snapshot(pid).jobs + finally: + probe.close() + + +def test_generation_replay_precedes_changed_declaration_and_preserves_snapshot(project): + store, pid, session = project + runner, config = runtime(project, Provider()) + state = {'support': 'supported', 'calls': 0} + def respond(request): + state['calls'] += 1 + return httpx.Response(200, json=document(model='test-agent', support=state['support'])) + probe = CapabilityProbe(transport=httpx.MockTransport(respond)) + runner.availability = ModelAvailability(runner.configs, probe) + try: + command = command_for(project, config) + receipt = runner.prepare(command, session=session) + assert receipt.status == 'accepted' + frozen = store.snapshot(pid).jobs[0].inputSnapshot.model_dump() + state['support'] = 'unsupported' + runner.availability.refresh([config['id']], manual=True) + calls = state['calls'] + replay = runner.prepare(command, session=session) + assert replay.jobIds == receipt.jobIds and state['calls'] == calls + assert store.snapshot(pid).jobs[0].inputSnapshot.model_dump() == frozen + assert 'availability' not in str(frozen) and 'capabilityAutoCheck' not in str(frozen) + finally: + probe.close() + + +def test_tools_reject_new_remote_work_and_keep_local_work_and_replays(project): + store, project_id, session = project + pid = str(project_id) + configs = ModelConfigs(store.local.repository) + configs.save('image', 'http://image.test/v1', 'Tongyi-MAI/Z-Image-Turbo', '', connection_type='self_hosted') + source = upload(store, pid, session, 'source.png', png()) + calls = [] + probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: httpx.Response(200, + json=document('image_edit', 'image_to_image', '/v1/images/edits', 'image', model='Tongyi-MAI/Z-Image-Turbo', support='unsupported')))) + availability = ModelAvailability(configs, probe) + tools = ToolTasks(store.local, configs, CreationToolAssets(store), projects=store, + transport=httpx.MockTransport(images_server(calls)), availability=availability) + async def run(): + try: + with pytest.raises(ProjectError) as error: + await tools.submit(pid, {'id': str(uuid4()), 'tool': 'outpaint', 'sourceAssetIds': [source], 'parameters': {}}, session) + assert error.value.code == 'capability_unavailable' + assert not tools.list(pid) and calls == [] + value = {'id': str(uuid4()), 'tool': 'image-hd', 'sourceAssetIds': [source], + 'parameters': {'kind': 'normal', 'resolution': '2K'}} + task = await tools.submit(pid, value, session) + await asyncio.gather(*tools.jobs.values()) + assert tools.get(pid, task['id'])['status'] == 'succeeded' + availability.prepare = Mock(side_effect=AssertionError('replay queried capability')) + replay = await tools.submit(pid, value, session) + assert replay['id'] == task['id'] and calls == [] + finally: + await tools.close() + try: + asyncio.run(run()) + finally: + probe.close() + + +def test_tool_edit_uses_its_real_route_and_skips_unsupported_optional_chat(project): + store, project_id, session = project + pid = str(project_id) + configs = ModelConfigs(store.local.repository) + image = configs.save('image', 'http://image.test/v1', 'Tongyi-MAI/Z-Image-Turbo', '', connection_type='self_hosted') + configs.save('agent', 'http://chat.test/v1', 'demo', '', connection_type='self_hosted') + source = upload(store, pid, session, 'source.png', png()) + calls = [] + def respond(request): + value = (document('image_edit', 'image_to_image', '/v1/images/edits', 'image', model=image['modelId']) + if request.url.host == 'image.test' else document(support='unsupported')) + return httpx.Response(200, json=value) + probe = CapabilityProbe(transport=httpx.MockTransport(respond)) + tools = ToolTasks(store.local, configs, CreationToolAssets(store), projects=store, + transport=httpx.MockTransport(images_server(calls)), availability=ModelAvailability(configs, probe)) + async def run(): + try: + task = await tools.submit(pid, {'id': str(uuid4()), 'tool': 'outpaint', 'sourceAssetIds': [source], + 'parameters': {'left': 0, 'right': 1, 'top': 0, 'bottom': 0}}, session) + await asyncio.gather(*tools.jobs.values()) + result = tools.get(pid, task['id']) + assert result['status'] == 'succeeded', result['error'] + assert len(calls) == 1 and 'text.chat' not in result['modelConfigIds'] + assert 'capabilityAutoCheck' not in str(result['modelSnapshots']) + finally: + await tools.close() + try: + asyncio.run(run()) + finally: + probe.close() + + +def test_saved_http_api_and_draft_checks_share_parser_but_not_observations(tmp_path): + calls = [] + probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: (calls.append(request) or httpx.Response(200, json=document())))) + headers = {'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': str(uuid4())} + with TestClient(create_app(LocalProjects(tmp_path), capability_probe=probe), headers=headers) as client: + payload = {'kind': 'agent', 'url': 'http://models.test/v1', 'modelId': 'demo', 'apiKey': '', 'connectionType': 'self_hosted'} + saved = client.post('/api/model-configs', json=payload).json() + assert client.get('/api/model-configs/availability').json()['models'][0]['status'] == 'unknown' + assert calls == [] + draft = client.post('/api/model-configs/availability/check', json=payload) + assert draft.status_code == 200, draft.text + assert draft.json()['status'] == 'available' and draft.json()['configId'] is None + assert client.get('/api/model-configs/availability').json()['models'][0]['status'] == 'unknown' + observed = client.post('/api/model-configs/availability', json={'configIds': [saved['id']], 'manual': True}) + assert observed.status_code == 200, observed.text + assert observed.json()['models'][0]['status'] == 'available' + assert client.get('/api/model-configs').json()[0]['availability']['status'] == 'available' + assert len(calls) == 2 + + +@pytest.mark.parametrize('scenario', ['unsupported', 'changed_before_acceptance', 'native']) +def test_assistant_admission_and_native_runtime_boundary(tmp_path, monkeypatch, scenario): + configs = ModelConfigs(tmp_path) + saved = configs.save('agent', 'http://assistant.test/v1', 'demo', '', connection_type='self_hosted') + calls, turns = [], [] + def respond(request): + calls.append(request) + return httpx.Response(200, json=document(support='unsupported' if scenario == 'unsupported' else 'supported')) + probe = CapabilityProbe(transport=httpx.MockTransport(respond)) + async def stream(self, context): + turns.append(context) + yield {'type': 'delta', 'text': 'Fixture response'} + async def skills(*args): + if scenario == 'changed_before_acceptance': + configs.save('agent', 'http://changed.test/v1', 'demo', '', saved['id'], connection_type='self_hosted') + return [] + monkeypatch.setattr(agent.AgentRuntimes, 'stream', stream) + monkeypatch.setattr(agent, 'runtime_available', lambda _: True) + monkeypatch.setattr(agent, 'skills', skills) + headers = {'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': str(uuid4())} + with TestClient(create_app(LocalProjects(tmp_path), capability_probe=probe), headers=headers) as client: + pid = str(uuid4()) + base = f'/api/creation/projects/{pid}' + client.post(base + '/commands', json={'type': 'project.create', 'projectId': pid, + 'commandId': str(uuid4()), 'payload': {'name': 'Capability assistant'}}).raise_for_status() + client.post(base + '/open').raise_for_status() + response = client.post(base + '/assistant/turns', json={'id': str(uuid4()), 'text': 'Hello', + 'modelId': saved['id'], 'runtime': 'codex' if scenario == 'native' else 'default'}) + if scenario == 'unsupported': + assert response.status_code == 409 and turns == [] + assert client.get(base + '/assistant').json()['messages'] == [] + elif scenario == 'changed_before_acceptance': + assert response.status_code == 200 and turns == [] + events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith('data: ')] + assert not any(item['type'] == 'start' for item in events) + assert client.get(base + '/assistant').json()['messages'] == [] + else: + assert response.status_code == 200 and len(turns) == 1 and calls == [] + + +def test_loopback_http_discovery_checks_real_route_without_inference(tmp_path): + calls = [] + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + calls.append((self.command, self.path)) + data = json.dumps(document()).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + def log_message(self, *args): + pass + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + configs = ModelConfigs(tmp_path) + configs.save('agent', f'http://127.0.0.1:{server.server_port}/v1', 'demo', '', connection_type='self_hosted') + probe = CapabilityProbe(transport=httpx.HTTPTransport()) + try: + result = ModelAvailability(configs, probe).refresh() + assert result['models'][0]['status'] == 'available' + assert calls == [('GET', '/v1/omni/capabilities')] + finally: + probe.close() + server.shutdown() + server.server_close() + thread.join(2) + + +@pytest.mark.parametrize('live', [False, True]) +def test_cli_availability_uses_shared_read_or_refresh(live, monkeypatch): + client = Mock(live=live, opened=False) + client.request.return_value = client.call.return_value = {'revision': 'test', 'models': []} + monkeypatch.setattr(cli, 'Client', Mock(return_value=client)) + model = str(uuid4()) + result = CliRunner().invoke(cli.app, ['model', 'availability', '--refresh', '--manual', '--model', model]) + assert result.exit_code == 0, result.output + if live: + client.call.assert_called_once_with('model_availability', {'refresh': True, 'manual': True, 'configIds': [model]}) + else: + client.request.assert_called_once_with('POST', '/api/model-configs/availability', json={'manual': True, 'configIds': [model]}) + + +def test_agent_reads_and_refreshes_the_same_availability(project): + store, pid, session = project + runner, config = runtime(project, Provider()) + probe = CapabilityProbe(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=document(model='test-agent')))) + runner.availability = ModelAvailability(runner.configs, probe) + tools = AgentTools(store, runner, runner.configs) + async def run(): + result = await tools.execute(pid, uuid4(), session, 'read', 'model_availability', {}, [], None) + assert result['models'][0]['status'] == 'unknown' + result = await tools.execute(pid, uuid4(), session, 'refresh', 'model_availability', {'refresh': True}, [], None) + assert result['models'][0]['status'] == 'available' + try: + asyncio.run(run()) + finally: + probe.close() diff --git a/tests/test_model_availability.py b/tests/test_model_availability.py new file mode 100644 index 00000000..218b393e --- /dev/null +++ b/tests/test_model_availability.py @@ -0,0 +1,348 @@ +"""Optional discovery uses real request contracts and never changes saved intent.""" +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import asyncio +import json +import threading +import time + +import httpx +import pytest + +from opendirector.application.model_availability import ModelAvailability, requests_for +from opendirector.application.model_catalog import ModelCatalog +from opendirector.domain.projects import ProjectError +from opendirector.infrastructure.model_configs import ModelConfigs +from opendirector.integrations.capability_probe import CapabilityProbe + + +NOW = 1794225600.0 + + +def document(operation='chat', profile='text_response', path='/v1/chat/completions', output='text', *, model='demo', support='supported'): + return {'object': 'omni.capabilities', 'schema_version': 1, + 'observed_at': datetime.fromtimestamp(NOW, timezone.utc).isoformat(), + 'models': [{'id': model, 'stage_ids': ['0'], 'operations': {operation: { + 'support': support, 'exposure': 'enabled', 'reason_code': 'model_declared', + 'profiles': [{'id': profile, 'support': support, 'outputs': [output], + 'required_inputs': ['text'], 'routes': [{'method': 'POST', 'path': path}]}], + 'execution': {'state': 'unpaused', 'scope': 'deployment', + 'observed_at': datetime.fromtimestamp(NOW, timezone.utc).isoformat()}}}}], + 'routes': [{'transport': 'http', 'method': 'POST', 'path': path, 'exposure': 'enabled'}]} + + +@pytest.fixture +def service(tmp_path): + configs = ModelConfigs(tmp_path) + saved = configs.save('agent', 'http://models.test/v1', 'demo', '', connection_type='self_hosted', auth_mode='none') + state = {'document': document(), 'status': 200, 'calls': [], 'clock': NOW} + + def respond(request): + state['calls'].append(request) + return httpx.Response(state['status'], json=state['document']) + + probe = CapabilityProbe(transport=httpx.MockTransport(respond), clock=lambda: state['clock'], wall_clock=lambda: state['clock']) + availability = ModelAvailability(configs, probe) + yield configs, saved, state, availability + probe.close() + + +def private(configs, saved): + return configs.resolve(saved['id'], saved['kind']) + + +def test_catalog_is_network_free_and_manual_refresh_runs_no_inference(service): + configs, saved, state, availability = service + catalog = ModelCatalog(configs, availability=availability) + before = configs.path.read_bytes() + assert catalog.list()[0]['availability']['status'] == 'unknown' + assert state['calls'] == [] + observed = availability.refresh()['models'][0] + assert observed['status'] == 'available' + assert [(r.method, str(r.url)) for r in state['calls']] == [('GET', 'http://models.test/v1/omni/capabilities')] + assert catalog.list()[0]['availability']['identity'] == observed['identity'] + assert configs.path.read_bytes() == before + + +@pytest.mark.parametrize('status,body', [(404, {}), (401, {}), (500, {}), (200, {'object': 'list'}), + (200, {**document(), 'schema_version': 2}), (200, {**document(), 'models': document()['models'] * 2})]) +def test_legacy_or_invalid_service_stays_unknown_and_preserves_manual_config(service, status, body): + configs, saved, state, availability = service + state.update(status=status, document=body) + before = configs.path.read_bytes() + availability.refresh() + config = private(configs, saved) + availability.require(config, requests_for(config)[0]) + assert availability.model(config)['status'] == 'unknown' + assert configs.path.read_bytes() == before + + +@pytest.mark.parametrize('status,body,reason', [(401, {}, 'authentication_failed'), (403, {}, 'authentication_failed'), + (404, {}, 'protocol_unavailable'), (200, {**document(), 'schema_version': 2}, 'unsupported_version'), + (200, {**document(), 'models': 'invalid'}, 'invalid_response')]) +def test_query_failures_keep_actionable_sanitized_reasons(service, status, body, reason): + configs, saved, state, availability = service + state.update(status=status, document=body) + availability.refresh() + value = availability.model(private(configs, saved)) + assert value['status'] == 'unknown' and value['reasonCode'] == reason + + +def test_connection_failure_is_distinct_from_invalid_document(service): + configs, saved, _, availability = service + def respond(request): + raise httpx.ConnectError('secret response must not escape', request=request) + availability.probe.transport = httpx.MockTransport(respond) + availability.refresh() + value = availability.model(private(configs, saved)) + assert value['reasonCode'] == 'connection_failed' + assert 'secret response' not in str(value) + + +@pytest.mark.parametrize('mutation,reason', [ + (lambda op, doc: (op.update(support='unsupported'), op['profiles'][0].update(support='unsupported')), 'operation_unsupported'), + (lambda op, doc: op.update(exposure='disabled'), 'operation_disabled'), + (lambda op, doc: op['profiles'][0].update(support='unsupported'), 'profile_unsupported'), + (lambda op, doc: op['profiles'][0].update(outputs=['image']), 'output_unsupported'), + (lambda op, doc: doc['routes'][0].update(exposure='disabled'), 'route_disabled'), +]) +def test_explicit_negative_evidence_rejects_exact_request(service, mutation, reason): + configs, saved, state, availability = service + mutation(state['document']['models'][0]['operations']['chat'], state['document']) + availability.refresh() + config = private(configs, saved) + assert availability.model(config)['operations'][0]['reasonCode'] == reason + with pytest.raises(ProjectError) as error: + availability.require(config, requests_for(config)[0]) + assert error.value.code == 'capability_unavailable' + + +@pytest.mark.parametrize('mutation', [ + lambda doc: doc['models'][0].update(id='different-alias'), + lambda doc: doc['models'][0]['operations']['chat']['profiles'][0].update(id='image_response'), + lambda doc: doc['models'][0]['operations']['chat']['profiles'][0]['routes'][0].update(path='/v1/other'), + lambda doc: doc['models'][0]['operations']['chat']['profiles'][0]['routes'][0].update(method='GET'), +]) +def test_no_inference_from_a_different_model_profile_or_route(service, mutation): + configs, saved, state, availability = service + mutation(state['document']) + availability.refresh() + assert availability.model(private(configs, saved))['status'] == 'unknown' + + +def test_independent_pause_and_declaration_expiry_changes_projection_without_network(service): + configs, saved, state, availability = service + execution = state['document']['models'][0]['operations']['chat']['execution'] + execution['state'] = 'paused' + availability.refresh() + initial = availability.list() + assert initial['models'][0]['reasonCode'] == 'execution_paused' + state['clock'] += 5.01 + expired_pause = availability.list() + assert expired_pause['models'][0]['status'] == 'available' + assert expired_pause['revision'] != initial['revision'] + state['clock'] = NOW + 30.01 + assert availability.list()['models'][0]['status'] == 'unknown' + assert len(state['calls']) == 1 + + +@pytest.mark.parametrize('scope,observed', [('worker', NOW), ('deployment', NOW - 10), ('deployment', NOW + 100)]) +def test_pause_scope_and_local_expiry_do_not_depend_on_remote_clock(service, scope, observed): + configs, saved, state, availability = service + state['document']['models'][0]['operations']['chat']['execution'].update(state='paused', scope=scope, + observed_at=datetime.fromtimestamp(observed, timezone.utc).isoformat()) + availability.refresh() + assert availability.list()['models'][0]['status'] == ('available' if scope == 'worker' else 'unavailable') + state['clock'] += 5.01 + assert availability.list()['models'][0]['status'] == 'available' + + +@pytest.mark.parametrize('contradiction', ['operation_profile', 'duplicate_route', 'duplicate_profile_route']) +def test_contradictory_document_is_unknown(service, contradiction): + configs, saved, state, availability = service + doc = state['document'] + if contradiction == 'operation_profile': + doc['models'][0]['operations']['chat']['support'] = 'unsupported' + elif contradiction == 'duplicate_route': + doc['routes'].append({**doc['routes'][0], 'exposure': 'disabled'}) + else: + routes = doc['models'][0]['operations']['chat']['profiles'][0]['routes'] + routes.append({**routes[0], 'exposure': 'disabled'}) + availability.refresh() + assert availability.model(private(configs, saved))['reasonCode'] == 'invalid_response' + + +def test_unknown_profile_outputs_do_not_create_negative_evidence(service): + configs, saved, state, availability = service + state['document']['models'][0]['operations']['chat']['profiles'][0].update(support='unknown', outputs=['image']) + availability.refresh() + assert availability.model(private(configs, saved))['status'] == 'unknown' + + +def test_wall_clock_change_does_not_extend_or_shorten_ttl(service): + configs, saved, state, availability = service + wall = [NOW] + availability.probe.wall_clock = lambda: wall[0] + availability.refresh() + wall[0] += 10000 + assert availability.model(private(configs, saved))['status'] == 'available' + wall[0] -= 20000 + state['clock'] += 31 + assert availability.model(private(configs, saved))['status'] == 'unknown' + + +def test_opt_out_is_persisted_and_manual_check_is_explicit(service): + configs, saved, state, availability = service + configs.save('agent', saved['url'], 'demo', '', saved['id'], connection_type='self_hosted', capability_auto_check=False) + availability.refresh() + assert state['calls'] == [] + with pytest.raises(ProjectError): + availability.refresh(manual=True) + availability.refresh([saved['id']], manual=True) + assert availability.list()['models'][0]['status'] == 'available' + assert len(state['calls']) == 1 + + +def test_shared_service_preference_preserves_omitted_and_explicit_false(tmp_path): + configs = ModelConfigs(tmp_path) + models = configs.save_many('agent', 'http://models.test/v1', ['a', 'b'], '', connection_type='self_hosted') + assert all('capabilityAutoCheck' not in item for item in models) + configs.save('agent', models[0]['url'], 'a', '', models[0]['id'], connection_type='self_hosted', capability_auto_check=False) + assert all(item['capabilityAutoCheck'] is False for item in ModelConfigs(tmp_path).list()) + configs.save_many('agent', models[0]['url'], ['a', 'b'], '', models[0]['id'], connection_type='self_hosted') + assert all(item['capabilityAutoCheck'] is False for item in configs.list()) + stored = json.loads(configs.path.read_text()) + assert all('capabilityAutoCheck' not in item for item in stored['models']) + assert next(iter(stored['services'].values()))['capabilityAutoCheck'] is False + + +def test_disabled_independent_connection_does_not_suppress_enabled_peer(service): + configs, saved, state, availability = service + configs.save('agent', saved['url'], 'demo', '', saved['id'], connection_type='self_hosted', capability_auto_check=False) + configs.save('agent', saved['url'], 'demo', '', connection_type='self_hosted', capability_auto_check=True) + availability.refresh() + assert len(state['calls']) == 1 + + +def test_independent_service_owners_do_not_share_cache_at_the_same_url(service): + configs, saved, state, availability = service + availability.refresh() + other = configs.save('agent', saved['url'], 'demo', '', connection_type='self_hosted') + assert availability.model(private(configs, other))['status'] == 'unknown' + availability.refresh() + assert len(state['calls']) == 2 + + +def test_one_provider_with_multiple_models_shares_one_declaration_query(service): + configs, saved, state, availability = service + configs.delete(saved['id']) + models = configs.save_many('agent', saved['url'], ['demo', 'other'], '', connection_type='self_hosted') + assert models[0]['providerId'] == models[1]['providerId'] + availability.refresh() + assert len(state['calls']) == 1 + assert availability.list()['models'][0]['status'] == 'available' + + +def test_distinct_providers_at_identical_urls_remain_isolated(service): + configs, saved, state, availability = service + configs.delete(saved['id']) + first = configs.save_many('agent', saved['url'], ['demo'], '', connection_type='self_hosted')[0] + second = configs.save_many('agent', saved['url'], ['demo'], '', connection_type='self_hosted')[0] + assert first['providerId'] != second['providerId'] + availability.refresh([first['id']]) + assert availability.model(private(configs, second))['status'] == 'unknown' + availability.refresh([second['id']]) + assert len(state['calls']) == 2 + + +def test_draft_does_not_publish_saved_observation_or_credentials(service): + configs, saved, state, availability = service + catalog = ModelCatalog(configs, availability=availability) + result = asyncio.run(catalog.check_availability('agent', saved['url'], 'demo', '', connection_type='self_hosted')) + assert result['status'] == 'available' and result['configId'] is None + assert availability.list()['models'][0]['status'] == 'unknown' + assert 'apiKey' not in str(result) + + +def test_connection_changes_invalidate_identity_and_late_response(service): + configs, saved, state, availability = service + original = private(configs, saved) + prepared = availability.prepare(original) + changed = configs.save('agent', 'http://other.test/v1', 'demo', '', saved['id'], connection_type='self_hosted') + with pytest.raises(ProjectError) as error: + availability.require(private(configs, changed), requests_for(original)[0], prepared) + assert error.value.code == 'capability_connection_changed' + assert availability.list()['models'][0]['status'] == 'unknown' + + +def test_music_uses_lyrics_profile_on_speech_and_not_generic_audio_generation(service): + configs, _, state, availability = service + saved = configs.save('audio', 'http://music.test/v1', 'demo', '', connection_type='self_hosted', protocol='vllm_omni_music') + state['document'] = document('speech', 'lyrics_to_music', '/v1/audio/speech', 'audio') + state['document']['models'][0]['operations']['speech']['profiles'].append({'id': 'text_to_speech', 'support': 'unsupported'}) + state['document']['models'][0]['operations']['audio_generation'] = {'support': 'unsupported'} + config = private(configs, saved) + availability.prepare(config) + assert availability.model(config)['status'] == 'available' + request = requests_for(config)[0] + assert (request.operation, request.profile, request.path) == ('speech', 'lyrics_to_music', '/v1/audio/speech') + ordinary = {**config, 'protocol': 'openai'} + with pytest.raises(ProjectError): + availability.require(ordinary, requests_for(ordinary)[0]) + + +def test_new_remote_profiles_are_accepted_without_creating_consumers(service): + configs, saved, state, availability = service + state['document']['models'][0]['operations']['speech'] = { + 'support': 'supported', 'profiles': [{'id': 'caption_to_audio', 'support': 'supported', + 'outputs': ['audio'], 'required_inputs': ['caption'], 'routes': [{'method': 'POST', 'path': '/v1/audio/speech'}]}]} + availability.refresh() + value = availability.model(private(configs, saved)) + assert value['status'] == 'available' + assert [item['profile'] for item in value['operations']] == ['text_response'] + + +def test_agent_connection_test_rejects_known_non_text_model_without_inference(service): + from test_model_catalog import Scripted + configs, saved, state, availability = service + state['document']['models'][0]['operations']['chat']['profiles'][0]['support'] = 'unsupported' + tester = Scripted([{'type': 'delta', 'text': 'should not run'}]) + catalog = ModelCatalog(configs, tester, availability) + with pytest.raises(ProjectError) as error: + asyncio.run(catalog.test_agent(saved['url'], 'demo', '', connection_type='self_hosted')) + assert error.value.code == 'capability_unavailable' and tester.calls == [] + + +def test_parallel_callers_share_one_query_and_deadline(tmp_path): + started, finish = threading.Event(), threading.Event() + calls = [] + def respond(request): + calls.append(request) + started.set() + finish.wait(2) + return httpx.Response(200, json=document()) + probe = CapabilityProbe(transport=httpx.MockTransport(respond), timeout=.15) + config = {'url': 'http://models.test/v1', 'connectionType': 'self_hosted'} + try: + with ThreadPoolExecutor(max_workers=6) as pool: + started_at = time.monotonic() + futures = [pool.submit(probe.query, config) for _ in range(6)] + assert started.wait(1) + results = [future.result(timeout=1) for future in futures] + assert time.monotonic() - started_at < 1 + assert all(value.reason == 'query_timeout' for value in results) + assert len(calls) == 1 + finally: + finish.set() + probe.close() + + +def test_cache_is_bounded_and_does_not_persist(tmp_path): + probe = CapabilityProbe(transport=httpx.MockTransport(lambda _: httpx.Response(404)), capacity=3) + try: + for index in range(7): + probe.query({'url': f'http://service-{index}.test/v1', 'connectionType': 'self_hosted'}) + assert len(probe._cache) == 3 + assert list(tmp_path.iterdir()) == [] + finally: + probe.close() diff --git a/tests/test_model_configs.py b/tests/test_model_configs.py index 46cd5bcf..6d324290 100644 --- a/tests/test_model_configs.py +++ b/tests/test_model_configs.py @@ -33,7 +33,9 @@ def test_four_categories_survive_restart_without_exposing_secrets(api): assert set(response.json()) == {"id", "kind", "url", "modelId", "inputRules", "connectionType", "protocol", "authMode", "proxyMode", "videoAdapter", "videoCapabilities", "inputModalities"} configs.append(response.json()) assert len({item["id"] for item in configs}) == 5 - assert client.get("/api/model-configs").json() == configs + observed = client.get("/api/model-configs").json() + assert all(item['availability']['status'] == 'unknown' for item in observed) + assert [{key: value for key, value in item.items() if key != 'availability'} for item in observed] == configs assert ModelConfigs(store.repository).list() == configs assert not store.root.exists() path = store.state_dir / "models.json" @@ -382,7 +384,8 @@ def test_delete_service_removes_group_atomically(api): other = client.post('/api/model-configs', json=payload(modelId='other')).json() response = client.delete('/api/model-services/' + saved[0]['id']) assert response.status_code == 204 - assert client.get('/api/model-configs').json() == [other] + observed = client.get('/api/model-configs').json() + assert [{key: value for key, value in item.items() if key != 'availability'} for item in observed] == [other] assert json.loads((store.state_dir / 'models.json').read_text())['services'] == {} From c4faa7b8779bc92b1e57cd4c17fcacae1f91ee9e Mon Sep 17 00:00:00 2001 From: bread-lxy <2022200058@ruc.edu.cn> Date: Mon, 9 Nov 2026 23:20:20 +0800 Subject: [PATCH 02/21] fix: validate capability inputs within shared query deadline --- apps/api/src/opendirector_api/agent.py | 4 +- apps/api/src/opendirector_api/localization.py | 2 +- docs/guides/model-availability.md | 8 +- .../application/model_availability.py | 61 +++++++--- .../opendirector/application/model_catalog.py | 2 +- .../opendirector/application/tool_tasks.py | 4 +- .../integrations/capability_probe.py | 27 ++++- tests/test_capability_acceptance.py | 25 ++++- tests/test_model_availability.py | 106 +++++++++++++++++- 9 files changed, 211 insertions(+), 28 deletions(-) diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index d3e9df7c..fd953546 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -311,7 +311,7 @@ async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Hea if config is not None: prepared = await asyncio.to_thread(generation.availability.prepare, config) current = await asyncio.to_thread(configs.resolve, str(value.modelId), 'agent') - generation.availability.require(current, requests_for(current)[0], prepared) + generation.availability.require(current, requests_for(current, inputs={'text'})[0], prepared) config = current available = await skills(projects.local.repository, directory) if spec.supportsSkills else [] if set(value.skills) - {item['name'] for item in available}: @@ -330,7 +330,7 @@ async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Hea def validate_connection(): if config is not None: current = configs.resolve(str(value.modelId), 'agent') - generation.availability.require(current, requests_for(current)[0], prepared) + generation.availability.require(current, requests_for(current, inputs={'text'})[0], prepared) async def produce() -> None: text, status, error, accepted = '', 'complete', None, False activities = [] diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py index 33cd91ee..4e3aa93d 100644 --- a/apps/api/src/opendirector_api/localization.py +++ b/apps/api/src/opendirector_api/localization.py @@ -21,7 +21,7 @@ 'kling_aspect': 'Kling keyframe generation follows the image framing. Choose Auto for frames, or a supported ratio for text and image references.', 'kling_output': 'Choose an output resolution and integer duration supported by the selected Kling model.', 'kling_cancel': 'Remote Kling cancellation is not connected. The task may continue running and incur charges.', - 'capability_unavailable': 'The service explicitly does not support this request. Check model capabilities and your selection.', + 'capability_unavailable': 'The service cannot accept this request. Check its supported capabilities, required inputs and your selection.', 'capability_paused': 'The execution endpoint is paused. Try again shortly.', 'capability_connection_changed': 'The model connection changed during the capability check. Submit again.', 'tool_concept_translation': 'Use a short English subject description, or add a chat model in Models to translate it automatically.', diff --git a/docs/guides/model-availability.md b/docs/guides/model-availability.md index 47b5e387..9e7a821b 100644 --- a/docs/guides/model-availability.md +++ b/docs/guides/model-availability.md @@ -10,7 +10,9 @@ 声明按精确 Model ID、输入模式、输出类型和实际 HTTP 路由匹配。一个模型支持聊天协议,不能证明它会返回助手需要的文字。音乐适配器通过 `/audio/speech` 发送歌词 `input`、风格 `instructions` 和音频帧数 `max_new_tokens`;其 `lyrics_to_music` 模式独立于普通 TTS 的 `text_to_speech`。`/audio/generate` 的通用音频生成声明不会启用现有音乐适配器。 -声明在内存中保留 30 秒;可信部署暂停状态最多保留 5 秒。内部使用本地单调时钟计算有效期,系统时钟调整和服务时钟偏差不改变到期规则;对外时间用于界面展示。查询和等待共享请求的总预算为 2 秒,失败可以短时间后重试。缓存有容量限制,进程重启后重新获得观察。目录读取不会访问模型服务,到期会改变目录版本。 +提交时还会核对该模式声明的必要输入。例如图生视频声明同时需要图片和音频,只提供图片会在接受任务前被拒绝;补充实际传输的音频后可以提交。仅作为上下文的素材不计入传输输入。目录没有具体任务输入,不会因此禁用整个模型;无法理解的必要输入名称保留 `unknown`。 + +声明在内存中保留 30 秒;可信部署暂停状态最多保留 5 秒。内部使用本地单调时钟计算有效期,系统时钟调整和服务时钟偏差不改变到期规则;对外时间用于界面展示。单次查询或一次多服务刷新共用 2 秒总等待预算,包含等待已有共享请求的时间。独立服务通过同一个最多 4 个线程的查询池并发读取,排队数量和缓存均有容量限制。失败可以短时间后重试,进程重启后重新获得观察。目录读取不会访问模型服务,到期会改变目录版本。 生成、工具和使用设置模型的助手共享接受规则。服务查询位于项目存储事务之外,接受新任务时再次核对连接身份。明确拒绝不会创建生成任务,也不会调用生成接口。工具的本地操作和可选翻译保留原有行为。已接受任务的快照、恢复、下载、取消和同一命令重放继续使用原有规则;最新能力状态不会重新拒绝它们。能力状态不会清除音色或替换用户选择。 @@ -24,7 +26,9 @@ The unsaved form check uses an isolated draft identity. It saves neither configu Text responses and image responses on Chat Completions are distinct. Music uses the `lyrics_to_music` profile on `/audio/speech`, with lyrics, style and an audio-frame budget; ordinary TTS uses `text_to_speech`. Generic `/audio/generate` declarations do not create a consumer for that API in OpenDirector. -Declarations expire after 30 seconds. A proven deployment-wide pause expires after at most 5 seconds. Internal expiration uses a local monotonic clock; service timestamps identify observations, and wall-clock timestamps are presentation data. Each query, including waiting for an existing query, has a two-second budget. The bounded process-local cache coalesces concurrent reads and permits short failure retries. Catalog reads never contact providers; expiration changes their revision. +Admission also checks a matched profile's required inputs against the actual adapter request. An image-to-video profile requiring audio rejects a submission without transmitted audio, while the catalog remains selectable. Context-only references are not transmitted inputs. Unrecognized required-input names remain unknown. + +Declarations expire after 30 seconds. A proven deployment-wide pause expires after at most 5 seconds. Internal expiration uses a local monotonic clock; service timestamps identify observations, and wall-clock timestamps are presentation data. Each query or multi-service refresh shares one two-second wait budget, including existing shared requests. Independent services use the same four-worker pool with bounded pending requests and cache. Concurrent reads are coalesced and failures permit short retries. Catalog reads never contact providers; expiration changes their revision. Generation, tools and settings-backed assistant turns share acceptance rules. Network reads occur outside project transactions; connection identity is checked again when accepting work. Explicit rejection creates no job and submits no inference. Local tools and optional translation retain their behavior. Accepted snapshots, recovery, downloads, cancellation and idempotent replay do not undergo new capability admission. Observations never clear voice selections or silently choose another model. diff --git a/packages/core/src/opendirector/application/model_availability.py b/packages/core/src/opendirector/application/model_availability.py index 6f202cd6..db0de913 100644 --- a/packages/core/src/opendirector/application/model_availability.py +++ b/packages/core/src/opendirector/application/model_availability.py @@ -4,7 +4,7 @@ Remote observations can reject a precisely matched request, but cannot add adapters, rewrite configuration, or change an already accepted execution. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace import hashlib import json from urllib.parse import urlsplit @@ -28,13 +28,16 @@ class RequestOperation: path: str output: str method: str = 'POST' + # None is a directory projection; an actual request fixes transmitted inputs. + inputs: frozenset[str] | None = None -def requests_for(config): +def requests_for(config, *, inputs=None): """Only the protocols this application actually sends, including music.""" kind, protocol = config['kind'], config.get('protocol', 'openai') def request(operation, profile, suffix, output): - return RequestOperation(operation, profile, urlsplit(endpoint(config['url'], suffix)).path, output) + return RequestOperation(operation, profile, urlsplit(endpoint(config['url'], suffix)).path, output, + inputs=frozenset(inputs) if inputs is not None else None) if kind == 'agent': return [request('chat', 'text_response', '/chat/completions', 'text')] if kind == 'image' and protocol == 'openai': @@ -64,15 +67,38 @@ def generation_request(config, spec, references): profile = 'lyrics_to_music' if spec.parameters.creationType == 'music' else 'text_to_speech' else: profile = 'text_response' - return next((item for item in candidates if item.profile == profile), None) - - -def tool_request(config, capability): + request = next((item for item in candidates if item.profile == profile), None) + # The resolved reference transmission is the same one frozen for execution. + # Music sends the prompt as instructions and lyrics (or arrangement tags + # for instrumental music) as the text field on /audio/speech. + inputs = {'text'} + if spec.kind in {'text', 'video'}: + inputs.update(kinds) + elif spec.kind == 'image': + inputs.update(kinds & {'image'}) + if profile == 'lyrics_to_music': + inputs.add('instructions') + return replace(request, inputs=frozenset(inputs)) if request else None + + +def tool_request(config, capability, *, actual=False): profile = {'text.chat': 'text_response', 'image.generate': 'text_to_image', 'image.edit': 'image_to_image', 'image.cutout': 'image_to_image', 'video.generate': 'text_to_video', 'video.first_frame': 'image_to_video', 'video.edit': 'video_to_video', 'audio.speech': 'text_to_speech', 'audio.music': 'lyrics_to_music'}.get(capability) - return next((item for item in requests_for(config) if item.profile == profile), None) + request = next((item for item in requests_for(config) if item.profile == profile), None) + if request is None or not actual: + return request + # These are the arguments of the existing tool adapters, not all source + # assets: e.g. a video-to-video tool does not transmit an audio attachment. + inputs = {'text'} + if profile in {'image_to_image', 'image_to_video'}: + inputs.add('image') + elif profile == 'video_to_video': + inputs.add('video') + elif profile == 'lyrics_to_music': + inputs.add('instructions') + return replace(request, inputs=frozenset(inputs)) def _revision(value): @@ -129,7 +155,13 @@ def decision(self, config, request): elif operation.support == profile.support == 'supported' and profile.outputs and request.output not in profile.outputs: result.update(status='unavailable', reasonCode='output_unsupported') elif operation.support == profile.support == 'supported' and request.output in profile.outputs: - result.update(status='available', reasonCode='supported') + required = set(profile.required_inputs) + if required - {'text', 'image', 'video', 'audio', 'instructions'}: + result['reasonCode'] = 'input_contract_unknown' + elif request.inputs is not None and required - request.inputs: + result.update(status='unavailable', reasonCode='required_inputs_missing') + else: + result.update(status='available', reasonCode='supported') else: result['reasonCode'] = 'declaration_incomplete' # Only a route which serves this exact request can establish pause. @@ -151,8 +183,10 @@ def require(self, config, request, prepared=None): decision = self.decision(config, request) if decision['status'] == 'unavailable': code = 'capability_paused' if decision['reasonCode'] == 'execution_paused' else 'capability_unavailable' - raise AvailabilityError(code, '模型执行入口已暂停,请稍后重试。' if code == 'capability_paused' - else '服务明确不支持当前请求,请查看模型能力并修改选择。') + message = ('模型执行入口已暂停,请稍后重试。' if code == 'capability_paused' + else '当前请求缺少服务明确要求的输入,请补充素材后重新提交。' if decision['reasonCode'] == 'required_inputs_missing' + else '服务明确不支持当前请求,请查看模型能力并修改选择。') + raise AvailabilityError(code, message) def model(self, config): operations = [self.decision(config, request) for request in requests_for(config)] @@ -181,7 +215,7 @@ def refresh(self, config_ids=None, *, manual=False): values = self.configs.list() if selected is not None and selected - {item['id'] for item in values}: raise ProjectError('模型配置已被删除,请重新加载。', 404) - seen = set() + seen, configs = set(), [] for item in values: if selected is not None and item['id'] not in selected: continue @@ -190,6 +224,7 @@ def refresh(self, config_ids=None, *, manual=False): continue key = identity(config, service=True) if key not in seen: - self.prepare(config, manual=manual) + configs.append(config) seen.add(key) + self.probe.query_many(configs, manual=manual) return self.list() diff --git a/packages/core/src/opendirector/application/model_catalog.py b/packages/core/src/opendirector/application/model_catalog.py index c8ec2e7f..99eb4ac7 100644 --- a/packages/core/src/opendirector/application/model_catalog.py +++ b/packages/core/src/opendirector/application/model_catalog.py @@ -92,7 +92,7 @@ async def test_agent(self, url, model_id, api_key, config_id=None, *, connection config['capabilityAutoCheck'] = capability_auto_check config = await self._with_saved_key(config, config_id, 'agent', require_key=True, threaded=True) await asyncio.to_thread(self.availability.prepare, config) - self.availability.require(config, requests_for(config)[0]) + self.availability.require(config, requests_for(config, inputs={'text'})[0]) payload = chat_payload(config, [{'role': 'user', 'content': 'Reply with OK only.'}], 'auto') payload['max_tokens'] = 1024 started = time.monotonic() diff --git a/packages/core/src/opendirector/application/tool_tasks.py b/packages/core/src/opendirector/application/tool_tasks.py index dd14a965..6d35ac62 100644 --- a/packages/core/src/opendirector/application/tool_tasks.py +++ b/packages/core/src/opendirector/application/tool_tasks.py @@ -131,7 +131,7 @@ async def chat(request): chat_config, chat_adapter = self._model('text.chat', None) prepared = await asyncio.to_thread(self.availability.prepare, chat_config) current, _ = self._model('text.chat', chat_config['id']) - self.availability.require(current, tool_request(current, 'text.chat'), prepared) + self.availability.require(current, tool_request(current, 'text.chat', actual=True), prepared) return await chat_adapter.chat(chat_config, request) context = ToolContext(sources=[], mask=None, boxes=[], parameters={}, image_model=None, video_model=None, @@ -297,7 +297,7 @@ def _prepare_locked(self, project_id, value, session, *, inspect=False, prepared if inspect: return models for capability, config in list(models.items()): - request = tool_request(config, capability) + request = tool_request(config, capability, actual=True) if request is None: continue if capability not in (prepared or {}): diff --git a/packages/core/src/opendirector/integrations/capability_probe.py b/packages/core/src/opendirector/integrations/capability_probe.py index 9a3681a2..29f7694a 100644 --- a/packages/core/src/opendirector/integrations/capability_probe.py +++ b/packages/core/src/opendirector/integrations/capability_probe.py @@ -71,10 +71,23 @@ def peek(self, config): return observation if observation and observation.valid_until > self.clock() else None def query(self, config, *, manual=False): + return self.query_many([config], manual=manual)[0] + + def query_many(self, configs, *, manual=False): + """Start independent services together and share one caller deadline. + + The same bounded worker pool and pending map serve both one-model + admission and directory refreshes; queued requests inherit the deadline. + """ + deadline = time.monotonic() + self.timeout + pending = [(config, self._start(config, manual, deadline)) for config in configs] + return [self._wait(config, value, deadline) if isinstance(value, Future) else value + for config, value in pending] + + def _start(self, config, manual, deadline): if not manual and not automatic(config): return self.peek(config) key = identity(config, service=True) - deadline = time.monotonic() + self.timeout with self._lock: observation = self._cache.get(key) # Paused observations need another read after their shorter lifetime. @@ -85,8 +98,12 @@ def query(self, config, *, manual=False): return observation future = self._pending.get(key) if future is None: - if len(self._pending) >= self.capacity: - return self._observation(None, 'query_busy') + if len(self._pending) >= self.capacity or time.monotonic() >= deadline: + value = self._observation(None, 'query_busy' if len(self._pending) >= self.capacity else 'query_timeout') + self._cache[key] = value + while len(self._cache) > self.capacity: + self._cache.popitem(last=False) + return value future = self._workers.submit(self._read, dict(config), deadline) self._pending[key] = future # Register outside the lock: already-completed futures invoke callbacks inline. @@ -95,6 +112,10 @@ def query(self, config, *, manual=False): register = False if register: future.add_done_callback(lambda done: self._finish(key, done)) + return future + + def _wait(self, config, future, deadline): + key = identity(config, service=True) try: value = future.result(timeout=max(0, deadline - time.monotonic())) # Future.result may wake before its completion callback publishes. diff --git a/tests/test_capability_acceptance.py b/tests/test_capability_acceptance.py index af8e42f6..2e333ff5 100644 --- a/tests/test_capability_acceptance.py +++ b/tests/test_capability_acceptance.py @@ -63,6 +63,22 @@ async def run(): probe.close() +def test_missing_required_input_is_rejected_before_job_acceptance(project): + store, pid, session = project + provider = Provider() + runner, config = runtime(project, provider) + declaration = document(model='test-agent') + declaration['models'][0]['operations']['chat']['profiles'][0]['required_inputs'] = ['text', 'audio'] + probe = CapabilityProbe(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=declaration))) + runner.availability = ModelAvailability(runner.configs, probe) + try: + receipt = runner.prepare(command_for(project, config), session=session) + assert receipt.status == 'rejected' and receipt.issues[0].code == 'capability_unavailable' + assert not store.snapshot(pid).jobs and provider.calls == [] + finally: + probe.close() + + def test_generation_identity_change_during_query_is_rejected_under_commit(project): store, pid, session = project runner, config = runtime(project, Provider()) @@ -192,14 +208,17 @@ def test_saved_http_api_and_draft_checks_share_parser_but_not_observations(tmp_p assert len(calls) == 2 -@pytest.mark.parametrize('scenario', ['unsupported', 'changed_before_acceptance', 'native']) +@pytest.mark.parametrize('scenario', ['unsupported', 'missing_input', 'changed_before_acceptance', 'native']) def test_assistant_admission_and_native_runtime_boundary(tmp_path, monkeypatch, scenario): configs = ModelConfigs(tmp_path) saved = configs.save('agent', 'http://assistant.test/v1', 'demo', '', connection_type='self_hosted') calls, turns = [], [] def respond(request): calls.append(request) - return httpx.Response(200, json=document(support='unsupported' if scenario == 'unsupported' else 'supported')) + value = document(support='unsupported' if scenario == 'unsupported' else 'supported') + if scenario == 'missing_input': + value['models'][0]['operations']['chat']['profiles'][0]['required_inputs'] = ['text', 'image'] + return httpx.Response(200, json=value) probe = CapabilityProbe(transport=httpx.MockTransport(respond)) async def stream(self, context): turns.append(context) @@ -220,7 +239,7 @@ async def skills(*args): client.post(base + '/open').raise_for_status() response = client.post(base + '/assistant/turns', json={'id': str(uuid4()), 'text': 'Hello', 'modelId': saved['id'], 'runtime': 'codex' if scenario == 'native' else 'default'}) - if scenario == 'unsupported': + if scenario in {'unsupported', 'missing_input'}: assert response.status_code == 409 and turns == [] assert client.get(base + '/assistant').json()['messages'] == [] elif scenario == 'changed_before_acceptance': diff --git a/tests/test_model_availability.py b/tests/test_model_availability.py index 218b393e..961f12c2 100644 --- a/tests/test_model_availability.py +++ b/tests/test_model_availability.py @@ -1,6 +1,7 @@ """Optional discovery uses real request contracts and never changes saved intent.""" from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone +from types import SimpleNamespace import asyncio import json import threading @@ -9,7 +10,7 @@ import httpx import pytest -from opendirector.application.model_availability import ModelAvailability, requests_for +from opendirector.application.model_availability import ModelAvailability, generation_request, requests_for, tool_request from opendirector.application.model_catalog import ModelCatalog from opendirector.domain.projects import ProjectError from opendirector.infrastructure.model_configs import ModelConfigs @@ -346,3 +347,106 @@ def test_cache_is_bounded_and_does_not_persist(tmp_path): assert list(tmp_path.iterdir()) == [] finally: probe.close() + + +@pytest.mark.parametrize('references,expected', [ + ([('image', 'attachment')], 'unavailable'), + ([('image', 'attachment'), ('audio', 'attachment')], 'available'), + ([('image', 'attachment'), ('audio', 'context_only')], 'unavailable'), +]) +def test_video_required_audio_is_checked_on_actual_request_not_catalog(service, references, expected): + configs, _, state, availability = service + config = {**configs.resolve(configs.list()[0]['id'], 'agent'), 'kind': 'video', 'protocol': 'vllm_omni'} + state['document'] = document('video_generation', 'image_to_video', '/v1/videos', 'video') + profile = state['document']['models'][0]['operations']['video_generation']['profiles'][0] + profile.update(required_inputs=['text', 'image', 'audio'], outputs=['video', 'audio']) + availability.prepare(config) + catalog_request = next(item for item in requests_for(config) if item.profile == 'image_to_video') + assert availability.decision(config, catalog_request)['status'] == 'available' + refs = [SimpleNamespace(asset=SimpleNamespace(kind=kind), transmission=transmission) for kind, transmission in references] + actual = generation_request(config, SimpleNamespace(kind='video'), refs) + assert actual.profile == 'image_to_video' + value = availability.decision(config, actual) + assert value['status'] == expected + if expected == 'unavailable': + assert value['reasonCode'] == 'required_inputs_missing' + with pytest.raises(ProjectError): + availability.require(config, actual) + else: + availability.require(config, actual) + + +def test_unrecognized_required_input_stays_unknown(service): + configs, saved, state, availability = service + state['document']['models'][0]['operations']['chat']['profiles'][0]['required_inputs'] = ['text', 'future_input'] + config = private(configs, saved) + availability.prepare(config) + request = requests_for(config, inputs={'text'})[0] + value = availability.decision(config, request) + assert value['status'] == 'unknown' and value['reasonCode'] == 'input_contract_unknown' + availability.require(config, request) + + +def test_generation_profiles_and_tool_inputs_follow_the_actual_adapter(service): + configs, saved, _, _ = service + base = private(configs, saved) + def ref(kind): + return SimpleNamespace(asset=SimpleNamespace(kind=kind), transmission='attachment') + for kind, protocol, refs, profile, inputs in [ + ('image', 'openai', [], 'text_to_image', {'text'}), + ('image', 'openai', [ref('image')], 'image_to_image', {'text', 'image'}), + ('video', 'vllm_omni', [ref('video')], 'video_to_video', {'text', 'video'}), + ('audio', 'vllm_omni_music', [], 'lyrics_to_music', {'text', 'instructions'}), + ('audio', 'vllm_omni_music', [ref('audio')], 'lyrics_to_music', {'text', 'instructions'}), + ]: + spec = SimpleNamespace(kind=kind, parameters=SimpleNamespace(creationType='music')) + request = generation_request({**base, 'kind': kind, 'protocol': protocol}, spec, refs) + assert request.profile == profile and request.inputs == inputs + video = {**base, 'kind': 'video', 'protocol': 'vllm_omni'} + assert tool_request(video, 'video.first_frame').inputs is None + assert tool_request(video, 'video.first_frame', actual=True).inputs == {'text', 'image'} + + +def test_refresh_two_slow_services_uses_one_total_wait_budget(tmp_path): + configs = ModelConfigs(tmp_path) + for name in ['one', 'two']: + configs.save('agent', f'http://{name}.test/v1', 'demo', '', connection_type='self_hosted') + finish, both_started = threading.Event(), threading.Event() + calls, lock = [], threading.Lock() + def respond(request): + with lock: + calls.append(request) + if len(calls) == 2: + both_started.set() + finish.wait(2) + return httpx.Response(200, json=document()) + probe = CapabilityProbe(transport=httpx.MockTransport(respond), timeout=.4) + try: + started = time.monotonic() + result = ModelAvailability(configs, probe).refresh() + assert time.monotonic() - started < .65 + assert both_started.is_set() and len(calls) == 2 + assert all(value['reasonCode'] == 'query_timeout' for value in result['models']) + finally: + finish.set() + probe.close() + + +def test_batch_refresh_keeps_worker_and_queue_limits(tmp_path): + finish = threading.Event() + calls, lock = [], threading.Lock() + def respond(request): + with lock: + calls.append(request) + finish.wait(2) + return httpx.Response(404) + probe = CapabilityProbe(transport=httpx.MockTransport(respond), timeout=.2, capacity=5) + configs = [{'id': str(i), 'url': f'http://service-{i}.test/v1', 'connectionType': 'self_hosted'} for i in range(9)] + try: + result = probe.query_many(configs) + assert len(calls) == 4 and len(probe._pending) == 5 + assert [value.reason for value in result] == ['query_timeout'] * 5 + ['query_busy'] * 4 + assert len(probe._cache) <= 5 + finally: + finish.set() + probe.close() From ac86ac56fec8b4de7537be043f4e8037bd29ad25 Mon Sep 17 00:00:00 2001 From: bread-lxy <2022200058@ruc.edu.cn> Date: Mon, 9 Nov 2026 23:44:15 +0800 Subject: [PATCH 03/21] feat: present model capabilities across configuration and creation --- docs/guides/model-availability.md | 9 + scripts/run-browser-suite.mjs | 1 + src/backend/availability.ts | 225 +++++++++ src/backend/models.ts | 187 +++++++- src/backend/rules.ts | 11 + src/canvas/AssistantPanel.tsx | 2 +- src/canvas/DraftAvailabilityCheck.tsx | 82 ++++ src/canvas/ModelAvailabilityCard.tsx | 80 ++++ src/canvas/ModelServiceForm.tsx | 11 + src/canvas/ModelSettings.tsx | 15 +- src/canvas/NodeComposer.tsx | 9 +- src/canvas/TextNodeComposer.tsx | 4 + src/canvas/model-settings.css | 9 + src/canvas/textGeneration.ts | 3 + src/canvas/useAssistantChat.ts | 8 + src/canvas/workspace/WorkspaceView.tsx | 2 + src/canvas/workspace/useWorkspaceRuntime.ts | 2 +- src/contracts/creation.schema.json | 492 ++++++++++++++++++++ src/contracts/generated.ts | 70 ++- src/i18n/messages/en-US/availability.ts | 76 +++ src/i18n/messages/en-US/index.ts | 2 + src/i18n/messages/zh-CN/availability.ts | 59 +++ src/i18n/messages/zh-CN/index.ts | 2 + tests/browser/capability_discovery.mjs | 469 +++++++++++++++++++ tests/browser/manifest.json | 9 + tests/modelAvailability.test.ts | 300 ++++++++++++ 26 files changed, 2117 insertions(+), 22 deletions(-) create mode 100644 src/backend/availability.ts create mode 100644 src/canvas/DraftAvailabilityCheck.tsx create mode 100644 src/canvas/ModelAvailabilityCard.tsx create mode 100644 src/i18n/messages/en-US/availability.ts create mode 100644 src/i18n/messages/zh-CN/availability.ts create mode 100644 tests/browser/capability_discovery.mjs create mode 100644 tests/modelAvailability.test.ts diff --git a/docs/guides/model-availability.md b/docs/guides/model-availability.md index 9e7a821b..c19170af 100644 --- a/docs/guides/model-availability.md +++ b/docs/guides/model-availability.md @@ -6,6 +6,8 @@ “检查服务能力”可以检查未保存的表单。草稿检查不会保存配置,也不会更新已保存模型的状态。显式刷新已保存模型可以检查关闭自动检查的服务。 +在“设置 → 模型接入”选择模型类别,展开服务卡片的“服务能力”,即可查看每种操作的状态和检查时间。编辑服务时,可在高级连接设置中调整“自动检查服务能力”。输入过程中不会查询;保存后在后台检查。生成按钮和助手会说明明确不可用的原因,并保留当前输入和选择。暂停观察到期后会解除该观察产生的禁用,提交时仍执行后端检查。 + 状态有三种:`available` 表示该请求具有匹配声明;`unavailable` 表示服务明确不支持或暂停当前执行入口;`unknown` 表示没有足够信息。旧服务没有接口、超时、断网、鉴权失败和无法理解的版本都保留手动配置与现有请求路径。错误原因分别显示,响应正文与凭据不进入状态数据。 声明按精确 Model ID、输入模式、输出类型和实际 HTTP 路由匹配。一个模型支持聊天协议,不能证明它会返回助手需要的文字。音乐适配器通过 `/audio/speech` 发送歌词 `input`、风格 `instructions` 和音频帧数 `max_new_tokens`;其 `lyrics_to_music` 模式独立于普通 TTS 的 `text_to_speech`。`/audio/generate` 的通用音频生成声明不会启用现有音乐适配器。 @@ -22,6 +24,8 @@ Model connections can read an optional service capability declaration. Self-host The unsaved form check uses an isolated draft identity. It saves neither configuration nor observations for saved connections. Explicit refresh can check selected saved connections even when automatic checks are disabled. +In Settings → Model connections, select a category and expand Service capabilities on a connection card to see operation states and check times. Advanced connection settings contain Automatically check service capabilities. Typing does not query the service; saving starts a background check. Generation and assistant controls explain explicit unavailability while retaining inputs and selections. Pause observations expire locally; submission still runs the backend check. + `available`, `unavailable`, and `unknown` describe evidence for a specific request. Matching requires the exact model, operation profile, consumed output and actual HTTP route. Unknown or failed discovery preserves manual configuration and the existing request path. Authentication, connection, timeout, unsupported-version and invalid-response reasons are distinct; credentials and response bodies are never included in observations. Text responses and image responses on Chat Completions are distinct. Music uses the `lyrics_to_music` profile on `/audio/speech`, with lyrics, style and an audio-frame budget; ordinary TTS uses `text_to_speech`. Generic `/audio/generate` declarations do not create a consumer for that API in OpenDirector. @@ -59,6 +63,11 @@ The remote protocol is `GET /omni/capabilities`, with `object="omni.ca ```sh .venv/bin/python -m pytest tests/test_model_availability.py tests/test_capability_acceptance.py -q .venv/bin/python -m pytest tests/test_model_configs.py tests/test_model_catalog.py tests/test_creation_generation_service.py tests/test_tool_tasks.py tests/test_assistant_conversations.py tests/test_cli.py -q +node --test tests/modelAvailability.test.ts +# With the isolated API and Vite running as described in development validation: +node tests/browser/capability_discovery.mjs ``` These tests use isolated configuration/project directories, model doubles and a local HTTP server. They cover identity changes, draft isolation, operation matching, TTL expiration, coalescing, errors, zero-job rejection, local tools and replay. They do not validate real model outputs. See [development validation](../development/validation.md) for browser, contracts and import-layer checks. + +The bilingual browser workflow checks editing without queries, stale draft responses, saving an unsupported configuration, zero-job rejection, UI submission, decoding the saved fixture image, pause expiration, project reopening and unchanged historical input. The frontend clock tests include system-clock changes and repeated observations. 浏览器用例实际保存并解码测试图片,模型输出来自测试替身;前端时钟测试包含系统时间变化和重复响应,不能作为真实模型验证结果。 diff --git a/scripts/run-browser-suite.mjs b/scripts/run-browser-suite.mjs index 70e56877..e7973f8d 100644 --- a/scripts/run-browser-suite.mjs +++ b/scripts/run-browser-suite.mjs @@ -22,6 +22,7 @@ const workflows = [ }, { name: "minimax h3", script: "tests/browser/minimax_h3.mjs" }, { name: "provider settings", script: "tests/browser/provider_settings.mjs" }, + { name: "capability discovery", script: "tests/browser/capability_discovery.mjs" }, { name: "created node focus", script: "tests/browser/created_node_focus.mjs" }, { name: "cross canvas paste", script: "tests/browser/cross_canvas_paste.mjs" }, { name: "reference reconnect", script: "tests/browser/reference_reconnect.mjs" }, diff --git a/src/backend/availability.ts b/src/backend/availability.ts new file mode 100644 index 00000000..916976d1 --- /dev/null +++ b/src/backend/availability.ts @@ -0,0 +1,225 @@ +import type { + ModelAvailability, + OperationAvailability, +} from "../contracts/generated.ts"; +import type { ModelConfig } from "./rules.ts"; +import { hasMessage, tNow } from "../i18n/messages.ts"; + +export type { ModelAvailability, OperationAvailability }; + +/** A transport failure clears decisions while retaining the saved connection identity for retry. */ +export function failedAvailability( + value: ModelAvailability, +): ModelAvailability { + return { + ...value, + status: "unknown", + reasonCode: "query_failed", + checkedAt: null, + expiresAt: null, + operations: value.operations.map((item) => ({ + ...item, + status: "unknown", + reasonCode: "query_failed", + checkedAt: null, + expiresAt: null, + })), + }; +} + +export interface AvailabilityClock { + wallNow(): number; + monotonicNow(): number; +} + +const clock: AvailabilityClock = { + wallNow: () => Date.now(), + monotonicNow: () => performance.now(), +}; +const deadlines = new WeakMap< + OperationAvailability, + { clock: AvailabilityClock; at: number } +>(); +const observations = new WeakMap>(); + +function deadline( + value: OperationAvailability, + timing: AvailabilityClock, +): number | null { + if (value.expiresAt === null) return null; + const existing = deadlines.get(value); + if (existing?.clock === timing) return existing.at; + const maximum = + value.checkedAt === null + ? 30_000 + : Math.max(0, value.expiresAt - value.checkedAt); + const at = + timing.monotonicNow() + + Math.max(0, Math.min(maximum, value.expiresAt - timing.wallNow())); + deadlines.set(value, { clock: timing, at }); + return at; +} + +/** Call when a response arrives. Re-reading the same observation cannot renew its lifetime. */ +export function receiveAvailability( + value: ModelAvailability, + timing: AvailabilityClock = clock, +): ModelAvailability { + let known = observations.get(timing); + if (!known) { + known = new Map(); + observations.set(timing, known); + } + for (const operation of value.operations) { + if (operation.expiresAt === null) continue; + const key = JSON.stringify([ + value.identity, + operation.operation, + operation.profile, + operation.method, + operation.path, + operation.output, + operation.status, + operation.reasonCode, + operation.checkedAt, + operation.expiresAt, + ]); + const previous = known.get(key); + const at = previous ?? deadline(operation, timing)!; + deadlines.set(operation, { clock: timing, at }); + known.delete(key); + known.set(key, at); + if (known.size > 1024) known.delete(known.keys().next().value!); + } + return value; +} + +/** Remaining elapsed time, independent of later wall-clock changes. */ +export function availabilityDelay( + value: ModelAvailability, + timing: AvailabilityClock = clock, +): number | null { + receiveAvailability(value, timing); + const now = timing.monotonicNow(); + const remaining = value.operations + .map((item) => deadline(item, timing)) + .filter((at): at is number => at !== null && at > now) + .map((at) => at - now); + return remaining.length ? Math.min(...remaining) : null; +} + +/** The server owns decisions. The browser only expires and presents observations. */ +export function currentOperation( + value: OperationAvailability, + timing: AvailabilityClock = clock, +): OperationAvailability { + const at = deadline(value, timing); + return at !== null && at <= timing.monotonicNow() + ? { + ...value, + status: "unknown", + reasonCode: "observation_expired", + expiresAt: null, + } + : value; +} + +export function currentAvailability( + value: ModelAvailability, + timing: AvailabilityClock = clock, +): ModelAvailability { + receiveAvailability(value, timing); + const operations = value.operations.map((item) => + currentOperation(item, timing), + ); + if (operations.every((item, index) => item === value.operations[index])) + return value; + const states = new Set(operations.map((item) => item.status)); + return { + ...value, + operations, + status: states.size === 1 ? operations[0].status : "unknown", + reasonCode: "observation_expired", + }; +} + +export function availabilityReason(code: string): string { + const key = `availability.reason.${code}`; + return hasMessage(key) + ? tNow(key) + : tNow("availability.reason.declaration_incomplete"); +} + +export function availabilityProfile(profile: string): string { + const key = `availability.profile.${profile}`; + return hasMessage(key) ? tNow(key) : tNow("availability.profile.other"); +} + +export function availabilityBlocker( + model: ModelConfig | undefined, + profile: string, + timing: AvailabilityClock = clock, +): string | null { + if (model?.availability) receiveAvailability(model.availability, timing); + const item = model?.availability?.operations.find( + (value) => value.profile === profile, + ); + if (!item) return null; + const current = currentOperation(item, timing); + return current.status === "unavailable" + ? availabilityReason(current.reasonCode) + : null; +} + +/** Local identity excludes credentials; saves additionally invalidate pending reads. */ +export function availabilityModelKey(model: ModelConfig): string { + return JSON.stringify([ + model.id, + model.providerId, + model.kind, + model.url, + model.modelId, + model.protocol, + model.connectionType, + model.authMode, + model.proxyMode, + model.videoAdapter, + model.inputModalities, + model.videoCapabilities, + model.capabilityAutoCheck, + ]); +} + +/** Saved observations require the opaque identity captured before the request. */ +export function availabilityMatchesModel( + model: ModelConfig, + observation: ModelAvailability, + expectedIdentity: string | undefined = model.availability?.identity, +): boolean { + return ( + expectedIdentity !== undefined && + observation.configId === model.id && + observation.modelId === model.modelId && + observation.identity === expectedIdentity + ); +} + +export function nextAvailabilityDelay( + models: readonly ModelConfig[], + timing: AvailabilityClock = clock, +): number | null { + const remaining = models + .map((model) => + model.availability ? availabilityDelay(model.availability, timing) : null, + ) + .filter((value): value is number => value !== null); + return remaining.length ? Math.min(...remaining) : null; +} + +export function nextAvailabilityExpiry( + models: readonly ModelConfig[], + timing: AvailabilityClock = clock, +): number | null { + const delay = nextAvailabilityDelay(models, timing); + return delay === null ? null : timing.wallNow() + delay; +} diff --git a/src/backend/models.ts b/src/backend/models.ts index 7f506230..2de554c0 100644 --- a/src/backend/models.ts +++ b/src/backend/models.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { errorMessage, json, request } from "./api.ts"; import type { ModelConfig, @@ -6,6 +6,16 @@ import type { VideoAdapter, VideoCapabilities, } from "./rules.ts"; +import { + availabilityModelKey, + availabilityMatchesModel, + currentAvailability, + failedAvailability, + nextAvailabilityDelay, + receiveAvailability, + type ModelAvailability, +} from "./availability.ts"; +import type { AvailabilityCatalog } from "../contracts/generated.ts"; import type { MessageKey } from "../i18n/types.ts"; export const MODEL_KINDS: readonly { @@ -35,9 +45,13 @@ export const MODEL_KINDS: readonly { }, ]; -export type ModelOptions = Pick; +export type ModelOptions = Pick< + ModelConfigInput, + "inputModalities" | "videoAdapter" | "videoCapabilities" +>; export interface ModelConfigInput { + capabilityAutoCheck?: boolean | null; kind: ModelKind; url: string; modelId: string; @@ -80,43 +94,177 @@ export interface GeneralSettings { removeWatermark: boolean; } -/** Model availability is independent of the filesystem project's editable draft. */ +export function checkDraftAvailability( + value: ModelConfigInput, + configId?: string, +) { + return request( + "/model-configs/availability/check", + json("POST", { ...value, configId }), + ).then((value) => receiveAvailability(value)); +} + +/** Saved configurations remain distinct from disposable capability observations. */ export function useBackendModels() { const [models, setModels] = useState([]); + const modelsRef = useRef([]); + const epoch = useRef(0); + const querySequence = useRef(0); + const latestQuery = useRef(new Map()); + const alive = useRef(true); + const [availabilityRevision, setAvailabilityRevision] = useState(0); + const [availabilityError, setAvailabilityError] = useState(""); + const [checkingIds, setCheckingIds] = useState>( + new Set(), + ); const [settings, setSettings] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [connected, setConnected] = useState(false); + const storeModels = useCallback((next: ModelConfig[]) => { + if (!alive.current) return; + modelsRef.current = next; + setModels(next); + setAvailabilityRevision((value) => value + 1); + }, []); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + epoch.current += 1; + }; + }, []); + const refreshAvailability = useCallback( + async (ids: readonly string[], manual = false) => { + if (!ids.length) return; + const serial = ++querySequence.current; + const initialEpoch = epoch.current; + const initial = new Map( + modelsRef.current + .filter((model) => ids.includes(model.id)) + .map((model) => [ + model.id, + { + key: availabilityModelKey(model), + identity: model.availability?.identity, + }, + ]), + ); + if (!initial.size) return; + initial.forEach((_, id) => latestQuery.current.set(id, serial)); + setCheckingIds((current) => new Set([...current, ...initial.keys()])); + try { + const result = await request( + "/model-configs/availability", + json("POST", { configIds: [...initial.keys()], manual }), + ); + result.models.forEach((value) => receiveAvailability(value)); + if (!alive.current || epoch.current !== initialEpoch) return; + const received = new Map( + result.models.map((item) => [item.configId, item]), + ); + storeModels( + modelsRef.current.map((model) => { + const observation = received.get(model.id); + const expected = initial.get(model.id); + return expected?.key === availabilityModelKey(model) && + latestQuery.current.get(model.id) === serial && + observation && + availabilityMatchesModel(model, observation, expected.identity) + ? { ...model, availability: observation } + : model; + }), + ); + setAvailabilityError(""); + } catch (failure) { + if (!alive.current || epoch.current !== initialEpoch) return; + // An unsuccessful check cannot keep an earlier denial active. + storeModels( + modelsRef.current.map((model) => + initial.get(model.id)?.key === availabilityModelKey(model) && + latestQuery.current.get(model.id) === serial + ? { + ...model, + availability: model.availability + ? failedAvailability(model.availability) + : undefined, + } + : model, + ), + ); + setAvailabilityError(errorMessage(failure)); + } finally { + if (alive.current) + setCheckingIds( + (current) => + new Set( + [...current].filter( + (id) => + !initial.has(id) || latestQuery.current.get(id) !== serial, + ), + ), + ); + } + }, + [storeModels], + ); const refresh = useCallback(async () => { + const initialEpoch = ++epoch.current; try { const [list, general] = await Promise.all([ - request("/model-configs"), + request("/model-configs").then((list) => { + list.forEach((model) => { + if (model.availability) receiveAvailability(model.availability); + }); + return list; + }), request("/settings"), ]); - setModels(list); + if (!alive.current || initialEpoch !== epoch.current) return; + storeModels(list); setSettings(general); setConnected(true); setError(""); } catch (failure) { + if (!alive.current || initialEpoch !== epoch.current) return; setConnected(false); setError(errorMessage(failure)); } finally { - setLoading(false); + if (alive.current) setLoading(false); } - }, []); + }, [storeModels]); useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + const delay = nextAvailabilityDelay(models); + if (delay === null) return; + const timer = setTimeout( + () => { + storeModels( + modelsRef.current.map((model) => + model.availability + ? { + ...model, + availability: currentAvailability(model.availability), + } + : model, + ), + ); + }, + Math.max(1, delay + 1), + ); + return () => clearTimeout(timer); + }, [models, storeModels]); const save = async (value: ModelConfigInput, id?: string) => { + epoch.current += 1; const saved = await request( `/model-configs${id ? `/${id}` : ""}`, json(id ? "PATCH" : "POST", value), ); - setModels((current) => - id - ? current.map((item) => (item.id === id ? saved : item)) - : [...current, saved], - ); + // The directory supplies the opaque server identity, including credentials. + await refresh(); + void refreshAvailability([saved.id]); }; const saveMany = async ( value: ModelConfigInput, @@ -124,19 +272,23 @@ export function useBackendModels() { id?: string, modelOptions: Record = {}, ) => { - await request( + epoch.current += 1; + const saved = await request( "/model-configs/batch", json("POST", { ...value, modelIds, modelOptions, configId: id }), ); await refresh(); + void refreshAvailability(saved.map((model) => model.id)); }; const removeService = async (id: string) => { + epoch.current += 1; await request(`/model-services/${id}`, { method: "DELETE" }); await refresh(); }; const remove = async (id: string) => { + epoch.current += 1; await request(`/model-configs/${id}`, { method: "DELETE" }); - setModels((current) => current.filter((item) => item.id !== id)); + storeModels(modelsRef.current.filter((item) => item.id !== id)); }; const updateSettings = async (patch: GeneralSettings) => { try { @@ -144,12 +296,11 @@ export function useBackendModels() { await request("/settings", json("PATCH", patch)), ); } catch (failure) { - // Reconcile a lost response before displaying a previous value as truth. setSettings(null); try { setSettings(await request("/settings")); } catch { - /* Await a successful refresh. */ + /* A later refresh reconciles the save. */ } throw failure; } @@ -166,6 +317,10 @@ export function useBackendModels() { remove, removeService, updateSettings, + availabilityRevision, + availabilityError, + checkingIds, + refreshAvailability, }; } diff --git a/src/backend/rules.ts b/src/backend/rules.ts index 378e4ceb..5989214d 100644 --- a/src/backend/rules.ts +++ b/src/backend/rules.ts @@ -1,3 +1,4 @@ +import { availabilityBlocker } from "./availability.ts"; import { voiceIssue, type VoiceCapabilities } from "./voices.ts"; import { nodeByteSize } from "../canvas/uploadPolicy.ts"; import type { CanvasNode, GenerationSettings } from "../canvas/model.ts"; @@ -15,6 +16,7 @@ import { frameImageLimitIssue } from "../canvas/frameReferences.ts"; import { nodeDownstreamKinds } from "../canvas/connectionPolicy.ts"; import { tNow } from "../i18n/messages.ts"; import type { MessageKey } from "../i18n/types.ts"; +import type { ModelAvailability } from "../contracts/generated.ts"; /** Model configurations and input rules published by the local API. */ export type ModelKind = "agent" | "image" | "video" | "audio" | "vision"; @@ -57,6 +59,8 @@ export interface InputRules { } export interface ModelConfig { + capabilityAutoCheck?: boolean | null; + availability?: ModelAvailability | null; providerId?: string | null; id: string; kind: ModelKind; @@ -515,6 +519,13 @@ export function backendBlocker( return tNow("errors.rules.transcriptionModel"); if (!isGenerationModel(config)) return tNow("errors.rules.segmentationModel"); + const transmitted = backendReferences(settings, incoming, config); + const profile = settings.mode === "audio" ? (isMusicModel(config) ? "lyrics_to_music" : "text_to_speech") + : settings.mode === "image" ? (transmitted.some((ref) => ref.node.data.kind === "image") ? "image_to_image" : "text_to_image") + : transmitted.some((ref) => ref.node.data.kind === "video") ? "video_to_video" + : transmitted.some((ref) => ref.node.data.kind === "image") ? "image_to_video" : "text_to_video"; + const capabilityIssue = availabilityBlocker(config, profile); + if (capabilityIssue) return capabilityIssue; if (!settings.prompt.trim()) return settings.mode !== "audio" ? tNow("errors.rules.promptRequired") diff --git a/src/canvas/AssistantPanel.tsx b/src/canvas/AssistantPanel.tsx index 3efd0d22..28116f6c 100644 --- a/src/canvas/AssistantPanel.tsx +++ b/src/canvas/AssistantPanel.tsx @@ -751,7 +751,7 @@ export function AssistantPanel({
- {!chat.loaded ? ( + {chat.availabilityIssue ? chat.availabilityIssue : !chat.loaded ? ( chat.changing ? t("assistant.panel.switching") : chat.error diff --git a/src/canvas/DraftAvailabilityCheck.tsx b/src/canvas/DraftAvailabilityCheck.tsx new file mode 100644 index 00000000..756948d1 --- /dev/null +++ b/src/canvas/DraftAvailabilityCheck.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; +import { + checkDraftAvailability, + type ModelConfigInput, +} from "../backend/models.ts"; +import type { ModelAvailability } from "../backend/availability.ts"; +import { errorMessage } from "../backend/api.ts"; +import { ModelAvailabilityCard } from "./ModelAvailabilityCard"; + +/** A draft check never publishes an observation into the saved model catalog. */ +export function DraftAvailabilityCheck({ + connection, + configId, + modelId, +}: { + connection: ModelConfigInput; + configId?: string; + modelId: string; +}) { + const signature = JSON.stringify([connection, configId, modelId]); + const latest = useRef(signature); + latest.current = signature; + const serial = useRef(0); + const alive = useRef(true); + const [value, setValue] = useState<{ + signature: string; + observation: ModelAvailability; + } | null>(null); + const [pending, setPending] = useState(null); + const [failure, setFailure] = useState<{ + signature: string; + message: string; + } | null>(null); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + const check = async () => { + const attempt = ++serial.current; + setPending(signature); + setFailure(null); + try { + const observation = await checkDraftAvailability( + { ...connection, modelId }, + configId, + ); + if ( + alive.current && + latest.current === signature && + serial.current === attempt + ) + setValue({ signature, observation }); + } catch (error) { + if ( + alive.current && + latest.current === signature && + serial.current === attempt + ) { + setValue(null); + setFailure({ signature, message: errorMessage(error) }); + } + } finally { + if (alive.current && serial.current === attempt) setPending(null); + } + }; + return ( + <> + void check()} + /> + {failure?.signature === signature && ( +

+ {failure.message} +

+ )} + + ); +} diff --git a/src/canvas/ModelAvailabilityCard.tsx b/src/canvas/ModelAvailabilityCard.tsx new file mode 100644 index 00000000..30f8b111 --- /dev/null +++ b/src/canvas/ModelAvailabilityCard.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { + availabilityDelay, + availabilityProfile, + availabilityReason, + currentAvailability, + type ModelAvailability, +} from "../backend/availability.ts"; +import { useT } from "../i18n/I18nProvider"; + +/** Observations do not edit the model's saved input rules or voice selection. */ +export function ModelAvailabilityCard({ + value, + onRefresh, + checking = false, + title, +}: { + value?: ModelAvailability | null; + onRefresh?: () => void; + checking?: boolean; + title?: string; +}) { + const t = useT(); + const [tick, setTick] = useState(0); + useEffect(() => { + const delay = value ? availabilityDelay(value) : null; + if (delay === null) return; + const timer = setTimeout( + () => setTick((previous) => previous + 1), + Math.max(1, delay + 1), + ); + return () => clearTimeout(timer); + }, [value, tick]); + const current = value ? currentAvailability(value) : null; + return ( +
+
+ {title ?? t("availability.title")} + {onRefresh && ( + + )} +
+

+ {checking + ? t("availability.checking") + : availabilityReason(current?.reasonCode ?? "not_checked")} +

+ {current?.operations.length ? ( +
    + {current.operations.map((item) => ( +
  • + {availabilityProfile(item.profile)} + + {t(`availability.status.${item.status}`)} —{" "} + {availabilityReason(item.reasonCode)} + +
  • + ))} +
+ ) : null} + {current?.checkedAt != null && ( + + {t("availability.checkedAt", { + time: new Date(current.checkedAt).toLocaleTimeString(), + })} + + )} +
+ ); +} diff --git a/src/canvas/ModelServiceForm.tsx b/src/canvas/ModelServiceForm.tsx index c3d775ad..5bcf6232 100644 --- a/src/canvas/ModelServiceForm.tsx +++ b/src/canvas/ModelServiceForm.tsx @@ -1,3 +1,4 @@ +import { DraftAvailabilityCheck } from "./DraftAvailabilityCheck"; import { ProviderPicker } from "./ProviderPicker"; import { useId, useState, type FormEvent } from "react"; import { errorMessage } from "../backend/api.ts"; @@ -107,6 +108,7 @@ export function ModelServiceForm({ modelId: existing?.modelId ?? "", apiKey: "", connectionType: existing?.connectionType ?? "self_hosted", + capabilityAutoCheck: existing?.capabilityAutoCheck, protocol: existing && isSegmentationModel(existing) ? "vllm_omni_segmentation" @@ -311,6 +313,7 @@ export function ModelServiceForm({ url: model.url, modelId: model.modelId, connectionType: model.connectionType, + capabilityAutoCheck: model.capabilityAutoCheck, inputModalities: model.inputModalities, videoAdapter: model.videoAdapter, videoCapabilities: model.videoCapabilities, @@ -464,6 +467,11 @@ export function ModelServiceForm({ {t("models.service.proxyNote")}

+
+ +

{t("availability.autoHint")}

+
( + <> + ({ ...current, [id]: message })) } /> + )} /> {segmentation && ( diff --git a/src/canvas/ModelSettings.tsx b/src/canvas/ModelSettings.tsx index 060bd2de..72a9db3f 100644 --- a/src/canvas/ModelSettings.tsx +++ b/src/canvas/ModelSettings.tsx @@ -1,7 +1,8 @@ +import { ModelAvailabilityCard } from "./ModelAvailabilityCard"; import { ModelSetup, modelSetupMissing, type SetupKind } from "./ModelSetup"; import { ModelLogo } from "./ModelLogo"; import { providerLabel } from "./providerPresets"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { errorMessage } from "../backend/api.ts"; import { MODEL_KINDS, type BackendModels } from "../backend/models.ts"; import type { ModelConfig } from "../backend/rules.ts"; @@ -44,6 +45,11 @@ export function ModelSettings({ catalog, onClose, guided = false }: { catalog: B models.findIndex((item) => item.providerId === model.providerId) === index, ); + const visibleIds = models.map((model) => model.id).join(","); + const checkAvailability = catalog.refreshAvailability; + useEffect(() => { + if (visibleIds && catalog.connected) void checkAvailability(visibleIds.split(",")); + }, [visibleIds, catalog.connected, checkAvailability]); const [skipped, setSkipped] = useState([]); const missing = modelSetupMissing(catalog.models); const pending = missing.filter(kind => !skipped.includes(kind)); @@ -185,6 +191,13 @@ export function ModelSettings({ catalog, onClose, guided = false }: { catalog: B ))} {members.length > 3 && +{members.length - 3}} +
+ {t("availability.title")} + {members.map((member) => void catalog.refreshAvailability([member.id], true)} />)} + {catalog.availabilityError &&

{catalog.availabilityError}

} +
{providerLabel(model)}{" "} diff --git a/src/canvas/NodeComposer.tsx b/src/canvas/NodeComposer.tsx index 7bbe4eb5..1481febb 100644 --- a/src/canvas/NodeComposer.tsx +++ b/src/canvas/NodeComposer.tsx @@ -1,3 +1,4 @@ +import { availabilityReason, currentAvailability } from "../backend/availability.ts"; import { VoiceLibrary } from "./VoiceLibrary"; import { modelDescription } from "./modelDescriptions"; import { SearchableSelect } from "./SearchableSelect"; @@ -61,6 +62,7 @@ export interface NodeComposerProps { running?: boolean; /** Models configured in the local API, usable alongside the catalog. */ backendModels?: readonly ModelConfig[]; + onCheckModels?: (ids: readonly string[]) => Promise; referenceControls?: GenerationReferenceControls; } @@ -85,11 +87,16 @@ export function NodeComposer({ onPickReference, running = false, backendModels = [], + onCheckModels, referenceControls, }: NodeComposerProps) { const { t } = useI18n(); const [openPanel, setPanel] = useState(null); const panel = running ? null : openPanel; + const visibleModelIds = backendModels.filter((item) => item.kind === selectedNode?.data.kind).map((item) => item.id).join(","); + useEffect(() => { + if (selectedNode && visibleModelIds) void onCheckModels?.(visibleModelIds.split(",")); + }, [selectedNode?.id, visibleModelIds, onCheckModels]); const rootRef = useRef(null); const panelAnchor = useRef(null); const mentions = useRef(null); @@ -487,7 +494,7 @@ export function NodeComposer({ id: `config:${item.id}`, name: modelLabel(item), modelId: item.modelId, - description: modelDescription(item), + description: item.availability ? `${modelDescription(item)} · ${availabilityReason(currentAvailability(item.availability).reasonCode)}` : modelDescription(item), group: providerLabel(item), })), ]} diff --git a/src/canvas/TextNodeComposer.tsx b/src/canvas/TextNodeComposer.tsx index 6c18cedf..2a610ff8 100644 --- a/src/canvas/TextNodeComposer.tsx +++ b/src/canvas/TextNodeComposer.tsx @@ -20,6 +20,7 @@ export function TextNodeComposer({ node, incoming, models, + onCheckModels, onUpdate, onRun, onCancel, @@ -30,6 +31,7 @@ export function TextNodeComposer({ node: CanvasNode; incoming: CanvasNode[]; models: readonly ModelConfig[]; + onCheckModels?: (ids: readonly string[]) => Promise; onUpdate: (data: Partial) => void; onRun: () => void; onCancel: () => void; @@ -38,6 +40,8 @@ export function TextNodeComposer({ referenceControls?: GenerationReferenceControls; }) { const t = useT(); + const agentIds = models.filter((model) => model.kind === "agent").map((model) => model.id).join(","); + useEffect(() => { if (agentIds) void onCheckModels?.(agentIds.split(",")); }, [node.id, agentIds, onCheckModels]); const root = useRef(null), anchor = useRef(null); const [openPanel, setPanel] = useState<"parameters" | null>(null); diff --git a/src/canvas/model-settings.css b/src/canvas/model-settings.css index 21397785..e7b61cc5 100644 --- a/src/canvas/model-settings.css +++ b/src/canvas/model-settings.css @@ -661,3 +661,12 @@ .cw-model-editor-scroll > .cw-model-connection-details { margin: 0 0 16px; } .cw-model-editor-scroll > .cw-provider-key { margin: 0 0 18px; } + +.cw-model-availability { padding: 12px 0; font-size: var(--settings-label-size, 13px); } +.cw-model-availability-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.cw-model-availability p, .cw-model-availability small { color: var(--settings-muted); } +.cw-model-availability ul { display: grid; gap: 8px; margin: 10px 0; padding: 0; list-style: none; } +.cw-model-availability li { display: grid; grid-template-columns: minmax(80px, 1fr) minmax(0, 3fr); gap: 12px; overflow-wrap: anywhere; } +.cw-model-availability-details { padding: 12px 16px; border-top: 1px solid var(--settings-border); } +.cw-model-availability-details summary { cursor: pointer; } +.cw-model-availability [data-availability="unavailable"] { color: var(--cw-danger, #b45336); } diff --git a/src/canvas/textGeneration.ts b/src/canvas/textGeneration.ts index 4f668e10..4aecccac 100644 --- a/src/canvas/textGeneration.ts +++ b/src/canvas/textGeneration.ts @@ -1,3 +1,4 @@ +import { availabilityBlocker } from "../backend/availability.ts"; import type { ModelConfig } from "../backend/rules.ts"; import { RULE_FACTS } from "../contracts/ruleFacts.generated.ts"; import type { CanvasDocument, CanvasNode, AgentNodeSettings } from "./model.ts"; @@ -21,6 +22,8 @@ export function agentGenerationBlocker( ) { if (!model || model.kind !== "agent") return tNow("errors.text.noAgent"); + const capabilityIssue = availabilityBlocker(model, "text_response"); + if (capabilityIssue) return capabilityIssue; if (!node.data.prompt.trim()) return tNow("errors.text.promptRequired"); const settings = agentNodeSettings(node); if ( diff --git a/src/canvas/useAssistantChat.ts b/src/canvas/useAssistantChat.ts index b2a00e49..72f0bca9 100644 --- a/src/canvas/useAssistantChat.ts +++ b/src/canvas/useAssistantChat.ts @@ -1,3 +1,4 @@ +import { availabilityBlocker } from "../backend/availability.ts"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { rememberAgentSelection, restoreAgentSelection, builtinThinking } from "./agentPreferences"; import { errorMessage } from "../backend/api"; @@ -312,7 +313,13 @@ export function useAssistantChat( return () => abort.abort(); }, [open, loaded, runtimesLoading, runtimeToCheck, refreshRuntimes]); + const checkCapabilities = catalog.refreshAvailability; + useEffect(() => { + if (open && currentRuntime?.modelSource === "settings" && agent) void checkCapabilities([agent.id]); + }, [open, currentRuntime?.modelSource, agent?.id, checkCapabilities]); + const availabilityIssue = currentRuntime?.modelSource === "settings" ? availabilityBlocker(agent, "text_response") : null; const canSend = + !availabilityIssue && currentRuntime?.status === "ready" && !!currentRuntime.available && (currentRuntime.modelSource === "cli" || !!agent); @@ -664,6 +671,7 @@ export function useAssistantChat( setError, agent, canSend, + availabilityIssue, adding, upload, addNodes, diff --git a/src/canvas/workspace/WorkspaceView.tsx b/src/canvas/workspace/WorkspaceView.tsx index 99493f7b..ce99a129 100644 --- a/src/canvas/workspace/WorkspaceView.tsx +++ b/src/canvas/workspace/WorkspaceView.tsx @@ -1590,6 +1590,7 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) { incoming={composerIncoming} referenceControls={generationReferenceControls(composerNode)} models={backendCatalog.models} + onCheckModels={backendCatalog.refreshAvailability} onUpdate={(patch) => command({ type: "update-node", @@ -1630,6 +1631,7 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) { }} running={composerRunning} backendModels={backendCatalog.models} + onCheckModels={backendCatalog.refreshAvailability} /> )}
diff --git a/src/canvas/workspace/useWorkspaceRuntime.ts b/src/canvas/workspace/useWorkspaceRuntime.ts index 21883871..fd06f575 100644 --- a/src/canvas/workspace/useWorkspaceRuntime.ts +++ b/src/canvas/workspace/useWorkspaceRuntime.ts @@ -147,7 +147,7 @@ export function useWorkspaceRuntime(): WorkspaceRuntime { ); const toolCatalog = useToolCatalog( backendCatalog.connected, - backendCatalog.models, + backendCatalog.availabilityRevision, ); const setupChecked = useRef(false); const theme = useTheme(); diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index 7664beb5..cd58f7cd 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -1417,6 +1417,30 @@ "title": "AudioParameters", "type": "object" }, + "AvailabilityCatalog": { + "additionalProperties": false, + "properties": { + "revision": { + "description": "Version of the complete observation projection.", + "title": "Revision", + "type": "string" + }, + "models": { + "description": "Current saved model observations; reading does not query providers.", + "items": { + "$ref": "#/$defs/ModelAvailability" + }, + "title": "Models", + "type": "array" + } + }, + "required": [ + "revision", + "models" + ], + "title": "AvailabilityCatalog", + "type": "object" + }, "BatchCommand": { "additionalProperties": false, "description": "Apply multiple operations atomically; all succeed or none are committed.", @@ -3472,6 +3496,9 @@ "kling_aspect", "kling_output", "kling_cancel", + "capability_unavailable", + "capability_paused", + "capability_connection_changed", "capability_unsupported", "preview_expired", "storage_unavailable", @@ -8000,6 +8027,105 @@ "title": "MediaPublicationOutput", "type": "object" }, + "ModelAvailability": { + "additionalProperties": false, + "properties": { + "configId": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Saved model identity; null for an unsaved draft.", + "title": "Configid" + }, + "modelId": { + "description": "Exact remote model identifier requested.", + "title": "Modelid", + "type": "string" + }, + "identity": { + "description": "Opaque identity of the checked connection and model.", + "title": "Identity", + "type": "string" + }, + "autoCheck": { + "description": "Effective service automatic-check preference.", + "title": "Autocheck", + "type": "boolean" + }, + "status": { + "description": "Summary across the configured adapter operations.", + "enum": [ + "available", + "unavailable", + "unknown" + ], + "title": "Status", + "type": "string" + }, + "reasonCode": { + "description": "Stable, localizable summary reason.", + "title": "Reasoncode", + "type": "string" + }, + "checkedAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Observation completion time in Unix milliseconds.", + "title": "Checkedat" + }, + "expiresAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Earliest operation expiry in Unix milliseconds.", + "title": "Expiresat" + }, + "revision": { + "description": "Projection version; changes on observations, identity changes and expiration.", + "title": "Revision", + "type": "string" + }, + "operations": { + "description": "Exact operations consumed by the configured adapter.", + "items": { + "$ref": "#/$defs/OperationAvailability" + }, + "title": "Operations", + "type": "array" + } + }, + "required": [ + "configId", + "modelId", + "identity", + "autoCheck", + "status", + "reasonCode", + "checkedAt", + "expiresAt", + "revision", + "operations" + ], + "title": "ModelAvailability", + "type": "object" + }, "ModelConfigPublic": { "additionalProperties": false, "description": "Application-level, never embedded in an editable project's credential data.", @@ -8141,6 +8267,31 @@ "inputRules": { "$ref": "#/$defs/InputRules", "description": "Verified model input limits and output choices." + }, + "capabilityAutoCheck": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Service preference; omitted defaults to automatic for self-hosted connections.", + "title": "Capabilityautocheck" + }, + "availability": { + "anyOf": [ + { + "$ref": "#/$defs/ModelAvailability" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Ephemeral observation, never persisted in project snapshots." } }, "required": [ @@ -8289,6 +8440,19 @@ "default": null, "description": "Provider credential; omitted from public model records.", "title": "Apikey" + }, + "capabilityAutoCheck": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Service automatic-check override; omission preserves the existing preference.", + "title": "Capabilityautocheck" } }, "required": [ @@ -8834,6 +8998,88 @@ "title": "ObjectVersion", "type": "object" }, + "OperationAvailability": { + "additionalProperties": false, + "properties": { + "operation": { + "description": "Canonical remote operation identifier.", + "title": "Operation", + "type": "string" + }, + "profile": { + "description": "Input and output mode used by the configured adapter.", + "title": "Profile", + "type": "string" + }, + "method": { + "description": "Actual HTTP method used for submission.", + "title": "Method", + "type": "string" + }, + "path": { + "description": "Actual HTTP submission path.", + "title": "Path", + "type": "string" + }, + "output": { + "description": "Output modality consumed by the adapter.", + "title": "Output", + "type": "string" + }, + "status": { + "description": "Current evidence for this exact request.", + "enum": [ + "available", + "unavailable", + "unknown" + ], + "title": "Status", + "type": "string" + }, + "reasonCode": { + "description": "Stable, localizable decision reason.", + "title": "Reasoncode", + "type": "string" + }, + "checkedAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Observation completion time in Unix milliseconds.", + "title": "Checkedat" + }, + "expiresAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Decision expiry in Unix milliseconds; recompute after expiry.", + "title": "Expiresat" + } + }, + "required": [ + "operation", + "profile", + "method", + "path", + "output", + "status", + "reasonCode", + "checkedAt", + "expiresAt" + ], + "title": "OperationAvailability", + "type": "object" + }, "PaletteData": { "additionalProperties": false, "properties": { @@ -16499,6 +16745,30 @@ "title": "AudioParameters", "type": "object" }, + "AvailabilityCatalog": { + "additionalProperties": false, + "properties": { + "revision": { + "description": "Version of the complete observation projection.", + "title": "Revision", + "type": "string" + }, + "models": { + "description": "Current saved model observations; reading does not query providers.", + "items": { + "$ref": "#/$defs/ModelAvailability" + }, + "title": "Models", + "type": "array" + } + }, + "required": [ + "revision", + "models" + ], + "title": "AvailabilityCatalog", + "type": "object" + }, "BatchCommand": { "additionalProperties": false, "description": "Apply multiple operations atomically; all succeed or none are committed.", @@ -18607,6 +18877,9 @@ "kling_aspect", "kling_output", "kling_cancel", + "capability_unavailable", + "capability_paused", + "capability_connection_changed", "capability_unsupported", "preview_expired", "storage_unavailable", @@ -23342,6 +23615,105 @@ "title": "MediaPublicationOutput", "type": "object" }, + "ModelAvailability": { + "additionalProperties": false, + "properties": { + "configId": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Saved model identity; null for an unsaved draft.", + "title": "Configid" + }, + "modelId": { + "description": "Exact remote model identifier requested.", + "title": "Modelid", + "type": "string" + }, + "identity": { + "description": "Opaque identity of the checked connection and model.", + "title": "Identity", + "type": "string" + }, + "autoCheck": { + "description": "Effective service automatic-check preference.", + "title": "Autocheck", + "type": "boolean" + }, + "status": { + "description": "Summary across the configured adapter operations.", + "enum": [ + "available", + "unavailable", + "unknown" + ], + "title": "Status", + "type": "string" + }, + "reasonCode": { + "description": "Stable, localizable summary reason.", + "title": "Reasoncode", + "type": "string" + }, + "checkedAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Observation completion time in Unix milliseconds.", + "title": "Checkedat" + }, + "expiresAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Earliest operation expiry in Unix milliseconds.", + "title": "Expiresat" + }, + "revision": { + "description": "Projection version; changes on observations, identity changes and expiration.", + "title": "Revision", + "type": "string" + }, + "operations": { + "description": "Exact operations consumed by the configured adapter.", + "items": { + "$ref": "#/$defs/OperationAvailability" + }, + "title": "Operations", + "type": "array" + } + }, + "required": [ + "configId", + "modelId", + "identity", + "autoCheck", + "status", + "reasonCode", + "checkedAt", + "expiresAt", + "revision", + "operations" + ], + "title": "ModelAvailability", + "type": "object" + }, "ModelConfigPublic": { "additionalProperties": false, "description": "Application-level, never embedded in an editable project's credential data.", @@ -23469,6 +23841,31 @@ "inputRules": { "$ref": "#/$defs/InputRules", "description": "Verified model input limits and output choices." + }, + "capabilityAutoCheck": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Service preference; omitted defaults to automatic for self-hosted connections.", + "title": "Capabilityautocheck" + }, + "availability": { + "anyOf": [ + { + "$ref": "#/$defs/ModelAvailability" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Ephemeral observation, never persisted in project snapshots." } }, "required": [ @@ -23621,6 +24018,19 @@ "default": null, "description": "Provider credential; omitted from public model records.", "title": "Apikey" + }, + "capabilityAutoCheck": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Service automatic-check override; omission preserves the existing preference.", + "title": "Capabilityautocheck" } }, "required": [ @@ -24181,6 +24591,88 @@ "title": "ObjectVersion", "type": "object" }, + "OperationAvailability": { + "additionalProperties": false, + "properties": { + "operation": { + "description": "Canonical remote operation identifier.", + "title": "Operation", + "type": "string" + }, + "profile": { + "description": "Input and output mode used by the configured adapter.", + "title": "Profile", + "type": "string" + }, + "method": { + "description": "Actual HTTP method used for submission.", + "title": "Method", + "type": "string" + }, + "path": { + "description": "Actual HTTP submission path.", + "title": "Path", + "type": "string" + }, + "output": { + "description": "Output modality consumed by the adapter.", + "title": "Output", + "type": "string" + }, + "status": { + "description": "Current evidence for this exact request.", + "enum": [ + "available", + "unavailable", + "unknown" + ], + "title": "Status", + "type": "string" + }, + "reasonCode": { + "description": "Stable, localizable decision reason.", + "title": "Reasoncode", + "type": "string" + }, + "checkedAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Observation completion time in Unix milliseconds.", + "title": "Checkedat" + }, + "expiresAt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Decision expiry in Unix milliseconds; recompute after expiry.", + "title": "Expiresat" + } + }, + "required": [ + "operation", + "profile", + "method", + "path", + "output", + "status", + "reasonCode", + "checkedAt", + "expiresAt" + ], + "title": "OperationAvailability", + "type": "object" + }, "PaletteData": { "additionalProperties": false, "properties": { diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index eae4201c..b1ad2c14 100644 --- a/src/contracts/generated.ts +++ b/src/contracts/generated.ts @@ -140,6 +140,11 @@ export interface AudioParametersInput extends ContractInput { readonly durationFromReference?: boolean | null; } +export interface AvailabilityCatalogInput extends ContractInput { + readonly revision: string; + readonly models: ReadonlyArray; +} + export interface BatchCommandInput extends CommandBaseInput { readonly type: "creation.batch"; readonly payload: BatchPayloadInput; @@ -387,7 +392,7 @@ export interface ContractInput { } export interface ContractIssueInput extends ContractInput { - readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; + readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unavailable" | "capability_paused" | "capability_connection_changed" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; readonly message: string; readonly objectId?: string | null; readonly field?: string | null; @@ -808,14 +813,30 @@ export interface MediaPublicationOutputInput extends ContractInput { readonly height?: number; } +export interface ModelAvailabilityInput extends ContractInput { + readonly configId: string | null; + readonly modelId: string; + readonly identity: string; + readonly autoCheck: boolean; + readonly status: "available" | "unavailable" | "unknown"; + readonly reasonCode: string; + readonly checkedAt: number | null; + readonly expiresAt: number | null; + readonly revision: string; + readonly operations: ReadonlyArray; +} + export interface ModelConfigPublicInput extends ModelConnectionInput { readonly id: string; readonly providerId?: string | null; readonly inputRules: InputRulesInput; + readonly capabilityAutoCheck?: boolean | null; + readonly availability?: ModelAvailabilityInput | null; } export interface ModelConfigWriteInput extends ModelConnectionInput { readonly apiKey?: string | null; + readonly capabilityAutoCheck?: boolean | null; } export interface ModelConnectionInput extends ContractInput { @@ -885,6 +906,18 @@ export interface ObjectVersionInput extends ContractInput { readonly revision: number; } +export interface OperationAvailabilityInput extends ContractInput { + readonly operation: string; + readonly profile: string; + readonly method: string; + readonly path: string; + readonly output: string; + readonly status: "available" | "unavailable" | "unknown"; + readonly reasonCode: string; + readonly checkedAt: number | null; + readonly expiresAt: number | null; +} + export interface PaletteDataInput extends ContractInput { readonly name?: string | null; readonly sourceImage?: AssetRefInput | null; @@ -1710,6 +1743,11 @@ export interface AudioParameters extends Contract { readonly durationFromReference?: boolean | null; } +export interface AvailabilityCatalog extends Contract { + readonly revision: string; + readonly models: ReadonlyArray; +} + export interface BatchCommand extends CommandBase { readonly type: "creation.batch"; readonly payload: BatchPayload; @@ -1957,7 +1995,7 @@ export interface Contract { } export interface ContractIssue extends Contract { - readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; + readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unavailable" | "capability_paused" | "capability_connection_changed" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; readonly message: string; readonly objectId: string | null; readonly field: string | null; @@ -2378,13 +2416,29 @@ export interface MediaPublicationOutput extends Contract { readonly height: number; } +export interface ModelAvailability extends Contract { + readonly configId: string | null; + readonly modelId: string; + readonly identity: string; + readonly autoCheck: boolean; + readonly status: "available" | "unavailable" | "unknown"; + readonly reasonCode: string; + readonly checkedAt: number | null; + readonly expiresAt: number | null; + readonly revision: string; + readonly operations: ReadonlyArray; +} + export interface ModelConfigPublic extends ModelConnection { readonly id: string; readonly inputRules: InputRules; + readonly capabilityAutoCheck?: boolean | null; + readonly availability?: ModelAvailability | null; } export interface ModelConfigWrite extends ModelConnection { readonly apiKey: string | null; + readonly capabilityAutoCheck?: boolean | null; } export interface ModelConnection extends Contract { @@ -2454,6 +2508,18 @@ export interface ObjectVersion extends Contract { readonly revision: number; } +export interface OperationAvailability extends Contract { + readonly operation: string; + readonly profile: string; + readonly method: string; + readonly path: string; + readonly output: string; + readonly status: "available" | "unavailable" | "unknown"; + readonly reasonCode: string; + readonly checkedAt: number | null; + readonly expiresAt: number | null; +} + export interface PaletteData extends Contract { readonly name: string | null; readonly sourceImage: AssetRef | null; diff --git a/src/i18n/messages/en-US/availability.ts b/src/i18n/messages/en-US/availability.ts new file mode 100644 index 00000000..f7fca802 --- /dev/null +++ b/src/i18n/messages/en-US/availability.ts @@ -0,0 +1,76 @@ +export const availability = { + "availability.reason.query_timeout": + "The query timed out. Existing validation remains in use.", + "availability.reason.query_busy": + "Capability checks are busy. Refresh shortly.", + "availability.reason.query_failed": + "The capability query failed. Existing validation remains in use.", + + "availability.title": "Service capabilities", + "availability.check": "Check service capabilities", + "availability.checking": "Checking service capabilities…", + "availability.autoCheck": "Automatically check service capabilities", + "availability.autoHint": + "Check when opening a relevant view or submitting a new task. Failed checks preserve manual configuration.", + "availability.checkedAt": "Last checked: {time}", + "availability.status.available": "Declared supported", + "availability.status.unavailable": "Currently unavailable", + "availability.status.unknown": "Not confirmed", + "availability.reason.not_checked": + "Not checked. Saved configuration remains usable.", + "availability.reason.supported": + "The service declares support for this request.", + "availability.reason.operation_unsupported": + "This model does not support this operation. Choose a suitable model.", + "availability.reason.profile_unsupported": + "This model does not support this input or output mode.", + "availability.reason.operation_disabled": + "This operation is disabled in the deployment.", + "availability.reason.route_disabled": + "The endpoint used by this adapter is disabled.", + "availability.reason.output_unsupported": + "The model does not declare the required output type.", + "availability.reason.execution_paused": + "Execution is paused. Resume the service and try again.", + "availability.reason.observation_expired": + "The observation has expired. Refresh it; submission will check again.", + "availability.reason.connection_failed": + "Could not connect. Existing configuration and validation remain in use.", + "availability.reason.authentication_failed": + "Service authentication failed. Check the connection credentials.", + "availability.reason.protocol_unavailable": + "The service has no recognized capabilities endpoint. Manual configuration remains available.", + "availability.reason.invalid_response": + "The capability response is invalid. Existing validation remains in use.", + "availability.reason.unsupported_version": + "The capability protocol version is not recognized. Existing validation remains in use.", + "availability.reason.declaration_incomplete": + "The declaration is incomplete. Existing validation remains in use.", + "availability.reason.required_inputs_missing": + "This request is missing inputs required by the model.", + "availability.reason.input_contract_unknown": + "The service requires an input this adapter cannot identify. Existing validation remains in use.", + "availability.reason.model_not_declared": + "The service has no declaration for this exact model name.", + "availability.reason.operation_not_declared": + "The service has no declaration for this operation.", + "availability.reason.profile_not_declared": + "The service has no declaration for this input mode.", + "availability.reason.route_not_declared": + "The service has no declaration for the endpoint used by this adapter.", + "availability.reason.adapter_not_mapped": + "This adapter continues to use its existing parameter rules.", + "availability.reason.mixed_operations": + "Individual operation states are shown below.", + "availability.profile.text_response": "Text response", + "availability.profile.image_response": "Image response through chat", + "availability.profile.text_to_image": "Image generation", + "availability.profile.image_to_image": "Image editing", + "availability.profile.text_to_video": "Text to video", + "availability.profile.image_to_video": "Image to video", + "availability.profile.video_to_video": "Video transformation", + "availability.profile.text_to_speech": "Speech synthesis", + "availability.profile.lyrics_to_music": "Music from lyrics and style", + "availability.profile.text_to_audio": "General audio generation", + "availability.profile.other": "Other request mode", +} as const; diff --git a/src/i18n/messages/en-US/index.ts b/src/i18n/messages/en-US/index.ts index 29898459..475ef04b 100644 --- a/src/i18n/messages/en-US/index.ts +++ b/src/i18n/messages/en-US/index.ts @@ -1,3 +1,4 @@ +import { availability } from "./availability.ts"; import { voiceLibrary } from "./voiceLibrary.ts"; import { trace } from "./trace.ts"; import { modelSetup } from "./modelSetup.ts"; @@ -23,6 +24,7 @@ type ZhCatalog = typeof import("../zh-CN/index.ts").zhCN; /** `Record` makes a missing English key a compile error. */ export const enUS: Record = { ...common, + ...availability, ...voiceLibrary, ...settings, ...modelSetup, diff --git a/src/i18n/messages/zh-CN/availability.ts b/src/i18n/messages/zh-CN/availability.ts new file mode 100644 index 00000000..d30cdea3 --- /dev/null +++ b/src/i18n/messages/zh-CN/availability.ts @@ -0,0 +1,59 @@ +export const availability = { + "availability.reason.query_timeout": "查询超时,继续使用现有校验。", + "availability.reason.query_busy": "服务检查繁忙,请稍后刷新。", + "availability.reason.query_failed": "能力查询未成功,继续使用现有校验。", + + "availability.title": "服务能力", + "availability.check": "检查服务能力", + "availability.checking": "正在检查服务能力…", + "availability.autoCheck": "自动检查服务能力", + "availability.autoHint": + "打开相关界面或提交新任务时检查。检查失败时保留手动配置。", + "availability.checkedAt": "最近检查:{time}", + "availability.status.available": "已声明支持", + "availability.status.unavailable": "当前不可使用", + "availability.status.unknown": "尚未确认", + "availability.reason.not_checked": "尚未检查,可继续使用已保存的配置。", + "availability.reason.supported": "服务声明支持此请求。", + "availability.reason.operation_unsupported": + "模型不支持此操作,请选择适合的模型。", + "availability.reason.profile_unsupported": + "模型不支持这种输入方式或输出用途。", + "availability.reason.operation_disabled": "服务部署未开放此操作。", + "availability.reason.route_disabled": "当前适配器使用的接口已被禁用。", + "availability.reason.output_unsupported": "模型未声明支持所需的输出类型。", + "availability.reason.execution_paused": "服务执行已暂停,恢复服务后重试。", + "availability.reason.observation_expired": + "检查结果已过期,可刷新;提交时将重新检查。", + "availability.reason.connection_failed": "无法连接服务,保留现有配置和校验。", + "availability.reason.authentication_failed": "服务鉴权失败,请检查连接凭据。", + "availability.reason.protocol_unavailable": + "服务未提供可识别的能力接口,继续使用手动配置。", + "availability.reason.invalid_response": "能力响应无效,继续使用现有校验。", + "availability.reason.unsupported_version": + "无法识别能力协议版本,继续使用现有校验。", + "availability.reason.declaration_incomplete": + "服务说明不足,继续使用现有校验。", + "availability.reason.required_inputs_missing": + "当前请求缺少模型要求的必要输入。", + "availability.reason.input_contract_unknown": + "服务要求的输入尚未被当前适配器识别,继续使用现有校验。", + "availability.reason.model_not_declared": + "服务未说明这个精确模型名称的能力。", + "availability.reason.operation_not_declared": "服务未说明此操作的能力。", + "availability.reason.profile_not_declared": "服务未说明这种输入方式。", + "availability.reason.route_not_declared": "服务未说明当前适配器使用的接口。", + "availability.reason.adapter_not_mapped": "此适配器继续使用已有参数规则。", + "availability.reason.mixed_operations": "不同操作的状态如下。", + "availability.profile.text_response": "文字响应", + "availability.profile.image_response": "聊天图片响应", + "availability.profile.text_to_image": "图片生成", + "availability.profile.image_to_image": "图片编辑", + "availability.profile.text_to_video": "文字生成视频", + "availability.profile.image_to_video": "图片生成视频", + "availability.profile.video_to_video": "视频变换", + "availability.profile.text_to_speech": "语音朗读", + "availability.profile.lyrics_to_music": "歌词与风格生成音乐", + "availability.profile.text_to_audio": "通用音频生成", + "availability.profile.other": "其他请求方式", +} as const; diff --git a/src/i18n/messages/zh-CN/index.ts b/src/i18n/messages/zh-CN/index.ts index 3561f1e8..d427f360 100644 --- a/src/i18n/messages/zh-CN/index.ts +++ b/src/i18n/messages/zh-CN/index.ts @@ -1,3 +1,4 @@ +import { availability } from "./availability.ts"; import { voiceLibrary } from "./voiceLibrary.ts"; import { trace } from "./trace.ts"; import { modelSetup } from "./modelSetup.ts"; @@ -20,6 +21,7 @@ import { tools } from "./tools.ts"; /** Canonical copy. Every other locale must provide the same keys. */ export const zhCN = { ...common, + ...availability, ...voiceLibrary, ...settings, ...modelSetup, diff --git a/tests/browser/capability_discovery.mjs b/tests/browser/capability_discovery.mjs new file mode 100644 index 00000000..d56fee39 --- /dev/null +++ b/tests/browser/capability_discovery.mjs @@ -0,0 +1,469 @@ +/** Real UI and application, with a controllable HTTP capability service and fixture generation. */ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { launchBrowser } from "./engine.mjs"; + +const base = process.env.CREATION_WEB_URL ?? "http://127.0.0.1:5179"; +const api = process.env.CREATION_API_URL ?? "http://127.0.0.1:8019"; +const headers = { + "X-OpenDirector-Request": "1", + "Content-Type": "application/json", +}; +async function call(path, body, method = body ? "POST" : "GET") { + const requestHeaders = { ...headers }; + if (body?.type === "project.create") + delete requestHeaders["X-OpenDirector-Session"]; + const response = await fetch(api + path, { + method, + headers: requestHeaders, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + assert.ok( + response.ok, + `${response.status}: ${await response.clone().text()}`, + ); + return response.status === 204 ? null : response.json(); +} +async function until(check) { + const deadline = performance.now() + 20000; + while (performance.now() < deadline) { + const value = await check(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw Error("Expected persisted capability workflow state was not observed"); +} +assert.equal((await call("/api/creation-test-health")).isolated, true); +let mode = "unsupported", + delay = 0, + queries = 0; +const served = `capability-image-${crypto.randomUUID().slice(0, 8)}`; +function document() { + const route = { + transport: "http", + method: "POST", + path: "/v1/images/generations", + exposure: "enabled", + }; + const operations = Object.fromEntries( + [ + "chat", + "image_generation", + "image_edit", + "video_generation", + "speech", + "audio_generation", + ].map((id) => [ + id, + { support: "unknown", exposure: "unknown", profiles: [] }, + ]), + ); + operations.image_generation = { + support: mode === "unsupported" ? "unsupported" : "supported", + exposure: "enabled", + profiles: [ + { + id: "text_to_image", + support: mode === "unsupported" ? "unsupported" : "supported", + required_inputs: ["text"], + outputs: ["image"], + routes: [route], + }, + ], + execution: { + state: mode === "paused" ? "paused" : "unpaused", + scope: "deployment", + observed_at: new Date().toISOString(), + }, + }; + return { + object: "omni.capabilities", + schema_version: 1, + observed_at: new Date().toISOString(), + models: [{ id: served, stage_ids: ["7"], operations }], + stages: [{ id: "7", type: "diffusion", depends_on: [] }], + routes: [route], + }; +} +const service = createServer((request, response) => { + if (request.url !== "/v1/omni/capabilities") { + response.writeHead(404).end(); + return; + } + queries += 1; + const body = document(), + status = mode === "legacy" ? 404 : 200; + setTimeout(() => { + response.writeHead(status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); + }, delay); +}); +service.listen(0, "127.0.0.1"); +await once(service, "listening"); +const serviceUrl = `http://127.0.0.1:${service.address().port}/v1`; +const browser = await launchBrowser(); +const page = await browser.newPage({ + locale: "en-US", + viewport: { width: 1500, height: 1000 }, +}); +await page.addInitScript(() => { + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"); + localStorage.setItem("opendirector.locale.v1", "en-US"); +}); +page.setDefaultTimeout(15000); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +page.on("request", (request) => { + const session = request.headers()["x-opendirector-session"]; + if (session) headers["X-OpenDirector-Session"] = session; +}); +let saved; +try { + const projectId = crypto.randomUUID(); + const projectPath = `/api/creation/projects/${projectId}`; + const command = (type, payload, extra = {}) => + call(`${projectPath}/commands`, { + commandId: crypto.randomUUID(), + projectId, + type, + payload, + ...extra, + }); + await command("project.create", { + name: `Capability checks ${projectId.slice(0, 8)}`, + }); + await page.goto(`${base}/ai-tool/ai-canvas/${projectId}?creation=unified`); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("button", { name: "Model connections", exact: true }) + .click(); + await page + .locator(".cw-model-setup-row") + .filter({ hasText: "Image model" }) + .getByRole("button") + .click(); + await page.getByRole("button", { name: "Add service", exact: true }).click(); + await page + .getByLabel("Service URL (Base URL)", { exact: true }) + .fill(serviceUrl); + await page.getByRole("button", { name: "Add manually", exact: true }).click(); + await page + .getByLabel("Model ID (the service's served-model-name)", { exact: true }) + .fill(served); + await page + .locator(".cw-model-manual") + .getByRole("button", { name: "Add", exact: true }) + .click(); + const draft = page.locator(".cw-model-editor .cw-model-availability"); + const previousQueries = queries; + await page + .getByLabel("Service URL (Base URL)", { exact: true }) + .fill(serviceUrl + "/"); + await page.waitForTimeout(150); + assert.equal( + queries, + previousQueries, + "editing a form must not query the model server", + ); + await page + .getByLabel("Service URL (Base URL)", { exact: true }) + .fill(serviceUrl); + delay = 350; + const lateDraft = page.waitForResponse((response) => + response.url().endsWith("/model-configs/availability/check"), + ); + await draft + .getByRole("button", { name: "Check service capabilities", exact: true }) + .click(); + await page + .getByLabel("Service URL (Base URL)", { exact: true }) + .fill(serviceUrl + "/changed"); + await lateDraft; + await page.waitForTimeout(50); + assert.equal( + await draft + .getByText( + "This model does not support this operation. Choose a suitable model.", + ) + .count(), + 0, + "an old draft response must not describe the edited connection", + ); + await page + .getByLabel("Service URL (Base URL)", { exact: true }) + .fill(serviceUrl); + delay = 0; + await draft + .getByRole("button", { name: "Check service capabilities", exact: true }) + .click(); + await draft + .getByText( + "This model does not support this operation. Choose a suitable model.", + ) + .first() + .waitFor(); + assert.equal( + (await call("/api/model-configs")).some((item) => item.modelId === served), + false, + "a draft check must not save a model", + ); + assert.equal( + await page + .getByLabel("Automatically check service capabilities", { exact: true }) + .isChecked(), + true, + ); + await page.getByRole("button", { name: "Save service", exact: true }).click(); + await page + .getByRole("button", { name: "Add service", exact: true }) + .waitFor(); + saved = (await call("/api/model-configs")).find( + (item) => item.modelId === served, + ); + assert.ok(saved); + const serviceCard = page + .locator(".cw-model-service") + .filter({ hasText: served }); + await serviceCard.locator("summary").click(); + await serviceCard + .getByText( + "This model does not support this operation. Choose a suitable model.", + ) + .first() + .waitFor(); + + // Create a real generation object and prove API rejection has no job/provider side effect. + const specId = crypto.randomUUID(), + itemId = crypto.randomUUID(); + await command("canvas.createGeneration", { + spec: { + id: specId, + projectId, + kind: "image", + purpose: "generation", + prompt: "Preserve this prompt", + modelConfigId: saved.id, + parameters: {}, + }, + item: { + id: itemId, + projectId, + kind: "image", + title: "Capability example", + position: { x: 300, y: 200 }, + binding: { role: "generation", objectRef: { id: specId } }, + }, + references: [], + }); + let snapshot = await call(projectPath); + const spec = snapshot.generationSpecs.find((item) => item.id === specId); + const callsBefore = (await call("/api/creation-test-health")).calls.length; + const denied = await command( + "generation.submit", + { specId, expectedSpecRevision: spec.revision, originItemId: itemId }, + { + expectedVersions: [ + { objectType: "generation_spec", id: specId, revision: spec.revision }, + { + objectType: "canvas_item", + id: itemId, + revision: snapshot.canvasItems.find((item) => item.id === itemId) + .revision, + }, + ], + }, + ); + assert.equal(denied.status, "rejected"); + assert.equal((await call(projectPath)).jobs.length, 0); + assert.equal( + (await call("/api/creation-test-health")).calls.length, + callsBefore, + ); + + const refreshUrl = "**/api/model-configs/availability"; + await page.route( + refreshUrl, + (route) => + route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ detail: "Fixture service unavailable" }), + }), + { times: 1 }, + ); + await serviceCard + .getByRole("button", { name: "Check service capabilities", exact: true }) + .click(); + await serviceCard.getByRole("alert").waitFor(); + await page.unroute(refreshUrl); + mode = "supported"; + await serviceCard + .getByRole("button", { name: "Check service capabilities", exact: true }) + .click(); + await serviceCard + .getByText("The service declares support for this request.") + .first() + .waitFor(); + await page + .getByRole("button", { name: "Close settings", exact: true }) + .click(); + await page.reload(); + const node = page.locator(`.react-flow__node[data-id="${itemId}"]`); + await node.click(); + const composer = page.locator(".cw-generation-composer"); + await composer.waitFor(); + assert.match(await composer.innerText(), /Preserve this prompt/); + const generate = composer.locator("button.cw-run-button"); + await generate.waitFor(); + assert.equal(await generate.isEnabled(), true); + await generate.click(); + snapshot = await until(async () => { + const value = await call(projectPath); + return value.jobs.some((job) => job.status === "succeeded") && + value.assets.length > 0 && + value.results.length > 0 + ? value + : null; + }); + assert.equal(snapshot.jobs.length, 1); + assert.equal(snapshot.jobs[0].inputSnapshot.model.modelId, served); + assert.ok(snapshot.assets.length > 0); + assert.deepEqual( + [snapshot.assets[0].metadata.width, snapshot.assets[0].metadata.height], + [320, 240], + ); + assert.equal(snapshot.jobs[0].technicalCheck.status, "passed"); + const decoded = await page.evaluate(async (path) => { + const response = await fetch(path, { + headers: { "X-OpenDirector-Request": "1" }, + }); + if (!response.ok) throw Error(`Media download failed: ${response.status}`); + const bitmap = await createImageBitmap(await response.blob()); + const size = [bitmap.width, bitmap.height]; + bitmap.close(); + return size; + }, `${projectPath}/assets/${snapshot.assets[0].id}/content`); + assert.deepEqual(decoded, [320, 240]); + assert.equal( + (await call("/api/creation-test-health")).calls.length, + callsBefore + 1, + ); + const history = JSON.stringify(snapshot.jobs[0].inputSnapshot); + + // A paused observation expires locally even without another successful response. + mode = "paused"; + await call("/api/model-configs/availability", { + configIds: [saved.id], + manual: true, + }); + await page.reload(); + await page.locator(`.react-flow__node[data-id="${itemId}"]`).click(); + await until( + async () => + await page + .locator(".cw-generation-composer button.cw-run-button") + .isDisabled(), + ); + await page.locator(".cw-generation-composer button.cw-run-button").hover(); + await page + .getByText("Execution is paused. Resume the service and try again.") + .first() + .waitFor(); + await until( + async () => + await page + .locator(".cw-generation-composer button.cw-run-button") + .isEnabled(), + ); + assert.equal( + JSON.stringify((await call(projectPath)).jobs[0].inputSnapshot), + history, + ); + + // A second browser context exercises the actual Chinese presentation and legacy fallback. + const chinese = await browser.newPage({ + locale: "zh-CN", + viewport: { width: 1500, height: 1000 }, + }); + await chinese.addInitScript(() => { + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"); + localStorage.setItem("opendirector.locale.v1", "zh-CN"); + }); + mode = "legacy"; + const chineseProjectId = crypto.randomUUID(); + await call(`/api/creation/projects/${chineseProjectId}/commands`, { + commandId: crypto.randomUUID(), + projectId: chineseProjectId, + type: "project.create", + payload: { name: `能力检查中文界面 ${chineseProjectId.slice(0, 8)}` }, + }); + await chinese.goto( + `${base}/ai-tool/ai-canvas/${chineseProjectId}?creation=unified`, + ); + await chinese.getByRole("button", { name: "设置", exact: true }).click(); + await chinese.getByRole("button", { name: "模型接入", exact: true }).click(); + await chinese + .locator(".cw-model-setup-row") + .filter({ hasText: "图片模型" }) + .getByRole("button") + .click(); + const chineseService = chinese + .locator(".cw-model-service") + .filter({ hasText: served }); + await chineseService.locator("summary").click(); + await chineseService + .getByRole("button", { name: "检查服务能力", exact: true }) + .click(); + await chineseService + .getByText("服务未提供可识别的能力接口,继续使用手动配置。") + .first() + .waitFor(); + await until( + async () => + await chineseService + .getByRole("button", { name: "检查服务能力", exact: true }) + .isEnabled(), + ); + await chineseService + .locator(".cw-model-availability") + .scrollIntoViewIfNeeded(); + if (process.env.CREATION_SCREENSHOT) + await chinese.screenshot({ + path: process.env.CREATION_SCREENSHOT, + fullPage: true, + }); + assert.equal( + (await call("/api/model-configs")).find((item) => item.id === saved.id) + .modelId, + served, + ); + assert.equal( + JSON.stringify((await call(projectPath)).jobs[0].inputSnapshot), + history, + ); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + passed: true, + queries, + checks: [ + "draft isolation", + "stale draft response", + "no query on typing", + "save unsupported configuration", + "zero-job rejection", + "real UI submission and saved media", + "pause expiration", + "immutable history", + "Chinese legacy fallback", + ], + }), + ); + await chinese.close(); +} finally { + if (saved) await call(`/api/model-configs/${saved.id}`, undefined, "DELETE"); + await browser.close(); + service.closeAllConnections(); + await new Promise((resolve) => service.close(resolve)); +} diff --git a/tests/browser/manifest.json b/tests/browser/manifest.json index a6e8c1ab..1e8997b0 100644 --- a/tests/browser/manifest.json +++ b/tests/browser/manifest.json @@ -111,6 +111,15 @@ "extended" ] }, + { + "file": "capability_discovery.mjs", + "previewSafe": true, + "lanes": [ + "gate", + "production" + ], + "note": "Real application with controllable local capability HTTP service; no model inference." + }, { "file": "composer_duration.mjs", "previewSafe": true, diff --git a/tests/modelAvailability.test.ts b/tests/modelAvailability.test.ts new file mode 100644 index 00000000..e75bf316 --- /dev/null +++ b/tests/modelAvailability.test.ts @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + availabilityBlocker, + availabilityDelay, + availabilityMatchesModel, + availabilityModelKey, + currentAvailability, + failedAvailability, + nextAvailabilityExpiry, + receiveAvailability, + type AvailabilityClock, +} from "../src/backend/availability.ts"; +import type { + ModelAvailability, + OperationAvailability, +} from "../src/contracts/generated.ts"; +import type { ModelConfig } from "../src/backend/rules.ts"; +import { setActiveLocale } from "../src/i18n/messages.ts"; + +const clockAt = (now: number): AvailabilityClock => ({ + wallNow: () => now, + monotonicNow: () => now, +}); + +test("a transport failure permits a matching successful retry without reloading the directory", () => { + const value = observation([operation("text_response")]); + const failed = failedAvailability(value); + const configured = model(failed); + assert.equal( + availabilityBlocker(configured, "text_response", clockAt(2000)), + null, + ); + assert.equal(failed.status, "unknown"); + assert.equal(availabilityMatchesModel(configured, value), true); + assert.equal(value.status, "unavailable"); +}); + +const operation = ( + profile: string, + changes: Partial = {}, +): OperationAvailability => ({ + operation: "chat", + profile, + method: "POST", + path: "/v1/chat/completions", + output: "text", + status: "unavailable", + reasonCode: "execution_paused", + checkedAt: 1000, + expiresAt: 6000, + ...changes, +}); +const observation = ( + operations: OperationAvailability[], +): ModelAvailability => ({ + configId: "saved", + modelId: "served", + identity: "private-identity-digest", + autoCheck: true, + status: "unavailable", + reasonCode: "execution_paused", + checkedAt: 1000, + expiresAt: 6000, + revision: "one", + operations, +}); +const model = (availability?: ModelAvailability): ModelConfig => ({ + id: "saved", + providerId: "service", + kind: "agent", + url: "http://localhost:8100/v1", + modelId: "served", + connectionType: "self_hosted", + protocol: "openai", + authMode: "none", + availability, + inputRules: { + profile: "chat", + allowedKinds: ["text"], + maxImages: 0, + maxVideos: 0, + maxAudios: 0, + videoModes: [], + }, +}); + +test("a paused observation expires independently of a 30-second declaration", () => { + const value = observation([ + operation("text_response"), + operation("image_response", { + reasonCode: "profile_unsupported", + expiresAt: 31000, + }), + ]); + assert.equal( + currentAvailability(value, clockAt(5999)).operations[0].status, + "unavailable", + ); + const expired = currentAvailability(value, clockAt(6000)); + assert.equal(expired.operations[0].status, "unknown"); + assert.equal(expired.operations[1].status, "unavailable"); + assert.equal( + value.operations[0].status, + "unavailable", + "the input observation is immutable", + ); + assert.equal( + currentAvailability(value, clockAt(31000)).operations[1].status, + "unknown", + ); +}); + +test("unknown observations and missing profiles keep manual configuration usable", () => { + assert.equal( + availabilityBlocker(model(), "text_response", clockAt(2000)), + null, + ); + assert.equal( + availabilityBlocker( + model(observation([operation("image_response")])), + "text_response", + clockAt(2000), + ), + null, + ); + assert.equal( + availabilityBlocker( + model(observation([operation("text_response", { status: "unknown" })])), + "text_response", + clockAt(2000), + ), + null, + ); +}); + +test("explicit text denial blocks text without blocking a music profile", () => { + setActiveLocale("en-US"); + const configured = model( + observation([ + operation("text_response", { reasonCode: "profile_unsupported" }), + operation("lyrics_to_music", { + status: "available", + reasonCode: "supported", + }), + ]), + ); + assert.match( + availabilityBlocker(configured, "text_response", clockAt(2000))!, + /input or output mode/, + ); + assert.equal( + availabilityBlocker(configured, "lyrics_to_music", clockAt(2000)), + null, + ); + assert.equal( + availabilityBlocker(configured, "text_response", clockAt(6000)), + null, + ); +}); + +test("a failed refreshed observation cannot reuse the earlier paused denial", () => { + const configured = model(observation([operation("text_response")])); + assert.ok(availabilityBlocker(configured, "text_response", clockAt(2000))); + assert.equal( + availabilityBlocker( + { ...configured, availability: undefined }, + "text_response", + clockAt(2000), + ), + null, + ); +}); + +test("expiry scheduling includes each operation and stops after all observations expire", () => { + const configured = model( + observation([ + operation("text_response"), + operation("image_response", { expiresAt: 31000 }), + ]), + ); + assert.equal(nextAvailabilityExpiry([configured], clockAt(2000)), 6000); + assert.equal(nextAvailabilityExpiry([configured], clockAt(6000)), 31000); + assert.equal(nextAvailabilityExpiry([configured], clockAt(31000)), null); +}); + +test("connection changes invalidate observations while observation updates preserve identity", () => { + const configured = model(); + const identity = availabilityModelKey(configured); + for (const patch of [ + { providerId: "other" }, + { modelId: "other" }, + { url: "http://localhost:8200/v1" }, + { proxyMode: "direct" as const }, + { capabilityAutoCheck: false }, + { inputModalities: ["text", "image"] as ("text" | "image")[] }, + { + videoCapabilities: { + outputs: [{ resolution: "720p", durations: [5] }], + defaultResolution: "720p", + defaultDuration: 5, + dimensionStep: 16 as const, + }, + }, + ]) { + assert.notEqual( + availabilityModelKey({ ...configured, ...patch }), + identity, + ); + } + assert.equal( + availabilityModelKey({ ...configured, availability: observation([]) }), + identity, + ); +}); + +test("rejection reasons follow the current language without altering business identity", () => { + const configured = model(observation([operation("text_response")])); + setActiveLocale("zh-CN"); + assert.match( + availabilityBlocker(configured, "text_response", clockAt(2000))!, + /暂停/, + ); + setActiveLocale("en-US"); + assert.match( + availabilityBlocker(configured, "text_response", clockAt(2000))!, + /paused/, + ); + assert.equal(configured.modelId, "served"); +}); + +test("wall-clock rollback and repeated responses do not renew a received pause", () => { + let wall = 1000, + elapsed = 0; + const timing: AvailabilityClock = { + wallNow: () => wall, + monotonicNow: () => elapsed, + }; + const original = receiveAvailability( + observation([operation("text_response")]), + timing, + ); + assert.equal(availabilityDelay(original, timing), 5000); + wall = -60_000; + elapsed = 4999; + const duplicate = receiveAvailability(structuredClone(original), timing); + assert.equal(availabilityDelay(duplicate, timing), 1); + assert.equal(currentAvailability(duplicate, timing).status, "unavailable"); + elapsed = 5000; + assert.equal(currentAvailability(duplicate, timing).status, "unknown"); + assert.equal(availabilityDelay(duplicate, timing), null); + assert.equal( + availabilityBlocker(model(duplicate), "text_response", timing), + null, + ); + assert.equal(original.operations[0].status, "unavailable"); +}); + +test("wall-clock advances do not shorten the already received lifetime", () => { + let wall = 1000, + elapsed = 0; + const timing: AvailabilityClock = { + wallNow: () => wall, + monotonicNow: () => elapsed, + }; + const value = receiveAvailability( + observation([operation("text_response")]), + timing, + ); + wall = 100_000; + elapsed = 1000; + assert.equal(currentAvailability(value, timing).status, "unavailable"); + assert.equal(availabilityDelay(value, timing), 4000); + assert.equal(nextAvailabilityExpiry([model(value)], timing), 104_000); +}); + +test("a matching model ID cannot authorize observations for a changed opaque identity", () => { + const expected = observation([operation("text_response")]); + const configured = model(expected); + assert.equal(availabilityMatchesModel(configured, expected), true); + assert.equal( + availabilityMatchesModel(configured, { + ...expected, + identity: "changed-account-or-url", + }), + false, + ); + assert.equal( + availabilityMatchesModel(configured, { + ...expected, + configId: "other-service", + }), + false, + ); + assert.equal(availabilityMatchesModel(model(), expected), false); + assert.equal( + availabilityMatchesModel(model(), expected, expected.identity), + true, + ); +}); From e6c11218f0f42ae306ef10ce3ed75571f0ff9d22 Mon Sep 17 00:00:00 2001 From: bread-lxy <2022200058@ruc.edu.cn> Date: Tue, 10 Nov 2026 00:01:41 +0800 Subject: [PATCH 04/21] test: preserve accepted work across capability changes --- .../application/model_availability.py | 6 + .../opendirector/application/tool_tasks.py | 7 +- tests/test_capability_acceptance.py | 281 +++++++++++++++++- 3 files changed, 288 insertions(+), 6 deletions(-) diff --git a/packages/core/src/opendirector/application/model_availability.py b/packages/core/src/opendirector/application/model_availability.py index db0de913..7325fe87 100644 --- a/packages/core/src/opendirector/application/model_availability.py +++ b/packages/core/src/opendirector/application/model_availability.py @@ -114,6 +114,12 @@ def prepare(self, config, *, manual=False): self.probe.query(config, manual=manual) return identity(config) + def prepare_many(self, configs): + """Check one acceptance's dependencies within a shared query budget.""" + configs = list(configs) + self.probe.query_many(configs) + return [identity(config) for config in configs] + def assert_identity(self, config, prepared): if identity(config) != prepared: raise AvailabilityError('capability_connection_changed', '能力检查期间模型服务已变化,请重新提交。') diff --git a/packages/core/src/opendirector/application/tool_tasks.py b/packages/core/src/opendirector/application/tool_tasks.py index 6d35ac62..948d4e2c 100644 --- a/packages/core/src/opendirector/application/tool_tasks.py +++ b/packages/core/src/opendirector/application/tool_tasks.py @@ -235,10 +235,9 @@ def _prepare(self, project_id, value, session): initial = self._prepare_locked(project_id, value, session, inspect=True) if isinstance(initial, tuple): return initial # A replay does not query or reaccept its frozen job. - prepared = {} - for capability, config in initial.items(): - if tool_request(config, capability) is not None: - prepared[capability] = self.availability.prepare(config) + selected = {capability: config for capability, config in initial.items() + if tool_request(config, capability) is not None} + prepared = dict(zip(selected, self.availability.prepare_many(selected.values()), strict=True)) with self.store.transaction(): return self._prepare_locked(project_id, value, session, prepared=prepared) diff --git a/tests/test_capability_acceptance.py b/tests/test_capability_acceptance.py index 2e333ff5..753f6438 100644 --- a/tests/test_capability_acceptance.py +++ b/tests/test_capability_acceptance.py @@ -1,8 +1,11 @@ """Shared acceptance, HTTP, CLI and Agent tests with isolated model doubles.""" import asyncio +from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json +import socket import threading +import time from unittest.mock import Mock from uuid import uuid4 @@ -10,23 +13,29 @@ import httpx import pytest from typer.testing import CliRunner +import uvicorn from opendirector import cli from opendirector.agent.tools import AgentTools from opendirector.application.creation_tool_assets import CreationToolAssets -from opendirector.application.model_availability import ModelAvailability +from opendirector.application.creation_generation import CreationGeneration +from opendirector.application.model_availability import ModelAvailability, requests_for from opendirector.application.tool_tasks import ToolTasks from opendirector.domain.creation.commands import parse_command from opendirector.domain.projects import ProjectError from opendirector.infrastructure.local_projects import LocalProjects +from opendirector.infrastructure.creation_projects import CreationProjects from opendirector.infrastructure.model_configs import ModelConfigs from opendirector.integrations.capability_probe import CapabilityProbe +from opendirector.integrations.providers import GenerationProviders from opendirector_api.main import create_app from opendirector_api import agent from test_creation_canvas_service import project, ver from test_creation_generation_service import Provider, runtime, configure from test_model_availability import document, NOW -from test_tool_tasks import png, upload, images_server +from test_tool_tasks import png, upload, images_server, video_server, H3, configure as configure_tools +from test_generation_recovery import video +from generation_helpers import command as generation_command, settled def command_for(project, config): @@ -188,6 +197,189 @@ async def run(): probe.close() +def deny_new_work(availability, config, declaration, mode): + """Publish real negative evidence before forbidding re-admission calls.""" + operation = next(iter(declaration['models'][0]['operations'].values())) + if mode == 'unsupported': + operation['support'] = 'unsupported' + for profile in operation['profiles']: + profile['support'] = 'unsupported' + else: + operation['execution']['state'] = 'paused' + availability.refresh([config['id']], manual=True) + private = availability.configs.resolve(config['id'], config['kind']) + request = next(item for item in requests_for(private) if item.profile == operation['profiles'][0]['id']) + assert availability.decision(private, request)['status'] == 'unavailable' + for method in ('prepare', 'prepare_many', 'require'): + setattr(availability, method, Mock(side_effect=AssertionError('accepted work must not be readmitted'))) + + +@pytest.mark.parametrize('denial', ['unsupported', 'paused']) +@pytest.mark.parametrize('action', ['remote', 'download', 'cancel']) +def test_accepted_generation_keeps_original_remote_work_after_capability_change(project, video, action, denial): + store, pid, session = project + kind = 'image' if action == 'download' else 'video' + configs = ModelConfigs(store.local.repository) + config = configs.save(kind, 'http://127.0.0.1:12345/v1', 'demo' if kind == 'image' else H3[2], '', + connection_type='self_hosted', protocol='openai' if kind == 'image' else 'vllm_omni') + declaration = document('image_generation' if kind == 'image' else 'video_generation', + 'text_to_image' if kind == 'image' else 'text_to_video', + '/v1/images/generations' if kind == 'image' else '/v1/videos', kind, model=config['modelId']) + queries, calls, state = [], [], {'healthy': False} + probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: ( + queries.append(request) or httpx.Response(200, json=declaration)))) + availability = ModelAvailability(configs, probe) + def handle(request): + calls.append((request.method, request.url.path)) + if request.method == 'POST': + return httpx.Response(200, json={'data': [{'url': config['url'] + '/result.png'}]} if kind == 'image' + else {'id': 'accepted-remote-id', 'status': 'queued'}) + if request.method == 'DELETE': + assert request.url.path == '/v1/videos/accepted-remote-id' + return httpx.Response(200, json={'id': 'accepted-remote-id', 'status': 'cancelled'}) + if not state['healthy']: + raise httpx.ReadTimeout('interrupted read', request=request) + if request.url.path.endswith('/content') or kind == 'image': + return httpx.Response(200, content=png() if kind == 'image' else video) + assert request.url.path == '/v1/videos/accepted-remote-id' + return httpx.Response(200, json={'id': 'accepted-remote-id', 'status': 'completed'}) + async def run(): + first = CreationGeneration(store, configs, availability=availability) + first.provider = GenerationProviders(first.store, httpx.MockTransport(handle), poll_interval=0) + second = None + try: + command = generation_command(project, config, prompt='Keep this accepted request') + accepted = await first.execute(command, session=session) + assert accepted.status == 'accepted' + await settled(first) + initial = store.snapshot(pid).jobs[0] + assert initial.recovery == ('download' if action == 'download' else 'remote') + frozen = initial.inputSnapshot.model_dump() + await first.close() + deny_new_work(availability, config, declaration, denial) + state['healthy'] = True + reopened = CreationProjects(LocalProjects(store.local.repository)) + second = CreationGeneration(reopened, configs, availability=availability) + second.provider = GenerationProviders(second.store, httpx.MockTransport(handle), poll_interval=0) + before = len(calls) + if action == 'cancel': + cancel = parse_command(dict(commandId=uuid4(), projectId=pid, type='job.cancel', + expectedVersions=[ver('job', initial)], payload=dict(jobId=initial.id))) + receipt = await second.execute(cancel, session=session) + assert receipt.status == 'applied' + assert await second.execute(cancel, session=session) == receipt + assert calls[before:] == [('DELETE', '/v1/videos/accepted-remote-id')] + else: + await second.start() + await settled(second) + assert calls[before:] == ([('GET', '/v1/result.png')] if kind == 'image' else + [('GET', '/v1/videos/accepted-remote-id'), ('GET', '/v1/videos/accepted-remote-id/content')]) + final = reopened.snapshot(pid) + assert len(final.jobs) == 1 and final.jobs[0].id == accepted.jobIds[0] + assert final.jobs[0].status == ('cancelled' if action == 'cancel' else 'succeeded') + assert final.jobs[0].externalTaskId == initial.externalTaskId + assert final.jobs[0].inputSnapshot.model_dump() == frozen + assert len(final.results) == (0 if action == 'cancel' else 1) + assert sum(method == 'POST' for method, _ in calls) == 1 and len(queries) == 2 + count = len(calls) + assert second.prepare(command, session=session) == accepted and len(calls) == count + for method in ('prepare', 'prepare_many', 'require'): + getattr(availability, method).assert_not_called() + finally: + await first.close() + if second: + await second.close() + try: + asyncio.run(run()) + finally: + probe.close() + + +@pytest.mark.parametrize('denial', ['unsupported', 'paused']) +def test_remote_tool_frozen_execution_recovery_and_replay_ignore_new_capabilities(project, denial): + store, project_id, session = project + pid = str(project_id) + configs = configure_tools(store, H3) + config = configs.list()[0] + source = upload(store, pid, session, 'poster.png', png((64, 36))) + declaration = document('video_generation', 'image_to_video', '/v1/videos', 'video', model=config['modelId']) + queries, calls = [], [] + probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: ( + queries.append(request) or httpx.Response(200, json=declaration)))) + availability = ModelAvailability(configs, probe) + transport = httpx.MockTransport(video_server(calls, hold=True)) + async def run(): + first = ToolTasks(store.local, configs, CreationToolAssets(store), projects=store, + transport=transport, poll_interval=.01, concurrency=1, availability=availability) + second = None + await first.limit.acquire() + try: + value = {'id': str(uuid4()), 'tool': 'dynamic-poster', 'sourceAssetIds': [source], 'parameters': {}} + accepted = await first.submit(pid, value, session) + frozen = json.dumps(accepted['modelSnapshots'], sort_keys=True) + deny_new_work(availability, config, declaration, denial) + first.limit.release() + async with asyncio.timeout(5): + while ('GET', '/v1/videos/video_1') not in calls: + await asyncio.sleep(.01) + await first.close() + second = ToolTasks(store.local, configs, CreationToolAssets(store), projects=store, + transport=transport, poll_interval=0, availability=availability) + before = len(calls) + await second.recover() + assert calls[before:] == [('GET', '/v1/videos/video_1')] + recovered = second.get(pid, accepted['id']) + assert recovered['status'] == 'interrupted' + assert recovered['modelCalls'][0]['externalTaskId'] == 'video_1' + assert json.dumps(recovered['modelSnapshots'], sort_keys=True) == frozen + before = len(calls) + replay = await second.submit(pid, value, session) + assert replay['id'] == accepted['id'] and replay['modelCalls'] == recovered['modelCalls'] + assert len(calls) == before and calls.count(('POST', '/v1/videos')) == 1 and len(queries) == 2 + for method in ('prepare', 'prepare_many', 'require'): + getattr(availability, method).assert_not_called() + finally: + await first.close() + if second: + await second.close() + try: + asyncio.run(run()) + finally: + probe.close() + + +def test_tool_admission_shares_one_budget_for_image_and_optional_chat(project): + store, project_id, session = project + pid = str(project_id) + configs = ModelConfigs(store.local.repository) + configs.save('image', 'http://image.test/v1', 'Tongyi-MAI/Z-Image-Turbo', '', connection_type='self_hosted') + configs.save('agent', 'http://chat.test/v1', 'demo', '', connection_type='self_hosted') + source = upload(store, pid, session, 'source.png', png()) + finish, queried = threading.Event(), [] + def respond(request): + queried.append(request.url.host) + finish.wait(2) + return httpx.Response(404) + probe = CapabilityProbe(transport=httpx.MockTransport(respond), timeout=.4) + async def run(): + tools = ToolTasks(store.local, configs, CreationToolAssets(store), projects=store, + concurrency=1, availability=ModelAvailability(configs, probe)) + await tools.limit.acquire() + try: + started = time.monotonic() + task = await tools.submit(pid, {'id': str(uuid4()), 'tool': 'outpaint', 'sourceAssetIds': [source], 'parameters': {}}, session) + assert time.monotonic() - started < .65 + assert sorted(queried) == ['chat.test', 'image.test'] + assert set(task['modelSnapshots']) == {'image.edit', 'text.chat'} + finally: + finish.set() + await tools.close() + try: + asyncio.run(run()) + finally: + probe.close() + + def test_saved_http_api_and_draft_checks_share_parser_but_not_observations(tmp_path): calls = [] probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: (calls.append(request) or httpx.Response(200, json=document())))) @@ -309,3 +501,88 @@ async def run(): asyncio.run(run()) finally: probe.close() + + +@contextmanager +def local_api(app): + """Run the real API on an ephemeral loopback port for the real CLI client.""" + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + url = f'http://127.0.0.1:{listener.getsockname()[1]}' + server = uvicorn.Server(uvicorn.Config(app, log_level='warning', access_log=False)) + thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True) + thread.start() + try: + deadline = time.monotonic() + 10 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(.01) + assert server.started, 'Local API did not start' + yield url + finally: + server.should_exit = True + thread.join(10) + assert not thread.is_alive(), 'Local API did not shut down' + + +@pytest.mark.parametrize('entry', ['cli', 'agent']) +@pytest.mark.parametrize('state', ['supported', 'unsupported', 'paused']) +def test_cli_and_agent_generation_apply_real_admission(project, monkeypatch, entry, state): + store, pid, session = project + provider = Provider() + provider.finish.set() + runner, config = runtime(project, provider) + declaration = document(model='test-agent', support='unsupported' if state == 'unsupported' else 'supported') + if state == 'paused': + declaration['models'][0]['operations']['chat']['execution']['state'] = 'paused' + queries = [] + probe = CapabilityProbe(transport=httpx.MockTransport(lambda request: + queries.append(request) or httpx.Response(200, json=declaration))) + runner.availability = ModelAvailability(runner.configs, probe) + command = command_for(project, config) + arguments = command.model_dump(mode='json', exclude={'commandId', 'projectId'}) + before = store.snapshot(pid) + + async def through_agent(): + tools = AgentTools(store, runner, runner.configs) + try: + value = await tools.execute(pid, uuid4(), session, 'submit', 'execute_command', arguments, [], None) + await settled(runner) + return value + finally: + await runner.close() + + try: + if entry == 'agent': + receipt = asyncio.run(through_agent()) + else: + monkeypatch.delenv('OPENDIRECTOR_AGENT_SOCKET', raising=False) + monkeypatch.delenv('OPENDIRECTOR_AGENT_HOST', raising=False) + app = create_app(store.local, generation_provider=provider, capability_probe=probe) + with local_api(app) as url: + result = CliRunner().invoke(cli.app, ['--url', url, '--project', str(pid), '--session', session, + '--operation-id', str(command.commandId), 'generation', 'submit', '--file', '-'], + input=json.dumps(arguments)) + assert result.exit_code == (0 if state == 'supported' else 1), result.output + receipt = json.loads(result.output) + if state == 'supported': + deadline = time.monotonic() + 5 + while store.snapshot(pid).jobs[0].status not in {'succeeded', 'failed'} and time.monotonic() < deadline: + time.sleep(.01) + after = store.snapshot(pid) + assert len(queries) == 1 and queries[0].method == 'GET' + if state == 'supported': + assert receipt['status'] == 'accepted' + assert len(after.jobs) == len(provider.calls) == 1 + job = after.jobs[0] + assert receipt['jobIds'] == [str(job.id)] and job.status == 'succeeded' + assert job.inputSnapshot.spec.modelConfigId == before.generationSpecs[0].modelConfigId + assert job.inputSnapshot.spec.prompt == before.generationSpecs[0].prompt + else: + assert receipt['status'] == 'rejected' + assert receipt['issues'][0]['code'] == ('capability_paused' if state == 'paused' else 'capability_unavailable') + assert not after.jobs and provider.calls == [] + assert after.generationSpecs == before.generationSpecs + assert after.canvasItems == before.canvasItems + assert after.assets == before.assets and after.results == before.results + finally: + probe.close() From 88dc12e943247c2825132af6763509bc4811c1d5 Mon Sep 17 00:00:00 2001 From: bread-lxy <2022200058@ruc.edu.cn> Date: Tue, 10 Nov 2026 12:08:38 +0800 Subject: [PATCH 05/21] fix(ui): clarify capability states and input requirements --- .../opendirector/domain/creation/models.py | 32 + scripts/check-i18n.mjs | 1 + src/backend/availability.ts | 150 ++++- src/backend/chat.ts | 1 + src/backend/rules.ts | 10 +- src/backend/tools.ts | 24 +- src/canvas/AssistantReply.tsx | 2 +- src/canvas/DraftAvailabilityCheck.tsx | 18 +- src/canvas/ModelAvailabilityCard.tsx | 81 ++- src/canvas/ModelServiceForm.tsx | 37 +- src/canvas/ModelSettings.tsx | 2 +- src/canvas/NodeComposer.tsx | 4 +- src/canvas/NodeTaskPanel.tsx | 7 +- src/canvas/assistantToolText.ts | 6 + src/canvas/availabilityPresentation.ts | 177 ++++++ src/canvas/capabilityErrorText.ts | 92 +++ src/canvas/generationFailureText.ts | 3 + src/canvas/model-settings.css | 8 + src/canvas/toolCatalog.ts | 25 +- src/canvas/toolReasonText.ts | 6 + src/canvas/toolTaskText.ts | 3 + src/canvas/toolText.ts | 46 +- src/canvas/workspace/useWorkspaceRuntime.ts | 1 + src/contracts/creation.schema.json | 566 +++++++++++++++++- src/contracts/generated.ts | 58 ++ src/i18n/messages/en-US/availability.ts | 117 ++-- src/i18n/messages/en-US/errors.ts | 51 ++ src/i18n/messages/en-US/models.ts | 4 +- src/i18n/messages/zh-CN/availability.ts | 108 ++-- src/i18n/messages/zh-CN/errors.ts | 51 ++ src/i18n/messages/zh-CN/models.ts | 4 +- tests/backendRules.test.ts | 32 + tests/browser/capability_discovery.mjs | 431 +++++++++++-- tests/capabilityPresentation.test.ts | 202 +++++++ tests/modelAvailability.test.ts | 121 +++- tests/toolReasonText.test.ts | 2 + 36 files changed, 2201 insertions(+), 282 deletions(-) create mode 100644 src/canvas/availabilityPresentation.ts create mode 100644 src/canvas/capabilityErrorText.ts create mode 100644 tests/capabilityPresentation.test.ts diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index b3ab049f..29a26880 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -141,6 +141,32 @@ def connection(self): return self +class CapabilityDeclaration(Contract): + status: Literal['available', 'unavailable', 'unknown'] = Field(description='Support decision excluding execution state.') + reasonCode: str = Field(description='Localizable declaration decision.') + checkedAt: int | None = Field(description='Completion time in Unix milliseconds.') + expiresAt: int | None = Field(description='Declaration expiry in Unix milliseconds.') + + +class CapabilityExecution(Contract): + state: Literal['unpaused', 'paused', 'unknown'] = Field(description='Observed execution pause state.') + scope: Literal['deployment', 'worker'] | None = Field(description='Scope proved by the service observation.') + checkedAt: int | None = Field(description='Observation receipt time in Unix milliseconds.') + expiresAt: int | None = Field(description='Execution observation expiry in Unix milliseconds.') + + +class CapabilityInputDetail(Contract): + name: str = Field(description='Canonical input role or parameter name.') + support: Literal['supported', 'unsupported', 'unknown'] = Field(description='Explicit fact, with omission kept unknown.') + source: Literal['service', 'application'] = Field(default='service', description='Owner of this restriction.') + minCount: int | None = Field(default=None, description='Minimum transmitted inputs of this role.') + maxCount: int | None = Field(default=None, description='Maximum transmitted inputs of this role.') + values: tuple[str | int | float | bool, ...] | None = Field(default=None, description='Explicit allowed parameter values.') + min: float | None = Field(default=None, description='Inclusive parameter lower bound.') + max: float | None = Field(default=None, description='Inclusive parameter upper bound.') + multipleOf: float | None = Field(default=None, description='Parameter step from zero.') + + class OperationAvailability(Contract): operation: str = Field(description='Canonical remote operation identifier.') profile: str = Field(description='Input and output mode used by the configured adapter.') @@ -151,6 +177,12 @@ class OperationAvailability(Contract): reasonCode: str = Field(description='Stable, localizable decision reason.') checkedAt: int | None = Field(description='Observation completion time in Unix milliseconds.') expiresAt: int | None = Field(description='Decision expiry in Unix milliseconds; recompute after expiry.') + declaration: CapabilityDeclaration | None = Field(default=None, exclude_if=lambda value: value is None, + description='Request support independently expires from the short execution observation.') + execution: CapabilityExecution | None = Field(default=None, exclude_if=lambda value: value is None, + description='Short-lived execution observation, separate from declared support.') + inputDetails: tuple[CapabilityInputDetail, ...] = Field(default=(), + description='Known role and parameter facts for the actual request route; no saved configuration changes.') class ModelAvailability(Contract): diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index af2d3e63..0add7e50 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -29,6 +29,7 @@ const skip = [ /[\\/]modelDescriptions\.ts$/, /[\\/]creationPreviewText\.ts$/, /[\\/]assistantToolText\.ts$/, + /[\\/]capabilityErrorText\.ts$/, /[\\/]generationFailureText\.ts$/, /\.test\./, ]; diff --git a/src/backend/availability.ts b/src/backend/availability.ts index 916976d1..60772dc8 100644 --- a/src/backend/availability.ts +++ b/src/backend/availability.ts @@ -23,6 +23,27 @@ export function failedAvailability( reasonCode: "query_failed", checkedAt: null, expiresAt: null, + ...(item.declaration + ? { + declaration: { + ...item.declaration, + status: "unknown" as const, + reasonCode: "query_failed", + checkedAt: null, + expiresAt: null, + }, + } + : {}), + ...(item.execution + ? { + execution: { + ...item.execution, + state: "unknown" as const, + checkedAt: null, + expiresAt: null, + }, + } + : {}), })), }; } @@ -36,14 +57,15 @@ const clock: AvailabilityClock = { wallNow: () => Date.now(), monotonicNow: () => performance.now(), }; +type TimedObservation = Pick; const deadlines = new WeakMap< - OperationAvailability, + TimedObservation, { clock: AvailabilityClock; at: number } >(); const observations = new WeakMap>(); function deadline( - value: OperationAvailability, + value: TimedObservation, timing: AvailabilityClock, ): number | null { if (value.expiresAt === null) return null; @@ -71,25 +93,33 @@ export function receiveAvailability( observations.set(timing, known); } for (const operation of value.operations) { - if (operation.expiresAt === null) continue; - const key = JSON.stringify([ - value.identity, - operation.operation, - operation.profile, - operation.method, - operation.path, - operation.output, - operation.status, - operation.reasonCode, - operation.checkedAt, - operation.expiresAt, - ]); - const previous = known.get(key); - const at = previous ?? deadline(operation, timing)!; - deadlines.set(operation, { clock: timing, at }); - known.delete(key); - known.set(key, at); - if (known.size > 1024) known.delete(known.keys().next().value!); + const parts: [string, TimedObservation][] = operation.declaration + ? [ + ["declaration", operation.declaration], + ...(operation.execution + ? [["execution", operation.execution] as [string, TimedObservation]] + : []), + ] + : [["combined", operation]]; + for (const [part, observation] of parts) { + if (observation.expiresAt === null) continue; + const key = JSON.stringify([ + value.identity, + operation.operation, + operation.profile, + operation.method, + operation.path, + operation.output, + part, + observation, + ]); + const previous = known.get(key); + const at = previous ?? deadline(observation, timing)!; + deadlines.set(observation, { clock: timing, at }); + known.delete(key); + known.set(key, at); + if (known.size > 1024) known.delete(known.keys().next().value!); + } } return value; } @@ -102,6 +132,11 @@ export function availabilityDelay( receiveAvailability(value, timing); const now = timing.monotonicNow(); const remaining = value.operations + .flatMap((item) => + item.declaration + ? [item.declaration, ...(item.execution ? [item.execution] : [])] + : [item], + ) .map((item) => deadline(item, timing)) .filter((at): at is number => at !== null && at > now) .map((at) => at - now); @@ -113,6 +148,41 @@ export function currentOperation( value: OperationAvailability, timing: AvailabilityClock = clock, ): OperationAvailability { + if (value.declaration) { + const declarationAt = deadline(value.declaration, timing); + const executionAt = value.execution + ? deadline(value.execution, timing) + : null; + const declarationExpired = + declarationAt !== null && declarationAt <= timing.monotonicNow(); + const executionExpired = + executionAt !== null && executionAt <= timing.monotonicNow(); + if (!declarationExpired && !executionExpired) return value; + const declaration = declarationExpired + ? { + ...value.declaration, + status: "unknown" as const, + reasonCode: "observation_expired", + expiresAt: null, + } + : value.declaration; + // Restore the server's declaration when its shorter execution observation + // expires. The browser never infers support from a model name or input. + return { + ...value, + ...declaration, + declaration, + ...(executionExpired && value.execution + ? { + execution: { + ...value.execution, + state: "unknown" as const, + expiresAt: null, + }, + } + : {}), + }; + } const at = deadline(value, timing); return at !== null && at <= timing.monotonicNow() ? { @@ -135,15 +205,22 @@ export function currentAvailability( if (operations.every((item, index) => item === value.operations[index])) return value; const states = new Set(operations.map((item) => item.status)); + const reasons = new Set(operations.map((item) => item.reasonCode)); return { ...value, operations, status: states.size === 1 ? operations[0].status : "unknown", - reasonCode: "observation_expired", + reasonCode: + reasons.size === 1 + ? operations[0].reasonCode + : reasons.has("observation_expired") + ? "observation_expired" + : "mixed_operations", }; } export function availabilityReason(code: string): string { + if (code === "mixed_operations") return tNow("availability.status.unknown"); const key = `availability.reason.${code}`; return hasMessage(key) ? tNow(key) @@ -171,6 +248,35 @@ export function availabilityBlocker( : null; } +/** Service limits are temporary observations, never saved input rules. */ +export function availabilityInputLimit( + model: ModelConfig | undefined, + profile: string, + role: "image" | "video" | "audio", + timing: AvailabilityClock = clock, +): number | null { + if (!model?.availability) return null; + receiveAvailability(model.availability, timing); + const item = model.availability.operations.find( + (value) => value.profile === profile, + ); + if (!item) return null; + const current = currentOperation(item, timing); + const declaration = current.declaration ?? current; + if (declaration.status === "unknown" || declaration.expiresAt === null) + return null; + const limits = (current.inputDetails ?? []) + .filter( + (detail) => + detail.name === role && + detail.source === "service" && + detail.support === "supported" && + detail.maxCount != null, + ) + .map((detail) => detail.maxCount!); + return limits.length ? Math.min(...limits) : null; +} + /** Local identity excludes credentials; saves additionally invalidate pending reads. */ export function availabilityModelKey(model: ModelConfig): string { return JSON.stringify([ diff --git a/src/backend/chat.ts b/src/backend/chat.ts index 93de2b62..995a244f 100644 --- a/src/backend/chat.ts +++ b/src/backend/chat.ts @@ -39,6 +39,7 @@ export interface ChatTool { status: "running" | "complete" | "failed" | "interrupted"; textOffset: number; error?: string | null; + code?: string; message?: string | null; taskId?: string | null; arguments?: Record; diff --git a/src/backend/rules.ts b/src/backend/rules.ts index 5989214d..a31f0fc0 100644 --- a/src/backend/rules.ts +++ b/src/backend/rules.ts @@ -1,4 +1,4 @@ -import { availabilityBlocker } from "./availability.ts"; +import { availabilityBlocker, availabilityInputLimit } from "./availability.ts"; import { voiceIssue, type VoiceCapabilities } from "./voices.ts"; import { nodeByteSize } from "../canvas/uploadPolicy.ts"; import type { CanvasNode, GenerationSettings } from "../canvas/model.ts"; @@ -657,6 +657,14 @@ export function backendBlocker( references.filter( (item) => item.role !== "continuation" && item.node.data.kind === kind, ).length; + for (const kind of ["image", "video", "audio"] as const) { + const limit = availabilityInputLimit(config, profile, kind); + const current = references.filter((item) => item.node.data.kind === kind).length; + if (limit !== null && current > limit) + return tNow(kind === "image" ? "errors.capability.imageCount" : "errors.rules.serviceInputLimit", { + kind: tNow(LABELS[kind] ?? "errors.rules.kindImage"), limit, current, + }); + } for (const [kind, limit] of [ ["image", rules.maxImages], ["video", rules.maxVideos], diff --git a/src/backend/tools.ts b/src/backend/tools.ts index 940157e3..f5770a1d 100644 --- a/src/backend/tools.ts +++ b/src/backend/tools.ts @@ -17,6 +17,8 @@ import { } from "../canvas/toolTaskText.ts"; import { toolReasonText } from "../canvas/toolReasonText.ts"; import { tNow } from "../i18n/messages.ts"; +import type { Translate } from "../i18n/I18nProvider.tsx"; +import { defaultTranslate } from "../canvas/assistantTranslate.ts"; import { EXTENSIONS, fileName, @@ -39,6 +41,7 @@ export interface ToolCatalogEntry { id: CanvasToolId; available: boolean; reason?: string; + reasonCode?: string; note: string; parametersConfirmed: boolean; fields: ToolField[]; @@ -167,7 +170,7 @@ export interface ToolRequest { } /** Availability and service-supplied options for every tool, refreshed with the model list. */ -export function useToolCatalog(connected: boolean, revision: unknown) { +export function useToolCatalog(connected: boolean, revision: unknown, t: Translate = defaultTranslate) { const [tools, setTools] = useState([]); const [error, setError] = useState(""); const [selectionPreview, setSelectionPreview] = useState<{ @@ -180,16 +183,8 @@ export function useToolCatalog(connected: boolean, revision: unknown) { tools: ToolCatalogEntry[]; selectionPreview?: { available: boolean; reason?: string }; }>("/tools"); - setTools( - value.tools.map((entry) => ({ - ...entry, - reason: toolReasonText(entry.reason), - })), - ); - setSelectionPreview({ - ...(value.selectionPreview ?? { available: false }), - reason: toolReasonText(value.selectionPreview?.reason), - }); + setTools(value.tools); + setSelectionPreview(value.selectionPreview ?? { available: false }); setError(""); } catch (failure) { setTools([]); @@ -204,19 +199,22 @@ export function useToolCatalog(connected: boolean, revision: unknown) { setSelectionPreview({ available: false }); } }, [connected, revision, refresh]); + const displayedTools = tools.map((entry) => ({ ...entry, reason: toolReasonText(entry.reason, t, entry.reasonCode) })); const capabilities: ToolCapabilities = Object.fromEntries( - tools.map((entry) => [ + displayedTools.map((entry) => [ entry.id, { available: entry.available, reason: entry.reason, + reasonCode: entry.reasonCode, fields: entry.fields, models: entry.models, parametersConfirmed: entry.parametersConfirmed, }, ]), ); - return { tools, capabilities, selectionPreview, error, refresh }; + return { tools: displayedTools, capabilities, + selectionPreview: { ...selectionPreview, reason: toolReasonText(selectionPreview.reason, t) }, error, refresh }; } /** Sources and masks become project assets once; identical files are reused. */ diff --git a/src/canvas/AssistantReply.tsx b/src/canvas/AssistantReply.tsx index e60d4e7e..13d7d57c 100644 --- a/src/canvas/AssistantReply.tsx +++ b/src/canvas/AssistantReply.tsx @@ -29,7 +29,7 @@ function ToolActivity({ tool, t }: { tool: ChatTool; t: Translate }) {
{(tool.error || tool.message) && ( -

{assistantToolText(tool.error || tool.message!, t)}

+

{assistantToolText(tool.error || tool.message!, t, tool.code)}

)} {tool.arguments &&
{t("assistant.reply.arguments")} diff --git a/src/canvas/DraftAvailabilityCheck.tsx b/src/canvas/DraftAvailabilityCheck.tsx index 756948d1..6230c01f 100644 --- a/src/canvas/DraftAvailabilityCheck.tsx +++ b/src/canvas/DraftAvailabilityCheck.tsx @@ -66,17 +66,11 @@ export function DraftAvailabilityCheck({ } }; return ( - <> - void check()} - /> - {failure?.signature === signature && ( -

- {failure.message} -

- )} - + void check()} + error={failure?.signature === signature ? failure.message : undefined} + /> ); } diff --git a/src/canvas/ModelAvailabilityCard.tsx b/src/canvas/ModelAvailabilityCard.tsx index 30f8b111..b4b8c69c 100644 --- a/src/canvas/ModelAvailabilityCard.tsx +++ b/src/canvas/ModelAvailabilityCard.tsx @@ -6,7 +6,12 @@ import { currentAvailability, type ModelAvailability, } from "../backend/availability.ts"; -import { useT } from "../i18n/I18nProvider"; +import { useI18n } from "../i18n/I18nProvider"; +import { + availabilityCardDetails, + availabilityNotice, + availabilityRow, +} from "./availabilityPresentation.ts"; /** Observations do not edit the model's saved input rules or voice selection. */ export function ModelAvailabilityCard({ @@ -14,13 +19,15 @@ export function ModelAvailabilityCard({ onRefresh, checking = false, title, + error, }: { value?: ModelAvailability | null; onRefresh?: () => void; checking?: boolean; title?: string; + error?: string; }) { - const t = useT(); + const { t, locale } = useI18n(); const [tick, setTick] = useState(0); useEffect(() => { const delay = value ? availabilityDelay(value) : null; @@ -32,6 +39,11 @@ export function ModelAvailabilityCard({ return () => clearTimeout(timer); }, [value, tick]); const current = value ? currentAvailability(value) : null; + const notice = availabilityNotice(current); + const message = checking + ? null + : error || (notice ? availabilityReason(notice) : null); + const details = availabilityCardDetails(current?.operations ?? []); return (
- {t(checking ? "availability.checking" : "availability.check")} + {t( + checking + ? "availability.checking" + : error || notice?.includes("failed") + ? "availability.retry" + : !current || notice === "not_checked" + ? "availability.check" + : "availability.refresh", + )} )}
-

- {checking - ? t("availability.checking") - : availabilityReason(current?.reasonCode ?? "not_checked")} -

+ {message &&

{message}

} {current?.operations.length ? (
    - {current.operations.map((item) => ( -
  • - {availabilityProfile(item.profile)} - - {t(`availability.status.${item.status}`)} —{" "} - {availabilityReason(item.reasonCode)} - -
  • - ))} + {current.operations.map((item) => { + const row = availabilityRow(item, notice); + return ( +
  • + {availabilityProfile(item.profile)} +
    + {row.text} +
    +
  • + ); + })}
) : null} + {details.length > 0 && ( +
+ {t("availability.details")} +
+ {details.map((detail, index) => ( +
+
{detail.label}
+
+ {detail.text} + {detail.scope && {detail.scope}} + {detail.source} +
+
+ ))} +
+
+ )} {current?.checkedAt != null && ( {t("availability.checkedAt", { - time: new Date(current.checkedAt).toLocaleTimeString(), + time: new Date(current.checkedAt).toLocaleTimeString(locale), })} )} diff --git a/src/canvas/ModelServiceForm.tsx b/src/canvas/ModelServiceForm.tsx index 5bcf6232..3fe3b3d0 100644 --- a/src/canvas/ModelServiceForm.tsx +++ b/src/canvas/ModelServiceForm.tsx @@ -53,26 +53,6 @@ const PROTOCOLS: Record< ], }; -const PLACEHOLDERS: Record = { - agent: ["http://127.0.0.1:8100/v1", "Qwen/Qwen3-30B-A3B"], - image: ["http://127.0.0.1:8200/v1", "Tongyi-MAI/Z-Image-Turbo"], - video: ["http://127.0.0.1:8010/v1", "MiniMaxAI/MiniMax-H3"], - audio: ["http://127.0.0.1:8220/v1", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"], - vision: ["http://127.0.0.1:8230/v1", "facebook/sam3"], -}; -const MUSIC_PLACEHOLDERS: [string, string] = [ - "http://127.0.0.1:8240/v1", - "m-a-p/YuE2-3B", -]; -const SCORE_PLACEHOLDERS: [string, string] = [ - "http://127.0.0.1:8260/v1", - "m-a-p/SheetSage2", -]; -const DEPTH_PLACEHOLDERS: [string, string] = [ - "http://127.0.0.1:8250/v1", - "depth-anything/Video-Depth-Anything-Small", -]; - export function ModelServiceForm({ kind, existing, @@ -146,18 +126,6 @@ export function ModelServiceForm({ const segmentation = value.protocol === "vllm_omni_segmentation"; const depth = value.protocol === "vllm_omni_depth"; const score = value.protocol === "vllm_omni_score"; - const placeholders = - value.protocol === "minimax_video_v2" - ? ["https://api.minimax.cn/v2", "MiniMax-H3"] - : value.protocol === "kling_video" - ? ["https://api-beijing.klingai.com", "kling-4.0"] - : value.protocol === "vllm_omni_music" - ? MUSIC_PLACEHOLDERS - : score - ? SCORE_PLACEHOLDERS - : depth - ? DEPTH_PLACEHOLDERS - : PLACEHOLDERS[segmentation ? "vision" : kind]; const hasSavedKey = Boolean( connection && connection.authMode === "bearer" && @@ -434,7 +402,7 @@ export function ModelServiceForm({ required maxLength={2048} value={value.url} - placeholder={placeholders[0]} + placeholder={t("models.service.urlPlaceholder")} aria-describedby={`${connectionFieldId}-url-note`} onChange={(event) => update({ url: event.target.value })} /> @@ -470,7 +438,6 @@ export function ModelServiceForm({
-

{t("availability.autoHint")}

@@ -528,7 +495,7 @@ export function ModelServiceForm({

{t("models.service.minimaxNote")}

)} {t("availability.title")} {members.map((member) => void catalog.refreshAvailability([member.id], true)} />)} - {catalog.availabilityError &&

{catalog.availabilityError}

}
diff --git a/src/canvas/NodeComposer.tsx b/src/canvas/NodeComposer.tsx index 1481febb..467d8fc0 100644 --- a/src/canvas/NodeComposer.tsx +++ b/src/canvas/NodeComposer.tsx @@ -1,4 +1,4 @@ -import { availabilityReason, currentAvailability } from "../backend/availability.ts"; +import { availabilitySummary } from "./availabilityPresentation.ts"; import { VoiceLibrary } from "./VoiceLibrary"; import { modelDescription } from "./modelDescriptions"; import { SearchableSelect } from "./SearchableSelect"; @@ -494,7 +494,7 @@ export function NodeComposer({ id: `config:${item.id}`, name: modelLabel(item), modelId: item.modelId, - description: item.availability ? `${modelDescription(item)} · ${availabilityReason(currentAvailability(item.availability).reasonCode)}` : modelDescription(item), + description: [modelDescription(item), availabilitySummary(item.availability)].filter(Boolean).join(" · ") || undefined, group: providerLabel(item), })), ]} diff --git a/src/canvas/NodeTaskPanel.tsx b/src/canvas/NodeTaskPanel.tsx index e9746e62..71898847 100644 --- a/src/canvas/NodeTaskPanel.tsx +++ b/src/canvas/NodeTaskPanel.tsx @@ -17,6 +17,7 @@ import { import { translateTool, translateToolText } from "./toolText.ts"; import { displayNodeTitle } from "./nodeTitle.ts"; import { useI18n } from "../i18n/I18nProvider"; +import { toolReasonText } from "./toolReasonText.ts"; import "./node-task-panel.css"; /** A single source-bound operation using the service's capability catalog. */ @@ -103,7 +104,7 @@ export function NodeTaskPanel({ errors[0] ?? (runnable ? t("tools.nodeTask.presetNote") - : (capability?.reason ?? t("tools.nodeTask.notConnected"))); + : (toolReasonText(capability?.reason, t) ?? t("tools.nodeTask.notConnected"))); const models = capability?.models?.["image.edit"] ?? []; return (
{runnable ? (errors[0] ?? t("tools.nodeTask.remoteNote")) - : `${capability?.reason ?? t("tools.nodeTask.unavailable")}${t("tools.nodeTask.saveHint")}`} + : `${toolReasonText(capability?.reason, t) ?? t("tools.nodeTask.unavailable")}${t("tools.nodeTask.saveHint")}`}