From fcb615dbe2bd8837290ad5a87b52ae7179b6db80 Mon Sep 17 00:00:00 2001 From: Wenyu Date: Fri, 23 Oct 2026 02:53:20 -0400 Subject: [PATCH] feat(wrapper): optional --service-tier / -ServiceTier pass-through (manifest 0.1.4) Both wrappers accept `--service-tier ` (PowerShell `-ServiceTier`) and pass `-c service_tier=""` to `codex exec`, so a delegated run can use Codex's Fast tier (`--effort ultra --service-tier fast`). CODEX_DELEGATE_SERVICE_TIER supplies the default when the flag is absent; the flag wins, and `default` (or neither set) passes nothing. Same shape as --effort: lowercased, one word of letters else exit 2 before Codex runs, no list of valid tiers. The value is a quoted TOML string; Windows PowerShell 5.1 drops the embedded quotes when launching a native command and Codex accepts the bare form. -ServiceTier is declared last so positional PowerShell calls keep their bindings. result.json gains `service_tier` after `reasoning_effort`. CODEX_PATH handling is unchanged. - scripts/run_codex.{sh,ps1} + bundled skills/ copies (byte-identical; ps1 keeps CRLF) - SKILL.md, references/{model-selection,wrapper,output-contract, runtime-compatibility}.md, README.md, README_zh-TW.md, CHANGELOG.md; .claude-plugin/plugin.json 0.1.3 -> 0.1.4 - tests/test_wrappers.py: service-tier cases for both wrappers and both copies (flag, env, flag beats env, `default`, invalid value, result.json, combined with --effort) and an autouse fixture that clears the ambient CODEX_DELEGATE_EFFORT / CODEX_DELEGATE_SERVICE_TIER Tests: 156 passed, 6 skipped, 9 failed in the full suite on this loaded host. Three failures (test_relative_brief_resolves_before_codex_changes_cwd[caller|repo], test_portable_skill_bash_wrapper_runs_without_repository_root) are the MSYS path-form failures already recorded for clean 8207498. Six test_incremental_adapter::test_quota_context[...bash] cases hit their own 15 s subprocess timeout under load and pass on rerun (7 passed in 22 s). Run with GIT_CEILING_DIRECTORIES=C:/Users/wenyu: C:\Users\wenyu is a git repo, so the wrapper's `git status` in a pytest temp dir otherwise scans the whole home directory and hangs. Written by Antigravity (agy); reviewed line by line and by a code-reviewer subagent (sonnet): APPROVE, one doc P2 fixed (unsupported-tier behaviour is not established, so the docs no longer claim Codex rejects it). Co-Reviewed-By: code-reviewer (sonnet) APPROVE (triggers #1, #4, multi-locale) Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 19 +++ README.md | 7 +- README_zh-TW.md | 4 +- scripts/run_codex.ps1 | 19 ++- scripts/run_codex.sh | 20 +++ skills/codex-delegate/SKILL.md | 7 +- .../references/model-selection.md | 18 +- .../references/output-contract.md | 2 + .../references/runtime-compatibility.md | 7 +- skills/codex-delegate/references/wrapper.md | 4 +- skills/codex-delegate/scripts/run_codex.ps1 | 19 ++- skills/codex-delegate/scripts/run_codex.sh | 20 +++ tests/test_wrappers.py | 158 ++++++++++++++++++ 14 files changed, 286 insertions(+), 20 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 88a1017..7fd78b2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codex-delegate", "description": "Hand token-heavy mechanical work (batch edits, scaffolding, refactors, test generation, plotting scripts) from Claude to Codex CLI.", - "version": "0.1.3", + "version": "0.1.4", "author": { "name": "Wenyu Chiou" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 5148375..d0262c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ marketplace; see that repo's CHANGELOG for the catalog-side history. ## [Unreleased] +### Added (manifest `0.1.4`) + +- Optional `--service-tier` / `-ServiceTier ` passes + `-c service_tier=""` to `codex exec`. The env var + `CODEX_DELEGATE_SERVICE_TIER` supplies a default when the flag is absent; + the flag wins over the env var, and `default` (or neither set) passes + nothing so `~/.codex/config.toml` applies. The value is lowercased and must + be one word of letters `a-z` (else exit 2 before Codex runs); the wrapper + keeps no list of valid tiers and does not check tier support. `-ServiceTier` + is declared last (after `-Effort`) so existing positional PowerShell calls + keep their bindings. +- `result.json` gains `service_tier` (immediately after `reasoning_effort`: the + value passed, or `default` when none was passed). +- `SKILL.md` and `references/` (`model-selection.md`, `wrapper.md`, + `output-contract.md`, `runtime-compatibility.md`) document the new option. + Tests in `tests/test_wrappers.py` cover pass-through on both wrappers and + both copies, plus precedence, invalid-value refusal (canonical copies), + combined `--effort` + `--service-tier`, and ambient env isolation. + ### Added (manifest `0.1.3`) - Optional `--effort` / `-Effort ` passes diff --git a/README.md b/README.md index a1ace9f..2bac018 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Current wrapper tests cover: - result contracts, quota/error classification, stdin closure, and changed-file snapshots - caller-relative/repo-relative brief resolution when Codex uses a different working root - canonical/packaged wrapper parity and isolated portable-skill execution -- `--model` / `--effort` pass-through only when given, including the `CODEX_DELEGATE_EFFORT` default and invalid-value refusal +- `--model` / `--effort` / `--service-tier` pass-through only when given, including the `CODEX_DELEGATE_EFFORT` and `CODEX_DELEGATE_SERVICE_TIER` defaults and invalid-value refusal - PowerShell parity (runs with pwsh or Windows PowerShell when available) ## Installation @@ -117,9 +117,10 @@ from the loaded skill; the host resolves the plugin's versioned installation pat For direct portable-skill installations, use `/scripts/run_codex.sh`. Do not assume marketplace plugins live in `~/.claude/skills/`. -Current stable manifest: `0.1.0`; unreleased patches propose `0.1.3` (`0.1.1`: +Current stable manifest: `0.1.0`; unreleased patches propose `0.1.4` (`0.1.1`: compatibility fixes; `0.1.2`: no built-in default model; `0.1.3`: optional -`--effort` / `-Effort` and `CODEX_DELEGATE_EFFORT`). See [runtime/package compatibility](skills/codex-delegate/references/runtime-compatibility.md) +`--effort` / `-Effort` and `CODEX_DELEGATE_EFFORT`; `0.1.4`: optional +`--service-tier` / `-ServiceTier` and `CODEX_DELEGATE_SERVICE_TIER`). See [runtime/package compatibility](skills/codex-delegate/references/runtime-compatibility.md) for the observed CLI version, current primary docs, and verification limits. Default scope is `user` (this OS account, all projects). Add diff --git a/README_zh-TW.md b/README_zh-TW.md index 9c2a103..72dd4dc 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -91,7 +91,7 @@ python -m pytest -q - success path 的 `result.json` 輸出 - PowerShell wrapper contract 行為 -- `--model` / `--effort` 只有指定時才傳給 Codex,含 `CODEX_DELEGATE_EFFORT` 預設值與無效值拒絕 +- `--model` / `--effort` / `--service-tier` 只有指定時才傳給 Codex,含 `CODEX_DELEGATE_EFFORT`、`CODEX_DELEGATE_SERVICE_TIER` 預設值與無效值拒絕 ## 安裝 @@ -124,6 +124,6 @@ Marketplace plugin 請從載入的 skill 使用 `/scripts/run_codex.sh`;封裝內的 Bash/PowerShell wrapper 與根目錄版本必須逐位元組一致。 -目前穩定 manifest 是 `0.1.0`,尚未發布的修補提議 `0.1.3`(`0.1.1`:相容性修補;`0.1.2`:不再內建預設 model;`0.1.3`:可選的 `--effort` / `-Effort` 與 `CODEX_DELEGATE_EFFORT`)。 +目前穩定 manifest 是 `0.1.0`,尚未發布的修補提議 `0.1.4`(`0.1.1`:相容性修補;`0.1.2`:不再內建預設 model;`0.1.3`:可選的 `--effort` / `-Effort` 與 `CODEX_DELEGATE_EFFORT`;`0.1.4`:可選的 `--service-tier` / `-ServiceTier` 與 `CODEX_DELEGATE_SERVICE_TIER`)。 版本、`-C` 工作目錄契約與未驗證項目見 [相容性紀錄](skills/codex-delegate/references/runtime-compatibility.md)。 diff --git a/scripts/run_codex.ps1 b/scripts/run_codex.ps1 index a38dcaa..b42fef0 100644 --- a/scripts/run_codex.ps1 +++ b/scripts/run_codex.ps1 @@ -8,8 +8,10 @@ param( [string]$LogFile = "", [string]$BriefFile = "", [bool]$Synchronous = $true, - # Declared last so existing positional calls keep their bindings. - [string]$Effort = "" + # Appended after the original parameters so existing positional calls + # keep their bindings; -ServiceTier follows the same rule. + [string]$Effort = "", + [string]$ServiceTier = "" ) # ValidateSet is case-insensitive, while Codex requires canonical values. @@ -29,6 +31,17 @@ if ($Effort -and $Effort -cnotmatch '^[a-z]+\z') { } $effortLabel = if ($Effort) { $Effort } else { "default" } +# -ServiceTier wins over CODEX_DELEGATE_SERVICE_TIER; "default" (or neither +# set) passes no override (parity with run_codex.sh). Only the shape is checked. +if (-not $ServiceTier) { $ServiceTier = "$env:CODEX_DELEGATE_SERVICE_TIER" } +$ServiceTier = $ServiceTier.ToLowerInvariant() +if ($ServiceTier -eq "default") { $ServiceTier = "" } +if ($ServiceTier -and $ServiceTier -cnotmatch '^[a-z]+\z') { + [Console]::Error.WriteLine("Error: -ServiceTier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default; got '$ServiceTier'") + exit 2 +} +$serviceTierLabel = if ($ServiceTier) { $ServiceTier } else { "default" } + # Default --repo to the caller's working directory. # Resolved here (not at param default) so it reflects the shell's PWD # at invocation time, not the script's parse-time location. @@ -178,6 +191,7 @@ function Write-ResultJson { ' "delegate": "codex",', (' "model": ' + (ConvertTo-JsonScalar $ModelUsed) + ","), (' "reasoning_effort": ' + (ConvertTo-JsonScalar $effortLabel) + ","), + (' "service_tier": ' + (ConvertTo-JsonScalar $serviceTierLabel) + ","), (' "log_file": ' + (ConvertTo-JsonScalar $logPath) + ","), (' "output_file": ' + (ConvertTo-JsonScalar $OutputFile) + ","), (' "summary": ' + (ConvertTo-JsonScalar $Summary) + ","), @@ -269,6 +283,7 @@ $codexArgs = @("exec", "--sandbox", $Sandbox, "-C", $Repo) # Empty -Model = no -m flag, so codex uses its own configured default model. if ($Model) { $codexArgs += @("-m", $Model) } if ($Effort) { $codexArgs += @("-c", "model_reasoning_effort=$Effort") } +if ($ServiceTier) { $codexArgs += @("-c", "service_tier=`"$ServiceTier`"") } $modelLabel = if ($Model) { "codex/$Model" } else { "codex/default" } if ($OutputFile) { $codexArgs += @("-o", $OutputFile) } $codexArgs += $safePrompt diff --git a/scripts/run_codex.sh b/scripts/run_codex.sh index 1a7e991..cd0b84e 100644 --- a/scripts/run_codex.sh +++ b/scripts/run_codex.sh @@ -84,6 +84,7 @@ write_result_json() { printf ' "delegate": "codex",\n' printf ' "model": %s,\n' "$(printf '%s' "$model" | json_escape)" printf ' "reasoning_effort": %s,\n' "$(printf '%s' "$EFFORT_LABEL" | json_escape)" + printf ' "service_tier": %s,\n' "$(printf '%s' "$SERVICE_TIER_LABEL" | json_escape)" printf ' "log_file": %s,\n' "$(printf '%s' "$LOG_PATH" | json_escape)" printf ' "output_file": %s,\n' "$(printf '%s' "$OUTPUT_FILE" | json_escape)" printf ' "summary": %s,\n' "$(printf '%s' "$summary" | json_escape)" @@ -102,6 +103,8 @@ REPO="${PWD}" MODEL="" # Empty = no -c override, so codex uses its configured model_reasoning_effort. EFFORT="" +# Empty = no -c override, so codex uses its configured service_tier. +SERVICE_TIER="" SANDBOX="workspace-write" OUTPUT_FILE="" LOG_FILE="" @@ -113,6 +116,7 @@ while [[ $# -gt 0 ]]; do --repo) REPO="$2"; shift 2 ;; --model) MODEL="$2"; shift 2 ;; --effort) EFFORT="$2"; shift 2 ;; + --service-tier) SERVICE_TIER="$2"; shift 2 ;; --sandbox) SANDBOX="$2"; shift 2 ;; --output-file) OUTPUT_FILE="$2"; shift 2 ;; --log-file) LOG_FILE="$2"; shift 2 ;; @@ -146,6 +150,21 @@ case "$EFFORT" in esac EFFORT_LABEL="${EFFORT:-default}" +# --service-tier wins over CODEX_DELEGATE_SERVICE_TIER; "default" (or neither +# set) passes no override. Only the shape is checked here: one lowercase word. +SERVICE_TIER="${SERVICE_TIER:-${CODEX_DELEGATE_SERVICE_TIER:-}}" +case "$SERVICE_TIER" in + *$'\n'*) echo "Error: --service-tier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default" >&2; exit 2 ;; +esac +SERVICE_TIER="$(printf '%s' "$SERVICE_TIER" | tr '[:upper:]' '[:lower:]')" +case "$SERVICE_TIER" in + ""|default) SERVICE_TIER="" ;; + *[!abcdefghijklmnopqrstuvwxyz]*) + echo "Error: --service-tier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default; got '$SERVICE_TIER'" >&2 + exit 2 ;; +esac +SERVICE_TIER_LABEL="${SERVICE_TIER:-default}" + # Brief discipline: refuse inline prompts > 500 chars unless a brief is on disk. # Auditability gap fix (research-hub v0.89.1 post-release audit, 2026-05-15): # inline --prompt with no on-disk brief leaves orphan results and breaks @@ -260,6 +279,7 @@ printf '%s' "$PROMPT" > "$PROMPT_FILE" CODEX_ARGS=("exec" "--sandbox" "$SANDBOX" "-C" "$REPO") [[ -n "$MODEL" ]] && CODEX_ARGS+=("-m" "$MODEL") [[ -n "$EFFORT" ]] && CODEX_ARGS+=("-c" "model_reasoning_effort=$EFFORT") +[[ -n "$SERVICE_TIER" ]] && CODEX_ARGS+=("-c" "service_tier=\"$SERVICE_TIER\"") [[ -n "$OUTPUT_FILE" ]] && CODEX_ARGS+=("-o" "$OUTPUT_FILE") MODEL_LABEL="codex/${MODEL:-default}" CODEX_ARGS+=("$(cat "$PROMPT_FILE")") diff --git a/skills/codex-delegate/SKILL.md b/skills/codex-delegate/SKILL.md index 873c9c8..2095da9 100644 --- a/skills/codex-delegate/SKILL.md +++ b/skills/codex-delegate/SKILL.md @@ -114,14 +114,15 @@ Full routing table and good/bad examples: `references/delegation-targets.md`. ## Output contract -`.result.json` includes at minimum: `status` (success|fallback|error), `delegate` ("codex"), `model`, `reasoning_effort`, `log_file`, `output_file`, `summary`, `risks`, `files_changed`, `tests_run`, `timestamp_utc`. Full schema and status semantics: `references/output-contract.md`. +`.result.json` includes at minimum: `status` (success|fallback|error), `delegate` ("codex"), `model`, `reasoning_effort`, `service_tier`, `log_file`, `output_file`, `summary`, `risks`, `files_changed`, `tests_run`, `timestamp_utc`. Full schema and status semantics: `references/output-contract.md`. ## Compatibility - Tested with `@openai/codex` 0.128.0–0.144.1 (May–July 2026). Should work with any version that accepts `codex exec --sandbox workspace-write`. - Wrapper default model: none. Without `--model` / `-Model` the wrapper passes no `-m`, so Codex uses its own configured default (reported as `codex/default`); pass `--model` or `-Model` to pick one. Availability and pricing depend on the account and installed CLI. `references/model-selection.md` contains a historical A/B snapshot, not a current price guarantee. Use the interactive `/model` picker to inspect available models. - Wrapper default reasoning effort: none. Without `--effort` / `-Effort` (or `CODEX_DELEGATE_EFFORT`) the wrapper passes no `-c model_reasoning_effort`, so the effort in `~/.codex/config.toml` applies (reported as `default`). Pass `--effort ` (for example `low`, `medium`, `high`, `xhigh`) per call, or set `CODEX_DELEGATE_EFFORT` so delegation defaults to a cheaper effort than interactive use; the flag wins over the env var. See `references/model-selection.md`. -- Wrapper calls `codex exec --sandbox workspace-write -C [-m ] [-c model_reasoning_effort=]`. In observed CLI `0.159.0-alpha.7`, `-C`/`--cd` selects a working-root directory, not a context file; `--full-auto` is absent. Check `codex exec --help` for your installed version. See `references/runtime-compatibility.md` for the primary-source checks and limits. +- Wrapper default service tier: none. Without `--service-tier` / `-ServiceTier` (or `CODEX_DELEGATE_SERVICE_TIER`) the wrapper passes no `-c service_tier`, so the tier in `~/.codex/config.toml` applies (reported as `default`). Pass `--service-tier ` (for example `fast` or `flex`) per call, or set `CODEX_DELEGATE_SERVICE_TIER`; the flag wins over the env var, and `default` passes nothing. See `references/model-selection.md`. +- Wrapper calls `codex exec --sandbox workspace-write -C [-m ] [-c model_reasoning_effort=] [-c service_tier=""]`. In observed CLI `0.159.0-alpha.7`, `-C`/`--cd` selects a working-root directory, not a context file; `--full-auto` is absent. Check `codex exec --help` for your installed version. See `references/runtime-compatibility.md` for the primary-source checks and limits. - `codex exec` is non-interactive. Sandbox selection is not blanket permission: the installed host's approval and policy settings still apply. The observed CLI has no exec-local `--ask-for-approval` option; do not infer bypassed approval or expand access from that absence. - Direct `codex exec` calls must close stdin (`` / `-Model ` | `-m ` | `model` from `~/.codex/config.toml` (reported as `codex/default`) | | Reasoning effort | `--effort ` / `-Effort ` | `-c model_reasoning_effort=` | `model_reasoning_effort` from `~/.codex/config.toml` (reported as `default`) | +| Service tier | `--service-tier ` / `-ServiceTier ` | `-c service_tier=""` | `service_tier` from `~/.codex/config.toml` (reported as `default`) | -Since manifest `0.1.2` the wrapper passes no `-m` unless asked. Earlier versions always passed `-m gpt-5.5` (from commit `70e6fdc`, 2026-05-15), which fails with HTTP 400 on accounts that lose access to that model; the wrapper still has no built-in model id. Since manifest `0.1.3` it also passes no effort unless asked. Both Codex defaults live in `~/.codex/config.toml` as plain strings, not tables: +Since manifest `0.1.2` the wrapper passes no `-m` unless asked. Earlier versions always passed `-m gpt-5.5` (from commit `70e6fdc`, 2026-05-15), which fails with HTTP 400 on accounts that lose access to that model; the wrapper still has no built-in model id. Since manifest `0.1.3` it also passes no effort unless asked. Since manifest `0.1.4` it also passes no service tier unless asked. The Codex defaults live in `~/.codex/config.toml` as plain strings, not tables: ```toml model = "" @@ -32,6 +33,17 @@ Rough guide: - `xhigh`: one-shot tasks where subtle correctness matters and the extra cost does not multiply across many calls. - `max` / `ultra`, where the model offers them: cost more than `xhigh`; rarely worth it for delegated mechanical work. +## Service tier + +Service tier selects Codex's speed/usage tier when a call needs it: + +- **Per call:** `--service-tier fast` (bash) / `-ServiceTier fast` (PowerShell), or combined with effort: `--effort ultra --service-tier fast` (PowerShell: `-Effort ultra -ServiceTier fast`). +- **For all delegations:** set `CODEX_DELEGATE_SERVICE_TIER` in the environment that runs the wrapper. Precedence is flag, then `CODEX_DELEGATE_SERVICE_TIER`, then `service_tier` in `~/.codex/config.toml`. `--service-tier default` (or `-ServiceTier default`) ignores the env var and passes nothing. + +The wrapper lowercases the value, checks only the shape (one word of letters `a-z`; anything else exits 2 before Codex runs), and passes `-c service_tier=""`. It keeps no list of valid tiers, and what Codex does with an unsupported one is not established here (a config load accepted an arbitrary string on 2026-10-23), so a typo that passes the shape check can reach `result.json` as `service_tier` unchanged. Windows PowerShell 5.1 drops the embedded double quotes when launching `codex`, so the child receives the bare `service_tier=fast`; Codex's `-c` parser accepts both forms. + +Observed on Codex CLI 0.163.0 with model `gpt-6.1-sol`: `codex exec -c 'service_tier="fast"' ...` is accepted, and a test session's rollout file recorded `"service_tier":"priority"` (`priority` is the API name of the Fast tier). `~/.codex/models_cache.json` lists, for `gpt-6.1-sol`, `service_tiers: [{id: "priority", name: "Fast", description: "2x speed, increased usage"}]` and `additional_speed_tiers: ["fast"]`, and the Codex binary's config enum shows `fast` and `flex`. Fast spends more usage for speed, so it is a deliberate choice, not a default. Whether other models offer a tier is not established here; check the `/model` picker or the model cache. + ## Choosing a model - Delegate to one of the newest models that is not the most expensive tier. Codex's own model list (`~/.codex/models_cache.json`, or the `/model` picker) describes each model; pick the current general-purpose "workhorse", not the "frontier" tier. Token cost compounds across N calls, and the brief plus Claude's review carry the judgment. diff --git a/skills/codex-delegate/references/output-contract.md b/skills/codex-delegate/references/output-contract.md index 95adbad..ad011ff 100644 --- a/skills/codex-delegate/references/output-contract.md +++ b/skills/codex-delegate/references/output-contract.md @@ -19,6 +19,7 @@ handoff replies (`references/handoff-protocol.md`). "delegate": "codex", "model": "codex/", "reasoning_effort": "|default", + "service_tier": "|default", "log_file": "", "output_file": "", "summary": "", @@ -35,6 +36,7 @@ handoff replies (`references/handoff-protocol.md`). |---|---|---| | `status` / `delegate` / `model` / `log_file` / `output_file` / `summary` / `timestamp_utc` | wrapper | always written | | `reasoning_effort` | wrapper | always written (manifest `0.1.3`+): the effort passed as `-c model_reasoning_effort`, or `default` when none was passed and Codex's configured value applied | +| `service_tier` | wrapper | always written (manifest `0.1.4`+): the tier passed as `-c service_tier=""`, or `default` when none was passed and Codex's configured value applied | | `files_changed` | wrapper, **auto-derived** | Before/after dirty-path status and content fingerprints, using NUL-delimited Git records. Includes additional edits to already-dirty tracked or untracked files, deletions, renames, and dirty paths restored to clean; unchanged pre-existing dirty files are excluded. Empty `[]` when Git is unavailable, the directory is not a work tree, or no observable delta exists. Logs/sidecars are written after the snapshot. This is best-effort observation, not proof of authorship: concurrent edits, ignored files, unreadable paths, and changes inside submodules need an independent audit. Reconcile with the actual candidate tree and run-specific baseline before acceptance. | | `tests_run` | **not auto-filled** — stays `[]` | The wrapper cannot see which tests ran: Codex runs them inside its own sandbox process and the wrapper only captures stdout. Treat `tests_run` as Claude's to fill during acceptance — run the brief's verification commands yourself and record them. | | `risks` | **not auto-filled** — stays `[]` | Risk assessment is a judgment call; it stays Claude's job. | diff --git a/skills/codex-delegate/references/runtime-compatibility.md b/skills/codex-delegate/references/runtime-compatibility.md index 78e4c14..572612e 100644 --- a/skills/codex-delegate/references/runtime-compatibility.md +++ b/skills/codex-delegate/references/runtime-compatibility.md @@ -20,10 +20,11 @@ its mechanical-only delegation boundary. ## Observed command contract -`codex exec --sandbox workspace-write -C [-m ] [-c model_reasoning_effort=] [ -o ] ` +`codex exec --sandbox workspace-write -C [-m ] [-c model_reasoning_effort=] [-c service_tier=""] [ -o ] ` uses a directory as `-C`/`--cd`; it does not attach a context file. The wrapper -closes stdin. It passes `-m` only for `--model` and `-c model_reasoning_effort` -only for `--effort` / `CODEX_DELEGATE_EFFORT`; each then wins over the matching +closes stdin. It passes `-m` only for `--model`, `-c model_reasoning_effort` +only for `--effort` / `CODEX_DELEGATE_EFFORT`, and `-c service_tier=""` +only for `--service-tier` / `CODEX_DELEGATE_SERVICE_TIER`; each then wins over the matching `~/.codex/config.toml` value, and otherwise that config applies. Codex 0.160.1 rejects an unsupported effort with HTTP 400 (observed 2026-10-17), which the wrapper reports as `error`. Installed help has no `codex models` command; use the diff --git a/skills/codex-delegate/references/wrapper.md b/skills/codex-delegate/references/wrapper.md index 1086e27..3017c23 100644 --- a/skills/codex-delegate/references/wrapper.md +++ b/skills/codex-delegate/references/wrapper.md @@ -24,6 +24,7 @@ Optional flags: - `--repo `: project root (default: the caller's `$PWD`) - `--model `: model string passed to `codex exec -m` - `--effort `: a level such as `low`, `medium`, `high` or `xhigh`, passed as `codex exec -c model_reasoning_effort=`; `default` (or no flag and no `CODEX_DELEGATE_EFFORT`) passes nothing, so `~/.codex/config.toml` applies. The wrapper lowercases the value and exits 2 before Codex runs unless it is one word of letters; whether the model supports that level is left to Codex (an unsupported one fails with HTTP 400 and `status: error`). +- `--service-tier `: a service tier such as `fast` or `flex`, passed as `codex exec -c service_tier=""`; `default` (or no flag and no `CODEX_DELEGATE_SERVICE_TIER`) passes nothing, so `~/.codex/config.toml` applies. The wrapper lowercases the value and exits 2 before Codex runs unless it is one word of letters; whether the model supports that tier is not checked (see `model-selection.md`, "Service tier"). - `--sandbox `: forwarded to Codex; default remains `workspace-write` - `--output-file `: passed to `codex exec -o` @@ -35,7 +36,7 @@ Optional flags: -Repo (Get-Location).Path ``` -PowerShell parameters: `-BriefFile` or `-Prompt`, `-Repo`, `-Model`, `-Sandbox`, `-OutputFile`, `-LogFile`, `-Synchronous`, `-Effort`. `-Effort` takes the same values as `--effort` (pass it by name; it is declared last so older positional calls keep their bindings). `-Sandbox` accepts `read-only` or `workspace-write`, with the existing `workspace-write` default. Marketplace hosts can resolve the plugin-root script instead. +PowerShell parameters: `-BriefFile` or `-Prompt`, `-Repo`, `-Model`, `-Sandbox`, `-OutputFile`, `-LogFile`, `-Synchronous`, `-Effort`, `-ServiceTier`. `-Effort` and `-ServiceTier` take the same values as `--effort` and `--service-tier` (pass them by name; `-ServiceTier` is declared last, after `-Effort`, so older positional calls keep their bindings). `-Sandbox` accepts `read-only` or `workspace-write`, with the existing `workspace-write` default. Marketplace hosts can resolve the plugin-root script instead. **Do not** wrap these in `Start-Process`. Call them inline so file writes persist before the wrapper exits. @@ -62,6 +63,7 @@ inspection task when appropriate; the host's policies still apply. - `CODEX_PATH` — override the Codex executable (testing or custom envs) - `CODEX_DELEGATE_EFFORT` — default for `--effort` / `-Effort` when the flag is absent or empty; the flag wins. Lets delegation run at a cheaper effort (for example `high`) without editing the interactive Codex config. +- `CODEX_DELEGATE_SERVICE_TIER` — default for `--service-tier` / `-ServiceTier` when the flag is absent or empty; the flag wins, and `default` passes nothing. - `PYTHON_BIN` (bash only) — override the Python used for JSON escaping ## Sentinels written by the wrapper diff --git a/skills/codex-delegate/scripts/run_codex.ps1 b/skills/codex-delegate/scripts/run_codex.ps1 index a38dcaa..b42fef0 100644 --- a/skills/codex-delegate/scripts/run_codex.ps1 +++ b/skills/codex-delegate/scripts/run_codex.ps1 @@ -8,8 +8,10 @@ param( [string]$LogFile = "", [string]$BriefFile = "", [bool]$Synchronous = $true, - # Declared last so existing positional calls keep their bindings. - [string]$Effort = "" + # Appended after the original parameters so existing positional calls + # keep their bindings; -ServiceTier follows the same rule. + [string]$Effort = "", + [string]$ServiceTier = "" ) # ValidateSet is case-insensitive, while Codex requires canonical values. @@ -29,6 +31,17 @@ if ($Effort -and $Effort -cnotmatch '^[a-z]+\z') { } $effortLabel = if ($Effort) { $Effort } else { "default" } +# -ServiceTier wins over CODEX_DELEGATE_SERVICE_TIER; "default" (or neither +# set) passes no override (parity with run_codex.sh). Only the shape is checked. +if (-not $ServiceTier) { $ServiceTier = "$env:CODEX_DELEGATE_SERVICE_TIER" } +$ServiceTier = $ServiceTier.ToLowerInvariant() +if ($ServiceTier -eq "default") { $ServiceTier = "" } +if ($ServiceTier -and $ServiceTier -cnotmatch '^[a-z]+\z') { + [Console]::Error.WriteLine("Error: -ServiceTier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default; got '$ServiceTier'") + exit 2 +} +$serviceTierLabel = if ($ServiceTier) { $ServiceTier } else { "default" } + # Default --repo to the caller's working directory. # Resolved here (not at param default) so it reflects the shell's PWD # at invocation time, not the script's parse-time location. @@ -178,6 +191,7 @@ function Write-ResultJson { ' "delegate": "codex",', (' "model": ' + (ConvertTo-JsonScalar $ModelUsed) + ","), (' "reasoning_effort": ' + (ConvertTo-JsonScalar $effortLabel) + ","), + (' "service_tier": ' + (ConvertTo-JsonScalar $serviceTierLabel) + ","), (' "log_file": ' + (ConvertTo-JsonScalar $logPath) + ","), (' "output_file": ' + (ConvertTo-JsonScalar $OutputFile) + ","), (' "summary": ' + (ConvertTo-JsonScalar $Summary) + ","), @@ -269,6 +283,7 @@ $codexArgs = @("exec", "--sandbox", $Sandbox, "-C", $Repo) # Empty -Model = no -m flag, so codex uses its own configured default model. if ($Model) { $codexArgs += @("-m", $Model) } if ($Effort) { $codexArgs += @("-c", "model_reasoning_effort=$Effort") } +if ($ServiceTier) { $codexArgs += @("-c", "service_tier=`"$ServiceTier`"") } $modelLabel = if ($Model) { "codex/$Model" } else { "codex/default" } if ($OutputFile) { $codexArgs += @("-o", $OutputFile) } $codexArgs += $safePrompt diff --git a/skills/codex-delegate/scripts/run_codex.sh b/skills/codex-delegate/scripts/run_codex.sh index 1a7e991..cd0b84e 100644 --- a/skills/codex-delegate/scripts/run_codex.sh +++ b/skills/codex-delegate/scripts/run_codex.sh @@ -84,6 +84,7 @@ write_result_json() { printf ' "delegate": "codex",\n' printf ' "model": %s,\n' "$(printf '%s' "$model" | json_escape)" printf ' "reasoning_effort": %s,\n' "$(printf '%s' "$EFFORT_LABEL" | json_escape)" + printf ' "service_tier": %s,\n' "$(printf '%s' "$SERVICE_TIER_LABEL" | json_escape)" printf ' "log_file": %s,\n' "$(printf '%s' "$LOG_PATH" | json_escape)" printf ' "output_file": %s,\n' "$(printf '%s' "$OUTPUT_FILE" | json_escape)" printf ' "summary": %s,\n' "$(printf '%s' "$summary" | json_escape)" @@ -102,6 +103,8 @@ REPO="${PWD}" MODEL="" # Empty = no -c override, so codex uses its configured model_reasoning_effort. EFFORT="" +# Empty = no -c override, so codex uses its configured service_tier. +SERVICE_TIER="" SANDBOX="workspace-write" OUTPUT_FILE="" LOG_FILE="" @@ -113,6 +116,7 @@ while [[ $# -gt 0 ]]; do --repo) REPO="$2"; shift 2 ;; --model) MODEL="$2"; shift 2 ;; --effort) EFFORT="$2"; shift 2 ;; + --service-tier) SERVICE_TIER="$2"; shift 2 ;; --sandbox) SANDBOX="$2"; shift 2 ;; --output-file) OUTPUT_FILE="$2"; shift 2 ;; --log-file) LOG_FILE="$2"; shift 2 ;; @@ -146,6 +150,21 @@ case "$EFFORT" in esac EFFORT_LABEL="${EFFORT:-default}" +# --service-tier wins over CODEX_DELEGATE_SERVICE_TIER; "default" (or neither +# set) passes no override. Only the shape is checked here: one lowercase word. +SERVICE_TIER="${SERVICE_TIER:-${CODEX_DELEGATE_SERVICE_TIER:-}}" +case "$SERVICE_TIER" in + *$'\n'*) echo "Error: --service-tier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default" >&2; exit 2 ;; +esac +SERVICE_TIER="$(printf '%s' "$SERVICE_TIER" | tr '[:upper:]' '[:lower:]')" +case "$SERVICE_TIER" in + ""|default) SERVICE_TIER="" ;; + *[!abcdefghijklmnopqrstuvwxyz]*) + echo "Error: --service-tier (or CODEX_DELEGATE_SERVICE_TIER) must be one word such as fast or flex, or default; got '$SERVICE_TIER'" >&2 + exit 2 ;; +esac +SERVICE_TIER_LABEL="${SERVICE_TIER:-default}" + # Brief discipline: refuse inline prompts > 500 chars unless a brief is on disk. # Auditability gap fix (research-hub v0.89.1 post-release audit, 2026-05-15): # inline --prompt with no on-disk brief leaves orphan results and breaks @@ -260,6 +279,7 @@ printf '%s' "$PROMPT" > "$PROMPT_FILE" CODEX_ARGS=("exec" "--sandbox" "$SANDBOX" "-C" "$REPO") [[ -n "$MODEL" ]] && CODEX_ARGS+=("-m" "$MODEL") [[ -n "$EFFORT" ]] && CODEX_ARGS+=("-c" "model_reasoning_effort=$EFFORT") +[[ -n "$SERVICE_TIER" ]] && CODEX_ARGS+=("-c" "service_tier=\"$SERVICE_TIER\"") [[ -n "$OUTPUT_FILE" ]] && CODEX_ARGS+=("-o" "$OUTPUT_FILE") MODEL_LABEL="codex/${MODEL:-default}" CODEX_ARGS+=("$(cat "$PROMPT_FILE")") diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 676edbc..36a19f9 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -28,6 +28,12 @@ ROOT = Path(__file__).resolve().parents[1] +@pytest.fixture(autouse=True) +def _clean_delegate_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CODEX_DELEGATE_EFFORT", raising=False) + monkeypatch.delenv("CODEX_DELEGATE_SERVICE_TIER", raising=False) + + def _resolve_bash() -> str | None: """Return a path to a bash interpreter we trust for the wrapper test. @@ -1161,6 +1167,158 @@ def test_run_codex_ps1_rejects_unknown_effort(tmp_path: Path, flag, env_value) - assert not (repo / ".ai").exists() +def _c_values(tokens: list[str]) -> list[str]: + return [tokens[i + 1] for i, tok in enumerate(tokens[:-1]) if tok == "-c"] + + +_SERVICE_TIER_CASES = [ + # (flag, env, expected tier passed to codex or None) + (None, "", None), + ("fast", "", "fast"), + ("Fast", "", "fast"), + (None, "fast", "fast"), + ("flex", "fast", "flex"), + ("default", "fast", None), +] + + +@pytest.mark.skipif(_BASH is None, reason="bash not available") +@pytest.mark.parametrize("wrapper_path", ["scripts/run_codex.sh", "skills/codex-delegate/scripts/run_codex.sh"]) +@pytest.mark.parametrize("flag, env_value, expected", _SERVICE_TIER_CASES) +def test_run_codex_sh_passes_service_tier_only_when_given(tmp_path: Path, wrapper_path, flag, env_value, expected) -> None: + repo = tmp_path / "repo" + repo.mkdir() + log_file = repo / ".ai" / "codex_log.txt" + fake_codex = tmp_path / "fake_codex.sh" + fake_codex.write_text("#!/usr/bin/env bash\nprintf '%s\\n' \"$@\"\n", encoding="utf-8", newline="\n") + extra = ["--prompt", "do work", "--log-file", to_bash_path(log_file)] + if flag: + extra += ["--service-tier", flag] + proc = _run_sh(repo, fake_codex, extra, {"CODEX_DELEGATE_SERVICE_TIER": env_value}, wrapper=ROOT / wrapper_path) + assert proc.returncode == 0, proc.stderr + args = log_file.read_text(encoding="utf-8").splitlines() + assert args[0] == "[MODEL_USED: codex/default]" + c_vals = _c_values(args) + if expected: + assert f'service_tier="{expected}"' in c_vals + else: + assert not any(v.startswith("service_tier=") for v in c_vals) + result = json.loads(Path(str(log_file) + ".result.json").read_text(encoding="utf-8-sig")) + assert result["service_tier"] == (expected or "default") + + +@pytest.mark.skipif(_BASH is None, reason="bash not available") +@pytest.mark.parametrize("flag, env_value", [("fa-st", ""), (None, "turbo2")]) +def test_run_codex_sh_rejects_unknown_service_tier(tmp_path: Path, flag, env_value) -> None: + repo = tmp_path / "repo" + repo.mkdir() + marker = tmp_path / "codex_ran" + fake_codex = tmp_path / "fake_codex.sh" + fake_codex.write_text(f"#!/usr/bin/env bash\ntouch '{to_bash_path(marker)}'\n", encoding="utf-8", newline="\n") + extra = ["--prompt", "do work"] + if flag: + extra += ["--service-tier", flag] + proc = _run_sh(repo, fake_codex, extra, {"CODEX_DELEGATE_SERVICE_TIER": env_value}) + assert proc.returncode == 2 + assert "must be one word" in proc.stderr + assert not marker.exists() + + +@pytest.mark.skipif(_POWERSHELL is None, reason="PowerShell runtime not on PATH") +@pytest.mark.parametrize("wrapper_path", ["scripts/run_codex.ps1", "skills/codex-delegate/scripts/run_codex.ps1"]) +@pytest.mark.parametrize("flag, env_value, expected", _SERVICE_TIER_CASES) +def test_run_codex_ps1_passes_service_tier_only_when_given(tmp_path: Path, wrapper_path, flag, env_value, expected) -> None: + repo = tmp_path / "repo" + repo.mkdir() + fake = _fake_ps_delegate(tmp_path, show_args=True) + env = os.environ.copy() + env["CODEX_PATH"] = str(fake) + env.pop("CODEX_DELEGATE_SERVICE_TIER", None) + if env_value: + env["CODEX_DELEGATE_SERVICE_TIER"] = env_value + cmd = [_POWERSHELL, "-NoLogo", "-NoProfile", "-File", str(ROOT / wrapper_path), + "-Prompt", "do work", "-Repo", str(repo)] + if flag: + cmd += ["-ServiceTier", flag] + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + assert proc.returncode == 0, proc.stderr + log = repo / ".ai/codex_output.txt" + lines = log.read_text(encoding="utf-8-sig").splitlines() + assert lines[0] == "[MODEL_USED: codex/default]" + tokens = " ".join(lines[1:]).split() + c_vals = _c_values(tokens) + if expected: + # Windows PowerShell 5.1 strips embedded double quotes when launching a native command, while PowerShell 7 keeps them. + assert any(v.replace('"', '') == f"service_tier={expected}" for v in c_vals) + else: + assert not any(v.replace('"', '').startswith("service_tier=") for v in c_vals) + result = json.loads(Path(str(log) + ".result.json").read_text(encoding="utf-8-sig")) + assert result["service_tier"] == (expected or "default") + + +@pytest.mark.skipif(_POWERSHELL is None, reason="PowerShell runtime not on PATH") +@pytest.mark.parametrize("flag, env_value", [("fa-st", ""), (None, "turbo2")]) +def test_run_codex_ps1_rejects_unknown_service_tier(tmp_path: Path, flag, env_value) -> None: + repo = tmp_path / "repo" + repo.mkdir() + fake = _fake_ps_delegate(tmp_path, show_args=True) + env = os.environ.copy() + env["CODEX_PATH"] = str(fake) + env.pop("CODEX_DELEGATE_SERVICE_TIER", None) + if env_value: + env["CODEX_DELEGATE_SERVICE_TIER"] = env_value + cmd = [_POWERSHELL, "-NoLogo", "-NoProfile", "-File", str(ROOT / "scripts" / "run_codex.ps1"), + "-Prompt", "do work", "-Repo", str(repo)] + if flag: + cmd += ["-ServiceTier", flag] + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + assert proc.returncode == 2 + assert "must be one word" in proc.stderr + assert not (repo / ".ai").exists() + + +@pytest.mark.skipif(_BASH is None, reason="bash not available") +def test_run_codex_sh_combines_effort_and_service_tier(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + log_file = repo / ".ai" / "codex_log.txt" + fake_codex = tmp_path / "fake_codex.sh" + fake_codex.write_text("#!/usr/bin/env bash\nprintf '%s\\n' \"$@\"\n", encoding="utf-8", newline="\n") + extra = ["--prompt", "do work", "--log-file", to_bash_path(log_file), "--effort", "ultra", "--service-tier", "fast"] + proc = _run_sh(repo, fake_codex, extra) + assert proc.returncode == 0, proc.stderr + args = log_file.read_text(encoding="utf-8").splitlines() + c_vals = _c_values(args) + assert "model_reasoning_effort=ultra" in c_vals + assert 'service_tier="fast"' in c_vals + result = json.loads(Path(str(log_file) + ".result.json").read_text(encoding="utf-8-sig")) + assert result["reasoning_effort"] == "ultra" + assert result["service_tier"] == "fast" + + +@pytest.mark.skipif(_POWERSHELL is None, reason="PowerShell runtime not on PATH") +def test_run_codex_ps1_combines_effort_and_service_tier(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + fake = _fake_ps_delegate(tmp_path, show_args=True) + env = os.environ.copy() + env["CODEX_PATH"] = str(fake) + cmd = [_POWERSHELL, "-NoLogo", "-NoProfile", "-File", str(ROOT / "scripts" / "run_codex.ps1"), + "-Prompt", "do work", "-Repo", str(repo), "-Effort", "ultra", "-ServiceTier", "fast"] + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + assert proc.returncode == 0, proc.stderr + log = repo / ".ai/codex_output.txt" + lines = log.read_text(encoding="utf-8-sig").splitlines() + tokens = " ".join(lines[1:]).split() + c_vals = _c_values(tokens) + assert "model_reasoning_effort=ultra" in c_vals + # Windows PowerShell 5.1 strips embedded double quotes when launching a native command, while PowerShell 7 keeps them. + assert any(v.replace('"', '') == "service_tier=fast" for v in c_vals) + result = json.loads(Path(str(log) + ".result.json").read_text(encoding="utf-8-sig")) + assert result["reasoning_effort"] == "ultra" + assert result["service_tier"] == "fast" + + @pytest.mark.parametrize("platform, available, expected", [ ("win32", {"powershell": "legacy", "pwsh": "core"}, "legacy"), ("linux", {"powershell": "legacy", "pwsh": "core"}, "core"),