diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index 52ca8b09..cfea9c95 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -57,6 +57,7 @@ export default function BaseUrlSelect({ const [mode, setMode] = useState(""); const [customInput, setCustomInput] = useState(""); const initializedRef = useRef(false); + const currentUrlRef = useRef(""); const customInputRef = useRef(""); useEffect(() => { @@ -85,23 +86,34 @@ export default function BaseUrlSelect({ [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Prefer a saved preset matching the currently configured URL, else first option + // Sync the active config URL without replacing edits unless the config itself changes. useEffect(() => { - if (initializedRef.current) return; if (!presetsLoaded || options.length === 0) return; + const normalizeUrl = (url) => (withV1 ? ensureV1(url) : stripSlash(url)); + const current = normalizeUrl(currentUrl); + if (initializedRef.current && currentUrlRef.current === current) return; initializedRef.current = true; - const current = stripSlash(currentUrl); + currentUrlRef.current = current; const matched = current - ? options.find((o) => o.saved && stripSlash(o.url) === current) + ? options.find((o) => o.value !== CUSTOM_VALUE && normalizeUrl(o.url) === current) : null; - const target = matched || options.find((o) => o.value !== CUSTOM_VALUE); - if (target) { + if (matched) { + setCustomInput(""); + customInputRef.current = ""; + setMode(matched.value); + onChange(matched.url); + } else if (current) { + setCustomInput(current); + customInputRef.current = current; + setMode(CUSTOM_VALUE); + onChange(current); + } else { + const target = options.find((o) => o.value !== CUSTOM_VALUE); + if (!target) return; setMode(target.value); onChange(target.url); - } else { - setMode(CUSTOM_VALUE); } - }, [presetsLoaded, options, onChange, currentUrl]); + }, [presetsLoaded, options, onChange, currentUrl, withV1]); const handleSelect = (e) => { const next = e.target.value; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 6fed52de..cea5522a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -7,6 +7,7 @@ import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; import { rememberEndpoint } from "./cliEndpointPresets"; +import { getCurrentCodexProviderSettings } from "./codexConfig"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -25,10 +26,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const [customBaseUrl, setCustomBaseUrl] = useState(""); useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { + if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) { setSelectedApiKey(apiKeys[0].key); } - }, [apiKeys, selectedApiKey]); + }, [apiKeys, selectedApiKey, codexStatus?.config]); useEffect(() => { if (initialStatus) setCodexStatus(initialStatus); @@ -51,24 +52,24 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; - // Parse model and subagent settings from config content + // Sync only when config content changes so local form edits are retained. useEffect(() => { - if (codexStatus?.config) { - const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); + const config = codexStatus?.config; + if (config) { + const { baseUrl, apiKey } = getCurrentCodexProviderSettings(config); + setCustomBaseUrl(baseUrl); + setSelectedApiKey(apiKey); + + const modelMatch = config.match(/^model\s*=\s*"([^"]+)"/m); if (modelMatch) setSelectedModel(modelMatch[1]); // Parse subagent settings - const subagentModelMatch = codexStatus.config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); + const subagentModelMatch = config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); } - }, [codexStatus]); + }, [codexStatus?.config]); - const getCurrentBaseUrl = () => { - const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/); - return parsed ? parsed[1] : ""; - }; - - const currentBaseUrl = getCurrentBaseUrl(); + const currentBaseUrl = getCurrentCodexProviderSettings(codexStatus?.config).baseUrl; const getConfigStatus = () => { if (!codexStatus?.installed) return null; @@ -79,7 +80,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const configStatus = getConfigStatus(); const getEffectiveBaseUrl = () => { - const url = customBaseUrl || `${baseUrl}/v1`; + const url = (customBaseUrl || `${baseUrl}/v1`).replace(/\/+$/, ""); // Ensure URL ends with /v1 return url.endsWith("/v1") ? url : `${url}/v1`; }; @@ -89,7 +90,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const checkCodexStatus = async () => { setCheckingCodex(true); try { - const res = await fetch("/api/cli-tools/codex-settings"); + const res = await fetch("/api/cli-tools/codex-settings", { cache: "no-store" }); const data = await res.json(); setCodexStatus(data); } catch (error) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js new file mode 100644 index 00000000..9f628fbd --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js @@ -0,0 +1,54 @@ +const parseTomlString = (line, key) => { + const match = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^\\n]*?)\\1\\s*(?:#.*)?$`)); + return match ? match[2] : ""; +}; + +// Only inspect the active provider tables so other providers cannot affect the form. +export function getCurrentCodexProviderSettings(config) { + if (typeof config !== "string") return { baseUrl: "", apiKey: "" }; + + const lines = config.split(/\r?\n/); + let modelProvider = ""; + let inRootTable = true; + + for (const line of lines) { + if (/^\s*\[/.test(line)) { + inRootTable = false; + continue; + } + if (inRootTable) { + modelProvider = parseTomlString(line, "model_provider") || modelProvider; + } + } + + if (!modelProvider) return { baseUrl: "", apiKey: "" }; + + const activeTable = `model_providers.${modelProvider}`; + let inActiveProviderTable = false; + let inActiveHeadersTable = false; + let baseUrl = ""; + let apiKey = ""; + + for (const line of lines) { + const tableMatch = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/); + if (tableMatch) { + inActiveProviderTable = tableMatch[1] === activeTable; + inActiveHeadersTable = tableMatch[1] === `${activeTable}.http_headers`; + continue; + } + if (inActiveProviderTable) { + baseUrl = parseTomlString(line, "base_url") || baseUrl; + } + if (inActiveHeadersTable) { + const authorization = parseTomlString(line, "Authorization"); + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + apiKey = bearerMatch ? bearerMatch[1] : apiKey; + } + } + + return { baseUrl, apiKey }; +} + +export function getCurrentCodexProviderBaseUrl(config) { + return getCurrentCodexProviderSettings(config).baseUrl; +} diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index 1a6d016f..badfc00b 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -1,5 +1,3 @@ -"use server"; - import { NextResponse } from "next/server"; import { exec } from "child_process"; import { promisify } from "util"; @@ -8,6 +6,8 @@ import path from "path"; import os from "os"; import { parseTOML, stringifyTOML } from "confbox"; +export const dynamic = "force-dynamic"; + const execAsync = promisify(exec); const getCodexDir = () => path.join(os.homedir(), ".codex"); diff --git a/tests/unit/codex-current-provider-base-url.test.js b/tests/unit/codex-current-provider-base-url.test.js new file mode 100644 index 00000000..8c3ef06a --- /dev/null +++ b/tests/unit/codex-current-provider-base-url.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { getCurrentCodexProviderBaseUrl, getCurrentCodexProviderSettings } from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js"; + +describe("Codex current provider base URL", () => { + it("uses the base URL from the configured model provider, not an earlier provider", () => { + const config = `model = "gpt-5" +model_provider = "9router" + +[model_providers.omniroute] +base_url = "https://omniroute.example/v1" + +[model_providers.9router] +base_url = "http://127.0.0.1:20128/v1" +`; + + expect(getCurrentCodexProviderBaseUrl(config)).toBe("http://127.0.0.1:20128/v1"); + }); + + it("reads the active provider URL and bearer key when another provider appears first", () => { + const config = `model_provider = "9router" + +[model_providers.omniroute] +base_url = "https://omniroute.example/v1" + +[model_providers.omniroute.http_headers] +Authorization = "Bearer placeholder-omniroute-key" + +[model_providers.9router] +base_url = "https://9router.example/v1/" + +[model_providers.9router.http_headers] +Authorization = "Bearer placeholder-9router-key" +`; + + expect(getCurrentCodexProviderSettings(config)).toEqual({ + baseUrl: "https://9router.example/v1/", + apiKey: "placeholder-9router-key", + }); + }); + + it("returns empty settings when no active provider is configured", () => { + expect(getCurrentCodexProviderSettings("model = \"gpt-5\"\n")).toEqual({ baseUrl: "", apiKey: "" }); + }); +}); diff --git a/tests/unit/codex-settings-refresh.test.js b/tests/unit/codex-settings-refresh.test.js new file mode 100644 index 00000000..2e4af6f1 --- /dev/null +++ b/tests/unit/codex-settings-refresh.test.js @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const readSource = (relativePath) => + readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); + +describe("Codex settings refresh", () => { + it("bypasses cached status after applying a selected endpoint", async () => { + const [routeSource, cardSource] = await Promise.all([ + readSource("../../src/app/api/cli-tools/codex-settings/route.js"), + readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js"), + ]); + + // Route Handlers already run on the server; a Server Action directive would reject this export. + expect(routeSource).not.toContain('"use server";'); + expect(routeSource).toContain('export const dynamic = "force-dynamic";'); + expect(cardSource).toContain('fetch("/api/cli-tools/codex-settings", { cache: "no-store" })'); + expect(cardSource).toContain("setSelectedApiKey(apiKey);"); + expect(cardSource).toContain("setCustomBaseUrl(baseUrl);"); + }); + + it("keeps an unmatched active URL in the custom endpoint slot", async () => { + const selectorSource = await readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js"); + + expect(selectorSource).toContain("if (current) {"); + expect(selectorSource).toContain("setCustomInput(current);"); + expect(selectorSource).toContain("onChange(current);"); + }); +});