feat(cli-tools): support multiple model profiles for Codex CLI
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
import ApiKeySelect from "./ApiKeySelect";
|
import ApiKeySelect from "./ApiKeySelect";
|
||||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
import { rememberEndpoint } from "./cliEndpointPresets";
|
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 }) {
|
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||||
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
||||||
@@ -24,6 +25,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
|||||||
const [modelAliases, setModelAliases] = useState({});
|
const [modelAliases, setModelAliases] = useState({});
|
||||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) {
|
if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) {
|
||||||
@@ -39,6 +51,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
|||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
if (!codexStatus) checkCodexStatus();
|
if (!codexStatus) checkCodexStatus();
|
||||||
fetchModelAliases();
|
fetchModelAliases();
|
||||||
|
fetchProfiles();
|
||||||
}
|
}
|
||||||
}, [isExpanded]);
|
}, [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.
|
// Sync only when config content changes so local form edits are retained.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const config = codexStatus?.config;
|
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
|
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</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
|
<ManualConfigModal
|
||||||
isOpen={showManualConfigModal}
|
isOpen={showManualConfigModal}
|
||||||
onClose={() => setShowManualConfigModal(false)}
|
onClose={() => setShowManualConfigModal(false)}
|
||||||
|
|||||||
@@ -52,3 +52,35 @@ export function getCurrentCodexProviderSettings(config) {
|
|||||||
export function getCurrentCodexProviderBaseUrl(config) {
|
export function getCurrentCodexProviderBaseUrl(config) {
|
||||||
return getCurrentCodexProviderSettings(config).baseUrl;
|
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] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
123
src/app/api/cli-tools/codex-profiles/route.js
Normal file
123
src/app/api/cli-tools/codex-profiles/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tests/unit/codex-profiles.test.js
Normal file
28
tests/unit/codex-profiles.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user