Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions apps/api/src/opendirector_api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, JsonValue

from opendirector.agent.catalog import model_choices
from opendirector.agent.context import RunContext, RuntimeId
from opendirector.agent.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec
from opendirector.agent.skills import skills
Expand Down Expand Up @@ -183,6 +184,11 @@ async def available_runtimes(project_id: UUID):
await asyncio.to_thread(conversations.path, project_id)
return await configured_runtimes()

@router.get('/runtimes/{runtime}/models')
async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude']):
await asyncio.to_thread(conversations.path, project_id)
return await model_choices(runtime)

@router.get('/skills')
async def catalog(project_id: UUID):
directory = await asyncio.to_thread(conversations.path, project_id)
Expand Down
10 changes: 9 additions & 1 deletion docs/architecture/agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Project changes go through `read_project`, `command_schema` and `execute_command

Conversation drafts and messages persist separately from project objects. Only one reply per project is admitted; stop and stream disconnect terminate the process group, drain accepted business operations and save the partial reply. Native events and business-tool events are recorded in `agent-runs/<turn-id>.opencode.jsonl`.

Image reading requires both a compatible model/API and enabled image input in model settings. Native tool availability is not proof that a model can understand a video; inspect actual output. ComfyUI execution and character-replacement quality are outside this change. External CLI selection and the richer Skill and preview UI follow separately.
Image reading requires both a compatible model/API and enabled image input in model settings. Native tool availability is not proof that a model can understand a video; inspect actual output. ComfyUI execution and character-replacement quality are outside this change. The richer Skill and preview UI follow separately.

Validation covers the real bundled OpenCode process with a simulated model transport, shared MCP calls, cancellation, persisted conversation state, and actual FFmpeg outputs published through business commands. Simulated model tests do not establish real model planning or visual quality.

## External CLI selection

The assistant picker separates installed OpenCode CLI, Codex and Claude Code from Default. Default continues to use bundled OpenCode and OpenDirector model settings; external harnesses use their installed binary, native authentication and model catalog. Unavailable harnesses provide setup guidance. Model and effort choices persist with each conversation, with a browser preference used while the draft is saving.

OpenCode CLI reads its configured model, recent selection or native provider default. Codex reads its native model catalog and configured default before an isolated execution; Claude Code reads its native initialization catalog. The server inherits authentication environment variables, including third-party API configuration, so launch it from the configured shell environment.

All harnesses receive the same turn-scoped business gateway and execution directory. Codex runs with workspace-write sandboxing; Claude Code receives the explicit Skill, Bash, Read and OpenDirector tool allowlist. Native events are recorded under the corresponding runtime name. Automated CLI fixtures validate routing and process behavior; each installed CLI and real model still require separate acceptance testing.
152 changes: 152 additions & 0 deletions packages/core/src/opendirector/agent/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Read model choices from the installed harness without starting a model turn."""
import asyncio
import json
import os
import re
import secrets
from pathlib import Path

import httpx
from pydantic import BaseModel, Field

from opendirector.agent.native import executable
from opendirector.agent.process import terminate
from opendirector.domain.projects import ProjectError


class ModelChoice(BaseModel):
id: str
label: str
efforts: list[str] = Field(default_factory=list)
isDefault: bool = Field(default=False, exclude_if=lambda value: not value)


async def opencode_fallback(binary: str, configured_providers: list[str]) -> str | None:
"""Use the native provider order and model ranking, without a model turn."""
password = secrets.token_urlsafe(24)
process = await asyncio.create_subprocess_exec(
binary, 'serve', '--pure', '--hostname', '127.0.0.1', '--port', '0',
env={**os.environ, 'OPENCODE_SERVER_PASSWORD': password},
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True)
try:
async with asyncio.timeout(15):
while line := await process.stdout.readline():
address = re.search(rb'http://127\.0\.0\.1:\d+', line)
if not address:
continue
async with httpx.AsyncClient(trust_env=False, auth=(
os.environ.get('OPENCODE_SERVER_USERNAME', 'opencode'), password)) as client:
response = await client.get(address[0].decode() + '/config/providers')
response.raise_for_status()
data = response.json()
provider = next((item for item in data['providers']
if not configured_providers or item['id'] in configured_providers), None)
if provider and (model := data['default'].get(provider['id'])):
return f"{provider['id']}/{model}"
return None
return None
finally:
await terminate(process, group=True)


async def model_choices(runtime: str) -> list[dict]:
if runtime not in {'opencode', 'codex', 'claude'}:
raise ProjectError('此引擎使用模型设置中的模型。')
binary = executable(runtime)
if binary is None:
raise ProjectError('CLI 尚未安装。', 503)
if runtime == 'opencode':
process = await asyncio.create_subprocess_exec(binary, 'models', '--pure',
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True)
try:
output, _ = await asyncio.wait_for(process.communicate(), 15)
if process.returncode != 0:
raise ProjectError('读取 OpenCode 模型列表失败,请检查本机配置。', 503)
rows = [ModelChoice(id=line, label=line) for line in output.decode().splitlines() if '/' in line]
config_process = await asyncio.create_subprocess_exec(binary, 'debug', 'config',
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True)
try:
data, _ = await asyncio.wait_for(config_process.communicate(), 10)
configuration = json.loads(data) if config_process.returncode == 0 else {}
configured = configuration.get('model')
finally:
await terminate(config_process, group=True)
if not configured:
state = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'opencode/model.json'
if state.is_file():
recent = json.loads(state.read_text()).get('recent', [])
configured = next((f"{item['providerID']}/{item['modelID']}" for item in recent
if any(row.id == f"{item['providerID']}/{item['modelID']}" for row in rows)), None)
if not configured:
configured = await opencode_fallback(binary, list(configuration.get('provider', {})))
for row in rows:
row.isDefault = row.id == configured
if configured and not any(row.id == configured for row in rows):
rows.insert(0, ModelChoice(id=configured, label=configured, isDefault=True))
return [row.model_dump() for row in rows]
except (TimeoutError, UnicodeError, OSError, ValueError, httpx.HTTPError) as error:
raise ProjectError('读取 OpenCode 模型列表失败,请重试。', 503) from error
finally:
await terminate(process, group=True)
args = ['app-server'] if runtime == 'codex' else [
'-p', '--verbose', '--input-format', 'stream-json', '--output-format', 'stream-json']
try:
process = await asyncio.create_subprocess_exec(
binary, *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, start_new_session=True, limit=4 * 1024 * 1024)
except OSError as error:
raise ProjectError('CLI 无法启动,请检查安装。', 503) from error

async def request(message, request_id):
process.stdin.write((json.dumps(message) + '\n').encode())
await process.stdin.drain()
while line := await process.stdout.readline():
event = json.loads(line)
if runtime == 'codex' and event.get('id') == request_id:
if 'error' in event:
raise ProjectError('CLI 无法提供模型列表。', 503)
return event['result']
if runtime == 'claude' and event.get('type') == 'control_response':
response = event['response']
if response.get('request_id') == request_id:
if response['subtype'] != 'success':
raise ProjectError('CLI 无法提供模型列表。', 503)
return response['response']
raise ProjectError('CLI 未返回模型列表。', 503)

try:
async with asyncio.timeout(15):
if runtime == 'codex':
await request({'id': 1, 'method': 'initialize', 'params': {
'clientInfo': {'name': 'opendirector', 'version': '0.1'},
'capabilities': {'experimentalApi': True}}}, 1)
process.stdin.write(b'{"method":"initialized"}\n')
try:
configuration = await request({'id': 3, 'method': 'config/read', 'params': {'includeLayers': False}}, 3)
configured = configuration.get('config', {}).get('model')
except ProjectError:
configured = None
rows, cursor = [], None
while True:
result = await request({'id': 2, 'method': 'model/list',
'params': {'cursor': cursor, 'limit': 100}}, 2)
rows.extend(result['data'])
cursor = result.get('nextCursor')
if not cursor:
break
choices = [ModelChoice(id=item['model'], label=item['displayName'],
efforts=[effort['reasoningEffort'] for effort in item.get('supportedReasoningEfforts', [])],
isDefault=item['model'] == configured if configured else item.get('isDefault', False))
for item in rows if not item.get('hidden', False)]
if configured and not any(item.id == configured for item in choices):
choices.insert(0, ModelChoice(id=configured, label=configured, isDefault=True))
return [item.model_dump() for item in choices]
result = await request({'type': 'control_request', 'request_id': 'model-catalog',
'request': {'subtype': 'initialize'}}, 'model-catalog')
return [ModelChoice(id=item['value'], label=(item.get('resolvedModel') if item['value'] == 'default' else None) or item.get('displayName') or item['value'],
efforts=item.get('supportedEffortLevels', []), isDefault=item['value'] == 'default' or item.get('isDefault', False)).model_dump()
for item in result['models'] if not item.get('disabled', False)]
except (TimeoutError, OSError, ValueError, KeyError, TypeError) as error:
raise ProjectError('读取 CLI 模型列表失败,请重试或检查 CLI 配置。', 503) from error
finally:
await terminate(process, group=True)
24 changes: 23 additions & 1 deletion packages/core/src/opendirector/agent/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,29 @@ def command(runtime: str, work: Path, socket: str, token: str, model: str | None
if model:
args += ['--model', f'opendirector/{model}' if bundled else model]
return args, env
raise ProjectError('未知 Agent 引擎。')
if runtime == 'codex':
settings = [f'mcp_servers.opendirector.command={json.dumps(bridge["command"])}',
f'mcp_servers.opendirector.args={json.dumps(bridge["args"])}', 'mcp_servers.opendirector.required=true',
'mcp_servers.opendirector.default_tools_approval_mode="approve"',
'features.shell_tool=true', 'features.apply_patch_freeform=false', 'web_search="disabled"',
'skills.bundled.enabled=false', f'developer_instructions={json.dumps(SYSTEM)}']
settings += [f'mcp_servers.opendirector.env.{key}={json.dumps(value)}' for key, value in bridge['env'].items()]
args = [binary, 'exec', '--json', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--sandbox', 'workspace-write']
for setting in settings:
args += ['-c', setting]
else:
plugin = work / 'plugin'
(plugin / '.claude-plugin').mkdir(parents=True)
(plugin / '.claude-plugin/plugin.json').write_text(json.dumps({'name': 'opendirector', 'version': '0.1.0'}))
(plugin / 'skills').symlink_to(work / '.agents/skills', target_is_directory=True)
args = [binary, '-p', '--verbose', '--system-prompt', SYSTEM,
'--output-format', 'stream-json', '--plugin-dir', str(plugin), '--tools', 'Skill,Bash,Read',
'--strict-mcp-config', '--mcp-config', json.dumps({'mcpServers': {'opendirector': bridge}}),
'--allowedTools', 'mcp__opendirector__*', 'Skill(opendirector:*)', 'Bash', 'Read',
'--permission-mode', 'dontAsk', '--setting-sources', '']
if model:
args += ['--model', model]
return args, env


def text_events(runtime: str, raw: dict) -> list[dict]:
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/opendirector/agent/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class RuntimeSpec:

RUNTIMES = {
'default': RuntimeSpec(id='default', label='Default', group='default', modelSource='settings', modelOverride=False, engine='default'),
'opencode': RuntimeSpec(id='opencode', label='OpenCode CLI', group='harness', modelSource='cli', modelOverride=True, engine='opencode'),
'codex': RuntimeSpec(id='codex', label='Codex', group='harness', modelSource='cli', modelOverride=True, engine='codex'),
'claude': RuntimeSpec(id='claude', label='Claude Code', group='harness', modelSource='cli', modelOverride=True, engine='claude'),
}


Expand Down Expand Up @@ -86,6 +89,13 @@ def __init__(self, projects, generation, configs, provider=None):

async def stream(self, run: RunContext) -> AsyncIterator[AgentEvent]:
runtime_spec(run.runtime)
if run.runtime == 'codex' and not run.runtime_model:
# Codex launch isolates user config; carry only its resolved model into this turn.
from opendirector.agent.catalog import model_choices
choices = await model_choices('codex')
default = next((item['id'] for item in choices if item.get('isDefault')), None)
if default:
run = replace(run, runtime_model=default)
gateway = ToolGateway(self.business, run)
try:
async for event in self.adapter.stream(run, gateway):
Expand Down
Loading