Merge pull request #3383 from paperclipai/pap-1347-codex-fast-mode

feat(codex-local): add fast mode support
This commit is contained in:
Dotta
2026-04-11 08:45:50 -05:00
committed by GitHub
12 changed files with 250 additions and 56 deletions

View File

@@ -20,6 +20,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
| `graceSec` | number | No | Grace period before force-kill |
| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster |
| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) |
## Session Persistence
@@ -30,8 +31,22 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
## Fast Mode
When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to:
```sh
-c 'service_tier="fast"' -c 'features.fast_mode=true'
```
Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs.
## Managed `CODEX_HOME`
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
## Manual Local CLI
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh

View File

@@ -372,6 +372,7 @@ export interface CreateConfigValues {
chrome: boolean;
dangerouslySkipPermissions: boolean;
search: boolean;
fastMode: boolean;
dangerouslyBypassSandbox: boolean;
command: string;
args: string;

View File

@@ -2,6 +2,14 @@ export const type = "codex_local";
export const label = "Codex (local)";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const;
export function isCodexLocalFastModeSupported(model: string | null | undefined): boolean {
const normalizedModel = typeof model === "string" ? model.trim() : "";
return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes(
normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number],
);
}
export const models = [
{ id: "gpt-5.4", label: "gpt-5.4" },
@@ -27,6 +35,7 @@ Core fields:
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- fastMode (boolean, optional): enable Codex Fast mode; currently supported on GPT-5.4 only and consumes credits faster
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
@@ -45,5 +54,6 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is currently supported on GPT-5.4 only. When enabled, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { buildCodexExecArgs } from "./codex-args.js";
describe("buildCodexExecArgs", () => {
it("enables Codex fast mode overrides for GPT-5.4", () => {
const result = buildCodexExecArgs({
model: "gpt-5.4",
search: true,
fastMode: true,
});
expect(result.fastModeRequested).toBe(true);
expect(result.fastModeApplied).toBe(true);
expect(result.fastModeIgnoredReason).toBeNull();
expect(result.args).toEqual([
"--search",
"exec",
"--json",
"--model",
"gpt-5.4",
"-c",
'service_tier="fast"',
"-c",
"features.fast_mode=true",
"-",
]);
});
it("ignores fast mode for unsupported models", () => {
const result = buildCodexExecArgs({
model: "gpt-5.3-codex",
fastMode: true,
});
expect(result.fastModeRequested).toBe(true);
expect(result.fastModeApplied).toBe(false);
expect(result.fastModeIgnoredReason).toContain("currently only supported on gpt-5.4");
expect(result.args).toEqual([
"exec",
"--json",
"--model",
"gpt-5.3-codex",
"-",
]);
});
});

View File

@@ -0,0 +1,74 @@
import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils";
import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
isCodexLocalFastModeSupported,
} from "../index.js";
export type BuildCodexExecArgsResult = {
args: string[];
model: string;
fastModeRequested: boolean;
fastModeApplied: boolean;
fastModeIgnoredReason: string | null;
};
function readExtraArgs(config: unknown): string[] {
const fromExtraArgs = asStringArray(asRecord(config).extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(asRecord(config).args);
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function formatFastModeSupportedModels(): string {
return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", ");
}
export function buildCodexExecArgs(
config: unknown,
options: { resumeSessionId?: string | null } = {},
): BuildCodexExecArgsResult {
const record = asRecord(config);
const model = asString(record.model, "").trim();
const modelReasoningEffort = asString(
record.modelReasoningEffort,
asString(record.reasoningEffort, ""),
).trim();
const search = asBoolean(record.search, false);
const fastModeRequested = asBoolean(record.fastMode, false);
const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model);
const bypass = asBoolean(
record.dangerouslyBypassApprovalsAndSandbox,
asBoolean(record.dangerouslyBypassSandbox, false),
);
const extraArgs = readExtraArgs(record);
const args = ["exec", "--json"];
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
if (modelReasoningEffort) {
args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
}
if (fastModeApplied) {
args.push("-c", 'service_tier="fast"', "-c", "features.fast_mode=true");
}
if (extraArgs.length > 0) args.push(...extraArgs);
if (options.resumeSessionId) args.push("resume", options.resumeSessionId, "-");
else args.push("-");
return {
args,
model,
fastModeRequested,
fastModeApplied,
fastModeIgnoredReason:
fastModeRequested && !fastModeApplied
? `Configured fast mode is currently only supported on ${formatFastModeSupportedModels()}; Paperclip will ignore it for model ${model || "(default)"}.`
: null,
};
}

View File

