feat(settings): land metadata-driven provider panel

This commit is contained in:
Jacobinwwey 2026-05-27 05:33:06 -05:00
parent 199acda84b
commit 0cdd4acc8c
15 changed files with 1148 additions and 124 deletions

View file

@ -250,8 +250,9 @@ Access plugin settings via:
* **API Key**: Required for most cloud providers (e.g., OpenAI, Anthropic, DeepSeek, Qwen, Qwen Code, Doubao, Moonshot, Xiaomi MiMo, GLM, Z AI, MiniMax, Huawei Cloud MaaS, Baidu Qianfan, SiliconFlow, Google, Mistral, Azure OpenAI, OpenRouter, xAI, Groq, Together, Fireworks, Requesty). Not needed for Ollama. Optional for LM Studio and the generic `OpenAI Compatible` preset when your endpoint accepts anonymous or placeholder access.
* **Base URL / Endpoint**: The API endpoint for the service. Defaults are provided, but you may need to change this for local models (LMStudio, Ollama), gateways (OpenRouter, Requesty, OpenAI Compatible), or specific Azure deployments. **Required for Azure OpenAI.**
* **Model**: The specific model name/ID to use (e.g., `gpt-4o`, `claude-3-5-sonnet-20240620`, `google/gemini-flash-1.5`, `grok-4`, `moonshotai/kimi-k2-instruct-0905`, `accounts/fireworks/models/kimi-k2p5`, `anthropic/claude-3-7-sonnet-latest`). Ensure the model is available at your endpoint/provider.
* **Temperature**: Controls the randomness of the LLM's output (0=deterministic, 1=max creativity). Lower values (e.g., 0.2-0.5) are generally better for structured tasks.
* **API Version (Azure Only)**: Required for Azure OpenAI deployments (e.g., `2024-02-15-preview`).
* **Advanced Settings**: Secondary tuning controls such as `Temperature`, `Top-p`, reasoning hints, DeepSeek thinking mode, and the provider-specific output-token override now live behind **Show advanced settings**. If a provider already has saved advanced overrides, this section auto-expands so existing behavior stays visible instead of being silently hidden.
* **Fetch Model List**: Supported providers can query the endpoint for available model IDs without replacing manual model entry. In the first batch this covers OpenAI-compatible `/models`, Ollama tags, and Google Gemini model listing. The fetched list is transient suggestions only; your saved `Model` string remains the source of truth.
3. **Test Connection**: Use the "Test Connection" button for the active provider to verify your settings. OpenAI-compatible providers now use provider-aware checks: endpoints such as `Qwen`, `Qwen Code`, `Doubao`, `Moonshot`, `Xiaomi MiMo`, `GLM`, `Z AI`, `MiniMax`, `Huawei Cloud MaaS`, `Baidu Qianfan`, `SiliconFlow`, `Groq`, `Together`, `Fireworks`, `LMStudio`, and `OpenAI Compatible` probe `chat/completions` directly, while providers with a reliable `/models` endpoint can still use model listing first. If the first probe fails with a transient network disconnect such as `ERR_CONNECTION_CLOSED`, Notemd automatically falls back to the stable retry sequence instead of failing immediately.
4. **Manage Provider Configurations**: Use the "Export Providers" and "Import Providers" buttons to save/load your LLM provider settings to/from a `notemd-providers.json` file within the plugin's configuration directory. This allows for easy backup and sharing.
5. **Preset Coverage**: In addition to the original providers, Notemd now includes preset entries for `Qwen`, `Qwen Code`, `Doubao`, `Moonshot`, `Xiaomi MiMo`, `GLM`, `Z AI`, `MiniMax`, `Huawei Cloud MaaS`, `Baidu Qianfan`, `SiliconFlow`, `xAI`, `Groq`, `Together`, `Fireworks`, `Requesty`, and a generic `OpenAI Compatible` target for LiteLLM, vLLM, Perplexity, Vercel AI Gateway, or custom proxies.

View file

