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({ /> + +