diff --git a/backend/api/routers/coding_agent.py b/backend/api/routers/coding_agent.py
index 9e16d10..20ee8c2 100644
--- a/backend/api/routers/coding_agent.py
+++ b/backend/api/routers/coding_agent.py
@@ -108,6 +108,11 @@ async def get_agent_options(
return {
"models": models,
"default_model": claude_code_runner.default_model_id(policy=policy),
+ "runtime_engines": [
+ {"id": "claude", "label": "Claude Agent SDK"},
+ {"id": "codex", "label": "Codex SDK"},
+ ],
+ "default_runtime_engine": "claude",
"skills": skills,
"mcp": databases,
}
@@ -418,6 +423,7 @@ async def create_agent_run(workspace_id: str, body: ClaudeAgentRunCreate, identi
profile = workspace_profile.get_profile(workspace)
run_skills = list(body.skills or []) or list(profile.get("default_skills") or [])
run_model = claude_code_runner.resolve_run_model(body.model or profile.get("default_model") or "")
+ runtime_engine = str(profile.get("runtime_engine") or "claude").strip().lower()
run = claude_agent_runs.create_run(
workspace_id=workspace_id,
@@ -431,6 +437,7 @@ async def create_agent_run(workspace_id: str, body: ClaudeAgentRunCreate, identi
"selected_skills": run_skills,
"previous_run_id": body.previous_run_id,
"attachments": attachment_paths,
+ "runtime_engine": runtime_engine,
},
)
claude_code_runner.schedule_run(run["run_id"])
diff --git a/backend/api/routers/gateway.py b/backend/api/routers/gateway.py
index 2a27993..1bd5928 100644
--- a/backend/api/routers/gateway.py
+++ b/backend/api/routers/gateway.py
@@ -219,6 +219,36 @@ def _extract_last_user_content(body: dict[str, Any]) -> str:
return last_user
+def _extract_responses_user_content(body: dict[str, Any]) -> str:
+ """Extract user text from an OpenAI Responses API request body."""
+ raw_input = body.get("input")
+ if isinstance(raw_input, str):
+ return raw_input.strip()
+ if not isinstance(raw_input, list):
+ return ""
+ last_user = ""
+ for item in raw_input:
+ if not isinstance(item, dict):
+ continue
+ role = str(item.get("role") or item.get("type") or "").lower()
+ if role not in {"user", "message"}:
+ continue
+ content = item.get("content")
+ if isinstance(content, str):
+ last_user = content
+ continue
+ if isinstance(content, list):
+ parts: list[str] = []
+ for block in content:
+ if isinstance(block, dict):
+ text = block.get("text") or block.get("output_text")
+ if text:
+ parts.append(str(text))
+ if parts:
+ last_user = "\n".join(parts)
+ return last_user.strip()
+
+
def _detect_client_type(body: dict[str, Any]) -> str:
"""Detect the AI client type from request body characteristics.
@@ -235,6 +265,9 @@ def _detect_client_type(body: dict[str, Any]) -> str:
if body.get("conversation_id") or body.get("thread_id"):
return "openclaw"
+ if body.get("input") is not None and not isinstance(body.get("messages"), list):
+ return "codex"
+
# ------------------------------------------------------------------
# Hermes: standard OpenAI messages with tool-call loop
# ------------------------------------------------------------------
@@ -248,8 +281,6 @@ def _detect_client_type(body: dict[str, Any]) -> str:
if role == "user":
# TODO(claude-code): Detect Claude Code — may include Anthropic-specific
# fields (system prompt shape, stop_sequences, etc.)
- # TODO(codex): Detect Codex — uses different body format
- # (responses API, not chat/completions)
return "hermes" # default until other clients are tested
return "unknown"
@@ -264,6 +295,8 @@ def _resolve_conversation_id(body: dict[str, Any], account_id: str, fallback: st
their first user message differs.
"""
first_user_content = _extract_first_user_content(body)
+ if not first_user_content:
+ first_user_content = _extract_responses_user_content(body)
if not first_user_content:
return fallback
@@ -345,7 +378,7 @@ def _prepare_chat_context(
policy=policy,
matched_route=matched_route,
via_alias=via_alias,
- user_message=_extract_last_user_content(body),
+ user_message=_extract_last_user_content(body) or _extract_responses_user_content(body),
)
@@ -956,6 +989,300 @@ async def chat_completions(
raise HTTPException(status_code=upstream_result.status_code, detail=data)
+@router.post("/responses")
+async def responses(
+ request: Request,
+ response: Response,
+ identity: dict[str, Any] = Depends(require_gateway_chat),
+ x_evotown_agent_id: str | None = Header(default=None),
+ x_evotown_team_id: str | None = Header(default=None),
+ x_evotown_engine_id: str | None = Header(default=None),
+ x_evotown_conversation_id: str | None = Header(default=None),
+):
+ """OpenAI Responses API proxy for Codex SDK / CLI."""
+ body = await request.json()
+ if not isinstance(body, dict):
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="JSON object body required.")
+
+ ctx = _prepare_chat_context(
+ body,
+ identity=identity,
+ x_evotown_agent_id=x_evotown_agent_id,
+ x_evotown_team_id=x_evotown_team_id,
+ x_evotown_engine_id=x_evotown_engine_id,
+ x_evotown_conversation_id=x_evotown_conversation_id,
+ )
+
+ if body.get("stream") is True:
+ return StreamingResponse(
+ _stream_upstream_responses(
+ ctx=ctx,
+ identity=identity,
+ x_evotown_agent_id=x_evotown_agent_id,
+ x_evotown_team_id=x_evotown_team_id,
+ x_evotown_engine_id=x_evotown_engine_id,
+ response=response,
+ ),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Evotown-Request-Id": ctx.request_id,
+ "X-Evotown-Conversation-Id": ctx.conversation_id,
+ },
+ )
+
+ started = time.perf_counter()
+ audit = _audit_identity_fields(
+ identity,
+ x_evotown_agent_id=x_evotown_agent_id,
+ x_evotown_team_id=x_evotown_team_id,
+ x_evotown_engine_id=x_evotown_engine_id,
+ )
+
+ build_call: Callable[[str], tuple[str, dict[str, str], dict[str, Any]]] = (
+ lambda model_name: gateway_upstream.build_responses_upstream_call(ctx.body, model_name)
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=_gateway_timeout_sec()) as client:
+ upstream_result = await gateway_retry.post_chat_with_resilience(
+ client=client,
+ build_call=build_call,
+ model_chain=ctx.model_chain,
+ policy=ctx.policy,
+ timeout_sec=_gateway_timeout_sec(),
+ )
+ except HTTPException:
+ raise
+ except httpx.HTTPError as exc:
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ _record_gateway_request(
+ {
+ "request_id": ctx.request_id,
+ "conversation_id": ctx.conversation_id,
+ **audit,
+ "model": "",
+ "model_alias": ctx.client_model if ctx.via_alias else "",
+ "status_code": 502,
+ "latency_ms": latency_ms,
+ "request_excerpt": _first_message_excerpt(ctx.body),
+ "error": str(exc),
+ "user_message": ctx.user_message,
+ }
+ )
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Upstream error: {exc}") from exc
+
+ _record_attempts_metadata(ctx.body, upstream_result.attempts)
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ data = upstream_result.data if upstream_result.data is not None else {"error": upstream_result.error}
+ usage = _usage_from_response(data if isinstance(data, dict) else {})
+
+ _record_gateway_request(
+ {
+ "request_id": ctx.request_id,
+ "conversation_id": ctx.conversation_id,
+ **audit,
+ "model": upstream_result.final_model or ctx.client_model,
+ "model_alias": ctx.client_model if ctx.via_alias else "",
+ "status_code": upstream_result.status_code,
+ **usage,
+ "cost_usd": _cost_from_response(data if isinstance(data, dict) else {}),
+ "latency_ms": latency_ms,
+ "risk_status": "allowed" if upstream_result.success else "upstream_error",
+ "request_excerpt": _first_message_excerpt(ctx.body),
+ "response_excerpt": {
+ **(data if isinstance(data, dict) else {"raw": data}),
+ "evotown_final_model": upstream_result.final_model,
+ "evotown_attempts": [a.to_dict() for a in upstream_result.attempts],
+ },
+ "error": "" if upstream_result.success else str(upstream_result.error or data),
+ "user_message": ctx.user_message,
+ }
+ )
+
+ if upstream_result.success:
+ _finalize_success_audit(identity, response, request_id=ctx.request_id)
+ response.headers["X-Evotown-Request-Id"] = ctx.request_id
+ response.headers["X-Evotown-Conversation-Id"] = ctx.conversation_id
+ if upstream_result.final_model:
+ response.headers["X-Evotown-Final-Model"] = upstream_result.final_model
+ response.headers["X-Evotown-Upstream-Attempts"] = str(len(upstream_result.attempts))
+ return data
+
+ raise HTTPException(status_code=upstream_result.status_code, detail=data)
+
+
+async def _stream_upstream_responses(
+ *,
+ ctx: GatewayChatContext,
+ identity: dict[str, Any],
+ x_evotown_agent_id: str | None,
+ x_evotown_team_id: str | None,
+ x_evotown_engine_id: str | None,
+ response: Response,
+) -> AsyncIterator[bytes]:
+ started = time.perf_counter()
+ usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
+ cost = 0.0
+ status_code = 200
+ error = ""
+ attempts: list[gateway_retry.AttemptRecord] = []
+ audit = _audit_identity_fields(
+ identity,
+ x_evotown_agent_id=x_evotown_agent_id,
+ x_evotown_team_id=x_evotown_team_id,
+ x_evotown_engine_id=x_evotown_engine_id,
+ )
+ policy = ctx.policy
+ total_attempts = 0
+ deadline = time.perf_counter() + _gateway_timeout_sec()
+
+ def build_call(model_name: str) -> tuple[str, dict[str, str], dict[str, Any]]:
+ return gateway_upstream.build_responses_upstream_call(ctx.body, model_name)
+
+ try:
+ async with httpx.AsyncClient(timeout=_gateway_timeout_sec()) as client:
+ for hop_index, model_name in enumerate(ctx.model_chain):
+ retries_on_hop = 0
+ while True:
+ if total_attempts >= policy.max_total_attempts or time.perf_counter() >= deadline:
+ status_code = 502
+ error = "gateway retry budget exceeded"
+ payload = json.dumps({"error": {"message": error, "type": "gateway_upstream_error"}})
+ yield f"data: {payload}\n\n".encode("utf-8")
+ return
+
+ total_attempts += 1
+ target, headers, req_body = build_call(model_name)
+ chunks_sent = False
+ try:
+ async with client.stream("POST", target, json=req_body, headers=headers) as upstream:
+ status_code = upstream.status_code
+ if upstream.status_code >= 400:
+ err_body = await upstream.aread()
+ error = err_body.decode("utf-8", errors="replace")
+ attempts.append(
+ gateway_retry.AttemptRecord(
+ model=model_name,
+ attempt_index=total_attempts,
+ hop_index=hop_index,
+ action="upstream_error",
+ status_code=upstream.status_code,
+ detail=error[:200],
+ )
+ )
+ if gateway_retry.should_retry_same_model(
+ policy=policy,
+ status_code=upstream.status_code,
+ error_kind="",
+ retries_used=retries_on_hop,
+ ):
+ delay_ms = gateway_retry.backoff_ms(policy, retries_on_hop, upstream)
+ retries_on_hop += 1
+ await asyncio.sleep(delay_ms / 1000.0)
+ continue
+ if gateway_retry.should_fallback(
+ policy=policy,
+ status_code=upstream.status_code,
+ error_kind="",
+ hop_index=hop_index,
+ chain_len=len(ctx.model_chain),
+ ):
+ break
+ payload = json.dumps({"error": {"message": error[:1000], "type": "gateway_upstream_error"}})
+ yield f"data: {payload}\n\n".encode("utf-8")
+ return
+
+ attempts.append(
+ gateway_retry.AttemptRecord(
+ model=model_name,
+ attempt_index=total_attempts,
+ hop_index=hop_index,
+ action="success",
+ status_code=upstream.status_code,
+ )
+ )
+ async for line in upstream.aiter_lines():
+ if not line:
+ continue
+ chunks_sent = True
+ usage, cost = _parse_sse_usage(line, usage, cost)
+ yield (line + "\n").encode("utf-8")
+ if chunks_sent:
+ if response is not None:
+ _finalize_success_audit(identity, response, request_id=ctx.request_id)
+ return
+ break
+ except httpx.TimeoutException:
+ kind = "timeout"
+ except httpx.HTTPError as exc:
+ kind = "connection_error"
+ error = str(exc)
+ else:
+ break
+
+ attempts.append(
+ gateway_retry.AttemptRecord(
+ model=model_name,
+ attempt_index=total_attempts,
+ hop_index=hop_index,
+ action="transport_error",
+ error_kind=kind,
+ detail=error[:200],
+ )
+ )
+ if gateway_retry.should_retry_same_model(
+ policy=policy,
+ status_code=None,
+ error_kind=kind,
+ retries_used=retries_on_hop,
+ ):
+ delay_ms = gateway_retry.backoff_ms(policy, retries_on_hop, None)
+ retries_on_hop += 1
+ await asyncio.sleep(delay_ms / 1000.0)
+ continue
+ if gateway_retry.should_fallback(
+ policy=policy,
+ status_code=None,
+ error_kind=kind,
+ hop_index=hop_index,
+ chain_len=len(ctx.model_chain),
+ ):
+ break
+ status_code = 502
+ payload = json.dumps({"error": {"message": error or kind, "type": "gateway_upstream_error"}})
+ yield f"data: {payload}\n\n".encode("utf-8")
+ return
+
+ status_code = 502
+ error = error or "all models in chain failed"
+ payload = json.dumps({"error": {"message": error, "type": "gateway_upstream_error"}})
+ yield f"data: {payload}\n\n".encode("utf-8")
+ finally:
+ _record_attempts_metadata(ctx.body, attempts)
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ _success_models = [a.model for a in attempts if a.action == "success"]
+ _stream_final_model = _success_models[-1] if _success_models else ""
+ _record_gateway_request(
+ {
+ "request_id": ctx.request_id,
+ "conversation_id": ctx.conversation_id,
+ **audit,
+ "model": _stream_final_model or ctx.client_model,
+ "model_alias": ctx.client_model if ctx.via_alias else "",
+ "status_code": status_code,
+ **usage,
+ "cost_usd": cost,
+ "latency_ms": latency_ms,
+ "risk_status": "allowed" if status_code < 400 else "upstream_error",
+ "request_excerpt": _first_message_excerpt(ctx.body),
+ "response_excerpt": {"stream": True, **usage, "attempts": [a.to_dict() for a in attempts]},
+ "error": error,
+ "user_message": ctx.user_message,
+ }
+ )
+
+
@anthropic_router.post("/messages")
async def anthropic_messages(
request: Request,
diff --git a/backend/domain/models.py b/backend/domain/models.py
index 2547bf4..1af8e38 100644
--- a/backend/domain/models.py
+++ b/backend/domain/models.py
@@ -130,6 +130,7 @@ class WorkspaceUpdate(BaseModel):
class WorkspaceProfileUpdate(BaseModel):
agent_type: str = Field(default="", max_length=64)
+ runtime_engine: Literal["claude", "codex"] = "claude"
soul: str = Field(default="", max_length=8000)
paradigm: str = Field(default="", max_length=8000)
standards: str = Field(default="", max_length=8000)
diff --git a/backend/infra/gateway_upstream.py b/backend/infra/gateway_upstream.py
index 2f6d251..dd75df3 100644
--- a/backend/infra/gateway_upstream.py
+++ b/backend/infra/gateway_upstream.py
@@ -66,6 +66,49 @@ def build_upstream_call(
return target, headers, req
+def build_responses_upstream_call(
+ body: dict[str, Any],
+ effective_model: str,
+) -> tuple[str, dict[str, str], dict[str, Any]]:
+ """Return (url, headers, request_body) for one upstream /responses call."""
+ req = copy.deepcopy(body)
+ req["model"] = effective_model
+ metadata = req.get("metadata") if isinstance(req.get("metadata"), dict) else {}
+ req["metadata"] = {**metadata, "evotown_effective_model": effective_model}
+
+ managed = gateway_models_store.get_by_model_name(effective_model)
+ if managed:
+ api_base = managed.get("_api_base") or ""
+ if not api_base.startswith("http"):
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=f"Upstream model '{effective_model}' has invalid api_base.",
+ )
+ req["model"] = managed.get("_litellm_model") or effective_model
+ req["metadata"]["evotown_upstream_model_id"] = managed.get("model_id", "")
+ req["metadata"]["evotown_upstream_mode"] = "managed"
+ target = f"{api_base.rstrip('/')}/responses"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {managed.get('_api_key', '')}",
+ }
+ return target, headers, req
+
+ litellm_base = litellm_base_url()
+ if not litellm_base:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=(
+ f"Model '{effective_model}' is not registered in Evotown and LITELLM_BASE_URL is not configured. "
+ "Add the model under Gateway → 上游模型 in the console."
+ ),
+ )
+ req["metadata"]["evotown_upstream_mode"] = "litellm"
+ target = f"{litellm_base}/responses"
+ headers = {"Content-Type": "application/json", **litellm_auth_header()}
+ return target, headers, req
+
+
def build_anthropic_upstream_call(
body: dict[str, Any],
effective_model: str,
diff --git a/backend/infra/workspace_profile.py b/backend/infra/workspace_profile.py
index f26ec2c..6633807 100644
--- a/backend/infra/workspace_profile.py
+++ b/backend/infra/workspace_profile.py
@@ -6,6 +6,7 @@
from typing import Any
from infra import workspaces
+from services.runtime_engine import DEFAULT_RUNTIME_ENGINE, normalize_runtime_engine
PROFILE_RELATIVE = ".evotown/profile.json"
PROFILE_MD_RELATIVE = ".evotown/AGENT_PROFILE.md"
@@ -15,6 +16,7 @@
DEFAULT_PROFILE: dict[str, Any] = {
"agent_type": "",
+ "runtime_engine": DEFAULT_RUNTIME_ENGINE,
"soul": "",
"paradigm": "",
"standards": "",
@@ -39,6 +41,7 @@ def get_profile(workspace: dict[str, Any]) -> dict[str, Any]:
if not isinstance(raw, dict):
return {**DEFAULT_PROFILE, "updated_at": None}
merged = {**DEFAULT_PROFILE, **raw}
+ merged["runtime_engine"] = normalize_runtime_engine(merged.get("runtime_engine"))
merged["default_skills"] = _normalize_id_list(merged.get("default_skills"))
merged["default_mcp"] = _normalize_id_list(merged.get("default_mcp"))
return merged
@@ -73,6 +76,9 @@ def save_profile(workspace: dict[str, Any], payload: dict[str, Any]) -> dict[str
payload.get("agent_type", current.get("agent_type", "")),
max_chars=AGENT_TYPE_MAX,
),
+ "runtime_engine": normalize_runtime_engine(
+ payload.get("runtime_engine", current.get("runtime_engine", DEFAULT_RUNTIME_ENGINE)),
+ ),
"soul": _validate_text_field(
"soul",
payload.get("soul", current.get("soul", "")),
@@ -109,6 +115,9 @@ def _write_profile_md(workspace: dict[str, Any], profile: dict[str, Any]) -> Non
lines = ["# Agent Profile", ""]
if profile.get("agent_type"):
lines.extend([f"**Type:** `{profile['agent_type']}`", ""])
+ runtime_engine = str(profile.get("runtime_engine") or "").strip()
+ if runtime_engine:
+ lines.extend([f"**Runtime:** `{runtime_engine}`", ""])
if profile.get("soul"):
lines.extend(["## Identity (SOUL)", "", str(profile["soul"]), ""])
if profile.get("paradigm"):
@@ -116,6 +125,8 @@ def _write_profile_md(workspace: dict[str, Any], profile: dict[str, Any]) -> Non
if profile.get("standards"):
lines.extend(["## Standards", "", str(profile["standards"]), ""])
defaults: list[str] = []
+ if runtime_engine:
+ defaults.append(f"- Runtime engine: `{runtime_engine}`")
if profile.get("default_model"):
defaults.append(f"- Default model: `{profile['default_model']}`")
if profile.get("default_skills"):
@@ -134,15 +145,18 @@ def profile_context_sections(profile: dict[str, Any]) -> list[str]:
"""Markdown sections to append to AGENT_CONTEXT.md."""
sections: list[str] = []
agent_type = str(profile.get("agent_type") or "").strip()
+ runtime_engine = str(profile.get("runtime_engine") or "").strip()
soul = str(profile.get("soul") or "").strip()
paradigm = str(profile.get("paradigm") or "").strip()
standards = str(profile.get("standards") or "").strip()
- if not any([agent_type, soul, paradigm, standards]):
+ if not any([agent_type, runtime_engine, soul, paradigm, standards]):
return sections
sections.extend(["## Agent Profile", ""])
if agent_type:
sections.append(f"- **Type:** `{agent_type}`")
+ if runtime_engine:
+ sections.append(f"- **Runtime engine:** `{runtime_engine}`")
if soul or paradigm or standards:
sections.append("- Persistent profile from console settings (`.evotown/profile.json`)")
sections.append("")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index fee300a..ce01a1d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -14,6 +14,8 @@ psutil>=5.9.0
tiktoken>=0.5.0
# Hosted Claude Coding Agent (embeds Claude Code CLI binary)
claude-agent-sdk>=0.2.93
+# Hosted Codex SDK (optional runtime; dry-run when not installed)
+openai-codex>=0.1.0
# Auth — password hashing
bcrypt>=4.0
# Database drivers (testing + MCP dynamic services)
diff --git a/backend/services/claude_code_runner.py b/backend/services/claude_code_runner.py
index addf283..2e6be68 100644
--- a/backend/services/claude_code_runner.py
+++ b/backend/services/claude_code_runner.py
@@ -728,8 +728,8 @@ def _sdk_ready() -> bool:
return bool(claude_agent_sdk_runner.sdk_available() and (direct_key or (gateway_enabled and gateway_key)))
-def _execution_backend() -> str:
- """Resolve run backend: embedded SDK (default), external CLI, or dry-run."""
+def _claude_execution_backend() -> str:
+ """Resolve Claude run backend: embedded SDK, external CLI, or dry-run."""
from services import claude_agent_sdk_runner
mode = os.environ.get("EVOTOWN_CLAUDE_EXECUTION_MODE", "auto").strip().lower()
@@ -748,8 +748,47 @@ def _execution_backend() -> str:
return "dry-run"
-async def _run_agent(*, workspace_root: Path, prompt: str, run: dict[str, Any], model: str) -> tuple[int, str, str]:
- backend = _execution_backend()
+def _execution_backend(runtime_engine: str) -> str:
+ """Resolve run backend for the workspace runtime engine."""
+ from services.runtime_engine import normalize_runtime_engine
+
+ if normalize_runtime_engine(runtime_engine) == "codex":
+ from services import codex_agent_sdk_runner
+
+ return codex_agent_sdk_runner.execution_backend()
+ return _claude_execution_backend()
+
+
+async def _run_agent(
+ *,
+ workspace_root: Path,
+ prompt: str,
+ run: dict[str, Any],
+ model: str,
+ runtime_engine: str = "claude",
+) -> tuple[int, str, str]:
+ from services.runtime_engine import normalize_runtime_engine
+
+ engine = normalize_runtime_engine(runtime_engine)
+ backend = _execution_backend(engine)
+ if engine == "codex":
+ if backend == "sdk":
+ from services import codex_agent_sdk_runner
+
+ exit_code, output = await codex_agent_sdk_runner.run_agent_sdk(
+ workspace_root=workspace_root,
+ prompt=prompt,
+ model=model,
+ run=run,
+ )
+ return exit_code, output, backend
+ summary = (
+ "Dry-run completed (Codex). Install openai-codex (pip install openai-codex) and set "
+ "OPENAI_API_KEY, or enable EVOTOWN_CODEX_USE_GATEWAY with EVOTOWN_CODEX_GATEWAY_API_KEY. "
+ "Workspace context files were written under .evotown/."
+ )
+ return 0, summary, "dry-run"
+
if backend == "sdk":
from services import claude_agent_sdk_runner
@@ -889,10 +928,23 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
root = workspaces.resolve_workspace_path(workspace)
model = resolve_run_model(str(run.get("model") or ""))
+ signals = run.get("signals") or {}
+ from infra import workspace_profile as _wp
+ from services.runtime_engine import engine_id_for_runtime, normalize_runtime_engine
+
+ ws_profile = _wp.get_profile(workspace)
+ runtime_engine = normalize_runtime_engine(
+ signals.get("runtime_engine") or ws_profile.get("runtime_engine"),
+ )
+ engine_id = engine_id_for_runtime(runtime_engine)
+
claude_agent_runs.update_run_status(run_id, status="running")
- claude_agent_runs.append_event(run_id, "context.prepare", {"workspace_root": str(root), "model": model})
+ claude_agent_runs.append_event(
+ run_id,
+ "context.prepare",
+ {"workspace_root": str(root), "model": model, "runtime_engine": runtime_engine},
+ )
- signals = run.get("signals") or {}
selected_skills = list(signals.get("selected_skills") or [])
previous_run_id = str(signals.get("previous_run_id") or "").strip()
attachment_paths = [str(p).strip() for p in (signals.get("attachments") or []) if str(p).strip()]
@@ -901,9 +953,7 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
prompt = _build_conversation_prompt(run["prompt"], history)
prompt = _append_attachments_to_prompt(prompt, workspace, attachment_paths)
- # Prepend identity profile to prompt so model sees it before Claude Code default identity
- from infra import workspace_profile as _wp
- ws_profile = _wp.get_profile(workspace)
+ # Prepend identity profile to prompt so model sees it before runner default identity
if ws_profile and ws_profile.get("soul"):
parts = [f"[SYSTEM IDENTITY - 你的身份设定]\n{ws_profile['soul']}"]
if ws_profile.get("paradigm"):
@@ -947,7 +997,6 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
identity = _runner_identity(run)
- ws_profile = _wp.get_profile(workspace)
shared_context = build_shared_context(
prompt=run["prompt"],
team_id=run.get("team_id", ""),
@@ -989,6 +1038,7 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
prompt=prompt,
run=run,
model=model,
+ runtime_engine=runtime_engine,
)
if timeout_sec > 0:
exit_code, output, execution_backend = await asyncio.wait_for(agent_coro, timeout=timeout_sec)
@@ -1006,7 +1056,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
- "engine_id": DEFAULT_ENGINE_ID,
+ "engine_id": engine_id,
+ "runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
@@ -1028,7 +1079,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
- "engine_id": DEFAULT_ENGINE_ID,
+ "engine_id": engine_id,
+ "runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
},
@@ -1046,7 +1098,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
- "engine_id": DEFAULT_ENGINE_ID,
+ "engine_id": engine_id,
+ "runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
@@ -1085,7 +1138,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
- "engine_id": DEFAULT_ENGINE_ID,
+ "engine_id": engine_id,
+ "runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
diff --git a/backend/services/codex_agent_sdk_runner.py b/backend/services/codex_agent_sdk_runner.py
new file mode 100644
index 0000000..25ea166
--- /dev/null
+++ b/backend/services/codex_agent_sdk_runner.py
@@ -0,0 +1,135 @@
+"""Embedded Codex SDK execution for hosted coding workspaces."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+_SDK_IMPORT_ERROR: str | None = None
+
+
+def sdk_available() -> bool:
+ """Return True when openai-codex is importable."""
+ global _SDK_IMPORT_ERROR
+ try:
+ import openai_codex # noqa: F401
+ except ImportError as exc:
+ _SDK_IMPORT_ERROR = str(exc)
+ return False
+ _SDK_IMPORT_ERROR = None
+ return True
+
+
+def sdk_import_error() -> str | None:
+ sdk_available()
+ return _SDK_IMPORT_ERROR
+
+
+def _truthy_env(name: str) -> bool:
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def gateway_sdk_env() -> dict[str, str]:
+ if not _truthy_env("EVOTOWN_CODEX_USE_GATEWAY"):
+ return {}
+ base_url = os.environ.get("EVOTOWN_CODEX_GATEWAY_BASE_URL", "").strip().rstrip("/")
+ api_key = os.environ.get("EVOTOWN_CODEX_GATEWAY_API_KEY", "").strip()
+ env: dict[str, str] = {}
+ if base_url:
+ env["OPENAI_BASE_URL"] = base_url
+ if api_key:
+ env["OPENAI_API_KEY"] = api_key
+ return env
+
+
+def _sdk_ready() -> bool:
+ direct_key = os.environ.get("OPENAI_API_KEY", "").strip()
+ gateway_key = os.environ.get("EVOTOWN_CODEX_GATEWAY_API_KEY", "").strip()
+ gateway_enabled = _truthy_env("EVOTOWN_CODEX_USE_GATEWAY")
+ return bool(sdk_available() and (direct_key or (gateway_enabled and gateway_key)))
+
+
+def _sandbox_mode():
+ from openai_codex import Sandbox
+
+ raw = os.environ.get("EVOTOWN_CODEX_SANDBOX", "workspace_write").strip().lower()
+ if raw in {"read_only", "readonly", "read-only"}:
+ return Sandbox.read_only
+ if raw in {"full_access", "full-access", "full"}:
+ return Sandbox.full_access
+ return Sandbox.workspace_write
+
+
+def _write_gateway_config(workspace_root: Path) -> None:
+ """Write per-workspace Codex config when routing through Evotown Gateway."""
+ base_url = os.environ.get("EVOTOWN_CODEX_GATEWAY_BASE_URL", "").strip().rstrip("/")
+ if not base_url:
+ return
+ config_dir = workspace_root / ".codex"
+ config_dir.mkdir(parents=True, exist_ok=True)
+ config_path = config_dir / "config.toml"
+ model = os.environ.get("EVOTOWN_CODEX_MODEL", "").strip()
+ lines = [f'openai_base_url = "{base_url}"']
+ if model:
+ lines.append(f'model = "{model}"')
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+async def run_agent_sdk(
+ *,
+ workspace_root: Path,
+ prompt: str,
+ model: str,
+ run: dict[str, Any],
+) -> tuple[int, str]:
+ """Run a single hosted agent task via the embedded Codex SDK."""
+ from openai_codex import AsyncCodex
+
+ if _truthy_env("EVOTOWN_CODEX_USE_GATEWAY"):
+ _write_gateway_config(workspace_root)
+
+ run_env = {
+ **gateway_sdk_env(),
+ "EVOTOWN_AGENT_RUN_ID": str(run.get("run_id") or ""),
+ "EVOTOWN_WORKSPACE_ROOT": str(workspace_root),
+ }
+ if model:
+ run_env["EVOTOWN_CODEX_MODEL"] = model
+
+ previous_cwd = os.getcwd()
+ previous_env = {key: os.environ.get(key) for key in run_env}
+ try:
+ os.environ.update(run_env)
+ os.chdir(workspace_root)
+ async with AsyncCodex() as codex:
+ kwargs: dict[str, Any] = {"sandbox": _sandbox_mode()}
+ if model:
+ kwargs["model"] = model
+ thread = await codex.thread_start(**kwargs)
+ result = await thread.run(prompt)
+ finally:
+ os.chdir(previous_cwd)
+ for key, value in previous_env.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+ output = str(getattr(result, "final_response", "") or "").strip()
+ if not output:
+ output = str(getattr(result, "output_text", "") or "").strip()
+ is_error = bool(getattr(result, "is_error", False))
+ exit_code = 1 if is_error or not output else 0
+ if not output:
+ output = "Codex SDK run completed with no text output."
+ return exit_code, output
+
+
+def execution_backend() -> str:
+ """Resolve Codex run backend: embedded SDK or dry-run."""
+ mode = os.environ.get("EVOTOWN_CODEX_EXECUTION_MODE", "auto").strip().lower()
+ if mode == "dry-run":
+ return "dry-run"
+ if mode == "sdk":
+ return "sdk" if _sdk_ready() else "dry-run"
+ return "sdk" if _sdk_ready() else "dry-run"
diff --git a/backend/services/runtime_engine.py b/backend/services/runtime_engine.py
new file mode 100644
index 0000000..9d265bb
--- /dev/null
+++ b/backend/services/runtime_engine.py
@@ -0,0 +1,23 @@
+"""Hosted coding workspace runtime engine identifiers."""
+from __future__ import annotations
+
+from typing import Any
+
+RUNTIME_ENGINES: frozenset[str] = frozenset({"claude", "codex"})
+DEFAULT_RUNTIME_ENGINE = "claude"
+
+ENGINE_IDS: dict[str, str] = {
+ "claude": "claude-code-hosted",
+ "codex": "codex-sdk-hosted",
+}
+
+
+def normalize_runtime_engine(value: Any) -> str:
+ text = str(value or "").strip().lower()
+ if text in RUNTIME_ENGINES:
+ return text
+ return DEFAULT_RUNTIME_ENGINE
+
+
+def engine_id_for_runtime(runtime_engine: str) -> str:
+ return ENGINE_IDS.get(normalize_runtime_engine(runtime_engine), ENGINE_IDS["claude"])
diff --git a/backend/tests/test_coding_agent.py b/backend/tests/test_coding_agent.py
index d4a3275..b70a833 100644
--- a/backend/tests/test_coding_agent.py
+++ b/backend/tests/test_coding_agent.py
@@ -348,7 +348,7 @@ def test_runner_gateway_env_marks_sdk_ready(self) -> None:
),
patch("services.claude_agent_sdk_runner.sdk_available", return_value=True),
):
- self.assertEqual(claude_code_runner._execution_backend(), "sdk") # noqa: SLF001
+ self.assertEqual(claude_code_runner._execution_backend("claude"), "sdk") # noqa: SLF001
env = claude_agent_sdk_runner.gateway_sdk_env()
self.assertEqual(env["ANTHROPIC_BASE_URL"], "http://backend:8000/api/gateway/anthropic")
@@ -494,6 +494,73 @@ def test_upload_files_and_create_run_with_attachments(self) -> None:
)
self.assertEqual(invalid.status_code, 400)
+ def test_workspace_profile_runtime_engine(self) -> None:
+ client = self._client()
+ _alice, alice_key = self._account_key("Alice")
+ create_ws = client.post(
+ "/api/v1/workspaces",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ json={"name": "Runtime Sandbox"},
+ )
+ ws_id = create_ws.json()["workspace"]["workspace_id"]
+
+ save = client.put(
+ f"/api/v1/workspaces/{ws_id}/profile",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ json={"runtime_engine": "codex", "default_model": "gpt-test"},
+ )
+ self.assertEqual(save.status_code, 200)
+ self.assertEqual(save.json()["profile"]["runtime_engine"], "codex")
+
+ options = client.get(
+ f"/api/v1/agent/options?workspace_id={ws_id}",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ )
+ self.assertEqual(options.status_code, 200)
+ engines = {item["id"] for item in options.json()["runtime_engines"]}
+ self.assertEqual(engines, {"claude", "codex"})
+
+ def test_codex_runtime_dry_run_when_sdk_unavailable(self) -> None:
+ from infra import workspaces
+ from services import claude_code_runner
+
+ client = self._client()
+ _alice, alice_key = self._account_key("Alice")
+ create_ws = client.post(
+ "/api/v1/workspaces",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ json={"name": "Codex Dry Run"},
+ )
+ workspace = create_ws.json()["workspace"]
+ ws_id = workspace["workspace_id"]
+ client.put(
+ f"/api/v1/workspaces/{ws_id}/profile",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ json={"runtime_engine": "codex"},
+ )
+
+ with patch("services.claude_code_runner.schedule_run", lambda run_id: None):
+ create_run = client.post(
+ f"/api/v1/workspaces/{ws_id}/runs",
+ headers={"Authorization": f"Bearer {alice_key}"},
+ json={"prompt": "Plan a refactor."},
+ )
+ run_id = create_run.json()["run"]["run_id"]
+ self.assertEqual(create_run.json()["run"]["signals"]["runtime_engine"], "codex")
+
+ with (
+ patch.dict(os.environ, {"EVOTOWN_CODEX_EXECUTION_MODE": "dry-run"}, clear=False),
+ patch("services.codex_agent_sdk_runner.sdk_available", return_value=False),
+ ):
+ updated = asyncio.run(claude_code_runner.run_claude_agent(run_id))
+
+ self.assertEqual(updated["status"], "succeeded")
+ self.assertEqual(updated["signals"]["runtime_engine"], "codex")
+ self.assertEqual(updated["signals"]["engine_id"], "codex-sdk-hosted")
+ self.assertIn("Dry-run completed (Codex)", updated["result_summary"])
+ profile_md = workspaces.resolve_workspace_path(workspace) / ".evotown" / "AGENT_PROFILE.md"
+ self.assertIn("codex", profile_md.read_text(encoding="utf-8"))
+
class ClaudeRunModelResolveTest(unittest.TestCase):
def test_resolve_run_model_prefers_explicit(self) -> None:
diff --git a/backend/tests/test_gateway_upstream_models.py b/backend/tests/test_gateway_upstream_models.py
index 2c5dca9..3c0e2fa 100644
--- a/backend/tests/test_gateway_upstream_models.py
+++ b/backend/tests/test_gateway_upstream_models.py
@@ -109,3 +109,22 @@ def test_anthropic_managed_upstream_routes_to_anthropic_api_base(self) -> None:
self.assertEqual(headers["x-api-key"], "dashscope-secret")
self.assertEqual(headers["anthropic-version"], "2023-06-01")
self.assertNotIn("Authorization", headers)
+
+ def test_responses_managed_upstream_without_litellm(self) -> None:
+ from infra import gateway_models
+ from infra import gateway_upstream
+
+ gateway_models.create_model(
+ model_name="corp-codex",
+ api_base="https://api.example.com/v1",
+ api_key="sk-test",
+ litellm_model="gpt-5.4",
+ )
+ with patch.dict(os.environ, {"LITELLM_BASE_URL": ""}, clear=False):
+ target, headers, body = gateway_upstream.build_responses_upstream_call(
+ {"model": "corp-codex", "input": "Fix CI"},
+ "corp-codex",
+ )
+ self.assertEqual(target, "https://api.example.com/v1/responses")
+ self.assertEqual(body["model"], "gpt-5.4")
+ self.assertEqual(headers["Authorization"], "Bearer sk-test")
diff --git a/docker-compose.yml b/docker-compose.yml
index 9984e18..d5b5427 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -101,6 +101,14 @@ services:
- EVOTOWN_CLAUDE_VISION_ENABLED=${EVOTOWN_CLAUDE_VISION_ENABLED:-}
- EVOTOWN_CLAUDE_VISION_MAX_TOKENS=${EVOTOWN_CLAUDE_VISION_MAX_TOKENS:-2048}
- EVOTOWN_CLAUDE_VISION_TIMEOUT_SEC=${EVOTOWN_CLAUDE_VISION_TIMEOUT_SEC:-120}
+ # Hosted Codex SDK (parallel runtime; routes via Gateway /responses)
+ - OPENAI_API_KEY=${OPENAI_API_KEY:-}
+ - EVOTOWN_CODEX_USE_GATEWAY=${EVOTOWN_CODEX_USE_GATEWAY:-}
+ - EVOTOWN_CODEX_GATEWAY_BASE_URL=${EVOTOWN_CODEX_GATEWAY_BASE_URL:-http://backend:8765/api/gateway/v1}
+ - EVOTOWN_CODEX_GATEWAY_API_KEY=${EVOTOWN_CODEX_GATEWAY_API_KEY:-${ADMIN_TOKEN:-}}
+ - EVOTOWN_CODEX_EXECUTION_MODE=${EVOTOWN_CODEX_EXECUTION_MODE:-auto}
+ - EVOTOWN_CODEX_MODEL=${EVOTOWN_CODEX_MODEL:-}
+ - EVOTOWN_CODEX_SANDBOX=${EVOTOWN_CODEX_SANDBOX:-workspace_write}
- EVOTOWN_DB_MCP_URL=${EVOTOWN_DB_MCP_URL:-http://db-mcp-proxy:9100}
- EVOTOWN_WORKSPACES_HOST_DIR=${EVOTOWN_WORKSPACES_HOST_DIR:-/usr/local/agent-data/workspace}
- EVOTOWN_WORKSPACES_DIR=/app/data/workspaces
diff --git a/frontend/src/components/WorkspaceAgentProfilePanel.tsx b/frontend/src/components/WorkspaceAgentProfilePanel.tsx
index 54c0430..b032812 100644
--- a/frontend/src/components/WorkspaceAgentProfilePanel.tsx
+++ b/frontend/src/components/WorkspaceAgentProfilePanel.tsx
@@ -10,6 +10,7 @@ import {
export type WorkspaceAgentProfile = {
agent_type: string;
+ runtime_engine: "claude" | "codex";
soul: string;
paradigm: string;
standards: string;
@@ -34,6 +35,7 @@ type Props = {
const EMPTY_PROFILE: WorkspaceAgentProfile = {
agent_type: "",
+ runtime_engine: "claude",
soul: "",
paradigm: "",
standards: "",
@@ -125,6 +127,7 @@ export function WorkspaceAgentProfilePanel({
readJson<{ profile?: WorkspaceAgentProfile }>(res),
);
let next = { ...EMPTY_PROFILE, ...(data.profile || {}) };
+ next.runtime_engine = next.runtime_engine === "codex" ? "codex" : "claude";
if (profileNeedsPresetFill(next)) {
next = mergePresetIntoProfile(next, next.agent_type, skillIdSet);
setTemplateHint(`已自动补全「${presetLabelForType(next.agent_type)}」模板内容`);
@@ -315,6 +318,25 @@ export function WorkspaceAgentProfilePanel({
/>
+
+