Skip to content
Open
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
27 changes: 24 additions & 3 deletions src/components/ui/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModelDisplay } from "@/components/ui/model-display";
import { useSettingsValue, getModelKeyFromModel } from "@/settings/model";
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { checkModelApiKey, err2String } from "@/utils";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getApiKeyForProvider,
isRequiredChatModel,
providerRequiresApiKey,
} from "@/utils/modelUtils";

interface ModelSelectorProps {
disabled?: boolean;
Expand All @@ -38,6 +43,22 @@ export function ModelSelector({
(model) => model.enabled && getModelKeyFromModel(model) === value
);

// Filter models: show required models, local models, or models with valid API keys
const showModels = settings.activeModels.filter((model) => {
const isRequired = isRequiredChatModel(model);
if (isRequired) {
return true;
}

// Local providers don't require API keys
if (!providerRequiresApiKey(model.provider)) {
return true;
}

// Cloud providers need API keys
const hasApiKey = !!getApiKeyForProvider(model.provider, model);
return hasApiKey;
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand All @@ -61,7 +82,7 @@ export function ModelSelector({
</DropdownMenuTrigger>

<DropdownMenuContent align="start">
{settings.activeModels
{showModels
.filter((model) => model.enabled)
.map((model) => {
const { hasApiKey, errorNotice } = checkModelApiKey(model, settings);
Expand All @@ -83,7 +104,7 @@ export function ModelSelector({
setModelError(msg);
new Notice(msg);
// Restore to the last valid model
const lastValidModel = settings.activeModels.find(
const lastValidModel = showModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === value
);
if (lastValidModel) {
Expand Down
13 changes: 4 additions & 9 deletions src/settings/v2/components/ApiKeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getProviderLabel,
safeFetch,
} from "@/utils";
import { getApiKeyForProvider } from "@/utils/modelUtils";
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { App, Modal, Notice } from "obsidian";
import React, { useEffect, useState } from "react";
Expand Down Expand Up @@ -55,17 +56,11 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) {
setSelectedModel(null);
}, []); // Empty dependency array ensures this runs on mount

// Get API key by provider
const getApiKeyByProvider = (provider: SettingKeyProviders): string => {
const settingKey = ProviderSettingsKeyMap[provider];
return (settings[settingKey] ?? "") as string;
};

const providers: ProviderKeyItem[] = getNeedSetKeyProvider()
.filter((provider) => provider !== ChatModelProviders.AMAZON_BEDROCK)
.map((provider) => {
const providerKey = provider as SettingKeyProviders;
const apiKey = getApiKeyByProvider(providerKey);
const apiKey = getApiKeyForProvider(providerKey);

return {
provider: providerKey,
Expand All @@ -74,7 +69,7 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) {
});

const handleApiKeyChange = (provider: SettingKeyProviders, value: string) => {
const currentKey = getApiKeyByProvider(provider);
const currentKey = getApiKeyForProvider(provider);
if (currentKey !== value) {
updateSetting(ProviderSettingsKeyMap[provider], value);
// Mark models as needing refresh for this provider
Expand Down Expand Up @@ -174,7 +169,7 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) {
let verificationError = "";

try {
const apiKey = getApiKeyByProvider(selectedModel.provider);
const apiKey = getApiKeyForProvider(selectedModel.provider);
const customModel: CustomModel = {
name: selectedModel.name,
provider: selectedModel.provider,
Expand Down
11 changes: 3 additions & 8 deletions src/settings/v2/components/ModelAddDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,14 @@ import {
EmbeddingModelProviders,
MODEL_CAPABILITIES,
ModelCapability,
Provider,
ProviderMetadata,
ProviderSettingsKeyMap,
SettingKeyProviders,
} from "@/constants";
import { useTab } from "@/contexts/TabContext";
import { logError } from "@/logger";
import { getSettings } from "@/settings/model";
import { err2String, getProviderInfo, getProviderLabel, omit } from "@/utils";
import { getApiKeyForProvider } from "@/utils/modelUtils";
import { ChevronDown, Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
Expand Down Expand Up @@ -136,18 +135,14 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
return isValid;
};

const getDefaultApiKey = (provider: Provider): string => {
return (settings[ProviderSettingsKeyMap[provider as SettingKeyProviders]] as string) || "";
};

const getInitialModel = (provider = defaultProvider): CustomModel => {
const baseModel = {
name: "",
provider,
enabled: true,
isBuiltIn: false,
baseUrl: "",
apiKey: getDefaultApiKey(provider),
apiKey: getApiKeyForProvider(provider as SettingKeyProviders),
isEmbeddingModel,
capabilities: [],
};
Expand Down Expand Up @@ -222,7 +217,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
setModel({
...model,
provider,
apiKey: getDefaultApiKey(provider),
apiKey: getApiKeyForProvider(provider as SettingKeyProviders),
...(provider === ChatModelProviders.OPENAI ? { openAIOrgId: settings.openAIOrgId } : {}),
...(provider === ChatModelProviders.AZURE_OPENAI
? {
Expand Down
12 changes: 5 additions & 7 deletions src/settings/v2/components/ModelEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@ import {
ChatModelProviders,
MODEL_CAPABILITIES,
ModelCapability,
Provider,
ProviderMetadata,
ProviderSettingsKeyMap,
SettingKeyProviders,
} from "@/constants";
import { getSettings } from "@/settings/model";
import { debounce, getProviderInfo, getProviderLabel } from "@/utils";
import { getApiKeyForProvider } from "@/utils/modelUtils";
import { App, Modal, Platform } from "obsidian";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createRoot, Root } from "react-dom/client";
Expand Down Expand Up @@ -47,10 +46,6 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
const settings = getSettings();
const isBedrockProvider = localModel.provider === ChatModelProviders.AMAZON_BEDROCK;

const getDefaultApiKey = (provider: Provider): string => {
return (settings[ProviderSettingsKeyMap[provider as SettingKeyProviders]] as string) || "";
};

useEffect(() => {
setLocalModel(model);
setOriginalModel(model);
Expand Down Expand Up @@ -120,7 +115,10 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
description,
})) as Array<{ id: ModelCapability; label: string; description: string }>;

const displayApiKey = localModel.apiKey || getDefaultApiKey(localModel.provider as Provider);
const displayApiKey = getApiKeyForProvider(
localModel.provider as SettingKeyProviders,
localModel
);
const showOtherParameters = !isEmbeddingModel && localModel.provider !== "copilot-plus-jina";

return (
Expand Down
2 changes: 2 additions & 0 deletions src/settings/v2/components/ModelTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { isRequiredChatModel } from "@/utils/modelUtils";

const CAPABILITY_ICONS: Record<
ModelCapability,
Expand Down Expand Up @@ -291,6 +292,7 @@ const DesktopSortableTableRow: React.FC<{
<Checkbox
id={`${getModelKeyFromModel(model)}-enabled`}
checked={model.enabled}
disabled={model.enabled && isRequiredChatModel(model)}
onCheckedChange={(checked: boolean) => onUpdateModel({ ...model, enabled: checked })}
className="tw-mx-auto"
/>
Expand Down
12 changes: 5 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Provider,
ProviderInfo,
ProviderMetadata,
ProviderSettingsKeyMap,
SettingKeyProviders,
USER_SENDER,
} from "@/constants";
Expand All @@ -20,6 +19,7 @@ import { BaseChain, RetrievalQAChain } from "@langchain/classic/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
import { getApiKeyForProvider } from "@/utils/modelUtils";
export { err2String } from "@/errorFormat";

// Add custom error type at the top of the file
Expand Down Expand Up @@ -1019,7 +1019,7 @@ export function getMessageRole(
return isOSeriesModel(model) ? "human" : defaultRole;
}

export function getNeedSetKeyProvider() {
export function getNeedSetKeyProvider(): Provider[] {
// List of providers to exclude
const excludeProviders: Provider[] = [
ChatModelProviders.OPENAI_FORMAT,
Expand All @@ -1030,9 +1030,7 @@ export function getNeedSetKeyProvider() {
EmbeddingModelProviders.COPILOT_PLUS_JINA,
];

return Object.entries(ProviderInfo)
.filter(([key]) => !excludeProviders.includes(key as Provider))
.map(([key]) => key as Provider);
return (Object.keys(ProviderInfo) as Provider[]).filter((key) => !excludeProviders.includes(key));
}

export function checkModelApiKey(
Expand All @@ -1057,9 +1055,9 @@ export function checkModelApiKey(
}

const needSetKeyPath = !!getNeedSetKeyProvider().find((provider) => provider === model.provider);
const providerKeyName = ProviderSettingsKeyMap[model.provider as SettingKeyProviders];
const hasNoApiKey = !model.apiKey && !settings[providerKeyName];
const hasNoApiKey = !getApiKeyForProvider(model.provider as SettingKeyProviders, model);

// For Providers that require setting a key in the dialog, an inspection is necessary.
if (needSetKeyPath && hasNoApiKey) {
const notice =
`Please configure API Key for ${model.name} in settings first.` +
Expand Down
75 changes: 75 additions & 0 deletions src/utils/modelUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
ChatModelProviders,
ChatModels,
ProviderSettingsKeyMap,
SettingKeyProviders,
} from "@/constants";
import { getSettings } from "@/settings/model";
import { CustomModel } from "@/aiParams";

/**
* Check if a provider requires an API key.
* Local providers (OLLAMA, LM_STUDIO, OPENAI_FORMAT) don't require API keys.
*
* @param provider - The provider to check
* @returns true if the provider requires an API key, false for local providers
*
* @example
* if (providerRequiresApiKey(model.provider)) {
* // This is a cloud provider, check for API key
* } else {
* // This is a local provider, no API key needed
* }
*/
export function providerRequiresApiKey(provider: string): provider is SettingKeyProviders {
return provider in ProviderSettingsKeyMap;
}

/**
* Get API key for a provider, with model-specific key taking precedence over global settings.
*
* @param provider - The provider to get the API key for
* @param model - Optional model instance; if provided and has apiKey, it will be used instead of global key
* @returns The API key (model-specific if available, otherwise global provider key, or empty string)
*
* @example
* // Get global API key for OpenAI
* const globalKey = getApiKeyForProvider(ChatModelProviders.OPENAI);
*
* // Get model-specific key (falls back to global if model.apiKey is empty)
* const modelKey = getApiKeyForProvider(ChatModelProviders.OPENAI, customModel);
*/
export function getApiKeyForProvider(provider: SettingKeyProviders, model?: CustomModel): string {
const settings = getSettings();
return model?.apiKey || (settings[ProviderSettingsKeyMap[provider]] as string | undefined) || "";
}

/**
* Get the list of models that are always required and cannot be disabled.
* These models provide essential functionality for the plugin.
* Uses a getter function to avoid circular dependency issues.
*/
function getRequiredModels(): ReadonlyArray<{ name: string; provider: string }> {
return [
{ name: ChatModels.COPILOT_PLUS_FLASH, provider: ChatModelProviders.COPILOT_PLUS },
{ name: ChatModels.OPENROUTER_GEMINI_2_5_FLASH, provider: ChatModelProviders.OPENROUTERAI },
];
}

/**
* Checks if a model is required and should always be enabled.
* Required models cannot be disabled by users as they provide core plugin functionality.
*
* @param model - The model to check
* @returns true if the model is required and must remain enabled, false otherwise
*
* @example
* if (isRequiredChatModel(model)) {
* // This model cannot be disabled
* }
*/
export function isRequiredChatModel(model: CustomModel): boolean {
return getRequiredModels().some(
(required) => required.name === model.name && required.provider === model.provider
);
}