diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
index cea5522a..f0cdd32b 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
@@ -3,11 +3,12 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
+import ProviderIcon from "@/shared/components/ProviderIcon";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
import { rememberEndpoint } from "./cliEndpointPresets";
-import { getCurrentCodexProviderSettings } from "./codexConfig";
+import { getCurrentCodexProviderSettings, deriveProfileNameFromModel } from "./codexConfig";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
@@ -24,6 +25,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
+ const [profiles, setProfiles] = useState([]);
+ const [aliasInput, setAliasInput] = useState("");
+ const [modelInput, setModelInput] = useState("");
+ const [profileModalOpen, setProfileModalOpen] = useState(false);
+ const [creatingProfile, setCreatingProfile] = useState(false);
+ const [deletingProfile, setDeletingProfile] = useState(null);
+ const [copiedCommand, setCopiedCommand] = useState("");
+
+ useEffect(() => {
+ fetchProfiles();
+ }, []);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) {
@@ -39,6 +51,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
if (isExpanded) {
if (!codexStatus) checkCodexStatus();
fetchModelAliases();
+ fetchProfiles();
}
}, [isExpanded]);
@@ -52,6 +65,83 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}
};
+ const fetchProfiles = async () => {
+ try {
+ const res = await fetch("/api/cli-tools/codex-profiles");
+ const data = await res.json();
+ if (res.ok) setProfiles(data.profiles || []);
+ } catch (error) {
+ console.log("Error fetching codex profiles:", error);
+ }
+ };
+
+ const handleModelSelectForAlias = (model) => {
+ setProfileModalOpen(false);
+ const selectedModelId = model?.value || model?.id;
+ if (!selectedModelId) return;
+
+ setModelInput(selectedModelId);
+ const providerName = model?.provider || (selectedModelId.includes("/") ? selectedModelId.split("/")[0] : selectedModelId);
+ const existingNames = profiles.map((p) => p.name);
+ setAliasInput(deriveProfileNameFromModel(providerName, existingNames));
+ };
+
+ const handleAddProfileWithAlias = async () => {
+ const cleanAlias = aliasInput.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
+ const cleanModel = modelInput.trim();
+ if (!cleanAlias || !cleanModel) return;
+
+ setCreatingProfile(true);
+ try {
+ const res = await fetch("/api/cli-tools/codex-profiles", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: cleanAlias,
+ model: cleanModel,
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setAliasInput("");
+ setModelInput("");
+ fetchProfiles();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to add model" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setCreatingProfile(false);
+ }
+ };
+
+ const handleDeleteProfile = async (name) => {
+ setDeletingProfile(name);
+ try {
+ const res = await fetch("/api/cli-tools/codex-profiles", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ if (res.ok) fetchProfiles();
+ } catch (error) {
+ console.log("Error deleting codex profile:", error);
+ } finally {
+ setDeletingProfile(null);
+ }
+ };
+
+ const handleCopyCommand = async (cmd) => {
+ try {
+ await navigator.clipboard.writeText(cmd);
+ setCopiedCommand(cmd);
+ setTimeout(() => setCopiedCommand(""), 2000);
+ } catch (e) {
+ console.log("Copy failed", e);
+ }
+ };
+
// Sync only when config content changes so local form edits are retained.
useEffect(() => {
const config = codexStatus?.config;
@@ -367,6 +457,148 @@ default_subagent_model = "${effectiveSubagentModel}"
content_copyManual Config
+
+ {/* Additional Models */}
+
+
+
+
+ layers
+ Additional Models
+
+ {profiles.length > 0 && (
+
+ {profiles.length}
+
+ )}
+
+
+
+ {/* Quick Add Model bar */}
+
+
+
setAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
+ placeholder="Alias (e.g. claude)"
+ className="w-full min-w-0 px-2.5 py-1.5 bg-surface rounded border border-border text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
+ onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()}
+ />
+
+ {
+ const val = e.target.value;
+ setModelInput(val);
+ const provider = val.includes("/") ? val.split("/")[0] : val;
+ setAliasInput(deriveProfileNameFromModel(provider, profiles.map((p) => p.name)));
+ }}
+ placeholder="provider/model-id"
+ className="w-full min-w-0 pl-2.5 pr-7 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
+ onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()}
+ />
+ {modelInput && (
+
+ )}
+
+
+
+
+
+
+ {profiles.length === 0 ? (
+
+
+ terminal
+
+
+ Only the main model is active. Add an alias above to configure more models for Codex CLI.
+
+
+ ) : (
+
+ {profiles.map((p) => {
+ const providerId = p.model.includes("/") ? p.model.split("/")[0] : p.name;
+ const isCopied = copiedCommand === p.command;
+ return (
+
+
+
+
+
+ {p.name}
+
+
+ {p.model}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
>
)}
@@ -396,6 +628,18 @@ default_subagent_model = "${effectiveSubagentModel}"
/>
)}
+ {profileModalOpen && (
+ setProfileModalOpen(false)}
+ onSelect={handleModelSelectForAlias}
+ selectedModel={modelInput}
+ activeProviders={activeProviders}
+ modelAliases={modelAliases}
+ title="Select Model for Codex CLI"
+ />
+ )}
+
setShowManualConfigModal(false)}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js
index 9f628fbd..2856e8a1 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js
@@ -52,3 +52,35 @@ export function getCurrentCodexProviderSettings(config) {
export function getCurrentCodexProviderBaseUrl(config) {
return getCurrentCodexProviderSettings(config).baseUrl;
}
+
+export function deriveProfileNameFromModel(modelId, existingNames = []) {
+ if (!modelId || typeof modelId !== "string") return "model";
+ let base = "";
+ const trimmed = modelId.trim();
+ if (trimmed.includes("/")) {
+ base = trimmed.split("/")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "");
+ } else {
+ base = trimmed.toLowerCase().replace(/[^a-z0-9_-]/g, "");
+ }
+ base = base.replace(/^[-_]+|[-_]+$/g, "") || "model";
+ if (base === "config") base = "model-config";
+
+ let candidate = base;
+ let counter = 2;
+ while (existingNames.includes(candidate)) {
+ candidate = `${base}-${counter}`;
+ counter++;
+ }
+ return candidate;
+}
+
+export function buildCodexProfileToml({ name, model }) {
+ return `# codex -p ${name}\nmodel = "${model}"\nmodel_provider = "9router"\n`;
+}
+
+export function parseCodexProfileModel(content) {
+ if (typeof content !== "string") return "";
+ const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m);
+ return match ? match[2] : "";
+}
+
diff --git a/src/app/api/cli-tools/codex-profiles/route.js b/src/app/api/cli-tools/codex-profiles/route.js
new file mode 100644
index 00000000..af578de3
--- /dev/null
+++ b/src/app/api/cli-tools/codex-profiles/route.js
@@ -0,0 +1,123 @@
+import { NextResponse } from "next/server";
+import fs from "fs/promises";
+import path from "path";
+import os from "os";
+
+export const dynamic = "force-dynamic";
+
+const getCodexDir = () => path.join(os.homedir(), ".codex");
+
+const isValidProfileName = (name) => {
+ return typeof name === "string" && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64 && name.toLowerCase() !== "config";
+};
+
+const isValidModel = (model) => {
+ return typeof model === "string" && model.trim().length > 0 && model.length <= 256 && !/[\r\n"]/.test(model);
+};
+
+// GET - List all custom profiles from ~/.codex/*.config.toml
+export async function GET() {
+ try {
+ const codexDir = getCodexDir();
+ let files = [];
+ try {
+ files = await fs.readdir(codexDir);
+ } catch (err) {
+ if (err.code === "ENOENT") return NextResponse.json({ profiles: [] });
+ throw err;
+ }
+
+ const profileFiles = files.filter(
+ (file) => file.endsWith(".config.toml") && file !== "config.toml"
+ );
+
+ const profiles = await Promise.all(
+ profileFiles.map(async (file) => {
+ const name = file.replace(/\.config\.toml$/, "");
+ try {
+ const content = await fs.readFile(path.join(codexDir, file), "utf-8");
+ const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m);
+ return {
+ name,
+ model: match ? match[2] : "",
+ command: `codex -p ${name}`,
+ };
+ } catch {
+ return { name, model: "", command: `codex -p ${name}` };
+ }
+ })
+ );
+
+ profiles.sort((a, b) => a.name.localeCompare(b.name));
+ return NextResponse.json({ profiles });
+ } catch (error) {
+ console.error("Error reading codex profiles:", error);
+ return NextResponse.json({ error: "Failed to read profiles" }, { status: 500 });
+ }
+}
+
+// POST - Create or update a profile ~/.codex/.config.toml
+export async function POST(request) {
+ try {
+ const { name, model } = await request.json();
+
+ const cleanName = typeof name === "string" ? name.trim().toLowerCase() : "";
+ if (!isValidProfileName(cleanName)) {
+ return NextResponse.json(
+ { error: "Profile name can only contain alphanumeric characters, dashes, underscores and cannot be 'config'" },
+ { status: 400 }
+ );
+ }
+
+ const cleanModel = typeof model === "string" ? model.trim() : "";
+ if (!isValidModel(cleanModel)) {
+ return NextResponse.json({ error: "Invalid model name" }, { status: 400 });
+ }
+
+ const codexDir = getCodexDir();
+ await fs.mkdir(codexDir, { recursive: true });
+
+ const filePath = path.join(codexDir, `${cleanName}.config.toml`);
+ const content = `# codex -p ${cleanName}\nmodel = "${cleanModel}"\nmodel_provider = "9router"\n`;
+ await fs.writeFile(filePath, content, "utf-8");
+
+ return NextResponse.json({
+ success: true,
+ profile: {
+ name: cleanName,
+ model: cleanModel,
+ command: `codex -p ${cleanName}`,
+ },
+ });
+ } catch (error) {
+ console.error("Error saving codex profile:", error);
+ return NextResponse.json({ error: "Failed to save profile" }, { status: 500 });
+ }
+}
+
+// DELETE - Remove a profile ~/.codex/.config.toml
+export async function DELETE(request) {
+ try {
+ const { name } = await request.json();
+ const cleanName = typeof name === "string" ? name.trim().toLowerCase() : "";
+
+ if (!isValidProfileName(cleanName)) {
+ return NextResponse.json({ error: "Invalid profile name" }, { status: 400 });
+ }
+
+ const filePath = path.join(getCodexDir(), `${cleanName}.config.toml`);
+ try {
+ await fs.unlink(filePath);
+ } catch (err) {
+ if (err.code === "ENOENT") {
+ return NextResponse.json({ error: "Profile not found" }, { status: 404 });
+ }
+ throw err;
+ }
+
+ return NextResponse.json({ success: true, message: `Profile ${cleanName} deleted` });
+ } catch (error) {
+ console.error("Error deleting codex profile:", error);
+ return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 });
+ }
+}
diff --git a/tests/unit/codex-profiles.test.js b/tests/unit/codex-profiles.test.js
new file mode 100644
index 00000000..6439f615
--- /dev/null
+++ b/tests/unit/codex-profiles.test.js
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import {
+ deriveProfileNameFromModel,
+ buildCodexProfileToml,
+ parseCodexProfileModel,
+} from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js";
+
+describe("Codex profiles configuration", () => {
+ it("derives provider name as profile name and avoids conflicts", () => {
+ expect(deriveProfileNameFromModel("anthropic/claude-3-7-sonnet")).toBe("anthropic");
+ expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic"])).toBe("anthropic-2");
+ expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic", "anthropic-2"])).toBe("anthropic-3");
+ expect(deriveProfileNameFromModel("deepseek/deepseek-chat")).toBe("deepseek");
+ expect(deriveProfileNameFromModel("google/gemini-2.5-pro")).toBe("google");
+ });
+
+ it("derives model name when model has no slash", () => {
+ expect(deriveProfileNameFromModel("claude-3-7-sonnet")).toBe("claude-3-7-sonnet");
+ expect(deriveProfileNameFromModel("gpt-4o")).toBe("gpt-4o");
+ });
+
+ it("builds and parses profile TOML", () => {
+ const toml = buildCodexProfileToml({ name: "claude", model: "anthropic/claude-3-7-sonnet" });
+ expect(toml).toContain('model = "anthropic/claude-3-7-sonnet"');
+ expect(toml).toContain('model_provider = "9router"');
+ expect(parseCodexProfileModel(toml)).toBe("anthropic/claude-3-7-sonnet");
+ });
+});