diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bab9ca..9c8dc43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Generalized Custom Provider endpoint handling and host permissions for OpenAI-compatible providers, including DeepSeek. + ## [1.2.4] - 2026-04-11 ### Added diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index a5f2503..c0ad934 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -41,6 +41,10 @@ "storage", "identity" ], + "optional_host_permissions": [ + "https://*/*", + "http://*/*" + ], "host_permissions": [ "https://api.openai.com/*", "https://generativelanguage.googleapis.com/*", @@ -67,6 +71,6 @@ ] }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://api.openai.com https://generativelanguage.googleapis.com https://www.googleapis.com https://apis.google.com https://www.gstatic.com https://securetoken.googleapis.com https://identitytoolkit.googleapis.com https://firebaseinstallations.googleapis.com https://firestore.googleapis.com https://api.anthropic.com https://api.mistral.ai https://api.groq.com https://openrouter.ai http://localhost:* http://127.0.0.1:*" + "extension_pages": "script-src 'self'; object-src 'self';" } } diff --git a/apps/extension/src/components/ModelConfig.tsx b/apps/extension/src/components/ModelConfig.tsx index 30d4507..cb039ba 100644 --- a/apps/extension/src/components/ModelConfig.tsx +++ b/apps/extension/src/components/ModelConfig.tsx @@ -103,6 +103,24 @@ function getResponseError(value: unknown) { return undefined; } +function getCustomProviderOriginPattern(baseUrl: string) { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return ""; + } + + try { + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + + return `${url.origin}/*`; + } catch { + return null; + } +} + function getSyncStatusMeta( status: SyncStatus, storageBackend: StorageBackendPreference, @@ -185,7 +203,9 @@ export function ModelConfig() { selectedProvider === "custom" || selectedProvider === "openrouter"; const loadAuthStatus = async () => { - const response = await chrome.runtime.sendMessage({ type: "GET_AUTH_STATUS" }); + const response = await chrome.runtime.sendMessage({ + type: "GET_AUTH_STATUS", + }); setIsSignedIn(isUserInfoResponse(response)); }; @@ -193,13 +213,16 @@ export function ModelConfig() { const rawResponse = await chrome.runtime.sendMessage({ type: "GET_STORAGE_SETTINGS", }); - const response = isStorageSettingsResponse(rawResponse) ? rawResponse : null; + const response = isStorageSettingsResponse(rawResponse) + ? rawResponse + : null; if (!response) { setBackendState({ loading: false, tone: "error", - message: getResponseError(rawResponse) || "Failed to load sync settings.", + message: + getResponseError(rawResponse) || "Failed to load sync settings.", }); return; } @@ -248,10 +271,7 @@ export function ModelConfig() { window.addEventListener("plenz-auth-status-changed", handleAuthChange); return () => { - window.removeEventListener( - "plenz-auth-status-changed", - handleAuthChange, - ); + window.removeEventListener("plenz-auth-status-changed", handleAuthChange); }; }, []); @@ -261,10 +281,15 @@ export function ModelConfig() { useEffect(() => { const fetchModelsWithCache = async () => { - const p = providers.find((prov) => prov.id === selectedProvider) || providers[0]; + const p = + providers.find((prov) => prov.id === selectedProvider) || providers[0]; const cacheKey = `models_${selectedProvider}`; - - if (!apiKey && selectedProvider !== "openrouter" && selectedProvider !== "custom") { + + if ( + !apiKey && + selectedProvider !== "openrouter" && + selectedProvider !== "custom" + ) { chrome.storage.local.get([cacheKey], (res) => { setModels((res[cacheKey] as ModelOption[]) || []); }); @@ -298,6 +323,41 @@ export function ModelConfig() { const handleTest = async () => { setTestStatus({ loading: true }); try { + if (selectedProvider === "custom") { + const originPattern = getCustomProviderOriginPattern(baseUrl); + + if (!originPattern) { + setTestStatus({ + loading: false, + result: { + success: false, + latencyMs: 0, + error: "Enter a valid http(s) Custom Provider URL.", + }, + }); + return; + } + + if (originPattern) { + const granted = await chrome.permissions.request({ + origins: [originPattern], + }); + + if (!granted) { + setTestStatus({ + loading: false, + result: { + success: false, + latencyMs: 0, + error: + "Permission denied for that provider host. Approve access and try again.", + }, + }); + return; + } + } + } + const response = await chrome.runtime.sendMessage({ type: "TEST_CONNECTION", payload: { @@ -412,6 +472,31 @@ export function ModelConfig() { setSaveState({ status: "syncing" }); try { + if (selectedProvider === "custom") { + const originPattern = getCustomProviderOriginPattern(baseUrl); + + if (!originPattern) { + const errorMessage = "Enter a valid http(s) Custom Provider URL."; + setSaveState({ status: "error", error: errorMessage }); + alert(`Error saving settings: ${errorMessage}`); + return; + } + + if (originPattern) { + const granted = await chrome.permissions.request({ + origins: [originPattern], + }); + + if (!granted) { + const errorMessage = + "Permission denied for that provider host. Approve access before saving."; + setSaveState({ status: "error", error: errorMessage }); + alert(`Error saving settings: ${errorMessage}`); + return; + } + } + } + const rawResponse = await chrome.runtime.sendMessage({ type: "SAVE_MODEL_CONFIG", payload: { @@ -455,20 +540,20 @@ export function ModelConfig() { }; const syncMeta = getSyncStatusMeta(saveState.status, storageBackend); - const defaultBackendMessage = - !firebaseConfigured - ? "Cloud Sync is unavailable in this build. Add the VITE_FIREBASE_* environment variables and rebuild the extension." - : !isSignedIn - ? "Sign in with Google to enable Cloud Sync and access encrypted synced API keys." - : storageBackend === "firebase" - ? "Cloud Sync stores provider settings in Firebase, with API keys encrypted before upload. Browser-local copies are unchanged." - : "Chrome Sync uses Chrome's built-in sync storage and falls back to local storage when needed."; + const defaultBackendMessage = !firebaseConfigured + ? "Cloud Sync is unavailable in this build. Add the VITE_FIREBASE_* environment variables and rebuild the extension." + : !isSignedIn + ? "Sign in with Google to enable Cloud Sync and access encrypted synced API keys." + : storageBackend === "firebase" + ? "Cloud Sync stores provider settings in Firebase, with API keys encrypted before upload. Browser-local copies are unchanged." + : "Chrome Sync uses Chrome's built-in sync storage and falls back to local storage when needed."; return (
{!isSignedIn ? (
- Sign in with Google to unlock Cloud Sync and decrypt synced API keys on this device. + Sign in with Google to unlock Cloud Sync and decrypt synced API keys + on this device.
) : null} @@ -498,7 +583,9 @@ export function ModelConfig() { isSelected ? "border-accent-secondary bg-muted" : "border-border bg-background hover:border-accent-secondary/50", - isDisabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", + isDisabled + ? "cursor-not-allowed opacity-60" + : "cursor-pointer", )} onClick={() => void handleStorageBackendChange(value)} disabled={isDisabled} @@ -571,13 +658,21 @@ export function ModelConfig() { setSelectedModel((e.target as HTMLInputElement).value) } placeholder={ - fetchingModels ? "Fetching models..." : - selectedProvider === "openrouter" - ? "e.g., anthropic/claude-3-opus" - : "e.g., llama3" + fetchingModels + ? "Fetching models..." + : selectedProvider === "openrouter" + ? "e.g., anthropic/claude-3-opus" + : "e.g., deepseek-chat or llama3.1" } list={`${selectedProvider}-models`} /> + {selectedProvider === "custom" ? ( +

+ Enter the exact model ID from your provider. Examples include{" "} + deepseek-chat, deepseek-reasoner, or{" "} + llama3.1. +

+ ) : null} {models.map((model) => (
) : null} @@ -710,4 +818,3 @@ export function ModelConfig() { ); } - diff --git a/apps/web/src/app/getting-started/page.tsx b/apps/web/src/app/getting-started/page.tsx index 46467e9..802a83f 100644 --- a/apps/web/src/app/getting-started/page.tsx +++ b/apps/web/src/app/getting-started/page.tsx @@ -110,6 +110,7 @@ const providers = [ "Locate the host URL (e.g., http://localhost:1234/v1).", "In plenz Settings, select 'Custom'.", "Enter your host URL and optional API Key.", + "plenz accepts either a provider base URL or a full OpenAI-compatible chat completions URL. For DeepSeek, use https://api.deepseek.com or https://api.deepseek.com/v1 with deepseek-chat or deepseek-reasoner.", "Test the connection to ensure plenz can reach your server.", ], image: Custom provider setup, diff --git a/packages/providers/src/custom.ts b/packages/providers/src/custom.ts index 9b07c4f..68a2ce2 100644 --- a/packages/providers/src/custom.ts +++ b/packages/providers/src/custom.ts @@ -88,14 +88,20 @@ export class CustomAdapter implements ProviderAdapter { if (formatted.endsWith("/")) { formatted = formatted.slice(0, -1); } - if (!formatted.endsWith("/chat/completions")) { - // Try to automatically append /chat/completions if it looks like a base URL only - // but if they provided the full path, use it. - if (formatted.endsWith("/v1")) { - formatted += "/chat/completions"; + + try { + const parsed = new URL(formatted); + const pathname = parsed.pathname.replace(/\/+$/, ""); + + if (pathname.endsWith("/chat/completions")) { + return formatted; } + + // Treat any other URL as a base path for an OpenAI-compatible API. + return `${formatted}/chat/completions`; + } catch { + return formatted; } - return formatted; } async testConnection(config: ProviderConfig): Promise {