Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/extension/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"storage",
"identity"
],
"optional_host_permissions": [
"https://*/*",
"http://*/*"
],
"host_permissions": [
"https://api.openai.com/*",
"https://generativelanguage.googleapis.com/*",
Expand All @@ -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';"
}
}
175 changes: 141 additions & 34 deletions apps/extension/src/components/ModelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,21 +203,26 @@ 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));
};

const loadStorageSettings = async () => {
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;
}
Expand Down Expand Up @@ -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);
};
}, []);

Expand All @@ -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[]) || []);
});
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-3">
{!isSignedIn ? (
<div className="rounded-sm border border-border bg-muted px-3 py-2 text-sm leading-relaxed text-muted-foreground">
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.
</div>
) : null}

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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" ? (
<p className="text-xs leading-relaxed text-muted-foreground">
Enter the exact model ID from your provider. Examples include{" "}
<code>deepseek-chat</code>, <code>deepseek-reasoner</code>, or{" "}
<code>llama3.1</code>.
</p>
) : null}
<datalist id={`${selectedProvider}-models`}>
{models.map((model) => (
<option key={model.id} value={model.id}>
Expand All @@ -593,9 +688,13 @@ export function ModelConfig() {
</SelectTrigger>
<SelectContent>
{models.length === 0 && fetchingModels ? (
<SelectItem value="..." disabled>Loading models...</SelectItem>
<SelectItem value="..." disabled>
Loading models...
</SelectItem>
) : models.length === 0 ? (
<SelectItem value="none" disabled>Enter API Key to load models</SelectItem>
<SelectItem value="none" disabled>
Enter API Key to load models
</SelectItem>
) : (
models.map((model) => (
<SelectItem key={model.id} value={model.id}>
Expand All @@ -616,13 +715,22 @@ export function ModelConfig() {
>
Base URL
</Label>
<Input
id="base-url"
type="text"
value={baseUrl}
onInput={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
placeholder="http://localhost:11434/v1"
/>
<div className="flex flex-col gap-2">
<Input
id="base-url"
type="text"
value={baseUrl}
onInput={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
placeholder="https://your-provider.example/v1"
/>
<p className="text-xs leading-relaxed text-muted-foreground">
Enter the provider base URL or the full chat completions endpoint.
plenz appends <code>/chat/completions</code> automatically for
base URLs. DeepSeek works with{" "}
<code>https://api.deepseek.com</code> or{" "}
<code>https://api.deepseek.com/v1</code>.
</p>
</div>
</div>
) : null}

Expand Down Expand Up @@ -710,4 +818,3 @@ export function ModelConfig() {
</div>
);
}

1 change: 1 addition & 0 deletions apps/web/src/app/getting-started/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Image src={customProvider} alt="Custom provider setup" />,
Expand Down
18 changes: 12 additions & 6 deletions packages/providers/src/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionTestResult> {
Expand Down
Loading