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