From a58621012fc329b2376ee74d605df973a2f6a275 Mon Sep 17 00:00:00 2001 From: Maurycy Blaszczak Date: Sun, 26 Apr 2026 13:00:04 +0200 Subject: [PATCH] feat(ai-settings): added custom model ID input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users supply an arbitrary model identifier instead of choosing from the preset list. The picker gains a "Custom Model ID…" entry that swaps the dropdown for a text input, with a "← Use preset" button to switch back. A new useCustomModel flag on AISettings bypasses preset validation in loadAIConfig and useAISettings, and web grounding is trusted to the user's toggle when they vouch the custom model supports it. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/project-sidebar.tsx | 120 ++++++++++++++++++++++----------- lib/ai-settings.ts | 72 +++++++++++++------- 2 files changed, 130 insertions(+), 62 deletions(-) diff --git a/components/project-sidebar.tsx b/components/project-sidebar.tsx index d71a98e..6b069d0 100644 --- a/components/project-sidebar.tsx +++ b/components/project-sidebar.tsx @@ -131,7 +131,9 @@ export function ProjectSidebar({ const currentPreset = getPreset(draft.provider) const models = getModelsForProvider(draft.provider) - const selectedModel = models.find(m => m.id === draft.modelId) || models[0] || undefined + const selectedModel = draft.useCustomModel + ? undefined + : models.find(m => m.id === draft.modelId) || models[0] || undefined return (
Model - {models.length === 0 ? ( -
- setDraft(d => ({ ...d, modelId: e.target.value }))} - placeholder="e.g. gpt-4o, claude-3-opus-20240229" - className="flex-1 bg-transparent font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/40" - autoComplete="off" - spellCheck={false} - /> + {(models.length === 0 || draft.useCustomModel) ? ( +
+
+ setDraft(d => ({ ...d, modelId: e.target.value, useCustomModel: true }))} + placeholder={draft.provider === "openrouter" ? "e.g. anthropic/claude-opus-4.1" : "e.g. gpt-4o"} + className="flex-1 bg-transparent font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/40" + autoComplete="off" + spellCheck={false} + autoFocus={draft.useCustomModel && !draft.modelId} + /> +
+ {models.length > 0 && ( + + )}
) : (
@@ -444,7 +463,7 @@ export function ProjectSidebar({ ))} + )} @@ -469,34 +503,44 @@ export function ProjectSidebar({
{/* Web Grounding (OpenRouter + OpenAI) */} - {(draft.provider === "openrouter" || draft.provider === "openai") && selectedModel && ( -
-
- -
-
Web Grounding
-
- {selectedModel.supportsGrounding - ? draft.provider === "openai" - ? `Uses ${selectedModel.groundingModelId ?? "search-preview"} for live web access` - : "Adds :online for live search" - : "Not available for this model"} + {(draft.provider === "openrouter" || draft.provider === "openai") && (selectedModel || draft.useCustomModel) && (() => { + // In custom mode we trust the user — no preset metadata to consult. + const groundingAllowed = draft.useCustomModel || !!selectedModel?.supportsGrounding + const groundingActive = draft.webGrounding && groundingAllowed + const description = draft.useCustomModel + ? draft.provider === "openrouter" + ? "Adds :online — your custom model must support it" + : "Sends web_search_options — your custom model must support it" + : selectedModel?.supportsGrounding + ? draft.provider === "openai" + ? `Uses ${selectedModel.groundingModelId ?? "search-preview"} for live web access` + : "Adds :online for live search" + : "Not available for this model" + return ( +
+
+ +
+
Web Grounding
+
+ {description} +
+
- -
- )} + ) + })()} {/* API Status */}
> } const STORAGE_KEY = "nodepad-ai-settings" +const EMPTY_SETTINGS: AISettings = { + apiKey: "", + modelId: DEFAULT_MODEL_ID, + webGrounding: false, + provider: DEFAULT_PROVIDER, + customBaseUrl: "", + useCustomModel: false, +} + function loadSettings(): AISettings { - if (typeof window === "undefined") { - return { apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, provider: DEFAULT_PROVIDER, customBaseUrl: "" } - } + if (typeof window === "undefined") return { ...EMPTY_SETTINGS } try { const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return { apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, provider: DEFAULT_PROVIDER, customBaseUrl: "" } - return { apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, provider: DEFAULT_PROVIDER, customBaseUrl: "", ...JSON.parse(raw) } + if (!raw) return { ...EMPTY_SETTINGS } + return { ...EMPTY_SETTINGS, ...JSON.parse(raw) } } catch { - return { apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, provider: DEFAULT_PROVIDER, customBaseUrl: "" } + return { ...EMPTY_SETTINGS } } } @@ -221,16 +230,19 @@ export function loadAIConfig(): AIConfig | null { if (!s.apiKey) return null const models = getModelsForProvider(s.provider) const model = models.find(m => m.id === s.modelId) - // Use the matched model's id if found; otherwise fall back to the first model - // for this provider. This handles the case where localStorage still holds an - // OpenRouter-prefixed id (e.g. "openai/gpt-4o") after switching to OpenAI — - // that string won't match any entry in OPENAI_MODELS so we fall back to "gpt-4o". - const modelId = model?.id ?? models[0]?.id ?? s.modelId ?? DEFAULT_MODEL_ID - // Z.ai does not support grounding; only openrouter and openai do + // For custom model IDs, pass the user-supplied value through unmodified. + // Otherwise, prefer the matched preset and fall back to the first model for + // this provider — this guards against stale localStorage holding an + // OpenRouter-prefixed id (e.g. "openai/gpt-4o") after switching to OpenAI. + const modelId = s.useCustomModel + ? (s.modelId || DEFAULT_MODEL_ID) + : (model?.id ?? models[0]?.id ?? s.modelId ?? DEFAULT_MODEL_ID) + // Z.ai does not support grounding; only openrouter and openai do. + // For custom models we trust the user's toggle — they vouch the model supports it. const supportsGrounding = (s.provider === "openrouter" || s.provider === "openai") && s.webGrounding && - (model?.supportsGrounding ?? false) + (s.useCustomModel || (model?.supportsGrounding ?? false)) return { apiKey: s.apiKey, modelId, supportsGrounding, provider: s.provider, customBaseUrl: s.customBaseUrl } } @@ -270,10 +282,7 @@ export function useAISettings() { // Load the real localStorage value after mount to avoid hydration mismatches // caused by settings.apiKey toggling conditional DOM blocks (API key banner, // modelLabel prop, etc.) between the server render and client hydration. - const [settings, setSettings] = useState({ - apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, - provider: DEFAULT_PROVIDER, customBaseUrl: "", - }) + const [settings, setSettings] = useState({ ...EMPTY_SETTINGS }) const [isHydrated, setIsHydrated] = useState(false) useEffect(() => { @@ -292,6 +301,13 @@ export function useAISettings() { const models = getModelsForProvider(settings.provider) const resolvedModelId = (() => { + if (settings.useCustomModel) { + const id = settings.modelId || DEFAULT_MODEL_ID + if (settings.provider === "openrouter" && settings.webGrounding) { + return id.endsWith(":online") ? id : `${id}:online` + } + return id + } const model = models.find(m => m.id === settings.modelId) || models[0] if (!model) return settings.modelId if (settings.provider === "openrouter" && settings.webGrounding && model.supportsGrounding) { @@ -300,13 +316,21 @@ export function useAISettings() { return model.id })() - const currentModel: AIModel = models.find(m => m.id === settings.modelId) || models[0] || { - id: settings.modelId, - label: settings.modelId, - shortLabel: settings.modelId.split("/").pop() || settings.modelId, - description: "Custom model", - supportsGrounding: false, - } + const currentModel: AIModel = settings.useCustomModel + ? { + id: settings.modelId, + label: settings.modelId, + shortLabel: settings.modelId.split("/").pop() || settings.modelId, + description: "Custom model", + supportsGrounding: false, + } + : models.find(m => m.id === settings.modelId) || models[0] || { + id: settings.modelId, + label: settings.modelId, + shortLabel: settings.modelId.split("/").pop() || settings.modelId, + description: "Custom model", + supportsGrounding: false, + } return { settings, updateSettings, resolvedModelId, currentModel, models, isHydrated } }