Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aad694a
feat: enforce optional model capability admission
bread-lxy Nov 9, 2026
c4faa7b
fix: validate capability inputs within shared query deadline
bread-lxy Nov 9, 2026
ac86ac5
feat: present model capabilities across configuration and creation
bread-lxy Nov 9, 2026
e6c1121
test: preserve accepted work across capability changes
bread-lxy Nov 9, 2026
88dc12e
fix(ui): clarify capability states and input requirements
bread-lxy Nov 10, 2026
235859c
fix(capabilities): validate serialized inputs without dropping images
bread-lxy Nov 10, 2026
58e79f5
feat(tools): preserve region edits with declared native masks
bread-lxy Nov 10, 2026
e2f2cf7
docs(test): document capability acceptance and portable audio cases
bread-lxy Nov 10, 2026
d948492
test(canvas): select a new region when continuing tool output
bread-lxy Nov 10, 2026
fadcf8a
fix: validate and freeze image tool request parameters
bread-lxy Nov 10, 2026
6afc56e
[bugfix] providers: match capabilities to transmitted requests
bread-lxy Nov 10, 2026
7504bb2
[bugfix] providers: reject disabled endpoints for unknown profiles
bread-lxy Nov 10, 2026
4732cbf
[bugfix] providers: apply endpoint closure across incomplete profiles
bread-lxy Nov 10, 2026
d3ec162
fix(ui): show shared capability sources once
bread-lxy Nov 10, 2026
3ddd53b
test(providers): isolate capability checks and await page renewals
bread-lxy Nov 10, 2026
327c35f
fix(tools): preserve missing model guidance in both languages
bread-lxy Nov 10, 2026
d6c98ad
fix(canvas): notify each task failure once across recovery
bread-lxy Nov 10, 2026
b28515e
fix: clarify busy service and blocked result download errors
bread-lxy Nov 10, 2026
faf43d1
fix: refresh generation history when the language changes
bread-lxy Nov 10, 2026
7f0f6df
Merge upstream main and preserve image capability checks
bread-lxy Nov 10, 2026
f2ddd22
docs: illustrate bilingual model capability cards
bread-lxy Nov 10, 2026
953ffb9
Merge upstream MiniMax image support
bread-lxy Nov 10, 2026
5e691a5
fix: check MiniMax image requests against transmitted parameters
bread-lxy Nov 10, 2026
a0fbd18
Merge remote-tracking branch 'upstream/main' into codex/final-validat…
bread-lxy Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/api/src/opendirector_api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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',
Expand Down Expand Up @@ -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, 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}:
raise ProjectError('技能已失效,请重新选择。', 409)
Expand All @@ -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, inputs={'text'})[0], prepared)
async def produce() -> None:
text, status, error, accepted = '', 'complete', None, False
activities = []
Expand All @@ -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 = []
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/opendirector_api/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from opendirector.domain.timeline_errors import timeline_error_message

from opendirector.domain.creation.commands import CommandReceipt
from opendirector.domain.capabilities import Capability
from opendirector.domain.capability_messages import english_capability_message, missing_tool_model_message

ENGLISH_BY_CODE = {
'execution_file_link': 'Execution files must not have hard links.',
Expand All @@ -21,6 +23,14 @@
'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': 'This request is not supported.',
'capability_paused': 'Service paused. Resume it and try again.',
'image_input_count': 'Too many input images.',
'image_input_bytes': 'The input images exceed the size limit.',
'image_input_mask': 'Check the image selection.',
'tool_source_count': 'Check the number of source images.',
'tool_source_type': 'Source assets must be images.',
'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.',
Expand Down Expand Up @@ -106,12 +116,19 @@ def localized_error_for_language(
chinese: str,
status: int,
code: str = "operation_rejected",
required_capability: Capability | None = None,
) -> str:
specific = timeline_error_message(code, language)
if specific is not None:
return specific
if not wants_english_language(language):
return chinese
if code == 'tool_model_missing':
return missing_tool_model_message(required_capability, english=True)
if code.startswith(('capability_', 'image_input_', 'tool_source_')):
translated = english_capability_message(chinese)
if translated:
return translated
# A generic operation code must not hide a more specific HTTP status.
generic = code in {"operation_rejected", "invalid_input"}
if generic and status in {413, 502, 504}:
Expand All @@ -134,12 +151,14 @@ def localized_error(
chinese: str,
status: int,
code: str = "operation_rejected",
required_capability: Capability | None = None,
) -> str:
return localized_error_for_language(
request.headers.get("accept-language"),
chinese=chinese,
status=status,
code=code,
required_capability=required_capability,
)


Expand Down
44 changes: 35 additions & 9 deletions apps/api/src/opendirector_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
from opendirector.installation import data_root, web_root
from opendirector.wiring import configure_runtime
from opendirector.domain.projects import ProjectError, identifier
from opendirector.domain.capability_messages import MissingToolModelError
from opendirector.domain.creation.validation import ContractError
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
Expand All @@ -30,7 +32,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):
Expand All @@ -41,17 +44,18 @@ 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:
store = LocalProjects(repository)
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):
Expand All @@ -64,6 +68,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))
Expand All @@ -87,15 +92,18 @@ async def invalid_request(request, error):

@app.exception_handler(ProjectError)
async def project_error(request, error):
required_capability = error.capability if isinstance(error, MissingToolModelError) else None
return JSONResponse(
{
"detail": localized_error(
request,
chinese=str(error),
status=error.status,
code=error.code,
required_capability=required_capability,
),
"code": error.code,
**({'requiredCapability': required_capability} if required_capability else {}),
},
status_code=error.status,
)
Expand Down Expand Up @@ -192,6 +200,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()
Expand All @@ -211,7 +234,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):
Expand All @@ -224,14 +247,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):
Expand All @@ -245,7 +270,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):
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/opendirector_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This directory is the entry point for the repository's shared documentation. The
| [Business CLI](guides/cli.md) | Querying, editing, and generating through a terminal or Agent Shell | Commands, authorization, or receipt semantics change |
| [DeepSeek Harness plugin](../plugins/deepseek-harness/README.md) | Installing the DSH bundle and editing an OpenDirector project from DSH | Plugin setup, tools, or creation workflows 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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/development/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Consult the [development lessons](lessons.md) when selecting relevant scenarios,

## 按风险选择验证

能力与提示修改还需检查实际页面和发送内容:共同失败原因只呈现一次,提示说明用户能够采取的操作,调研记录和测试配置不进入产品文案。分别查看中英文页面的正常、未知、过期、暂停和失败状态。按最终请求统计素材,确认超量输入零新增任务、零生成请求;默认参数与服务允许范围、工具来源与模型输入分别核对。

| 修改范围 | 应核对的重点 |
| --- | --- |
| 业务规则、参考及上传 | 实际类型、字节预算、来源身份、跟随与固定、拒绝后状态不变 |
Expand Down
Binary file added docs/guides/images/model-availability-en-US.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/guides/images/model-availability-zh-CN.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading