diff --git a/docs/guideline.html b/docs/guideline.html
index 8712012d..3c8f6705 100644
--- a/docs/guideline.html
+++ b/docs/guideline.html
@@ -352,7 +352,7 @@
1.3 Key Features #
Validation gating
Every candidate skill is scored on a held-out selection split and only accepted if it beats the current/best skill.
Slow update
Epoch-boundary longitudinal comparison writes guidance into a protected region — momentum against forgetting. Force-injected or selection-gated.
Meta skill
Optimizer-side memory that reflects on what worked across epochs and feeds back into reflection.
- Pluggable backends
OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex/Claude-Code exec backends for the target.
+ Pluggable backends
OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex exec for optimizer and target, and Claude-Code exec for the target.
Six benchmarks
SearchQA, DocVQA, ALFWorld, LiveMathematicianBench, SpreadsheetBench, OfficeQA — each a self-contained env module.
Auto-resume
Every run is checkpointed step-by-step; re-running the same command continues from the last completed step.
@@ -727,7 +727,7 @@ 6.2 model.* #
backend | str | azure_openai | High-level backend label for the run. |
optimizer | str | gpt-5.5 | Optimizer model deployment (writes skill edits). |
target | str | gpt-5.5 | Target model deployment (executes tasks). |
- optimizer_backend | str | openai_chat | Client path for the optimizer: openai_chat or claude_chat. |
+ optimizer_backend | str | openai_chat | Client path for the optimizer: openai_chat / claude_chat / qwen_chat / minimax_chat / codex_exec. |
target_backend | str | openai_chat | Client path for the target: openai_chat / claude_chat / qwen_chat / codex_exec / claude_code_exec. |
reasoning_effort | str | medium | low / medium / high / xhigh / max (or empty). |
rewrite_reasoning_effort | str | "" | Override effort for full-rewrite calls (empty = inherit). |
@@ -738,7 +738,7 @@ 6.2 model.* #
Separate optimizer / target endpoints
-
Every azure_openai_* key also has optimizer_azure_openai_* and target_azure_openai_* variants, letting you point the optimizer and target at different Azure resources. Exec backends (codex_exec, claude_code_exec) add their own codex_exec_* / claude_code_exec_* knobs (sandbox, reasoning effort, SDK mode, etc.).
+
Every azure_openai_* key also has optimizer_azure_openai_* and target_azure_openai_* variants, letting you point the optimizer and target at different Azure resources. Exec backends add their own knobs, including codex_exec_* for optimizer/target Codex runs and claude_code_exec_* for target Claude-Code runs (sandbox, reasoning effort, SDK mode, etc.).
diff --git a/docs/reference/api.md b/docs/reference/api.md
index 8e364c7a..509fd768 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -187,7 +187,7 @@ not via a base class subclass. Supported values (as of this writing):
| `claude_chat` | ✓ | ✓ |
| `qwen_chat` | ✓ | ✓ |
| `minimax_chat` | ✓ | ✓ |
-| `codex_exec` | — | ✓ |
+| `codex_exec` | ✓ | ✓ |
| `claude_code_exec` | — | ✓ |
See `skillopt/model/backend_config.py` for the live whitelist and
diff --git a/docs/reference/config.md b/docs/reference/config.md
index 0b39bd0a..fb6c8c2d 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -10,7 +10,7 @@ Complete reference for all SkillOpt configuration parameters.
| `model.optimizer` | str | `gpt-5.5` | Optimizer model (for reflection & slow update) |
| `model.target` | str | `gpt-5.5` | Target model (for rollout execution) |
| `model.reasoning_effort` | str | `medium` | Reasoning effort level |
-| `model.optimizer_backend` | str | `openai_chat` | Optimizer backend: `openai_chat` / `claude_chat` / `qwen_chat` / `minimax_chat` |
+| `model.optimizer_backend` | str | `openai_chat` | Optimizer backend: `openai_chat` / `claude_chat` / `qwen_chat` / `minimax_chat` / `codex_exec` |
| `model.target_backend` | str | `openai_chat` | Target backend: chat backends plus execution harnesses |
| `model.qwen_chat_base_url` | str | `http://localhost:8000/v1` | Shared Qwen/vLLM OpenAI-compatible endpoint |
| `model.qwen_chat_enable_thinking` | bool | `false` | Shared Qwen thinking flag |
diff --git a/scripts/eval_only.py b/scripts/eval_only.py
index 79dfab09..f3c5f86f 100644
--- a/scripts/eval_only.py
+++ b/scripts/eval_only.py
@@ -320,8 +320,10 @@ def _has_model_override(dotted_key: str, legacy_key: str) -> bool:
cfg.setdefault("optimizer_backend", "claude_chat")
cfg.setdefault("target_backend", "claude_chat")
elif backend in {"codex", "codex_exec"}:
- cfg.setdefault("optimizer_backend", "openai_chat")
- cfg.setdefault("target_backend", "codex_exec")
+ if not _has_model_override("model.optimizer_backend", "optimizer_backend"):
+ cfg["optimizer_backend"] = "codex_exec"
+ if not _has_model_override("model.target_backend", "target_backend"):
+ cfg["target_backend"] = "codex_exec"
elif backend == "claude_code_exec":
cfg.setdefault("optimizer_backend", "openai_chat")
cfg.setdefault("target_backend", "claude_code_exec")
diff --git a/scripts/train.py b/scripts/train.py
index 5c0621ac..918e684a 100644
--- a/scripts/train.py
+++ b/scripts/train.py
@@ -438,8 +438,10 @@ def _has_model_override(dotted_key: str, legacy_key: str) -> bool:
flat.setdefault("optimizer_backend", "claude_chat")
flat.setdefault("target_backend", "claude_chat")
elif backend in {"codex", "codex_exec"}:
- flat.setdefault("optimizer_backend", "openai_chat")
- flat.setdefault("target_backend", "codex_exec")
+ if not _has_model_override("model.optimizer_backend", "optimizer_backend"):
+ flat["optimizer_backend"] = "codex_exec"
+ if not _has_model_override("model.target_backend", "target_backend"):
+ flat["target_backend"] = "codex_exec"
elif backend == "claude_code_exec":
flat.setdefault("optimizer_backend", "openai_chat")
flat.setdefault("target_backend", "claude_code_exec")
diff --git a/skillopt/engine/trainer.py b/skillopt/engine/trainer.py
index 85aae53c..39c24d86 100644
--- a/skillopt/engine/trainer.py
+++ b/skillopt/engine/trainer.py
@@ -670,12 +670,14 @@ def _build_eval_env(split: str, env_num: int, seed: int):
optimizer_backend = cfg.get("optimizer_backend")
target_backend = cfg.get("target_backend")
if not optimizer_backend or not target_backend:
- if backend in {"claude", "claude_chat"}:
- optimizer_backend = optimizer_backend or "claude_chat"
- target_backend = target_backend or "claude_chat"
- elif backend in {"codex", "codex_exec"}:
- optimizer_backend = optimizer_backend or "openai_chat"
- target_backend = target_backend or "codex_exec"
+ if backend in {"claude", "claude_chat"}:
+ optimizer_backend = optimizer_backend or "claude_chat"
+ target_backend = target_backend or "claude_chat"
+ elif backend in {"codex", "codex_exec"}:
+ if optimizer_backend in (None, "", "openai_chat"):
+ optimizer_backend = "codex_exec"
+ if target_backend in (None, "", "openai_chat"):
+ target_backend = "codex_exec"
elif backend == "claude_code_exec":
optimizer_backend = optimizer_backend or "openai_chat"
target_backend = target_backend or "claude_code_exec"
diff --git a/skillopt/envs/_template/config_template.yaml b/skillopt/envs/_template/config_template.yaml
index b482cc71..d98851dc 100644
--- a/skillopt/envs/_template/config_template.yaml
+++ b/skillopt/envs/_template/config_template.yaml
@@ -50,6 +50,6 @@ evaluation:
# ── Model ────────────────────────────────────────
# Override only what differs from the inherited defaults.
model:
- optimizer_backend: openai_chat # openai_chat | claude_chat | qwen_chat | minimax_chat
- target_backend: openai_chat # … plus codex_exec / claude_code_exec for target only
+ optimizer_backend: openai_chat # openai_chat | claude_chat | qwen_chat | minimax_chat | codex_exec
+ target_backend: openai_chat # chat backends plus codex_exec / claude_code_exec
reasoning_effort: medium
diff --git a/skillopt/model/__init__.py b/skillopt/model/__init__.py
index a09e6e0c..31a9f14b 100644
--- a/skillopt/model/__init__.py
+++ b/skillopt/model/__init__.py
@@ -6,6 +6,7 @@
from skillopt.model import azure_openai as _openai
from skillopt.model import claude_backend as _claude
+from skillopt.model import codex_backend as _codex
from skillopt.model import minimax_backend as _minimax
from skillopt.model import qwen_backend as _qwen
from skillopt.model.backend_config import ( # noqa: F401
@@ -40,10 +41,14 @@ def set_backend(name: str | None) -> str:
set_target_backend("claude_chat")
return "claude_chat"
if normalized == "codex":
- set_optimizer_backend("openai_chat")
+ set_optimizer_backend("codex_exec")
set_target_backend("codex_exec")
return "codex"
- if normalized in {"codex_exec", "claude_code_exec"}:
+ if normalized == "codex_exec":
+ set_optimizer_backend("codex_exec")
+ set_target_backend("codex_exec")
+ return normalized
+ if normalized == "claude_code_exec":
set_optimizer_backend("openai_chat")
set_target_backend(normalized)
return normalized
@@ -68,7 +73,7 @@ def get_backend_name() -> str:
return "qwen_chat"
if optimizer == "openai_chat" and target == "openai_chat":
return "azure_openai"
- if optimizer == "openai_chat" and target == "codex_exec":
+ if optimizer == "codex_exec" and target == "codex_exec":
return "codex"
if optimizer == "openai_chat" and target == "qwen_chat":
return "qwen_chat"
@@ -105,6 +110,15 @@ def chat_optimizer(
reasoning_effort=reasoning_effort,
timeout=timeout,
)
+ if get_optimizer_backend() == "codex_exec":
+ return _codex.chat_optimizer(
+ system=system,
+ user=user,
+ max_completion_tokens=max_completion_tokens,
+ retries=retries,
+ stage=stage,
+ timeout=timeout,
+ )
return _openai.chat_optimizer(
system=system,
user=user,
@@ -204,6 +218,17 @@ def chat_optimizer_messages(
return_message=return_message,
timeout=timeout,
)
+ if get_optimizer_backend() == "codex_exec":
+ return _codex.chat_optimizer_messages(
+ messages=messages,
+ max_completion_tokens=max_completion_tokens,
+ retries=retries,
+ stage=stage,
+ tools=tools,
+ tool_choice=tool_choice,
+ return_message=return_message,
+ timeout=timeout,
+ )
return _openai.chat_optimizer_messages(
messages=messages,
max_completion_tokens=max_completion_tokens,
@@ -365,6 +390,17 @@ def get_token_summary() -> dict:
summary[stage]["prompt_tokens"] += values["prompt_tokens"]
summary[stage]["completion_tokens"] += values["completion_tokens"]
summary[stage]["total_tokens"] += values["total_tokens"]
+ codex_summary = _codex.get_token_summary()
+ for stage, values in codex_summary.items():
+ if stage == "_total":
+ continue
+ if stage not in summary:
+ summary[stage] = values
+ continue
+ summary[stage]["calls"] += values["calls"]
+ summary[stage]["prompt_tokens"] += values["prompt_tokens"]
+ summary[stage]["completion_tokens"] += values["completion_tokens"]
+ summary[stage]["total_tokens"] += values["total_tokens"]
total = {
"calls": 0,
"prompt_tokens": 0,
@@ -387,6 +423,7 @@ def reset_token_tracker() -> None:
_claude.reset_token_tracker()
_qwen.reset_token_tracker()
_minimax.reset_token_tracker()
+ _codex.reset_token_tracker()
def configure_azure_openai(
@@ -499,6 +536,7 @@ def set_reasoning_effort(effort: str | None) -> None:
_claude.set_reasoning_effort(effort)
_qwen.set_reasoning_effort(effort)
_minimax.set_reasoning_effort(effort)
+ _codex.set_reasoning_effort(effort)
def set_target_deployment(deployment: str) -> None:
@@ -506,9 +544,11 @@ def set_target_deployment(deployment: str) -> None:
_claude.set_target_deployment(deployment)
_qwen.set_target_deployment(deployment)
_minimax.set_target_deployment(deployment)
+ _codex.set_target_deployment(deployment)
def set_optimizer_deployment(deployment: str) -> None:
_openai.set_optimizer_deployment(deployment)
_claude.set_optimizer_deployment(deployment)
_qwen.set_optimizer_deployment(deployment)
+ _codex.set_optimizer_deployment(deployment)
diff --git a/skillopt/model/backend_config.py b/skillopt/model/backend_config.py
index f23725c5..d90cd2d4 100644
--- a/skillopt/model/backend_config.py
+++ b/skillopt/model/backend_config.py
@@ -49,10 +49,10 @@ def _parse_int(value: str | None, default: int) -> int:
def set_optimizer_backend(backend: str) -> None:
global OPTIMIZER_BACKEND
OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat")
- if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}:
+ if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec"}:
raise ValueError(
f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. "
- "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', and 'minimax_chat'."
+ "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', and 'codex_exec'."
)
os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND
@@ -81,7 +81,7 @@ def is_target_exec_backend() -> bool:
def is_optimizer_chat_backend() -> bool:
- return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}
+ return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec"}
def is_target_chat_backend() -> bool:
diff --git a/skillopt/model/codex_backend.py b/skillopt/model/codex_backend.py
index 64b6f355..ff7f79cd 100644
--- a/skillopt/model/codex_backend.py
+++ b/skillopt/model/codex_backend.py
@@ -18,6 +18,7 @@
CompatToolFunction,
tracker,
)
+from skillopt.model.backend_config import get_codex_exec_config
CODEX_BIN = os.environ.get("CODEX_CLI_BIN", "codex")
@@ -286,20 +287,21 @@ def _run_codex_exec(
timeout: int | None,
) -> tuple[str, dict[str, int]]:
with tempfile.TemporaryDirectory(prefix="skillopt_codex_") as temp_dir:
+ config = get_codex_exec_config()
output_path = os.path.join(temp_dir, "last_message.txt")
image_paths = _materialize_attachments(attachments, temp_dir)
+ profile = str(config.get("profile") or os.environ.get("CODEX_PROFILE", "")).strip()
+ reasoning_effort = str(REASONING_EFFORT or config.get("reasoning_effort") or "").strip()
command = [
- CODEX_BIN,
+ str(config.get("path") or CODEX_BIN),
"exec",
"--json",
"--ephemeral",
- "--profile",
- CODEX_PROFILE,
"-c",
- "approval_policy=\"never\"",
+ f"approval_policy={json.dumps(str(config.get('approval_policy') or 'never'))}",
"--sandbox",
- CODEX_SANDBOX_MODE,
+ str(config.get("sandbox") or CODEX_SANDBOX_MODE),
"--skip-git-repo-check",
"--cd",
_default_working_directory(),
@@ -309,8 +311,11 @@ def _run_codex_exec(
output_path,
]
- if REASONING_EFFORT:
- command.extend(["-c", f"model_reasoning_effort={json.dumps(REASONING_EFFORT)}"])
+ if profile:
+ command.extend(["--profile", profile])
+
+ if reasoning_effort and reasoning_effort != "none":
+ command.extend(["-c", f"model_reasoning_effort={json.dumps(reasoning_effort)}"])
schema_path = None
if output_schema is not None:
diff --git a/tests/test_codex_optimizer_backend.py b/tests/test_codex_optimizer_backend.py
new file mode 100644
index 00000000..08677a2e
--- /dev/null
+++ b/tests/test_codex_optimizer_backend.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import importlib.util
+import os
+import sys
+import types
+from collections.abc import Iterator
+from typing import Any
+
+import pytest
+
+
+class _OpenAIClientStub:
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ self.args = args
+ self.kwargs = kwargs
+
+
+def _install_openai_stub() -> None:
+ if "openai" in sys.modules or importlib.util.find_spec("openai") is not None:
+ return
+ openai_stub = types.ModuleType("openai")
+ openai_stub.AzureOpenAI = _OpenAIClientStub
+ openai_stub.OpenAI = _OpenAIClientStub
+ sys.modules["openai"] = openai_stub
+
+
+def _import_model_modules() -> tuple[Any, Any, Any, Any]:
+ _install_openai_stub()
+ import skillopt.model as model_module
+ from skillopt.model import azure_openai, backend_config, codex_backend
+
+ return model_module, backend_config, codex_backend, azure_openai
+
+
+@pytest.fixture(autouse=True)
+def isolate_backend_state() -> Iterator[tuple[Any, Any, Any, Any]]:
+ model_module, backend_config, codex_backend, azure_openai = _import_model_modules()
+ optimizer_backend = backend_config.get_optimizer_backend()
+ target_backend = backend_config.get_target_backend()
+ env = {
+ key: os.environ.get(key)
+ for key in (
+ "OPTIMIZER_BACKEND",
+ "TARGET_BACKEND",
+ "OPTIMIZER_DEPLOYMENT",
+ "TARGET_DEPLOYMENT",
+ )
+ }
+ yield model_module, backend_config, codex_backend, azure_openai
+ backend_config.set_optimizer_backend(optimizer_backend)
+ backend_config.set_target_backend(target_backend)
+ for key, value in env.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+
+def test_codex_exec_can_be_optimizer_backend(
+ isolate_backend_state: tuple[Any, Any, Any, Any],
+) -> None:
+ _model_module, backend_config, _codex_backend, _azure_openai = isolate_backend_state
+
+ backend_config.set_optimizer_backend("codex_exec")
+
+ assert backend_config.get_optimizer_backend() == "codex_exec"
+
+
+def test_set_backend_codex_uses_codex_for_optimizer_and_target(
+ isolate_backend_state: tuple[Any, Any, Any, Any],
+) -> None:
+ model_module, backend_config, _codex_backend, _azure_openai = isolate_backend_state
+
+ assert model_module.set_backend("codex") == "codex"
+
+ assert backend_config.get_optimizer_backend() == "codex_exec"
+ assert backend_config.get_target_backend() == "codex_exec"
+ assert model_module.get_backend_name() == "codex"
+
+
+def test_chat_optimizer_routes_to_codex_backend(
+ monkeypatch: pytest.MonkeyPatch,
+ isolate_backend_state: tuple[Any, Any, Any, Any],
+) -> None:
+ model_module, backend_config, codex_backend, azure_openai = isolate_backend_state
+ codex_calls: list[dict[str, Any]] = []
+
+ def fake_codex_optimizer(**kwargs: Any) -> tuple[str, dict[str, int]]:
+ codex_calls.append(kwargs)
+ return "codex result", {
+ "prompt_tokens": 1,
+ "completion_tokens": 2,
+ "total_tokens": 3,
+ }
+
+ def fail_openai_optimizer(**_kwargs: Any) -> tuple[str, dict[str, int]]:
+ raise AssertionError("openai optimizer should not be called for codex_exec")
+
+ monkeypatch.setattr(codex_backend, "chat_optimizer", fake_codex_optimizer)
+ monkeypatch.setattr(azure_openai, "chat_optimizer", fail_openai_optimizer)
+ backend_config.set_optimizer_backend("codex_exec")
+
+ text, usage = model_module.chat_optimizer("system", "user", retries=1, timeout=5)
+
+ assert text == "codex result"
+ assert usage["total_tokens"] == 3
+ assert codex_calls[0]["system"] == "system"
+ assert codex_calls[0]["user"] == "user"
+ assert codex_calls[0]["timeout"] == 5