@ -232,8 +232,9 @@ Notemd 通过与各种大型语言模型 (LLM) 集成来增强您的 Obsidian
- **API 密钥**: 大多数云提供商(例如 OpenAI、Anthropic、DeepSeek、Qwen、Qwen Code、Doubao、Moonshot、Xiaomi MiMo、GLM、Z AI、MiniMax、Huawei Cloud MaaS、Baidu Qianfan、SiliconFlow、Google、Mistral、Azure OpenAI、OpenRouter、xAI、Groq、Together、Fireworks、Requesty需要。Ollama 不需要。LMStudio 和通用的 `OpenAI Compatible` 预设在某些允许匿名或占位密钥的端点上可留空。
- **基础 URL / 端点**: 服务的 API 端点。提供了默认值但您可能需要为本地模型LMStudio、Ollama、网关OpenRouter、Requesty、OpenAI Compatible或特定 Azure 部署修改此项。**Azure OpenAI 必填。**
- **模型**: 要使用的具体模型名称/ID例如 `gpt-4o`, `claude-3-5-sonnet-20240620`, `google/gemini-flash-1.5`, `grok-4`, `moonshotai/kimi-k2-instruct-0905`, `accounts/fireworks/models/kimi-k2p5`, `anthropic/claude-3-7-sonnet-latest`)。请确保该模型在您的端点/提供商处可用。
- **温度**: 控制 LLM 输出的随机性0=确定性1=最大创造力)。较低的值(例如 0.2-0.5)通常更适合结构化任务。
- **API 版本(仅 Azure**: Azure OpenAI 部署需要(例如 `2024-02-15-preview`)。
- **高级设置**: `Temperature`、`Top-p`、reasoning hint、DeepSeek thinking mode、provider 级 output-token override 等次级调优项现在统一收入 **显示高级设置**。如果某个 provider 已经保存了 advanced override该区域会默认展开避免现有行为被静默藏起来。
- **获取模型列表**: 已支持的 provider 可以直接向端点查询可用 model ID但不会替代手动 `Model` 输入。首批仅覆盖 OpenAI-compatible `/models`、Ollama tags 与 Google Gemini model listing。获取到的列表只是瞬时建议持久化真值仍然是用户保存的 `Model` 字符串。
3. **测试连接**: 使用活动提供商的“测试连接”按钮来验证您的设置。OpenAI-compatible 提供商现在会按 provider 特性自动选择测试策略:`Qwen`、`Qwen Code`、`Doubao`、`Moonshot`、`Xiaomi MiMo`、`GLM`、`Z AI`、`MiniMax`、`Huawei Cloud MaaS`、`Baidu Qianfan`、`SiliconFlow`、`Groq`、`Together`、`Fireworks`、`LMStudio` 与 `OpenAI Compatible` 会直接探测 `chat/completions`,而具备稳定 `/models` 端点的服务仍会优先走模型列表探测。如果首次探测遇到 `ERR_CONNECTION_CLOSED` 这类瞬时网络断连Notemd 会自动切入稳定重试序列,而不是立刻报错。

View file

@ -8,17 +8,22 @@ This document summarizes the major functional and architectural changes implemen
### Highlights
- Provider settings now converge on a metadata-driven core/advanced split, and supported providers can fetch transient model suggestions without creating a second persisted provider catalog.
- Developer-gated advanced batch folder selection now supports saved profiles, one-run overrides, preset chips, and inline file preview before execution.
- Developer-gated relaxed input file restrictions now land as a shared runtime contract for selected source-preserving tasks, including PDF text extraction through Obsidian's PDF runtime.
- Release-facing version truth, welcome digest, release notes, and chronicle refresh are synchronized at the `1.9.0` boundary.
### New Features
- Added provider-field taxonomy metadata (`core`, `contextual`, `advanced`, `developer`) so the provider settings panel can render required/default-visible controls separately from secondary tuning controls.
- Added lightweight in-settings model discovery for OpenAI-compatible `/models`, Ollama tags, and Google Gemini model listing. Discovery results are transient suggestions only; persisted provider state still uses the existing manual `model` string.
- Added the `Relax input file restrictions` developer setting plus the shared supported-input reader layer.
- Extended advanced folder-task selection with profile-aware folder locking, temporary overrides, preview panels, and example/preset chips.
### Fixes
- Provider advanced settings now auto-expand when a provider already has saved advanced overrides, preserving visibility for existing `data.json` behavior instead of silently collapsing non-default live config.
- Added discovery prerequisite validation and endpoint normalization so `/chat/completions`, `/models`, and `/tags` style base URLs do not cause malformed model-list requests.
- Unified task-aware input validation across sidebar actions, command entrypoints, batch adapters, and path-based diagram generation.
- Corrected relaxed-mode batch empty-folder notices so they describe supported inputs rather than hard-coded `.md` / `.txt` assumptions.
- Kept source-mutating and verbatim-sensitive flows intentionally restricted while widening only the safe allowlist.

View file

@ -205,6 +205,20 @@ export const STRINGS_EN = {
modelName: 'Model',
modelDesc: 'Model name to use with {provider}.',
modelPlaceholder: 'Enter model name',
advancedSettingsName: 'Show advanced settings',
advancedSettingsDesc: 'Reveal tuning and provider-specific overrides for {provider}.',
advancedSettingsHint: 'Advanced settings are expanded because this provider already has non-default advanced values saved.',
fetchModelsName: 'Fetch model list',
fetchModelsDesc: 'Query the provider endpoint for available model IDs. Manual model entry remains supported.',
fetchModelsButton: 'Fetch models',
fetchModelsLoading: 'Fetching...',
fetchModelsSuccess: 'Fetched {count} models for {provider}.',
fetchModelsEmpty: 'No models were returned by {provider}. You can still enter a model manually.',
fetchModelsUnavailable: '{provider} does not expose a supported model-list endpoint in this first batch. Enter the model manually.',
fetchModelsError: 'Failed to fetch models from {provider}: {message}',
discoveredModelsName: 'Discovered models',
discoveredModelsDesc: 'Choose a discovered model to fill the model field, or keep manual entry.',
discoveredModelsUseButton: 'Use',
temperatureName: 'Temperature',
temperatureDesc: 'Controls randomness (0=deterministic, 1=creative).',
maxOutputTokensName: 'Provider output token override',
@ -220,6 +234,7 @@ export const STRINGS_EN = {
apiVersionName: 'API version',
apiVersionDesc: 'Required API version for Azure OpenAI (e.g., 2024-02-15-preview)',
apiVersionPlaceholder: 'Enter API version',
contextualSettingsName: 'Provider-specific required settings',
testConnectionName: 'Test {provider} connection',
testConnectionDesc: 'Verify API key, endpoint, and model accessibility.',
testConnectionButton: 'Test connection',

View file

@ -208,6 +208,20 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
modelName: '模型',
modelDesc: '{provider} 使用的模型名称。',
modelPlaceholder: '输入模型名称',
advancedSettingsName: '显示高级设置',
advancedSettingsDesc: '展开 {provider} 的调优项与提供商特定覆盖项。',
advancedSettingsHint: '由于当前提供商已保存非默认高级值,因此默认展开高级设置。',
fetchModelsName: '获取模型列表',
fetchModelsDesc: '向提供商 endpoint 查询可用模型 ID。仍然支持手动填写模型。',
fetchModelsButton: '获取模型',
fetchModelsLoading: '获取中...',
fetchModelsSuccess: '已为 {provider} 获取 {count} 个模型。',
fetchModelsEmpty: '{provider} 没有返回模型列表。你仍然可以手动输入模型。',
fetchModelsUnavailable: '{provider} 在当前第一批实现中不提供受支持的模型列表 endpoint。请手动输入模型。',
fetchModelsError: '从 {provider} 获取模型列表失败:{message}',
discoveredModelsName: '已发现的模型',
discoveredModelsDesc: '选择一个已发现模型填入模型字段,或继续手动输入。',
discoveredModelsUseButton: '使用',
temperatureName: '温度',
temperatureDesc: '控制随机性0=确定性1=更有创造性)。',
maxOutputTokensName: '提供商输出 Token 覆盖上限',
@ -223,6 +237,7 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
apiVersionName: 'API 版本',
apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview',
apiVersionPlaceholder: '输入 API 版本',
contextualSettingsName: '提供商特定必填设置',
testConnectionName: '测试 {provider} 连接',
testConnectionDesc: '验证 API Key、endpoint 和模型是否可访问。',
testConnectionButton: '测试连接',

View file

@ -208,6 +208,20 @@ export const STRINGS_ZH_TW: DeepPartial<NotemdEnglishStrings> = {
modelName: '模型',
modelDesc: '{provider} 使用的模型名稱。',
modelPlaceholder: '輸入模型名稱',
advancedSettingsName: '顯示進階設定',
advancedSettingsDesc: '展開 {provider} 的調校項與提供者特定覆寫項。',
advancedSettingsHint: '由於目前提供者已儲存非預設的進階值,因此預設展開進階設定。',
fetchModelsName: '取得模型清單',
fetchModelsDesc: '向提供者 endpoint 查詢可用模型 ID。仍然支援手動輸入模型。',
fetchModelsButton: '取得模型',
fetchModelsLoading: '取得中...',
fetchModelsSuccess: '已為 {provider} 取得 {count} 個模型。',
fetchModelsEmpty: '{provider} 沒有回傳模型清單。你仍然可以手動輸入模型。',
fetchModelsUnavailable: '{provider} 在目前第一批實作中不提供受支援的模型清單 endpoint。請手動輸入模型。',
fetchModelsError: '從 {provider} 取得模型清單失敗:{message}',
discoveredModelsName: '已發現的模型',
discoveredModelsDesc: '選擇一個已發現模型填入模型欄位,或繼續手動輸入。',
discoveredModelsUseButton: '使用',
temperatureName: '溫度',
temperatureDesc: '控制隨機性0=決定性1=更具創造性)。',
maxOutputTokensName: '提供者輸出 Token 覆蓋上限',
@ -223,6 +237,7 @@ export const STRINGS_ZH_TW: DeepPartial<NotemdEnglishStrings> = {
apiVersionName: 'API 版本',
apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview',
apiVersionPlaceholder: '輸入 API 版本',
contextualSettingsName: '提供者特定必填設定',
testConnectionName: '測試 {provider} 連線',
testConnectionDesc: '驗證 API Key、endpoint 與模型是否可存取。',
testConnectionButton: '測試連線',

View file

@ -5,6 +5,27 @@ export type LLMProviderTransport = 'openai-compatible' | 'anthropic' | 'google'
export type LLMProviderApiKeyMode = 'required' | 'optional' | 'none';
export type LLMProviderApiTestMode = 'models-then-chat' | 'chat-only';
export type LLMProviderValidationLevel = 'warning' | 'error';
export type LLMProviderSettingFieldId =
| 'apiKey'
| 'baseUrl'
| 'model'
| 'temperature'
| 'topP'
| 'reasoningEffort'
| 'thinkingEnabled'
| 'apiVersion'
| 'maxOutputTokens';
export type LLMProviderSettingFieldGroup = 'core' | 'contextual' | 'advanced' | 'developer';
export type LLMProviderModelDiscoveryMode = 'none' | 'openai-compatible-models' | 'ollama-tags' | 'google-models';
export interface LLMProviderSettingFieldDefinition {
id: LLMProviderSettingFieldId;
group: LLMProviderSettingFieldGroup;
}
export interface LLMProviderModelDiscoveryDefinition {
mode: LLMProviderModelDiscoveryMode;
}
export interface LLMProviderDefinition {
name: string;
@ -15,6 +36,8 @@ export interface LLMProviderDefinition {
description: string;
setupHint: string;
defaultConfig: LLMProviderConfig;
settingFields: LLMProviderSettingFieldDefinition[];
modelDiscovery: LLMProviderModelDiscoveryDefinition;
}
export interface LLMProviderValidationIssue {
@ -23,6 +46,23 @@ export interface LLMProviderValidationIssue {
}
const DEEPSEEK_RECOMMENDED_THINKING_OUTPUT_TOKENS = 8000;
const OPENAI_COMPATIBLE_REASONING_PROVIDER_NAMES = new Set([
'DeepSeek',
'OpenAI',
'OpenAI Compatible',
'Azure OpenAI'
]);
const OPENAI_COMPATIBLE_MODEL_DISCOVERY_PROVIDER_NAMES = new Set([
'DeepSeek',
'OpenAI',
'Mistral',
'OpenRouter',
'xAI',
'Requesty',
'OpenAI Compatible'
]);
type ProviderDefinitionInput = Omit<LLMProviderDefinition, 'settingFields' | 'modelDiscovery'>;
const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial<Record<string, Record<string, number>>> = {
'DeepSeek': {
@ -273,7 +313,70 @@ const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial<Record<string, Record<string, numbe
}
};
export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = [
function supportsReasoningEffortSetting(definition: ProviderDefinitionInput): boolean {
return OPENAI_COMPATIBLE_REASONING_PROVIDER_NAMES.has(definition.name);
}
function resolveModelDiscoveryMode(definition: ProviderDefinitionInput): LLMProviderModelDiscoveryDefinition {
if (definition.transport === 'ollama') {
return { mode: 'ollama-tags' };
}
if (definition.transport === 'google') {
return { mode: 'google-models' };
}
if (definition.transport === 'openai-compatible' && OPENAI_COMPATIBLE_MODEL_DISCOVERY_PROVIDER_NAMES.has(definition.name)) {
return { mode: 'openai-compatible-models' };
}
return { mode: 'none' };
}
function buildProviderSettingFields(definition: ProviderDefinitionInput): LLMProviderSettingFieldDefinition[] {
const fields: LLMProviderSettingFieldDefinition[] = [];
if (definition.apiKeyMode !== 'none') {
fields.push({ id: 'apiKey', group: 'core' });
}
fields.push(
{ id: 'baseUrl', group: 'core' },
{ id: 'model', group: 'core' }
);
if (definition.transport === 'azure-openai') {
fields.push({ id: 'apiVersion', group: 'contextual' });
}
fields.push({ id: 'temperature', group: 'advanced' });
if (definition.transport === 'openai-compatible') {
fields.push({ id: 'topP', group: 'advanced' });
}
if (supportsReasoningEffortSetting(definition)) {
fields.push({ id: 'reasoningEffort', group: 'advanced' });
}
if (definition.name === 'DeepSeek') {
fields.push({ id: 'thinkingEnabled', group: 'advanced' });
}
fields.push({ id: 'maxOutputTokens', group: 'developer' });
return fields;
}
function createProviderDefinition(definition: ProviderDefinitionInput): LLMProviderDefinition {
return {
...definition,
settingFields: buildProviderSettingFields(definition),
modelDiscovery: resolveModelDiscoveryMode(definition)
};
}
const RAW_LLM_PROVIDER_DEFINITIONS: ProviderDefinitionInput[] = [
{
name: 'DeepSeek',
category: 'cloud',
@ -693,6 +796,8 @@ export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = [
}
];
export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = RAW_LLM_PROVIDER_DEFINITIONS.map(createProviderDefinition);
const PROVIDER_INDEX = new Map(LLM_PROVIDER_DEFINITIONS.map((definition, index) => [definition.name, index]));
const PROVIDER_MAP = new Map(LLM_PROVIDER_DEFINITIONS.map(definition => [definition.name, definition]));
@ -725,6 +830,92 @@ export function isOpenAICompatibleProvider(name: string): boolean {
return getLLMProviderDefinition(name)?.transport === 'openai-compatible';
}
export function getProviderSettingFields(name: string): LLMProviderSettingFieldDefinition[] {
return getLLMProviderDefinition(name)?.settingFields ?? [];
}
export function getProviderModelDiscoveryDefinition(name: string): LLMProviderModelDiscoveryDefinition {
return getLLMProviderDefinition(name)?.modelDiscovery ?? { mode: 'none' };
}
function getProviderFieldValue(provider: LLMProviderConfig, fieldId: LLMProviderSettingFieldId): unknown {
switch (fieldId) {
case 'apiKey':
return provider.apiKey;
case 'baseUrl':
return provider.baseUrl;
case 'model':
return provider.model;
case 'temperature':
return provider.temperature;
case 'topP':
return provider.topP;
case 'reasoningEffort':
return provider.reasoningEffort;
case 'thinkingEnabled':
return provider.thinkingEnabled;
case 'apiVersion':
return provider.apiVersion;
case 'maxOutputTokens':
return provider.maxOutputTokens;
default:
return undefined;
}
}
function hasExplicitProviderFieldValue(
provider: LLMProviderConfig,
definition: LLMProviderDefinition,
field: LLMProviderSettingFieldDefinition
): boolean {
const currentValue = getProviderFieldValue(provider, field.id);
const defaultValue = getProviderFieldValue(definition.defaultConfig, field.id);
switch (field.id) {
case 'temperature':
return Number.isFinite(Number(currentValue)) && Number(currentValue) !== Number(defaultValue);
case 'topP':
return Number.isFinite(Number(currentValue));
case 'reasoningEffort':
return typeof currentValue === 'string' && currentValue.trim() !== '' && currentValue.trim().toLowerCase() !== 'none';
case 'thinkingEnabled':
return currentValue === true;
case 'maxOutputTokens':
return Number.isFinite(Number(currentValue)) && Number(currentValue) > 0;
default:
return currentValue !== undefined && currentValue !== null && currentValue !== defaultValue;
}
}
export function hasPersistedAdvancedProviderSettings(provider: LLMProviderConfig): boolean {
const definition = getLLMProviderDefinition(provider.name);
if (!definition) {
return false;
}
return definition.settingFields.some(field =>
(field.group === 'advanced' || field.group === 'developer')
&& hasExplicitProviderFieldValue(provider, definition, field)
);
}
export function shouldShowProviderSettingField(
provider: LLMProviderConfig,
field: LLMProviderSettingFieldDefinition,
options?: { developerMode?: boolean }
): boolean {
if (field.group !== 'developer') {
return true;
}
const definition = getLLMProviderDefinition(provider.name);
if (!definition) {
return options?.developerMode === true;
}
return options?.developerMode === true || hasExplicitProviderFieldValue(provider, definition, field);
}
function looksLikeDoubaoEndpointId(model: string): boolean {
return /^ep-[a-z0-9-]{8,}$/i.test(model.trim());
}

View file

@ -0,0 +1,161 @@
import { requestUrl } from 'obsidian';
import { LLMProviderConfig } from './types';
import {
getProviderModelDiscoveryDefinition
} from './llmProviders';
type ProviderModelDiscoveryResult = {
models: string[];
source: 'remote' | 'none';
};
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
function normalizeEndpointBaseUrl(baseUrl: string, suffixes: string[]): string {
let normalized = trimTrailingSlashes(baseUrl.trim());
if (!normalized) {
return normalized;
}
for (const suffix of suffixes) {
if (normalized.endsWith(suffix)) {
normalized = normalized.slice(0, -suffix.length);
break;
}
}
return trimTrailingSlashes(normalized);
}
function requireBaseUrl(provider: LLMProviderConfig): string {
const normalized = provider.baseUrl.trim();
if (!normalized) {
throw new Error(`${provider.name} model discovery requires a Base URL / endpoint.`);
}
return normalized;
}
function normalizeOpenAICompatibleBaseUrl(baseUrl: string): string {
return normalizeEndpointBaseUrl(baseUrl, ['/chat/completions', '/models']);
}
function normalizeOllamaBaseUrl(baseUrl: string): string {
return normalizeEndpointBaseUrl(baseUrl, ['/tags']);
}
function normalizeGoogleBaseUrl(baseUrl: string): string {
return normalizeEndpointBaseUrl(baseUrl, ['/models']);
}
function buildOpenAICompatibleHeaders(provider: LLMProviderConfig): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (provider.apiKey || provider.name === 'LMStudio') {
headers['Authorization'] = `Bearer ${provider.apiKey || 'EMPTY'}`;
}
if (provider.name === 'OpenRouter' || provider.name === 'Requesty') {
headers['HTTP-Referer'] = 'https://github.com/Jacobinwwey/obsidian-NotEMD';
headers['X-Title'] = 'Notemd Obsidian Plugin';
}
return headers;
}
function normalizeModelList(values: Iterable<string>): string[] {
const seen = new Set<string>();
const models: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
models.push(trimmed);
}
return models.sort((left, right) => left.localeCompare(right));
}
async function fetchJson(url: string, options?: { headers?: Record<string, string> }): Promise<any> {
const response = await requestUrl({
url,
method: 'GET',
headers: options?.headers ?? {},
throw: false
});
if (response.status < 200 || response.status >= 300) {
throw new Error(`Model discovery request failed: ${response.status} ${response.text || ''}`.trim());
}
return response.json;
}
async function discoverOpenAICompatibleModels(provider: LLMProviderConfig): Promise<string[]> {
const baseUrl = normalizeOpenAICompatibleBaseUrl(requireBaseUrl(provider));
const data = await fetchJson(`${baseUrl}/models`, {
headers: buildOpenAICompatibleHeaders(provider)
});
const modelIds = Array.isArray(data?.data)
? data.data
.map((entry: any) => typeof entry?.id === 'string' ? entry.id : '')
: [];
return normalizeModelList(modelIds);
}
async function discoverOllamaModels(provider: LLMProviderConfig): Promise<string[]> {
const baseUrl = normalizeOllamaBaseUrl(requireBaseUrl(provider));
const data = await fetchJson(`${baseUrl}/tags`);
const modelIds = Array.isArray(data?.models)
? data.models
.flatMap((entry: any) => [
typeof entry?.model === 'string' ? entry.model : '',
typeof entry?.name === 'string' ? entry.name : ''
])
: [];
return normalizeModelList(modelIds);
}
async function discoverGoogleModels(provider: LLMProviderConfig): Promise<string[]> {
const baseUrl = normalizeGoogleBaseUrl(requireBaseUrl(provider));
const apiKey = provider.apiKey.trim();
if (!apiKey) {
throw new Error(`${provider.name} model discovery requires an API key.`);
}
const data = await fetchJson(`${baseUrl}/models?key=${encodeURIComponent(apiKey)}`);
const modelIds = Array.isArray(data?.models)
? data.models
.map((entry: any) => typeof entry?.name === 'string' ? entry.name.replace(/^models\//, '') : '')
: [];
return normalizeModelList(modelIds);
}
export async function discoverProviderModels(provider: LLMProviderConfig): Promise<ProviderModelDiscoveryResult> {
const definition = getProviderModelDiscoveryDefinition(provider.name);
switch (definition.mode) {
case 'openai-compatible-models':
return { models: await discoverOpenAICompatibleModels(provider), source: 'remote' };
case 'ollama-tags':
return { models: await discoverOllamaModels(provider), source: 'remote' };
case 'google-models':
return { models: await discoverGoogleModels(provider), source: 'remote' };
case 'none':
default:
return { models: [], source: 'none' };
}
}

View file

@ -1,8 +1,12 @@
import {
createDefaultProviders,
getProviderModelDiscoveryDefinition,
getProviderSettingFields,
getKnownModelMaxOutputTokens,
getLLMProviderDefinition,
getOrderedProviderNames,
hasPersistedAdvancedProviderSettings,
shouldShowProviderSettingField,
isOpenAICompatibleProvider
} from '../llmProviders';
@ -158,4 +162,94 @@ describe('llmProviders registry', () => {
expect(getKnownModelMaxOutputTokens('OpenAI', 'unknown-model')).toBeUndefined();
expect(getKnownModelMaxOutputTokens('Unknown Provider', 'gpt-4o')).toBeUndefined();
});
test('provider metadata exposes field taxonomy and first-batch discovery capabilities', () => {
expect(getProviderSettingFields('OpenAI')).toEqual(expect.arrayContaining([
{ id: 'apiKey', group: 'core' },
{ id: 'baseUrl', group: 'core' },
{ id: 'model', group: 'core' },
{ id: 'temperature', group: 'advanced' },
{ id: 'topP', group: 'advanced' },
{ id: 'reasoningEffort', group: 'advanced' },
{ id: 'maxOutputTokens', group: 'developer' }
]));
expect(getProviderSettingFields('Azure OpenAI')).toEqual(expect.arrayContaining([
{ id: 'apiVersion', group: 'contextual' }
]));
expect(getProviderSettingFields('DeepSeek')).toEqual(expect.arrayContaining([
{ id: 'thinkingEnabled', group: 'advanced' }
]));
expect(getProviderModelDiscoveryDefinition('OpenAI')).toEqual({ mode: 'openai-compatible-models' });
expect(getProviderModelDiscoveryDefinition('Ollama')).toEqual({ mode: 'ollama-tags' });
expect(getProviderModelDiscoveryDefinition('Google')).toEqual({ mode: 'google-models' });
expect(getProviderModelDiscoveryDefinition('Azure OpenAI')).toEqual({ mode: 'none' });
});
test('advanced visibility detection only expands when persisted advanced values are present', () => {
expect(hasPersistedAdvancedProviderSettings({
name: 'OpenAI',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5
})).toBe(false);
expect(hasPersistedAdvancedProviderSettings({
name: 'OpenAI',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5,
topP: 0.7
})).toBe(true);
expect(hasPersistedAdvancedProviderSettings({
name: 'Azure OpenAI',
apiKey: '',
baseUrl: 'https://example.azure.com',
model: 'gpt-4o',
temperature: 0.5,
apiVersion: '2025-01-01-preview'
})).toBe(false);
expect(hasPersistedAdvancedProviderSettings({
name: 'DeepSeek',
apiKey: '',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-v4-pro',
temperature: 0.5,
thinkingEnabled: true
})).toBe(true);
});
test('developer-only provider fields stay hidden unless developer mode is on or a persisted override exists', () => {
const developerField = getProviderSettingFields('OpenAI').find(field => field.id === 'maxOutputTokens');
expect(developerField).toBeDefined();
expect(shouldShowProviderSettingField({
name: 'OpenAI',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5
}, developerField!, { developerMode: false })).toBe(false);
expect(shouldShowProviderSettingField({
name: 'OpenAI',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5
}, developerField!, { developerMode: true })).toBe(true);
expect(shouldShowProviderSettingField({
name: 'OpenAI',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5,
maxOutputTokens: 4096
}, developerField!, { developerMode: false })).toBe(true);
});
});

View file

@ -11,4 +11,14 @@ describe('settings provider connection reuse', () => {
expect(source).not.toContain("import { testAPI } from '../llmUtils'");
expect(source).toContain('hasBlockingProviderValidationIssues');
});
test('renders provider settings through shared metadata/discovery helpers instead of provider-name-only field branching', () => {
const source = fs.readFileSync(settingsTabPath, 'utf8');
expect(source).toContain('getProviderSettingFields');
expect(source).toContain('hasPersistedAdvancedProviderSettings');
expect(source).toContain('discoverProviderModels');
expect(source).toContain('advancedSettingsName');
expect(source).toContain('fetchModelsName');
});
});

View file

@ -0,0 +1,230 @@
import { requestUrl } from 'obsidian';
import { discoverProviderModels } from '../providerModelDiscovery';
jest.mock('obsidian');
describe('provider model discovery', () => {
const mockedRequestUrl = requestUrl as jest.MockedFunction<typeof requestUrl>;
beforeEach(() => {
mockedRequestUrl.mockReset();
});
test('discovers models from OpenAI-compatible providers via /models', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: {
data: [
{ id: 'gpt-4o' },
{ id: 'gpt-4.1' },
{ id: 'gpt-4o' }
]
}
} as any);
const result = await discoverProviderModels({
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
temperature: 0.5
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'https://api.openai.com/v1/models',
method: 'GET'
}));
expect(result).toEqual({
models: ['gpt-4.1', 'gpt-4o'],
source: 'remote'
});
});
test('normalizes OpenAI-compatible chat/completions endpoints before fetching models', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: { data: [{ id: 'gpt-4.1' }] }
} as any);
await discoverProviderModels({
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4.1',
temperature: 0.5
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'https://api.openai.com/v1/models'
}));
});
test('sends gateway headers for Requesty/OpenRouter-compatible discovery calls', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: { data: [{ id: 'claude-3.7-sonnet' }] }
} as any);
await discoverProviderModels({
name: 'Requesty',
apiKey: 'rq-test',
baseUrl: 'https://router.requesty.ai/v1',
model: 'claude-3.7-sonnet',
temperature: 0.5
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer rq-test',
'HTTP-Referer': 'https://github.com/Jacobinwwey/obsidian-NotEMD',
'X-Title': 'Notemd Obsidian Plugin'
})
}));
});
test('discovers models from Ollama providers via /tags', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: {
models: [
{ model: 'llama3.1:latest', name: 'llama3.1:latest' },
{ name: 'qwen2.5:14b' }
]
}
} as any);
const result = await discoverProviderModels({
name: 'Ollama',
apiKey: '',
baseUrl: 'http://localhost:11434/api',
model: 'llama3',
temperature: 0.7
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'http://localhost:11434/api/tags'
}));
expect(result.models).toEqual(['llama3.1:latest', 'qwen2.5:14b']);
});
test('normalizes Ollama /tags endpoints before fetching model tags', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: { models: [{ model: 'llama3.1:latest' }] }
} as any);
await discoverProviderModels({
name: 'Ollama',
apiKey: '',
baseUrl: 'http://localhost:11434/api/tags',
model: 'llama3',
temperature: 0.7
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'http://localhost:11434/api/tags'
}));
});
test('discovers models from Google providers via v1 models list', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: {
models: [
{ name: 'models/gemini-2.5-pro' },
{ name: 'models/gemini-2.5-flash' }
]
}
} as any);
const result = await discoverProviderModels({
name: 'Google',
apiKey: 'google-key',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
model: 'gemini-2.5-pro',
temperature: 0.5
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'https://generativelanguage.googleapis.com/v1beta/models?key=google-key'
}));
expect(result.models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
});
test('normalizes Google /models endpoints before fetching model list', async () => {
mockedRequestUrl.mockResolvedValue({
status: 200,
text: '',
json: { models: [{ name: 'models/gemini-2.5-pro' }] }
} as any);
await discoverProviderModels({
name: 'Google',
apiKey: 'google-key',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models',
model: 'gemini-2.5-pro',
temperature: 0.5
});
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'https://generativelanguage.googleapis.com/v1beta/models?key=google-key'
}));
});
test('fails with a clear error when required discovery prerequisites are missing', async () => {
await expect(discoverProviderModels({
name: 'Google',
apiKey: '',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
model: 'gemini-2.5-pro',
temperature: 0.5
})).rejects.toThrow('Google model discovery requires an API key.');
await expect(discoverProviderModels({
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: '',
model: 'gpt-4.1',
temperature: 0.5
})).rejects.toThrow('OpenAI model discovery requires a Base URL / endpoint.');
});
test('surfaces non-success model-discovery responses as actionable errors', async () => {
mockedRequestUrl.mockResolvedValue({
status: 401,
text: 'Unauthorized',
json: {}
} as any);
await expect(discoverProviderModels({
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
temperature: 0.5
})).rejects.toThrow('Model discovery request failed: 401 Unauthorized');
});
test('returns an empty none-source result for unsupported providers', async () => {
const result = await discoverProviderModels({
name: 'Azure OpenAI',
apiKey: 'azure-key',
baseUrl: 'https://example.openai.azure.com',
model: 'deployment-name',
temperature: 0.5,
apiVersion: '2025-01-01-preview'
});
expect(mockedRequestUrl).not.toHaveBeenCalled();
expect(result).toEqual({
models: [],
source: 'none'
});
});
});