@@ -5,8 +5,6 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
buildPaperclipEnv,
buildInvocationEnvForLogs,
@@ -26,6 +24,7 @@ import {
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
import { buildCodexExecArgs } from "./codex-args.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const CODEX_ROLLOUT_NOISE_RE =
@@ -223,15 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
const command = asString(config.command, "codex");
const model = asString(config.model, "");
const modelReasoningEffort = asString(
config.modelReasoningEffort,
asString(config.reasoningEffort, ""),
);
const search = asBoolean(config.search, false);
const bypass = asBoolean(
config.dangerouslyBypassApprovalsAndSandbox,
asBoolean(config.dangerouslyBypassSandbox, false),
);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
@@ -399,11 +389,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -499,26 +484,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
if (extraArgs.length > 0) args.push(...extraArgs);
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
else args.push("-");
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
const execArgs = buildCodexExecArgs(config, { resumeSessionId });
const args = execArgs.args;
const commandNotesWithFastMode =
execArgs.fastModeIgnoredReason == null
? commandNotes
: [...commandNotes, execArgs.fastModeIgnoredReason];
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command: resolvedCommand,
cwd,
commandNotes,
commandNotes: commandNotesWithFastMode,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
return value;

View File

@@ -5,8 +5,6 @@ import type {
} from "@paperclipai/adapter-utils";
import {
asString,
asBoolean,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -16,6 +14,7 @@ import {
import path from "node:path";
import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
import { buildCodexExecArgs } from "./codex-args.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -140,31 +139,16 @@ export async function testEnvironment(
hint: "Use the `codex` CLI command to run the automatic login and installation probe.",
});
} else {
const model = asString(config.model, "").trim();
const modelReasoningEffort = asString(
config.modelReasoningEffort,
asString(config.reasoningEffort, ""),
).trim();
const search = asBoolean(config.search, false);
const bypass = asBoolean(
config.dangerouslyBypassApprovalsAndSandbox,
asBoolean(config.dangerouslyBypassSandbox, false),
);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["exec", "--json"];
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
if (modelReasoningEffort) {
args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
const execArgs = buildCodexExecArgs({ ...config, fastMode: false });
const args = execArgs.args;
if (execArgs.fastModeIgnoredReason) {
checks.push({
code: "codex_fast_mode_unsupported_model",
level: "warn",
message: execArgs.fastModeIgnoredReason,
hint: "Switch the agent model to GPT-5.4 to enable Codex Fast mode.",
});
}
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("-");
const probe = await runChildProcess(
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildCodexLocalConfig } from "./build-config.js";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
return {
adapterType: "codex_local",
cwd: "",
instructionsFilePath: "",
promptTemplate: "",
model: "gpt-5.4",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: true,
search: false,
fastMode: false,
dangerouslyBypassSandbox: true,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "",
bootstrapPrompt: "",
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
...overrides,
};
}
describe("buildCodexLocalConfig", () => {
it("persists the fastMode toggle into adapter config", () => {
const config = buildCodexLocalConfig(
makeValues({
search: true,
fastMode: true,
}),
);
expect(config).toMatchObject({
model: "gpt-5.4",
search: true,
fastMode: true,
dangerouslyBypassApprovalsAndSandbox: true,
});
});
});

View File

@@ -85,6 +85,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
}
if (Object.keys(env).length > 0) ac.env = env;
ac.search = v.search;
ac.fastMode = v.fastMode;
ac.dangerouslyBypassApprovalsAndSandbox =
typeof v.dangerouslyBypassSandbox === "boolean"
? v.dangerouslyBypassSandbox

View File

@@ -7,6 +7,10 @@ import {
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
isCodexLocalFastModeSupported,
} from "@paperclipai/adapter-codex-local";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -27,6 +31,14 @@ export function CodexLocalConfigFields({
}: AdapterConfigFieldsProps) {
const bypassEnabled =
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
const fastModeEnabled = isCreate
? Boolean(values!.fastMode)
: eff("adapterConfig", "fastMode", Boolean(config.fastMode));
const currentModel = isCreate
? String(values!.model ?? "")
: eff("adapterConfig", "model", String(config.model ?? ""));
const fastModeSupported = isCodexLocalFastModeSupported(currentModel);
const supportedModelsLabel = CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", ");
return (
<>
@@ -88,6 +100,23 @@ export function CodexLocalConfigFields({
: mark("adapterConfig", "search", v)
}
/>
<ToggleField
label="Fast mode"
hint={help.fastMode}
checked={fastModeEnabled}
onChange={(v) =>
isCreate
? set!({ fastMode: v })
: mark("adapterConfig", "fastMode", v)
}
/>
{fastModeEnabled && (
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
{fastModeSupported
? "Fast mode consumes credits/tokens much faster than standard Codex runs."
: `Fast mode currently only works on ${supportedModelsLabel}. Paperclip will ignore this toggle until the model is switched.`}
</div>
)}
<LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}

View File

@@ -10,6 +10,7 @@ export const defaultCreateValues: CreateConfigValues = {
chrome: false,
dangerouslySkipPermissions: true,
search: false,
fastMode: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",

View File

@@ -34,6 +34,7 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is currently supported on GPT-5.4 only.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",