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