feat(cli-tools): support multiple model profiles for Codex CLI

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decolua
2026-09-26 17:03:06 +07:00
parent 239bcfc568
commit 0249464d74
4 changed files with 428 additions and 1 deletions

View File

@@ -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}"
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
{/* Additional Models */}
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px] text-primary">layers</span>
Additional Models
</span>
{profiles.length > 0 && (
<span className="px-1.5 py-0.2 bg-primary/10 text-primary text-[10px] font-medium rounded-full">
{profiles.length}
</span>
)}
</div>
</div>
{/* Quick Add Model bar */}
<div className="flex flex-col gap-1.5 p-2.5 bg-surface/40 border border-border rounded-lg">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[10rem_1fr_auto_auto] sm:items-center">
<input
type="text"
value={aliasInput}
onChange={(e) => 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()}
/>
<div className="relative w-full min-w-0">
<input
type="text"
value={modelInput}
onChange={(e) => {
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 && (
<button
onClick={() => setModelInput("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => setProfileModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2.5 py-1.5 text-xs transition-colors whitespace-nowrap sm:shrink-0 ${
activeProviders?.length
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
</button>
<Button
variant="primary"
size="sm"
onClick={handleAddProfileWithAlias}
disabled={!aliasInput.trim() || !modelInput.trim() || creatingProfile}
loading={creatingProfile}
className="!h-7.5 whitespace-nowrap"
>
<span className="material-symbols-outlined text-[15px] mr-1">add</span>
Add
</Button>
</div>
</div>
{profiles.length === 0 ? (
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-border rounded-lg text-center bg-surface/30">
<span className="material-symbols-outlined text-[20px] text-text-muted mb-1 opacity-60">
terminal
</span>
<p className="text-xs text-text-muted">
Only the main model is active. Add an alias above to configure more models for Codex CLI.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{profiles.map((p) => {
const providerId = p.model.includes("/") ? p.model.split("/")[0] : p.name;
const isCopied = copiedCommand === p.command;
return (
<div
key={p.name}
className="flex items-center justify-between gap-2 p-2.5 bg-surface/50 border border-border hover:border-border-hover rounded-lg transition-colors group"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="size-6 flex items-center justify-center shrink-0 rounded bg-black/5 dark:bg-white/5 p-0.5">
<ProviderIcon providerId={providerId} size={18} fallbackText={p.name.slice(0, 2).toUpperCase()} />
</div>
<div className="min-w-0 flex flex-col">
<span className="font-medium text-xs text-text-main truncate">
{p.name}
</span>
<span className="text-[11px] text-text-muted truncate font-mono">
{p.model}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleCopyCommand(p.command)}
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-mono border transition-all ${
isCopied
? "bg-green-500/10 border-green-500/30 text-green-600 dark:text-green-400"
: "bg-surface border-border text-text-muted hover:text-text-main hover:border-primary/50 cursor-pointer"
}`}
title="Click to copy command"
>
<span className="material-symbols-outlined text-[13px]">
{isCopied ? "check" : "terminal"}
</span>
<span className="hidden md:inline">{p.command}</span>
<span className="md:hidden">copy</span>
</button>
<button
onClick={() => handleDeleteProfile(p.name)}
disabled={deletingProfile === p.name}
className="p-1 text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity rounded"
title="Delete model"
>
<span className="material-symbols-outlined text-[15px]">close</span>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
@@ -396,6 +628,18 @@ default_subagent_model = "${effectiveSubagentModel}"
/>
)}
{profileModalOpen && (
<ModelSelectModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
onSelect={handleModelSelectForAlias}
selectedModel={modelInput}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex CLI"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}

View File

@@ -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] : "";
}

View File

@@ -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/<name>.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/<name>.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 });
}
}

View File

@@ -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");
});
});