diff --git a/README.md b/README.md index f62b9a99..a0331889 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_zh.md b/README_zh.md index 90ceddb3..886f6864 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 会自动切入稳定重试序列,而不是立刻报错。 diff --git a/change.md b/change.md index c3477c5c..cc8d1daa 100644 --- a/change.md +++ b/change.md @@ -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. diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0a511d70..f2aa4fae 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/zh_cn.ts b/src/i18n/locales/zh_cn.ts index d8483414..a90446e6 100644 --- a/src/i18n/locales/zh_cn.ts +++ b/src/i18n/locales/zh_cn.ts @@ -208,6 +208,20 @@ export const STRINGS_ZH_CN: DeepPartial = { 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 = { apiVersionName: 'API 版本', apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview)', apiVersionPlaceholder: '输入 API 版本', + contextualSettingsName: '提供商特定必填设置', testConnectionName: '测试 {provider} 连接', testConnectionDesc: '验证 API Key、endpoint 和模型是否可访问。', testConnectionButton: '测试连接', diff --git a/src/i18n/locales/zh_tw.ts b/src/i18n/locales/zh_tw.ts index 89985c10..4cb8e5cd 100644 --- a/src/i18n/locales/zh_tw.ts +++ b/src/i18n/locales/zh_tw.ts @@ -208,6 +208,20 @@ export const STRINGS_ZH_TW: DeepPartial = { 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 = { apiVersionName: 'API 版本', apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview)', apiVersionPlaceholder: '輸入 API 版本', + contextualSettingsName: '提供者特定必填設定', testConnectionName: '測試 {provider} 連線', testConnectionDesc: '驗證 API Key、endpoint 與模型是否可存取。', testConnectionButton: '測試連線', diff --git a/src/llmProviders.ts b/src/llmProviders.ts index 8b5344ca..cf778454 100644 --- a/src/llmProviders.ts +++ b/src/llmProviders.ts @@ -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; const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial>> = { 'DeepSeek': { @@ -273,7 +313,70 @@ const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial [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()); } diff --git a/src/providerModelDiscovery.ts b/src/providerModelDiscovery.ts new file mode 100644 index 00000000..d3e15dbf --- /dev/null +++ b/src/providerModelDiscovery.ts @@ -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 { + const headers: Record = { + '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[] { + const seen = new Set(); + 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 }): Promise { + 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 { + 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 { + 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 { + 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 { + 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' }; + } +} diff --git a/src/tests/llmProviders.test.ts b/src/tests/llmProviders.test.ts index 109a5d20..9341e440 100644 --- a/src/tests/llmProviders.test.ts +++ b/src/tests/llmProviders.test.ts @@ -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); + }); }); diff --git a/src/tests/providerConnectionSettingsReuse.test.ts b/src/tests/providerConnectionSettingsReuse.test.ts index 7a3718f5..7c6d3c5d 100644 --- a/src/tests/providerConnectionSettingsReuse.test.ts +++ b/src/tests/providerConnectionSettingsReuse.test.ts @@ -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'); + }); }); diff --git a/src/tests/providerModelDiscovery.test.ts b/src/tests/providerModelDiscovery.test.ts new file mode 100644 index 00000000..8addd293 --- /dev/null +++ b/src/tests/providerModelDiscovery.test.ts @@ -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; + + 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' + }); + }); +}); diff --git a/src/tests/providerSettingsStyles.test.ts b/src/tests/providerSettingsStyles.test.ts new file mode 100644 index 00000000..6a15a9d3 --- /dev/null +++ b/src/tests/providerSettingsStyles.test.ts @@ -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'); + }); +}); diff --git a/src/tests/supportedUiLocaleProviderConfigCoverage.test.ts b/src/tests/supportedUiLocaleProviderConfigCoverage.test.ts index 7d9d302e..c800ddc8 100644 --- a/src/tests/supportedUiLocaleProviderConfigCoverage.test.ts +++ b/src/tests/supportedUiLocaleProviderConfigCoverage.test.ts @@ -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 }, diff --git a/src/ui/NotemdSettingTab.ts b/src/ui/NotemdSettingTab.ts index b0237717..d754323c 100644 --- a/src/ui/NotemdSettingTab.ts +++ b/src/ui/NotemdSettingTab.ts @@ -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(); 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; } ): 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 { + update(provider); + await this.plugin.saveSettings(); + } + + private addProviderTextField( + setting: Setting, + provider: LLMProviderConfig, + read: () => string, + placeholder: string, + onCommit: (value: string) => Promise + ): void { + this.addDeferredTextSetting(setting, { + placeholder, + value: read(), + onCommit + }); + } + + private addProviderNumberField( + setting: Setting, + provider: LLMProviderConfig, + read: () => string, + placeholder: string, + onCommit: (value: string) => Promise + ): 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) diff --git a/styles.css b/styles.css index 2e666748..d904fdf7 100644 --- a/styles.css +++ b/styles.css @@ -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;