View file

@ -0,0 +1,15 @@
import * as fs from 'fs';
import * as path from 'path';
const stylesPath = path.join(__dirname, '..', '..', 'styles.css');
describe('provider settings styles', () => {
test('styles ship dedicated selectors for advanced provider settings and discovered model rows', () => {
const styles = fs.readFileSync(stylesPath, 'utf8');
expect(styles).toContain('.notemd-provider-advanced-settings');
expect(styles).toContain('.notemd-provider-model-list');
expect(styles).toContain('.notemd-provider-model-item');
expect(styles).toContain('.notemd-provider-model-item code');
});
});

View file

@ -37,6 +37,20 @@ describe('supported UI locale provider config coverage', () => {
{ label: 'settings.providerConfig.modelName', get: (strings: typeof en) => strings.settings.providerConfig.modelName },
{ label: 'settings.providerConfig.modelDesc', get: (strings: typeof en) => strings.settings.providerConfig.modelDesc },
{ label: 'settings.providerConfig.modelPlaceholder', get: (strings: typeof en) => strings.settings.providerConfig.modelPlaceholder },
{ label: 'settings.providerConfig.advancedSettingsName', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsName },
{ label: 'settings.providerConfig.advancedSettingsDesc', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsDesc },
{ label: 'settings.providerConfig.advancedSettingsHint', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsHint },
{ label: 'settings.providerConfig.fetchModelsName', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsName },
{ label: 'settings.providerConfig.fetchModelsDesc', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsDesc },
{ label: 'settings.providerConfig.fetchModelsButton', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsButton },
{ label: 'settings.providerConfig.fetchModelsLoading', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsLoading },
{ label: 'settings.providerConfig.fetchModelsSuccess', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsSuccess },
{ label: 'settings.providerConfig.fetchModelsEmpty', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsEmpty },
{ label: 'settings.providerConfig.fetchModelsUnavailable', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsUnavailable },
{ label: 'settings.providerConfig.fetchModelsError', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsError },
{ label: 'settings.providerConfig.discoveredModelsName', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsName },
{ label: 'settings.providerConfig.discoveredModelsDesc', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsDesc },
{ label: 'settings.providerConfig.discoveredModelsUseButton', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsUseButton },
{ label: 'settings.providerConfig.temperatureName', get: (strings: typeof en) => strings.settings.providerConfig.temperatureName },
{ label: 'settings.providerConfig.temperatureDesc', get: (strings: typeof en) => strings.settings.providerConfig.temperatureDesc },
{ label: 'settings.providerConfig.maxOutputTokensDesc', get: (strings: typeof en) => strings.settings.providerConfig.maxOutputTokensDesc },
@ -51,6 +65,7 @@ describe('supported UI locale provider config coverage', () => {
{ label: 'settings.providerConfig.apiVersionName', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionName },
{ label: 'settings.providerConfig.apiVersionDesc', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionDesc },
{ label: 'settings.providerConfig.apiVersionPlaceholder', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionPlaceholder },
{ label: 'settings.providerConfig.contextualSettingsName', get: (strings: typeof en) => strings.settings.providerConfig.contextualSettingsName },
{ label: 'settings.providerConfig.testConnectionName', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionName },
{ label: 'settings.providerConfig.testConnectionDesc', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionDesc },
{ label: 'settings.providerConfig.testConnectionButton', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionButton },

View file

@ -4,9 +4,14 @@ import { FolderTaskFileSelectionProfile, LLMProviderConfig, NotemdSettings, Task
import { DEFAULT_SETTINGS } from '../constants';
import {
getLLMProviderDefinition,
getProviderModelDiscoveryDefinition,
getProviderSettingFields,
getOrderedProviderNames,
getProviderValidationIssues,
hasBlockingProviderValidationIssues
hasBlockingProviderValidationIssues,
hasPersistedAdvancedProviderSettings,
LLMProviderSettingFieldDefinition,
shouldShowProviderSettingField
} from '../llmProviders';
import { getDefaultPrompt } from '../promptUtils'; // Import for default prompts
import {
@ -28,14 +33,22 @@ import { SUPPORTED_UI_LOCALES } from '../i18n/uiLocales';
import { formatI18n, getI18nStrings } from '../i18n';
import { runProviderConnectionTestWithHost } from '../operations/providerConnectionTestCommandHostAdapter';
import { getFolderTaskFileSelectionProfiles, getFolderTaskRegexValidationError } from '../folderTaskFileSelector';
import { discoverProviderModels } from '../providerModelDiscovery';
// Define specific key types for settings accessed dynamically
type ProviderSettingKey = 'addLinksProvider' | 'researchProvider' | 'generateTitleProvider' | 'translateProvider';
type ModelSettingKey = 'addLinksModel' | 'researchModel' | 'generateTitleModel' | 'translateModel';
type ProviderPanelState = {
discoveredModels: string[];
fetchStatus: 'idle' | 'loading' | 'success' | 'error';
fetchMessage?: string;
};
export class NotemdSettingTab extends PluginSettingTab {
plugin: NotemdPlugin;
private providerPanelState = new Map<string, ProviderPanelState>();
constructor(app: App, plugin: NotemdPlugin) {
super(app, plugin);
@ -129,13 +142,13 @@ export class NotemdSettingTab extends PluginSettingTab {
setting: Setting,
options: {
placeholder?: string;
value: number;
value: number | string;
onCommit: (rawValue: string) => Promise<string | void>;
}
): void {
this.addDeferredTextSetting(setting, {
placeholder: options.placeholder,
value: String(options.value),
value: typeof options.value === 'string' ? options.value : String(options.value),
onCommit: async (rawValue) => {
const normalized = await options.onCommit(rawValue);
if (typeof normalized === 'string') {
@ -247,6 +260,300 @@ export class NotemdSettingTab extends PluginSettingTab {
};
}
private getProviderPanelState(providerName: string): ProviderPanelState {
let state = this.providerPanelState.get(providerName);
if (!state) {
state = {
discoveredModels: [],
fetchStatus: 'idle'
};
this.providerPanelState.set(providerName, state);
}
return state;
}
private async updateProviderField(
provider: LLMProviderConfig,
update: (draft: LLMProviderConfig) => void
): Promise<void> {
update(provider);
await this.plugin.saveSettings();
}
private addProviderTextField(
setting: Setting,
provider: LLMProviderConfig,
read: () => string,
placeholder: string,
onCommit: (value: string) => Promise<void>
): void {
this.addDeferredTextSetting(setting, {
placeholder,
value: read(),
onCommit
});
}
private addProviderNumberField(
setting: Setting,
provider: LLMProviderConfig,
read: () => string,
placeholder: string,
onCommit: (value: string) => Promise<string | void>
): void {
this.addDeferredNumberSetting(setting, {
placeholder,
value: read(),
onCommit
});
}
private renderProviderModelDiscovery(containerEl: HTMLElement, provider: LLMProviderConfig): void {
const providerI18n = getI18nStrings({ uiLocale: this.plugin.settings.uiLocale }).settings.providerConfig;
const discovery = getProviderModelDiscoveryDefinition(provider.name);
const panelState = this.getProviderPanelState(provider.name);
new Setting(containerEl)
.setName(providerI18n.fetchModelsName)
.setDesc(
discovery.mode === 'none'
? formatI18n(providerI18n.fetchModelsUnavailable, { provider: provider.name })
: providerI18n.fetchModelsDesc
)
.addButton(button => {
button
.setButtonText(panelState.fetchStatus === 'loading' ? providerI18n.fetchModelsLoading : providerI18n.fetchModelsButton)
.setDisabled(discovery.mode === 'none' || panelState.fetchStatus === 'loading')
.onClick(async () => {
panelState.fetchStatus = 'loading';
panelState.fetchMessage = undefined;
this.display();
try {
const result = await discoverProviderModels(provider);
panelState.discoveredModels = result.models;
panelState.fetchStatus = 'success';
panelState.fetchMessage = result.models.length > 0
? formatI18n(providerI18n.fetchModelsSuccess, {
provider: provider.name,
count: result.models.length
})
: formatI18n(providerI18n.fetchModelsEmpty, { provider: provider.name });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
panelState.fetchStatus = 'error';
panelState.fetchMessage = formatI18n(providerI18n.fetchModelsError, {
provider: provider.name,
message
});
}
this.display();
});
});
if (panelState.fetchMessage) {
const callout = containerEl.createDiv({
cls: `notemd-provider-validation ${panelState.fetchStatus === 'error' ? 'notemd-provider-validation-error' : 'notemd-provider-validation-warning'}`
});
callout.createEl('strong', {
text: panelState.fetchStatus === 'error'
? providerI18n.validationWarning
: providerI18n.discoveredModelsName
});
callout.createEl('p', { text: panelState.fetchMessage });
}
if (panelState.discoveredModels.length > 0) {
const listWrapper = containerEl.createDiv({ cls: 'notemd-provider-callout' });
listWrapper.createEl('strong', { text: providerI18n.discoveredModelsName });
listWrapper.createEl('p', { text: providerI18n.discoveredModelsDesc });
const list = listWrapper.createDiv({ cls: 'notemd-provider-model-list' });
panelState.discoveredModels.forEach(modelId => {
const row = list.createDiv({ cls: 'notemd-provider-model-item' });
row.createEl('code', { text: modelId });
const useButton = new ButtonComponent(row);
useButton
.setButtonText(providerI18n.discoveredModelsUseButton)
.onClick(async () => {
provider.model = modelId;
await this.plugin.saveSettings();
this.display();
});
});
}
}
private renderProviderField(
containerEl: HTMLElement,
provider: LLMProviderConfig,
field: LLMProviderSettingFieldDefinition
): void {
const providerI18n = getI18nStrings({ uiLocale: this.plugin.settings.uiLocale }).settings.providerConfig;
const defaultProvider = DEFAULT_SETTINGS.providers.find(p => p.name === provider.name);
switch (field.id) {
case 'apiKey': {
const definition = getLLMProviderDefinition(provider.name);
const apiKeyMode = definition?.apiKeyMode ?? 'required';
const apiKeyDescription = apiKeyMode === 'optional'
? formatI18n(providerI18n.apiKeyDescOptional, { provider: provider.name })
: formatI18n(providerI18n.apiKeyDescRequired, {
provider: provider.name,
extra: provider.name === 'LMStudio' ? providerI18n.apiKeyExtraLmStudio : ''
});
const setting = new Setting(containerEl)
.setName(providerI18n.apiKeyName)
.setDesc(apiKeyDescription);
this.addProviderTextField(
setting,
provider,
() => provider.apiKey,
provider.name === 'LMStudio' ? providerI18n.apiKeyPlaceholderLmStudio : providerI18n.apiKeyPlaceholderDefault,
async value => this.updateProviderField(provider, draft => { draft.apiKey = value; })
);
break;
}
case 'baseUrl': {
const setting = new Setting(containerEl)
.setName(providerI18n.baseUrlName)
.setDesc(formatI18n(providerI18n.baseUrlDesc, {
provider: provider.name,
required: provider.name === 'Azure OpenAI' ? providerI18n.baseUrlRequired : ''
}));
this.addProviderTextField(
setting,
provider,
() => provider.baseUrl,
defaultProvider?.baseUrl || providerI18n.baseUrlPlaceholder,
async value => this.updateProviderField(provider, draft => { draft.baseUrl = value.trim(); })
);
break;
}
case 'model': {
const setting = new Setting(containerEl)
.setName(providerI18n.modelName)
.setDesc(formatI18n(providerI18n.modelDesc, { provider: provider.name }));
this.addProviderTextField(
setting,
provider,
() => provider.model,
defaultProvider?.model || providerI18n.modelPlaceholder,
async value => this.updateProviderField(provider, draft => { draft.model = value.trim(); })
);
break;
}
case 'temperature': {
const setting = new Setting(containerEl)
.setName(providerI18n.temperatureName)
.setDesc(providerI18n.temperatureDesc);
setting.addSlider(slider => slider
.setLimits(0, 1, 0.1)
.setValue(provider.temperature)
.onChange(async value => {
provider.temperature = value;
await this.plugin.saveSettings();
})
.setDynamicTooltip());
break;
}
case 'topP': {
const setting = new Setting(containerEl)
.setName(providerI18n.topPName)
.setDesc(providerI18n.topPDesc);
this.addProviderNumberField(
setting,
provider,
() => provider.topP !== undefined ? String(provider.topP) : '',
providerI18n.topPPlaceholder,
async value => {
const parsed = Number.parseFloat(value.trim());
await this.updateProviderField(provider, draft => {
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
draft.topP = parsed;
} else {
delete draft.topP;
}
});
return provider.topP !== undefined ? String(provider.topP) : '';
}
);
break;
}
case 'reasoningEffort': {
const setting = new Setting(containerEl)
.setName(providerI18n.reasoningEffortName)
.setDesc(providerI18n.reasoningEffortDesc);
this.addProviderTextField(
setting,
provider,
() => provider.reasoningEffort || '',
providerI18n.reasoningEffortPlaceholder,
async value => {
const normalized = value.trim().toLowerCase();
await this.updateProviderField(provider, draft => {
if (['none', 'low', 'medium', 'high'].includes(normalized)) {
draft.reasoningEffort = normalized;
} else {
delete draft.reasoningEffort;
}
});
}
);
break;
}
case 'thinkingEnabled': {
new Setting(containerEl)
.setName(providerI18n.thinkingEnabledName)
.setDesc(providerI18n.thinkingEnabledDesc)
.addToggle(toggle => toggle
.setValue(provider.thinkingEnabled === true)
.onChange(async value => {
provider.thinkingEnabled = value;
await this.plugin.saveSettings();
}));
break;
}
case 'apiVersion': {
const setting = new Setting(containerEl)
.setName(providerI18n.apiVersionName)
.setDesc(providerI18n.apiVersionDesc);
this.addProviderTextField(
setting,
provider,
() => provider.apiVersion || '',
providerI18n.apiVersionPlaceholder,
async value => this.updateProviderField(provider, draft => { draft.apiVersion = value.trim(); })
);
break;
}
case 'maxOutputTokens': {
const setting = new Setting(containerEl)
.setName(providerI18n.maxOutputTokensName)
.setDesc(providerI18n.maxOutputTokensDesc);
this.addProviderNumberField(
setting,
provider,
() => provider.maxOutputTokens !== undefined ? String(provider.maxOutputTokens) : '',
String(this.plugin.settings.maxTokens),
async value => {
const parsed = Number.parseInt(value.trim(), 10);
await this.updateProviderField(provider, draft => {
if (Number.isFinite(parsed) && parsed > 0) {
draft.maxOutputTokens = parsed;
} else {
delete draft.maxOutputTokens;
}
});
return provider.maxOutputTokens !== undefined ? String(provider.maxOutputTokens) : '';
}
);
break;
}
}
}
private renderProviderSummary(containerEl: HTMLElement, provider: LLMProviderConfig): void {
const definition = getLLMProviderDefinition(provider.name);
if (!definition) {
@ -811,129 +1118,40 @@ export class NotemdSettingTab extends PluginSettingTab {
this.renderProviderSummary(containerEl, activeProvider);
this.renderProviderValidation(containerEl, activeProvider);
const providerDefinition = getLLMProviderDefinition(activeProvider.name);
const apiKeyMode = providerDefinition?.apiKeyMode ?? 'required';
const isOpenAICompatible = providerDefinition?.transport === 'openai-compatible';
const providerFields = getProviderSettingFields(activeProvider.name)
.filter(field => shouldShowProviderSettingField(activeProvider, field, {
developerMode: this.plugin.settings.enableDeveloperMode
}));
if (apiKeyMode !== 'none') {
const apiKeyDescription = apiKeyMode === 'optional'
? formatI18n(providerI18n.apiKeyDescOptional, { provider: activeProvider.name })
: formatI18n(providerI18n.apiKeyDescRequired, {
provider: activeProvider.name,
extra: activeProvider.name === 'LMStudio' ? providerI18n.apiKeyExtraLmStudio : ''
});
providerFields
.filter(field => field.group === 'core')
.forEach(field => this.renderProviderField(containerEl, activeProvider, field));
const hasContextualFields = providerFields.some(field => field.group === 'contextual');
if (hasContextualFields) {
new Setting(containerEl)
.setName(providerI18n.apiKeyName)
.setDesc(apiKeyDescription)
.addText(text => text
.setPlaceholder(activeProvider.name === 'LMStudio' ? providerI18n.apiKeyPlaceholderLmStudio : providerI18n.apiKeyPlaceholderDefault)
.setValue(activeProvider.apiKey)
.onChange(async (value) => { activeProvider.apiKey = value; await this.plugin.saveSettings(); }));
.setName(providerI18n.contextualSettingsName)
.setHeading();
providerFields
.filter(field => field.group === 'contextual')
.forEach(field => this.renderProviderField(containerEl, activeProvider, field));
}
new Setting(containerEl)
.setName(providerI18n.baseUrlName)
.setDesc(formatI18n(providerI18n.baseUrlDesc, {
provider: activeProvider.name,
required: activeProvider.name === 'Azure OpenAI' ? providerI18n.baseUrlRequired : ''
}))
.addText(text => text
.setPlaceholder(DEFAULT_SETTINGS.providers.find(p => p.name === activeProvider.name)?.baseUrl || providerI18n.baseUrlPlaceholder)
.setValue(activeProvider.baseUrl)
.onChange(async (value) => { activeProvider.baseUrl = value; await this.plugin.saveSettings(); }));
this.renderProviderModelDiscovery(containerEl, activeProvider);
new Setting(containerEl)
.setName(providerI18n.modelName)
.setDesc(formatI18n(providerI18n.modelDesc, { provider: activeProvider.name }))
.addText(text => text
.setPlaceholder(DEFAULT_SETTINGS.providers.find(p => p.name === activeProvider.name)?.model || providerI18n.modelPlaceholder)
.setValue(activeProvider.model)
.onChange(async (value) => { activeProvider.model = value; await this.plugin.saveSettings(); }));
new Setting(containerEl)
.setName(providerI18n.temperatureName)
.setDesc(providerI18n.temperatureDesc)
.addSlider(slider => slider
.setLimits(0, 1, 0.1)
.setValue(activeProvider.temperature)
.onChange(async (value) => { activeProvider.temperature = value; await this.plugin.saveSettings(); })
.setDynamicTooltip());
const showManualOutputTokenOverride =
this.plugin.settings.enableDeveloperMode || activeProvider.maxOutputTokens !== undefined;
if (showManualOutputTokenOverride) {
new Setting(containerEl)
.setName(providerI18n.maxOutputTokensName)
.setDesc(providerI18n.maxOutputTokensDesc)
.addText(text => text
.setPlaceholder(String(this.plugin.settings.maxTokens))
.setValue(activeProvider.maxOutputTokens !== undefined ? String(activeProvider.maxOutputTokens) : '')
.onChange(async (value) => {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
activeProvider.maxOutputTokens = parsed;
} else {
delete activeProvider.maxOutputTokens;
}
await this.plugin.saveSettings();
}));
}
if (isOpenAICompatible) {
new Setting(containerEl)
.setName(providerI18n.topPName)
.setDesc(providerI18n.topPDesc)
.addText(text => text
.setPlaceholder(providerI18n.topPPlaceholder)
.setValue(activeProvider.topP !== undefined ? String(activeProvider.topP) : '')
.onChange(async (value) => {
const parsed = Number.parseFloat(value.trim());
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
activeProvider.topP = parsed;
} else {
delete activeProvider.topP;
}
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName(providerI18n.reasoningEffortName)
.setDesc(providerI18n.reasoningEffortDesc)
.addText(text => text
.setPlaceholder(providerI18n.reasoningEffortPlaceholder)
.setValue(activeProvider.reasoningEffort || '')
.onChange(async (value) => {
const normalized = value.trim().toLowerCase();
if (['none', 'low', 'medium', 'high'].includes(normalized)) {
activeProvider.reasoningEffort = normalized;
} else {
delete activeProvider.reasoningEffort;
}
await this.plugin.saveSettings();
}));
}
if (activeProvider.name === 'DeepSeek') {
new Setting(containerEl)
.setName(providerI18n.thinkingEnabledName)
.setDesc(providerI18n.thinkingEnabledDesc)
.addToggle(toggle => toggle
.setValue(activeProvider.thinkingEnabled === true)
.onChange(async (value) => {
activeProvider.thinkingEnabled = value;
await this.plugin.saveSettings();
}));
}
if (activeProvider.name === 'Azure OpenAI') {
new Setting(containerEl)
.setName(providerI18n.apiVersionName)
.setDesc(providerI18n.apiVersionDesc)
.addText(text => text
.setPlaceholder(providerI18n.apiVersionPlaceholder)
.setValue(activeProvider.apiVersion || '')
.onChange(async (value) => { activeProvider.apiVersion = value; await this.plugin.saveSettings(); }));
const advancedFields = providerFields.filter(field => field.group === 'advanced' || field.group === 'developer');
if (advancedFields.length > 0) {
const advancedDetails = containerEl.createEl('details', { cls: 'notemd-section-card notemd-provider-advanced-settings' });
advancedDetails.open = hasPersistedAdvancedProviderSettings(activeProvider);
const summary = advancedDetails.createEl('summary', { cls: 'notemd-section-summary' });
summary.setText(providerI18n.advancedSettingsName);
advancedDetails.createEl('p', {
text: advancedDetails.open
? providerI18n.advancedSettingsHint
: formatI18n(providerI18n.advancedSettingsDesc, { provider: activeProvider.name }),
cls: 'notemd-section-description'
});
advancedFields.forEach(field => this.renderProviderField(advancedDetails, activeProvider, field));
}
new Setting(containerEl)

View file

@ -985,6 +985,44 @@
border-color: color-mix(in srgb, var(--notemd-border) 70%, #f97316 30%);
}
.notemd-provider-advanced-settings {
margin: 0 0 0.95em;
}
.notemd-provider-advanced-settings > .setting-item {
margin-top: 0;
}
.notemd-provider-model-list {
display: flex;
flex-direction: column;
gap: 0.55em;
margin-top: 0.75em;
}
.notemd-provider-model-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.6em 0.7em;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--notemd-border) 78%, #14b8a6 22%);
background: color-mix(in srgb, var(--background-primary) 90%, #f0fdfa 10%);
}
.notemd-provider-model-item code {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
white-space: break-spaces;
}
.notemd-provider-model-item button {
flex: 0 0 auto;
white-space: nowrap;
}
.notemd-language-select {
width: 100%;
min-height: 34px;