From 89823e1d030d2354aa96fe953f908f497c71dd64 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Sun, 7 Jun 2026 05:52:50 +0800 Subject: [PATCH] feat(chat): power legacy chat from the model-management chat backend (#2556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): power legacy chat from the model-management "chat" backend Legacy chat, project chat, vault QA, quick command, and quick ask now select models from the model-management "chat" backend (backends.chat.enabledModels) instead of the legacy activeModels / defaultModelKey. A new ConfiguredModel→CustomModel bridge feeds the existing ChatModelManager (the stubbed v4 ChatModelFactory is untouched), and a new "Quick Chat" curation section under the Agents settings tab lets users choose which BYOK/Plus models appear in the chat picker. Co-Authored-By: Claude Opus 4.8 (1M context) * style(skills): apply prettier formatting to builtinSkills.test.ts Collapse a multi-line expect() onto one line to satisfy the project's prettier config (surfaced when running npm run format). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(chat): stabilize chat backend model selections * fix(chat): address chat backend review regressions * fix(chat): show legacy chat models in a flat list Drop the per-provider _group on chat picker entries so the legacy chat model picker renders a single flat list (provider icons inline, no section headers), matching pre-migration behavior. Agent Mode keeps its per-backend headers via its own helper. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): remove dead per-model parameter editing After the model-management chat migration, per-model parameter editing is unreachable: the chat selection is now a configuredModelId UUID, so the ChatSettingsPopover editor (gated on a legacy activeModels match) never renders, and TokenLimitWarning's activeModels lookup always misses and falls back to global settings. v4 agent mode no longer supports per-model parameter editing. - ChatSettingsPopover: drop the model-param plumbing (originalModel / bridgedModel / canEditModelParameters, local-model save/debounce, ModelParametersEditor); keep System Prompt + Disable Builtin controls. - TokenLimitWarning: always open the global Copilot settings tab. - Delete the now-orphaned ModelEditDialog and ModelParametersEditor. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): drop dead legacy model-key fallback in streaming session The streaming chat session's chain cache key fell back to name|provider when configuredModelId was absent. Both callers (Quick Ask, custom-command chat) now source their model from useResolvedChatBackendModel -> configuredModelToCustomModel, which always stamps configuredModelId, so the fallback was unreachable. Derive the key directly from configuredModelId; a model without one is treated as no usable model via the existing guards. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): remove test-only getLegacyChatModelKey helper The singular getLegacyChatModelKey was exported but had no production callers — only the barrel re-export and one test assertion referenced it. Remove it (and its export + assertion); the load-bearing plural getLegacyChatModelKeys, which powers legacy-key resolution in isChatModelSelectionForEntry, is kept and now carries the rationale comment. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(model-management): dedupe catalog-id to provider map Drop the duplicate CATALOG_ID_TO_LEGACY_PROVIDER table in chatModelSelection and reuse the now-exported CATALOG_ID_TO_CHAT_PROVIDER from configuredModelToCustomModel (extended with anthropic/google for the legacy-key path). Single source of truth for catalog-id to ChatModelProviders. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 33 +- docs/llm-providers.md | 7 +- docs/models-and-parameters.md | 27 +- docs/projects.md | 9 +- src/LLMProviders/chainManager.ts | 76 ++-- .../chainRunner/LLMChainRunner.ts | 17 +- .../chainRunner/VaultQAChainRunner.ts | 15 +- .../chatModelManager.bridge.test.ts | 44 ++ src/LLMProviders/chatModelManager.ts | 215 ++++++++-- src/LLMProviders/projectManager.ts | 2 +- src/aiParams.ts | 2 + src/commands/CustomCommandChatModal.tsx | 41 +- src/commands/CustomCommandSettingsModal.tsx | 18 +- src/commands/customCommandChatEngine.ts | 6 +- src/components/Chat.tsx | 24 +- .../chat-components/ChatSettingsPopover.tsx | 136 +----- .../chat-components/TokenLimitWarning.tsx | 30 +- .../chat-components/useChatModelPicker.ts | 108 +++++ .../command-ui/menu-command-modal.tsx | 6 +- .../modals/project/AddProjectModal.tsx | 77 ++-- src/components/quick-ask/QuickAskPanel.tsx | 14 +- .../quick-ask/useQuickAskSession.ts | 27 +- src/components/ui/ModelParametersEditor.tsx | 280 ------------ src/hooks/use-streaming-chat-session.ts | 13 +- src/hooks/useChatBackendModelOptions.ts | 37 ++ src/hooks/useResolvedChatBackendModel.ts | 39 ++ .../chatModel/chatModelSelection.test.ts | 104 +++++ .../chatModel/chatModelSelection.ts | 73 ++++ .../configuredModelToCustomModel.test.ts | 204 +++++++++ .../chatModel/configuredModelToCustomModel.ts | 154 +++++++ .../chatModel/resolveChatBackendModel.test.ts | 104 +++++ .../chatModel/resolveChatBackendModel.ts | 49 +++ src/modelManagement/index.ts | 12 + src/plusUtils.ts | 22 +- src/settings/v2/components/AgentSettings.tsx | 23 + src/settings/v2/components/BasicSettings.tsx | 43 +- .../v2/components/ChatModelEnableList.tsx | 75 ++++ .../v2/components/ModelEditDialog.tsx | 399 ------------------ .../v2/components/configuredModelGrouping.ts | 28 ++ 39 files changed, 1482 insertions(+), 1111 deletions(-) create mode 100644 src/LLMProviders/chatModelManager.bridge.test.ts create mode 100644 src/components/chat-components/useChatModelPicker.ts delete mode 100644 src/components/ui/ModelParametersEditor.tsx create mode 100644 src/hooks/useChatBackendModelOptions.ts create mode 100644 src/hooks/useResolvedChatBackendModel.ts create mode 100644 src/modelManagement/chatModel/chatModelSelection.test.ts create mode 100644 src/modelManagement/chatModel/chatModelSelection.ts create mode 100644 src/modelManagement/chatModel/configuredModelToCustomModel.test.ts create mode 100644 src/modelManagement/chatModel/configuredModelToCustomModel.ts create mode 100644 src/modelManagement/chatModel/resolveChatBackendModel.test.ts create mode 100644 src/modelManagement/chatModel/resolveChatBackendModel.ts create mode 100644 src/settings/v2/components/ChatModelEnableList.tsx delete mode 100644 src/settings/v2/components/ModelEditDialog.tsx diff --git a/docs/getting-started.md b/docs/getting-started.md index 35f3030a..2617a94a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,18 +37,18 @@ Go to **Settings** → **Copilot** (scroll down to the Community Plugins section On the **Basic** tab, click **Set Keys** to open the API key dialog. Enter the key for your chosen provider: -| Provider | Where to get a key | -|---|---| -| OpenRouter (default) | https://openrouter.ai/keys | -| OpenAI | https://platform.openai.com/api-keys | -| Anthropic | https://console.anthropic.com/settings/keys | -| Google Gemini | https://makersuite.google.com/app/apikey | +| Provider | Where to get a key | +| -------------------- | ------------------------------------------- | +| OpenRouter (default) | https://openrouter.ai/keys | +| OpenAI | https://platform.openai.com/api-keys | +| Anthropic | https://console.anthropic.com/settings/keys | +| Google Gemini | https://makersuite.google.com/app/apikey | The default model is **OpenRouter Gemini 2.5 Flash**, which requires an OpenRouter API key. If you'd prefer a different provider, set up that key first, then change the default model. ### Step 3: Choose a Default Model -Still on the **Basic** tab, use the **Default Chat Model** dropdown to select the model you want to use. Any model whose provider has an API key configured will be available. +Add a provider and its models on the **Models (BYOK)** tab, then choose which of those models appear in chat under **Agents → Quick Chat models**. On the **Basic** tab, use the **Default Chat Model** dropdown to select the model you want to use. ### Step 4: Choose a Chat Mode @@ -74,6 +74,7 @@ You can open Copilot in several ways: ### Sidebar vs. Editor Tab By default, Copilot opens as a **view** (sidebar panel). You can change this in Settings → Copilot → Basic → **Open chat in**: + - **View** — Opens in the sidebar, stays visible as you work - **Editor** — Opens as an editor tab, giving it more screen space @@ -95,14 +96,14 @@ The AI will automatically include your currently open note as context, so you ca These are the default shortcuts. You can customize them in **Obsidian Settings** → **Hotkeys** → search for "Copilot". -| Action | Default Shortcut | -|---|---| -| Open Copilot Chat Window | *(unbound — assign in Hotkeys)* | -| Toggle Copilot Chat Window | *(unbound — assign in Hotkeys)* | -| New Copilot Chat | *(unbound — assign in Hotkeys)* | -| Quick Ask (floating input) | *(unbound — assign in Hotkeys)* | -| Trigger Quick Command | *(unbound — assign in Hotkeys)* | -| Add selection to chat context | *(unbound — assign in Hotkeys)* | +| Action | Default Shortcut | +| ----------------------------- | ------------------------------- | +| Open Copilot Chat Window | _(unbound — assign in Hotkeys)_ | +| Toggle Copilot Chat Window | _(unbound — assign in Hotkeys)_ | +| New Copilot Chat | _(unbound — assign in Hotkeys)_ | +| Quick Ask (floating input) | _(unbound — assign in Hotkeys)_ | +| Trigger Quick Command | _(unbound — assign in Hotkeys)_ | +| Add selection to chat context | _(unbound — assign in Hotkeys)_ | ### Send Shortcut @@ -118,7 +119,7 @@ By default, **Enter** sends a message and **Shift+Enter** adds a new line. You c The AI "brain" behind Copilot — a model trained on vast text to understand and generate human language, powering chat, summarization, and writing assistance. **API (Application Programming Interface)** -A way for Copilot to communicate with external AI services. You provide an API key, which is like a password that lets Copilot use a provider's AI models on your behalf. Note: an OpenAI API key is *different* from a ChatGPT Plus subscription — you don't need ChatGPT Plus to use Copilot. +A way for Copilot to communicate with external AI services. You provide an API key, which is like a password that lets Copilot use a provider's AI models on your behalf. Note: an OpenAI API key is _different_ from a ChatGPT Plus subscription — you don't need ChatGPT Plus to use Copilot. **API Key** A secret token from an AI provider that authorizes Copilot to make requests. Most providers require you to have a billing account with a positive balance. diff --git a/docs/llm-providers.md b/docs/llm-providers.md index c9d6c885..7ebfaf9b 100644 --- a/docs/llm-providers.md +++ b/docs/llm-providers.md @@ -6,10 +6,11 @@ Copilot includes 16 built-in AI providers, and you can add an unlimited number o ## How to Set API Keys -1. Go to **Settings → Copilot → Basic** -2. Click **Set Keys** to open the API key dialog -3. Enter your key for the provider you want to use +1. Go to **Settings → Copilot → Models (BYOK)** +2. Add a provider +3. Enter the provider API key and select the models you want to configure 4. Click Save +5. Enable models for chat under **Agents → Quick Chat models** You can configure multiple providers simultaneously and switch between them by changing the default model. diff --git a/docs/models-and-parameters.md b/docs/models-and-parameters.md index f7c1c078..cc7aef69 100644 --- a/docs/models-and-parameters.md +++ b/docs/models-and-parameters.md @@ -42,30 +42,27 @@ Models may show capability badges: ### Managing Models -Go to **Settings → Copilot → Model** to see the full model list. - -- **Enable/disable** — Toggle individual models on or off to control what appears in the model selector -- **Reorder** — Drag models to change their order in the dropdown -- **Delete** — Remove custom models you've added +Go to **Settings → Copilot → Models (BYOK)** to add or edit providers and choose the models +available from each provider. Then go to **Settings → Copilot → Agents → Quick Chat models** to +control which configured models appear in chat model selectors. ### Adding Custom Models If your provider offers a model that isn't in the built-in list, you can add it manually: -1. Go to **Settings → Copilot → Model** -2. Click **Add Model** -3. Enter the model name exactly as the provider expects it (e.g., `gpt-4-turbo-preview`) -4. Select the provider -5. Optionally set a custom base URL (useful for proxies or alternate endpoints) -6. Save +1. Go to **Settings → Copilot → Models (BYOK)** +2. Add a provider or custom OpenAI-compatible endpoint +3. Enter the API key and base URL when required +4. Select or enter the models exposed by that provider +5. Save ### Importing Models from Provider You can automatically import the full list of available models from a provider: -1. Go to **Settings → Copilot → Model** -2. Find the **Import models** button for your provider -3. Copilot will fetch the provider's model list and add new ones +1. Go to **Settings → Copilot → Models (BYOK)** +2. Add or edit a provider +3. Copilot will fetch the provider's model list so you can select models to configure --- @@ -168,7 +165,7 @@ Reduces the likelihood of the model repeating itself. Your **default model** is the one Copilot uses when you open a new chat. Set it in: **Settings → Copilot → Basic → Default Chat Model** -The default is **OpenRouter Gemini 2.5 Flash** (requires OpenRouter API key). +The dropdown contains models enabled under **Agents → Quick Chat models**. --- diff --git a/docs/projects.md b/docs/projects.md index 9925f1eb..41c4db7c 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -18,6 +18,7 @@ In regular chat, all conversations share the same settings and model. Projects l - **Isolated chat history** — Conversations in one project don't mix with conversations in another **Example use cases:** + - A "Research" project that always has your research notes as context - A "Client Work" project with a specific system prompt and access to client-related notes - A "Learning" project with YouTube video URLs for study materials @@ -39,18 +40,24 @@ In regular chat, all conversations share the same settings and model. Projects l Each project has the following settings: ### Name + A short name for the project. Appears in the project list. ### Description + An optional description of what the project is for. ### Model -Choose which AI model to use for this project. The available options depend on which models you have enabled. + +Choose which AI model to use for this project. The available options are the models enabled under +**Settings → Copilot → Agents → Quick Chat models**. ### Model Settings + Override the default temperature and max tokens specifically for this project. ### System Prompt + Set a custom system prompt for this project. This replaces (or supplements) the global default. See [System Prompts](system-prompts.md) for details. --- diff --git a/src/LLMProviders/chainManager.ts b/src/LLMProviders/chainManager.ts index 5ce6b02e..d0862674 100644 --- a/src/LLMProviders/chainManager.ts +++ b/src/LLMProviders/chainManager.ts @@ -1,6 +1,6 @@ import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams"; import { ChainType } from "@/chainType"; -import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants"; +import { USER_SENDER } from "@/constants"; import { AutonomousAgentChainRunner, ChainRunner, @@ -13,7 +13,8 @@ import { logError, logInfo } from "@/logger"; import { getSettings, subscribeToSettingsChange } from "@/settings/model"; import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder"; import { ChatMessage } from "@/types/message"; -import { findCustomModel, isOSeriesModel } from "@/utils"; +import { isOSeriesModel } from "@/utils"; +import { resolveChatBackendModel, type ModelManagementApi } from "@/modelManagement"; import { MissingModelKeyError } from "@/error"; import { ChatPromptTemplate, @@ -21,7 +22,7 @@ import { MessagesPlaceholder, } from "@langchain/core/prompts"; import { Document } from "@langchain/core/documents"; -import { App, Notice } from "obsidian"; +import { App } from "obsidian"; import ChatModelManager from "./chatModelManager"; import MemoryManager from "./memoryManager"; import PromptManager from "./promptManager"; @@ -40,10 +41,13 @@ export default class ChainManager { public promptManager: PromptManager; public userMemoryManager: UserMemoryManager; private pendingModelError: Error | null = null; + /** Model-management API — resolves the chat backend's selected model. */ + private readonly modelManagement: ModelManagementApi; - constructor(app: App) { + constructor(app: App, modelManagement: ModelManagementApi) { // Instantiate singletons this.app = app; + this.modelManagement = modelManagement; this.memoryManager = MemoryManager.getInstance(); this.chatModelManager = ChatModelManager.getInstance(); this.promptManager = PromptManager.getInstance(); @@ -57,6 +61,16 @@ export default class ChainManager { logError("createChainWithNewModel failed", err) ); }); + modelManagement.providerRegistry.subscribe(() => { + void this.createChainWithNewModel().catch((err) => + logError("createChainWithNewModel after provider change failed", err) + ); + }); + modelManagement.backendConfigRegistry.subscribe(() => { + void this.createChainWithNewModel().catch((err) => + logError("createChainWithNewModel after chat backend change failed", err) + ); + }); } private async initialize() { @@ -91,7 +105,10 @@ export default class ChainManager { options: SetChainOptions = {}, neededReInitChatMode: boolean = true ): Promise { - let newModelKey: string | undefined; + // The selection is a `configuredModelId` in the chat backend (no longer a + // legacy "name|provider" key). Project mode keeps its own per-project + // selection; everything else uses the session/global selection. + let selectedModelId: string | undefined; const chainType = getChainType(); const currentProject = getCurrentProject(); @@ -100,46 +117,29 @@ export default class ChainManager { } try { - newModelKey = + const preferredId = chainType === ChainType.PROJECT_CHAIN ? currentProject?.projectModelKey : getModelKey(); - if (!newModelKey) { - throw new MissingModelKeyError("No model key found. Please select a model in settings."); - } - if (neededReInitChatMode) { - let customModel = findCustomModel(newModelKey, getSettings().activeModels); - if (!customModel) { - // Reset default model if no model is found - console.error("Resetting default model. No model configuration found for: ", newModelKey); - customModel = BUILTIN_CHAT_MODELS[0]; - newModelKey = customModel.name + "|" + customModel.provider; - } - - // Add validation for project mode - if (chainType === ChainType.PROJECT_CHAIN && !customModel.projectEnabled) { - // If the model is not project-enabled, find the first project-enabled model - const projectEnabledModel = getSettings().activeModels.find( - (m) => m.enabled && m.projectEnabled + const resolution = await resolveChatBackendModel( + this.modelManagement, + preferredId || undefined + ); + if (!resolution.ok) { + throw new MissingModelKeyError( + "No chat model enabled. Enable a model under Settings → Agents → Quick Chat, " + + "or add one on the Models (BYOK) tab." ); - if (projectEnabledModel) { - customModel = projectEnabledModel; - newModelKey = projectEnabledModel.name + "|" + projectEnabledModel.provider; - new Notice( - `Model ${customModel.name} is not available in project mode. Switching to ${projectEnabledModel.name}.` - ); - } else { - throw new Error( - "No project-enabled models available. Please enable a model for project mode in settings." - ); - } } + selectedModelId = resolution.configuredModelId; + // Project temperature / maxTokens overrides still apply on top of the + // bridged model. const mergedModel = { - ...customModel, + ...resolution.customModel, ...currentProject?.modelConfigs, }; - await this.chatModelManager.setChatModel(mergedModel); + await this.chatModelManager.setChatModelFromBridged(mergedModel); this.pendingModelError = null; } @@ -160,11 +160,11 @@ export default class ChainManager { "createChainWithNewModel: skipping chain-type housekeeping — no chat model set." ); } - logInfo(`Setting model to ${newModelKey}`); + logInfo(`Setting chat model to configuredModelId=${selectedModelId}`); } catch (error) { this.pendingModelError = error instanceof Error ? error : new Error(String(error)); logError(`createChainWithNewModel failed: ${error}`); - logInfo(`modelKey: ${newModelKey || getModelKey()}`); + logInfo(`configuredModelId: ${selectedModelId ?? getModelKey()}`); } } diff --git a/src/LLMProviders/chainRunner/LLMChainRunner.ts b/src/LLMProviders/chainRunner/LLMChainRunner.ts index 90e76003..77bd4ceb 100644 --- a/src/LLMProviders/chainRunner/LLMChainRunner.ts +++ b/src/LLMProviders/chainRunner/LLMChainRunner.ts @@ -1,14 +1,12 @@ import { ABORT_REASON, ModelCapability } from "@/constants"; import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter"; import { logInfo } from "@/logger"; -import { getSettings } from "@/settings/model"; import { ChatMessage } from "@/types/message"; -import { findCustomModel, withSuppressedTokenWarnings } from "@/utils"; +import { withSuppressedTokenWarnings } from "@/utils"; import { BaseChainRunner } from "./BaseChainRunner"; import { loadAndAddChatHistory } from "./utils/chatHistoryUtils"; import { recordPromptPayload } from "./utils/promptPayloadRecorder"; import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer"; -import { getModelKey } from "@/aiParams"; export class LLMChainRunner extends BaseChainRunner { /** @@ -82,20 +80,15 @@ export class LLMChainRunner extends BaseChainRunner { } ): Promise { // Check if the current model has reasoning capability - const settings = getSettings(); - const modelKey = getModelKey(); let excludeThinking = false; - try { - const currentModel = findCustomModel(modelKey, settings.activeModels); + const currentModel = this.chainManager.chatModelManager.getActiveModel(); + if (currentModel) { // Exclude thinking blocks if model doesn't have REASONING capability excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING); - } catch (error) { + } else { // If we can't find the model, default to including thinking blocks - logInfo( - "Could not determine model capabilities, defaulting to include thinking blocks", - error - ); + logInfo("Could not determine model capabilities, defaulting to include thinking blocks"); } const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking); diff --git a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts index 3b53e43e..1a63b032 100644 --- a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts +++ b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts @@ -11,7 +11,6 @@ import { ChatMessage } from "@/types/message"; import { extractChatHistory, extractUniqueTitlesFromDocs, - findCustomModel, getMessageRole, withSuppressedTokenWarnings, } from "@/utils"; @@ -27,7 +26,6 @@ import { } from "./utils/citationUtils"; import { recordPromptPayload } from "./utils/promptPayloadRecorder"; import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer"; -import { getModelKey } from "@/aiParams"; export class VaultQAChainRunner extends BaseChainRunner { async run( @@ -42,20 +40,15 @@ export class VaultQAChainRunner extends BaseChainRunner { } ): Promise { // Check if the current model has reasoning capability - const settings = getSettings(); - const modelKey = getModelKey(); let excludeThinking = false; - try { - const currentModel = findCustomModel(modelKey, settings.activeModels); + const currentModel = this.chainManager.chatModelManager.getActiveModel(); + if (currentModel) { // Exclude thinking blocks if model doesn't have REASONING capability excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING); - } catch (error) { + } else { // If we can't find the model, default to including thinking blocks - logInfo( - "Could not determine model capabilities, defaulting to include thinking blocks", - error - ); + logInfo("Could not determine model capabilities, defaulting to include thinking blocks"); } const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking); diff --git a/src/LLMProviders/chatModelManager.bridge.test.ts b/src/LLMProviders/chatModelManager.bridge.test.ts new file mode 100644 index 00000000..6c17c6ac --- /dev/null +++ b/src/LLMProviders/chatModelManager.bridge.test.ts @@ -0,0 +1,44 @@ +import type { CustomModel } from "@/aiParams"; +import { ChatModelProviders } from "@/constants"; +import { MissingApiKeyError } from "@/error"; +import { getSettings, setSettings } from "@/settings/model"; + +import ChatModelManager from "./chatModelManager"; + +jest.mock("@langchain/anthropic", () => ({ ChatAnthropic: class {} })); +jest.mock("@langchain/groq", () => ({ ChatGroq: class {} })); + +function bridgedModel(overrides: Partial = {}): CustomModel { + return { + name: "gpt-4o-mini", + provider: ChatModelProviders.OPENAI, + enabled: true, + ...overrides, + }; +} + +describe("ChatModelManager bridged models", () => { + const originalOpenAiKey = getSettings().openAIApiKey; + + afterEach(() => { + setSettings({ openAIApiKey: originalOpenAiKey }); + }); + + it("does not fall back to a legacy top-level provider key", async () => { + setSettings({ openAIApiKey: "legacy-key" }); + + await expect( + ChatModelManager.getInstance().createModelInstanceFromBridged(bridgedModel()) + ).rejects.toBeInstanceOf(MissingApiKeyError); + }); + + it("retains the active bridged model for temperature overrides", async () => { + const manager = ChatModelManager.getInstance(); + const model = bridgedModel({ apiKey: "bridge-key" }); + + await manager.setChatModelFromBridged(model); + + expect(manager.getActiveModel()).toBe(model); + await expect(manager.getChatModelWithTemperature(0)).resolves.toBeDefined(); + }); +}); diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index 368a50bd..b7e078bc 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -124,6 +124,8 @@ export function normalizeAzureUrl(raw: string | undefined): { export default class ChatModelManager { private static instance: ChatModelManager; private static chatModel: BaseChatModel | null; + private static activeModel: CustomModel | null = null; + private static activeModelSource: "legacy" | "bridged" | null = null; private static modelMap: Record< string, { @@ -196,7 +198,10 @@ export default class ChatModelManager { return customModel.temperature ?? settings.temperature; } - private async getModelConfig(customModel: CustomModel): Promise { + private async getModelConfig( + customModel: CustomModel, + allowLegacyCredentialFallback: boolean = true + ): Promise { const settings = getSettings(); const modelName = customModel.name; @@ -223,7 +228,11 @@ export default class ChatModelManager { } = { [ChatModelProviders.OPENAI]: { modelName: modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.openAIApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl, fetch: customModel.enableCors ? safeFetch : undefined, @@ -237,7 +246,11 @@ export default class ChatModelManager { ), }, [ChatModelProviders.ANTHROPIC]: { - anthropicApiKey: await getDecryptedKey(customModel.apiKey || settings.anthropicApiKey), + anthropicApiKey: await this.resolveApiKey( + customModel.apiKey, + settings.anthropicApiKey, + allowLegacyCredentialFallback + ), model: modelName, anthropicApiUrl: customModel.baseUrl, clientOptions: { @@ -265,7 +278,11 @@ export default class ChatModelManager { modelName: customModel.baseUrl ? modelName : customModel.azureOpenAIApiDeploymentName || settings.azureOpenAIApiDeploymentName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.azureOpenAIApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: azureUrl.baseUrl || @@ -279,7 +296,11 @@ export default class ChatModelManager { }, defaultHeaders: { "Content-Type": "application/json", - "api-key": await getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey), + "api-key": await this.resolveApiKey( + customModel.apiKey, + settings.azureOpenAIApiKey, + allowLegacyCredentialFallback + ), }, fetch: customModel.enableCors ? safeFetch : undefined, }, @@ -293,26 +314,42 @@ export default class ChatModelManager { })(), [ChatModelProviders.COHEREAI]: { modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.cohereApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.COHEREAI].host, fetch: customModel.enableCors ? safeFetch : undefined, }, }, [ChatModelProviders.GOOGLE]: { - apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.googleApiKey, + allowLegacyCredentialFallback + ), model: modelName, safetySettings: GOOGLE_SAFETY_SETTINGS_BLOCK_NONE, baseUrl: customModel.baseUrl, }, [ChatModelProviders.XAI]: { - apiKey: await getDecryptedKey(customModel.apiKey || settings.xaiApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.xaiApiKey, + allowLegacyCredentialFallback + ), model: modelName, // This langchainjs XAI client does not support baseURL override }, [ChatModelProviders.OPENROUTERAI]: { modelName: modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.openRouterAiApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.openRouterAiApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl || "https://openrouter.ai/api/v1", fetch: customModel.enableCors ? safeFetch : undefined, @@ -333,8 +370,13 @@ export default class ChatModelManager { enablePromptCaching: customModel.enablePromptCaching ?? true, }, [ChatModelProviders.GROQ]: { - apiKey: await getDecryptedKey(customModel.apiKey || settings.groqApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.groqApiKey, + allowLegacyCredentialFallback + ), model: modelName, + baseUrl: customModel.baseUrl, }, [ChatModelProviders.OLLAMA]: { // ChatOllama has `model` instead of `modelName`!! @@ -373,7 +415,11 @@ export default class ChatModelManager { }, [ChatModelProviders.OPENAI_FORMAT]: { modelName: modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.openAIApiKey, + allowLegacyCredentialFallback + ), streamUsage: customModel.streamUsage ?? false, configuration: { baseURL: customModel.baseUrl, @@ -389,7 +435,11 @@ export default class ChatModelManager { }, [ChatModelProviders.SILICONFLOW]: { modelName: modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.siliconflowApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.siliconflowApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.SILICONFLOW].host, fetch: customModel.enableCors ? safeFetch : undefined, @@ -403,7 +453,11 @@ export default class ChatModelManager { }, [ChatModelProviders.COPILOT_PLUS]: { modelName: modelName, - apiKey: await getDecryptedKey(settings.plusLicenseKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.plusLicenseKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: BREVILABS_MODELS_BASE_URL, fetch: safeFetch, @@ -411,7 +465,11 @@ export default class ChatModelManager { }, [ChatModelProviders.MISTRAL]: { modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.mistralApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.mistralApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.MISTRAL].host, fetch: customModel.enableCors ? safeFetch : undefined, @@ -419,7 +477,11 @@ export default class ChatModelManager { }, [ChatModelProviders.DEEPSEEK]: { modelName: modelName, - apiKey: await getDecryptedKey(customModel.apiKey || settings.deepseekApiKey), + apiKey: await this.resolveApiKey( + customModel.apiKey, + settings.deepseekApiKey, + allowLegacyCredentialFallback + ), configuration: { baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.DEEPSEEK].host, fetch: customModel.enableCors ? safeFetch : undefined, @@ -447,7 +509,8 @@ export default class ChatModelManager { modelName, settings, maxTokens, - resolvedTemperature + resolvedTemperature, + allowLegacyCredentialFallback ); } @@ -472,6 +535,14 @@ export default class ChatModelManager { return finalConfig as ModelConfig; } + private async resolveApiKey( + modelApiKey: string | undefined, + legacyApiKey: string, + allowLegacyCredentialFallback: boolean + ): Promise { + return getDecryptedKey(modelApiKey || (allowLegacyCredentialFallback ? legacyApiKey : "")); + } + /** * Adds special configuration for OpenAI models that support reasoning * LangChain 0.6.6+ handles most of the token/temperature logic internally @@ -537,9 +608,11 @@ export default class ChatModelManager { modelName: string, settings: CopilotSettings, maxTokens: number, - temperature: number | undefined + temperature: number | undefined, + allowLegacyCredentialFallback: boolean ): Promise { - const apiKeySource = customModel.apiKey || settings.amazonBedrockApiKey; + const apiKeySource = + customModel.apiKey || (allowLegacyCredentialFallback ? settings.amazonBedrockApiKey : ""); if (!apiKeySource) { throw new Error( "Amazon Bedrock API key is not configured. Provide a key in Settings > Copilot > BYOK or the model definition." @@ -643,7 +716,7 @@ export default class ChatModelManager { allModels.forEach((model) => { if (model.enabled) { - if (!Object.values(ChatModelProviders).contains(model.provider as ChatModelProviders)) { + if (!Object.values(ChatModelProviders).includes(model.provider as ChatModelProviders)) { console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`); return; } @@ -665,10 +738,14 @@ export default class ChatModelManager { * @param model - The custom model definition. * @returns True when the provider requirements are satisfied, otherwise false. */ - private hasProviderCredentials(model: CustomModel): boolean { + private hasProviderCredentials( + model: CustomModel, + allowLegacyCredentialFallback: boolean = true + ): boolean { if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) { const settings = getSettings(); - const apiKey = model.apiKey || settings.amazonBedrockApiKey; + const apiKey = + model.apiKey || (allowLegacyCredentialFallback ? settings.amazonBedrockApiKey : ""); // Region defaults to us-east-1 if not specified, so API key is the only requirement return Boolean(apiKey); } @@ -678,7 +755,7 @@ export default class ChatModelManager { return Boolean(model.apiKey); } - return Boolean(model.apiKey || getDefaultApiKey()); + return Boolean(model.apiKey || (allowLegacyCredentialFallback ? getDefaultApiKey() : "")); } getProviderConstructor(model: CustomModel): ChatConstructorType { @@ -699,6 +776,10 @@ export default class ChatModelManager { return ChatModelManager.chatModel; } + getActiveModel(): CustomModel | null { + return ChatModelManager.activeModel; + } + /** * Helper to validate a model config has valid credentials and meets entitlement requirements. * Does NOT check believerExclusive - that's validated at usage time, not selection time. @@ -766,7 +847,7 @@ export default class ChatModelManager { * so we need to create a new model instance with the specified temperature. */ async getChatModelWithTemperature(temperature: number): Promise { - const modelConfig = this.resolveModelForTemperatureOverride(); + const modelConfig = ChatModelManager.activeModel ?? this.resolveModelForTemperatureOverride(); // Create a temporary model config with overridden temperature const modelWithTempOverride: CustomModel = { @@ -774,13 +855,17 @@ export default class ChatModelManager { temperature, }; - return await this.createModelInstance(modelWithTempOverride); + return ChatModelManager.activeModelSource === "bridged" + ? await this.createModelInstanceFromBridged(modelWithTempOverride) + : await this.createModelInstance(modelWithTempOverride); } async setChatModel(model: CustomModel): Promise { try { const modelInstance = await this.createModelInstance(model); ChatModelManager.chatModel = modelInstance; + ChatModelManager.activeModel = model; + ChatModelManager.activeModelSource = "legacy"; // Log if Responses API is enabled for GPT-5 const modelInfo = getModelInfo(model.name); @@ -797,6 +882,22 @@ export default class ChatModelManager { } } + /** + * Set the active chat model from a chat-backend `CustomModel` produced by the + * bridge. Counterpart to `setChatModel` that goes through + * `createModelInstanceFromBridged` (no `activeModels` modelMap gate). + */ + async setChatModelFromBridged(model: CustomModel): Promise { + try { + ChatModelManager.chatModel = await this.createModelInstanceFromBridged(model); + ChatModelManager.activeModel = model; + ChatModelManager.activeModelSource = "bridged"; + } catch (error) { + logError(error); + throw error; + } + } + async createModelInstance(model: CustomModel): Promise { // Create and return the appropriate model const modelKey = getModelKeyFromModel(model); @@ -814,7 +915,53 @@ export default class ChatModelManager { throw new MissingApiKeyError(errorMessage); } - const modelConfig = await this.getModelConfig(model); + return this.instantiateChatModel( + model, + selectedModel.vendor as ChatModelProviders, + selectedModel.AIConstructor + ); + } + + /** + * Build a chat model from a `CustomModel` produced by the model-management + * "chat" backend bridge (`configuredModelToCustomModel`). Unlike + * `createModelInstance`, this does NOT consult `modelMap` — that map is built + * from the legacy `settings.activeModels`, whereas chat-backend models live + * in the `Provider` / `ConfiguredModel` registries, so the `activeModels` + * gate would reject every bridged model. The bridge already resolved the + * provider + key, so credentials are validated directly off the model here. + */ + async createModelInstanceFromBridged(model: CustomModel): Promise { + if (!this.hasProviderCredentials(model, false)) { + if ((model.provider as ChatModelProviders) === ChatModelProviders.COPILOT_PLUS) { + throw new MissingPlusLicenseError( + "Copilot Plus license key is not configured. Please enter your license key in the Copilot Plus section at the top of Basic Settings." + ); + } + throw new MissingApiKeyError(`API key is not provided for the model: ${model.name}.`); + } + + return this.instantiateChatModel( + model, + model.provider as ChatModelProviders, + this.getProviderConstructor(model), + false + ); + } + + /** + * Shared construction path for both `createModelInstance` (legacy + * activeModels) and `createModelInstanceFromBridged` (chat backend). Builds + * the provider config, applies the GPT-5 / GitHub-Copilot Responses-API and + * LM Studio special cases, and constructs the LangChain client. + */ + private async instantiateChatModel( + model: CustomModel, + vendor: ChatModelProviders, + AIConstructor: ChatConstructorType, + allowLegacyCredentialFallback: boolean = true + ): Promise { + const modelConfig = await this.getModelConfig(model, allowLegacyCredentialFallback); const modelInfo = getModelInfo(model.name); // For GPT-5 models, automatically use Responses API for proper verbosity support @@ -822,11 +969,10 @@ export default class ChatModelManager { const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model); if ( modelInfo.isGPT5 && - ((selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI || - (selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT) + (vendor === ChatModelProviders.OPENAI || vendor === ChatModelProviders.OPENAI_FORMAT) ) { constructorConfig.useResponsesApi = true; - logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`); + logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${vendor})`); } if (useCopilotResponses) { @@ -849,9 +995,7 @@ export default class ChatModelManager { return new GitHubCopilotResponsesModel(constructorConfig); } - const newModelInstance = new selectedModel.AIConstructor(constructorConfig); - - return newModelInstance; + return new AIConstructor(constructorConfig); } validateChatModel(chatModel: BaseChatModel): boolean { @@ -882,10 +1026,15 @@ export default class ChatModelManager { // Get the model configuration const selectedModel = ChatModelManager.modelMap[currentModelKey]; - // If API key is missing or model doesn't exist in map - if (!selectedModel?.hasApiKey) { + // Only invalidate keys the legacy modelMap actually knows about. A chat- + // backend selection is a `configuredModelId` that never appears in the + // activeModels-derived map; its validity is owned by chainManager's + // resolver, so an absent entry here must NOT clear the bridged model. + if (selectedModel && !selectedModel.hasApiKey) { // Clear the current chat model ChatModelManager.chatModel = null; + ChatModelManager.activeModel = null; + ChatModelManager.activeModelSource = null; logInfo("Failed to reinitialize model due to missing API key"); } } diff --git a/src/LLMProviders/projectManager.ts b/src/LLMProviders/projectManager.ts index db0eee56..b8f616df 100644 --- a/src/LLMProviders/projectManager.ts +++ b/src/LLMProviders/projectManager.ts @@ -46,7 +46,7 @@ export default class ProjectManager { this.app = app; this.plugin = plugin; this.currentProjectId = null; - this.chainMangerInstance = new ChainManager(app); + this.chainMangerInstance = new ChainManager(app, plugin.modelManagement); this.projectContextCache = ProjectContextCache.getInstance(); this.fileParserManager = new FileParserManager( BrevilabsClient.getInstance(), diff --git a/src/aiParams.ts b/src/aiParams.ts index 60087f7f..3661bf7f 100644 --- a/src/aiParams.ts +++ b/src/aiParams.ts @@ -132,6 +132,8 @@ export interface SetChainOptions { } export interface CustomModel { + /** Present for chat-backend bridged models; distinguishes same wire id across providers. */ + configuredModelId?: string; name: string; provider: string; baseUrl?: string; diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index f5f7f61a..cc531c33 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -1,4 +1,4 @@ -import { CustomModel, useModelKey } from "@/aiParams"; +import { useModelKey } from "@/aiParams"; import { processCommandPrompt } from "@/commands/customCommandUtils"; import { MenuCommandModal, type ContentState } from "@/components/command-ui"; import { useApp } from "@/context"; @@ -9,7 +9,9 @@ import { import { SelectionHighlight } from "@/editor/selectionHighlight"; import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard"; import { logError } from "@/logger"; -import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils"; +import { cleanMessageForCopy, insertIntoEditor } from "@/utils"; +import { useChatModelPicker } from "@/components/chat-components/useChatModelPicker"; +import { useResolvedChatBackendModel } from "@/hooks/useResolvedChatBackendModel"; import { computeVerticalPlacement } from "@/utils/panelPlacement"; import { computeSelectionAnchors } from "@/utils/selectionAnchors"; import type { EditorView } from "@codemirror/view"; @@ -212,30 +214,14 @@ function CustomCommandChatModalContent({ updateSetting("quickCommandIncludeNoteContext", checked); }, []); - // Safely resolve the selected model with fallback to first enabled model - const resolvedModel = useMemo((): CustomModel | null => { - try { - const model = findCustomModel(userSelectedModelKey, settings.activeModels); - // Treat disabled models as invalid selections (ModelSelector won't present them) - if (!model.enabled) { - throw new Error(`Selected model is disabled: ${userSelectedModelKey}`); - } - return model; - } catch { - // Stale model key can happen when a model is removed/renamed/disabled; don't crash the modal. - // Avoid side effects during render; notify/log in the effect below. - return settings.activeModels.find((m) => m.enabled) ?? null; - } - }, [userSelectedModelKey, settings.activeModels]); + // Resolve the selected chat-backend model (preferred id → first enabled → null). + const resolvedModel = useResolvedChatBackendModel(app, userSelectedModelKey); - // Compute the key for the resolved model - const resolvedModelKey = useMemo(() => { - if (!resolvedModel) return null; - return `${resolvedModel.name}|${resolvedModel.provider}`; - }, [resolvedModel]); - - // Effective model key for the UI — falls back to user selection when resolution fails. - const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey; + // Chat-backend picker entries; `value` reflects the effective model. + const chatPicker = useChatModelPicker({ + value: userSelectedModelKey, + onChange: handleModelChange, + }); // Use shared streaming hook const { @@ -441,8 +427,9 @@ function CustomCommandChatModalContent({ followUpValue={followUpValue} onFollowUpChange={setFollowUpValue} onFollowUpSubmit={handleFollowUpSubmit} - selectedModel={effectiveModelKey} - onSelectModel={handleModelChange} + selectedModel={chatPicker.value} + onSelectModel={chatPicker.onChange} + models={chatPicker.models} onStop={handleStop} onCopy={handleCopy} onInsert={handleInsert} diff --git a/src/commands/CustomCommandSettingsModal.tsx b/src/commands/CustomCommandSettingsModal.tsx index 91b4000a..bf8b6dfc 100644 --- a/src/commands/CustomCommandSettingsModal.tsx +++ b/src/commands/CustomCommandSettingsModal.tsx @@ -7,14 +7,13 @@ import { Textarea } from "@/components/ui/textarea"; import React, { useState } from "react"; import { Root } from "react-dom/client"; import { createPluginRoot } from "@/utils/react/createPluginRoot"; -import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; -import { getModelDisplayText } from "@/components/ui/model-display"; import { cn } from "@/lib/utils"; import { ChevronDown } from "lucide-react"; import { logError } from "@/logger"; import { CustomPromptSyntaxInstruction } from "@/components/CustomPromptSyntaxInstruction"; import { CustomCommand } from "@/commands/type"; import { validateCommandName } from "@/commands/customCommandUtils"; +import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions"; type FormErrors = { title?: string; @@ -32,14 +31,13 @@ function CustomCommandSettingsModalContent({ onConfirm: (command: CustomCommand) => void; onCancel: () => void; }) { - const settings = useSettingsValue(); - const activeModels = settings.activeModels - .filter((m) => m.enabled) - .map((model) => ({ - label: getModelDisplayText(model), - value: getModelKeyFromModel(model), - })); - const [command, setCommand] = useState(initialCommand); + const { options: activeModels, resolveSelectionId } = useChatBackendModelOptions(); + const [command, setCommand] = useState(() => ({ + ...initialCommand, + modelKey: initialCommand.modelKey + ? resolveSelectionId(initialCommand.modelKey) || initialCommand.modelKey + : "", + })); const [errors, setErrors] = useState({}); const handleUpdate = (field: keyof CustomCommand, value: CustomCommand[keyof CustomCommand]) => { diff --git a/src/commands/customCommandChatEngine.ts b/src/commands/customCommandChatEngine.ts index afd7da6e..0829df1e 100644 --- a/src/commands/customCommandChatEngine.ts +++ b/src/commands/customCommandChatEngine.ts @@ -37,7 +37,11 @@ export async function createChatChain( systemPrompt: string, memory: BaseChatMemory ): Promise { - const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel); + // Bridged path: `selectedModel` comes from the model-management "chat" + // backend (resolveChatBackendModel / useResolvedChatBackendModel), not the + // legacy activeModels, so it must bypass the activeModels-derived modelMap gate. + const chatModel = + await ChatModelManager.getInstance().createModelInstanceFromBridged(selectedModel); const defaultSystemPrompt = "You are a helpful assistant. You'll help the user with their content editing needs."; diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index cbb5d6c2..1838a6dc 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -4,6 +4,7 @@ import { getSelectedTextContexts, ProjectConfig, removeSelectedTextContext, + subscribeToProjectChange, useChainType, updateIndexingProgressState, useIndexingProgress, @@ -19,6 +20,7 @@ import type { WebTabContext } from "@/types/message"; import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls"; import ChatInput from "@/components/chat-components/ChatModeInput"; import ChatMessages from "@/components/chat-components/ChatMessages"; +import { useChatModelPicker } from "@/components/chat-components/useChatModelPicker"; import { NewVersionBanner } from "@/components/chat-components/NewVersionBanner"; import { ProjectList } from "@/components/chat-components/ProjectList"; import IndexingProgressCard from "@/components/IndexingProgressCard"; @@ -52,7 +54,15 @@ import { arrayBufferToBase64 } from "@/utils/base64"; import { appendUniqueFiles } from "@/utils/fileListUtils"; import { Notice, TFile } from "obsidian"; import { ContextManageModal } from "@/components/modals/project/context-manage-modal"; -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { v4 as uuidv4 } from "uuid"; import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; import { useActiveWebTabState } from "@/components/chat-components/hooks/useActiveWebTabState"; @@ -84,8 +94,17 @@ const ChatInternal: React.FC(null); @@ -933,6 +952,7 @@ const ChatInternal: React.FC { diff --git a/src/components/chat-components/ChatSettingsPopover.tsx b/src/components/chat-components/ChatSettingsPopover.tsx index aefafaad..bfadceb4 100644 --- a/src/components/chat-components/ChatSettingsPopover.tsx +++ b/src/components/chat-components/ChatSettingsPopover.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useApp } from "@/context"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -9,10 +9,6 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AlertTriangle, ArrowUpRight, RotateCcw, Settings } from "lucide-react"; import { SettingSwitch } from "@/components/ui/setting-switch"; -import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor"; -import { CustomModel, getModelKey } from "@/aiParams"; -import { getSettings, updateSetting } from "@/settings/model"; -import { debounce } from "@/utils/debounce"; import { getDefaultSystemPromptTitle, getDisableBuiltinSystemPrompt, @@ -22,34 +18,8 @@ import { useSystemPrompts, } from "@/system-prompts"; -/** - * Optional model parameters that can be reset to global defaults - * These are model-specific overrides that should be cleared on reset - */ -const RESETTABLE_MODEL_PARAMS: (keyof CustomModel)[] = [ - "topP", - "frequencyPenalty", - "reasoningEffort", - "verbosity", -]; - export function ChatSettingsPopover() { const app = useApp(); - const settings = getSettings(); - const modelKey = getModelKey(); - - // Find the currently selected model (original model) - const originalModel = settings.activeModels.find( - (model) => `${model.name}|${model.provider}` === modelKey - ); - - // Local editing state - const [localModel, setLocalModel] = useState(originalModel); - const [prevModelKey, setPrevModelKey] = useState(modelKey); - if (prevModelKey !== modelKey) { - setPrevModelKey(modelKey); - setLocalModel(originalModel); - } // System prompt state (session-level, in-memory) const prompts = useSystemPrompts(); @@ -83,95 +53,28 @@ export function ChatSettingsPopover() { } }, [showConfirmation]); - // Debounced save function - must be defined before handleOpenChange. - // Uses debounce.cancel() / debounce.flush() to manage pending writes on unmount. - const debouncedSave = useMemo( - () => - debounce((updatedModel: CustomModel) => { - const updatedModels = settings.activeModels.map((model) => - `${model.name}|${model.provider}` === modelKey ? updatedModel : model - ); - updateSetting("activeModels", updatedModels); - }, 500), - [settings.activeModels, modelKey] - ); - - // Cleanup debounced save on unmount to ensure pending changes are persisted - useEffect(() => { - return () => { - debouncedSave.flush(); - debouncedSave.cancel(); - }; - }, [debouncedSave]); - /** * Sync global disableBuiltinSystemPrompt state to local UI state when popover opens * This ensures the UI reflects the current state after chat switches (new chat or load history) */ - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - // Flush pending debounced saves when popover closes to ensure changes are persisted - // Reason: cancel() would discard user's last modification if they close quickly - debouncedSave.flush(); + const handleOpenChange = useCallback((open: boolean) => { + if (open) { + const currentValue = getDisableBuiltinSystemPrompt(); + setDisableBuiltin(currentValue); + if (!currentValue) { + setShowConfirmation(false); } - if (open) { - const currentValue = getDisableBuiltinSystemPrompt(); - setDisableBuiltin(currentValue); - if (!currentValue) { - setShowConfirmation(false); - } - } - }, - [debouncedSave] - ); - - /** - * Update model parameters (immediately update UI, delayed save) - */ - const handleParamChange = useCallback( - (field: keyof CustomModel, value: CustomModel[keyof CustomModel]) => { - if (!localModel) return; - - const updatedModel = { ...localModel, [field]: value }; - setLocalModel(updatedModel); - debouncedSave(updatedModel); - }, - [localModel, debouncedSave] - ); - - /** - * Reset parameters (delete model-specific values, revert to global defaults) - */ - const handleParamReset = useCallback( - (field: keyof CustomModel) => { - if (!localModel) return; - - const updatedModel = { ...localModel }; - delete updatedModel[field]; - setLocalModel(updatedModel); - debouncedSave(updatedModel); - }, - [localModel, debouncedSave] - ); + } + }, []); const handleReset = useCallback(() => { - // Reset all optional parameters in one operation - // Reason: Calling handleParamReset multiple times would capture stale localModel - // Reference: Command module uses single object construction pattern - if (localModel) { - const updatedModel = { ...localModel }; - RESETTABLE_MODEL_PARAMS.forEach((key) => delete updatedModel[key]); - setLocalModel(updatedModel); - debouncedSave(updatedModel); - } // Reset session prompt to use global default setSessionPrompt(""); setDisableBuiltin(false); setShowConfirmation(false); // Clear session settings setDisableBuiltinSystemPrompt(false); - }, [localModel, debouncedSave, setSessionPrompt]); + }, [setSessionPrompt]); const handleDisableBuiltinToggle = (checked: boolean) => { if (checked) { @@ -204,10 +107,6 @@ export function ChatSettingsPopover() { void app.workspace.openLinkText(filePath, "", true); }; - if (!localModel) { - return null; - } - return ( @@ -282,17 +181,6 @@ export function ChatSettingsPopover() { - {/* Model Parameters Editor */} - - - - {/* Disable Builtin System Prompt */}
@@ -372,9 +260,7 @@ export function ChatSettingsPopover() {
System Prompt and Disable Builtin System Prompt{" "} - apply to this chat session only; -
- other settings are bound to the current model. + apply to this chat session only.
diff --git a/src/components/chat-components/TokenLimitWarning.tsx b/src/components/chat-components/TokenLimitWarning.tsx index 4a2a177d..274b34ac 100644 --- a/src/components/chat-components/TokenLimitWarning.tsx +++ b/src/components/chat-components/TokenLimitWarning.tsx @@ -1,7 +1,4 @@ -import { getModelKey, type CustomModel } from "@/aiParams"; import { Button } from "@/components/ui/button"; -import { getModelKeyFromModel, getSettings, updateSetting } from "@/settings/model"; -import { ModelEditModal } from "@/settings/v2/components/ModelEditDialog"; import { ChatMessage } from "@/types/message"; import { AlertTriangle } from "lucide-react"; import { App, Notice } from "obsidian"; @@ -18,30 +15,11 @@ interface TokenLimitWarningProps { */ export const TokenLimitWarning: React.FC = ({ message, app }) => { const handleOpenSettings = () => { - const settings = getSettings(); - const currentModelKey = getModelKey(); - - // Find the current model - const model = settings.activeModels.find((m) => getModelKeyFromModel(m) === currentModelKey); - - if (!model) { - new Notice("Could not find the current model settings"); - return; - } - - // Create update handler - const handleModelUpdate = ( - isEmbedding: boolean, - original: CustomModel, - updated: CustomModel - ) => { - const updatedModels = settings.activeModels.map((m) => (m === original ? updated : m)); - updateSetting("activeModels", updatedModels); + const setting = app as App & { + setting: { openTabById: (id: string) => { display: () => void } }; }; - - // Open the model edit modal - const modal = new ModelEditModal(app, model, false, handleModelUpdate); - modal.open(); + setting.setting.openTabById("copilot").display(); + new Notice("Adjust the global token limit in Copilot settings."); }; return ( diff --git a/src/components/chat-components/useChatModelPicker.ts b/src/components/chat-components/useChatModelPicker.ts new file mode 100644 index 00000000..8546c00e --- /dev/null +++ b/src/components/chat-components/useChatModelPicker.ts @@ -0,0 +1,108 @@ +import { ModelCapability } from "@/constants"; +import { + backendPickerAtomFamily, + mapProviderTypeToChatModelProvider, + providerRequiresApiKey, + resolveChatModelSelectionId, +} from "@/modelManagement"; +import { getModelKeyFromModel, settingsStore } from "@/settings/model"; +import type { ModelSelectorEntry } from "@/components/ui/ModelSelector"; +import { useAtomValue } from "jotai"; +import React from "react"; + +/** + * Minimal `ChatInput.modelPickerOverride` shape for the non-agent chat picker. + * Omitting `effort`/`commitSelection` makes `ChatInput` render the flat + * `ModelSelector` (decision: no per-pick effort stepper for legacy chat). + */ +export interface ChatModelPickerOverride { + models: ModelSelectorEntry[]; + value: string; + onChange: (modelKey: string) => void; +} + +const NOOP = () => {}; + +/** + * Synthetic disabled row shown when no chat model is enabled, so the picker + * trigger guides the user instead of rendering an empty dropdown. + */ +const EMPTY_ENTRY: ModelSelectorEntry = { + name: "__chat_no_models__", + provider: "", + displayName: "No models — enable under Agents → Quick Chat", + enabled: true, + _disabledReason: "Add a model", +}; +const EMPTY_ENTRY_KEY = getModelKeyFromModel(EMPTY_ENTRY); + +/** + * Drives the chat model picker off the model-management "chat" backend + * (`backends.chat.enabledModels`) instead of the legacy `settings.activeModels`. + * + * Picker entries are keyed by `configuredModelId` (a UUID) rather than the + * legacy `name|provider` key; `value`/`onChange` translate between that id + * (what the caller stores) and the `ModelSelector` model key internally. The + * displayed value reflects the *effective* model — the stored selection if it's + * still enabled, else the first enabled model — matching `resolveChatBackendModel`. + */ +export function useChatModelPicker(params: { + /** Current selection — a `configuredModelId`. */ + value: string; + /** Persist a new `configuredModelId` selection. */ + onChange: (configuredModelId: string) => void; +}): ChatModelPickerOverride { + const { value, onChange } = params; + const entries = useAtomValue(backendPickerAtomFamily("chat"), { store: settingsStore }); + + const { models, byModelKey, idToModelKey } = React.useMemo(() => { + const models: ModelSelectorEntry[] = []; + const byModelKey = new Map(); + const idToModelKey = new Map(); + for (const entry of entries) { + if (entry.state !== "ok") continue; + const { configuredModel, provider, configuredModelId } = entry; + const capabilities: ModelCapability[] = []; + if (configuredModel.info.reasoning) capabilities.push(ModelCapability.REASONING); + if (configuredModel.info.modalities?.input?.includes("image")) { + capabilities.push(ModelCapability.VISION); + } + const needsKey = providerRequiresApiKey(provider) && !provider.apiKeyKeychainId; + const modelEntry: ModelSelectorEntry = { + name: configuredModelId, + provider: mapProviderTypeToChatModelProvider(provider), + displayName: configuredModel.info.displayName || configuredModel.info.id, + enabled: true, + capabilities, + _disabledReason: needsKey ? "Add API key" : undefined, + }; + const modelKey = getModelKeyFromModel(modelEntry); + models.push(modelEntry); + byModelKey.set(modelKey, configuredModelId); + idToModelKey.set(configuredModelId, modelKey); + } + return { models, byModelKey, idToModelKey }; + }, [entries]); + + const resolvedValue = React.useMemo(() => { + const resolvedId = resolveChatModelSelectionId(entries, value); + const current = resolvedId ? idToModelKey.get(resolvedId) : undefined; + if (current) return current; + const first = models[0]; + return first ? getModelKeyFromModel(first) : ""; + }, [entries, value, idToModelKey, models]); + + const handleChange = React.useCallback( + (modelKey: string) => { + const id = byModelKey.get(modelKey); + if (id) onChange(id); + }, + [byModelKey, onChange] + ); + + if (models.length === 0) { + return { models: [EMPTY_ENTRY], value: EMPTY_ENTRY_KEY, onChange: NOOP }; + } + + return { models, value: resolvedValue, onChange: handleChange }; +} diff --git a/src/components/command-ui/menu-command-modal.tsx b/src/components/command-ui/menu-command-modal.tsx index fa887b9b..bd949cb6 100644 --- a/src/components/command-ui/menu-command-modal.tsx +++ b/src/components/command-ui/menu-command-modal.tsx @@ -5,7 +5,7 @@ import { DraggableModal } from "./draggable-modal"; import { CommandLabel } from "./command-label"; import { ContentArea, type ContentState } from "./content-area"; import { FollowUpInput } from "./follow-up-input"; -import { ModelSelector } from "@/components/ui/ModelSelector"; +import { ModelSelector, type ModelSelectorEntry } from "@/components/ui/ModelSelector"; import { Checkbox } from "@/components/ui/checkbox"; import { HelpTooltip } from "@/components/ui/help-tooltip"; import { ActionButtons } from "./action-buttons"; @@ -30,6 +30,8 @@ interface MenuCommandModalProps { selectedModel: string; /** Callback when model changes */ onSelectModel: (modelKey: string) => void; + /** Optional model list override (e.g. the chat-backend picker entries). */ + models?: ModelSelectorEntry[]; onStop?: () => void; onCopy?: () => void; onInsert?: () => void; @@ -74,6 +76,7 @@ export function MenuCommandModal({ onFollowUpSubmit, selectedModel, onSelectModel, + models, onStop, onCopy, onInsert, @@ -185,6 +188,7 @@ export function MenuCommandModal({ variant="ghost" value={selectedModel} onChange={onSelectModel} + models={models} disabled={isBusy} /> {onIncludeNoteContextChange && ( diff --git a/src/components/modals/project/AddProjectModal.tsx b/src/components/modals/project/AddProjectModal.tsx index 3a190ccc..7908427a 100644 --- a/src/components/modals/project/AddProjectModal.tsx +++ b/src/components/modals/project/AddProjectModal.tsx @@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button"; import { FormField } from "@/components/ui/form-field"; import { HelpTooltip } from "@/components/ui/help-tooltip"; import { Input } from "@/components/ui/input"; -import { getModelDisplayWithIcons } from "@/components/ui/model-display"; import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SettingSlider } from "@/components/ui/setting-slider"; @@ -18,12 +17,12 @@ import { UrlTagInput } from "@/components/ui/url-tag-input"; import { SystemPromptSyntaxInstruction } from "@/components/SystemPromptSyntaxInstruction"; import { DEFAULT_MODEL_SETTING } from "@/constants"; import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadgeList"; -import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; -import { checkModelApiKey, err2String, randomUUID } from "@/utils"; +import { err2String, randomUUID } from "@/utils"; import { Settings } from "lucide-react"; import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils"; import type CopilotPlugin from "@/main"; import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions"; import { App, Modal, Notice } from "obsidian"; import React, { useMemo, useState } from "react"; import { Root } from "react-dom/client"; @@ -42,7 +41,9 @@ function AddProjectModalContent({ plugin, }: AddProjectModalContentProps) { const app = useApp(); - const settings = useSettingsValue(); + // Project model options come from the model-management "chat" backend + // (same enabled set as Quick Chat), keyed by configuredModelId. + const { options: chatModelOptions, resolveSelectionId } = useChatBackendModelOptions(); const [isSubmitting, setIsSubmitting] = useState(false); const [touched, setTouched] = useState({ name: false, @@ -51,27 +52,32 @@ function AddProjectModalContent({ inclusions: false, }); - const [formData, setFormData] = useState( - () => - initialProject || { - id: randomUUID(), - name: "", - description: "", - systemPrompt: "", - projectModelKey: "", - modelConfigs: { - temperature: DEFAULT_MODEL_SETTING.TEMPERATURE, - maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS, - }, - contextSource: { - inclusions: "", - exclusions: "", - webUrls: "", - youtubeUrls: "", - }, - created: Date.now(), - UsageTimestamps: Date.now(), - } + const [formData, setFormData] = useState(() => + initialProject + ? { + ...initialProject, + projectModelKey: + resolveSelectionId(initialProject.projectModelKey) || initialProject.projectModelKey, + } + : { + id: randomUUID(), + name: "", + description: "", + systemPrompt: "", + projectModelKey: "", + modelConfigs: { + temperature: DEFAULT_MODEL_SETTING.TEMPERATURE, + maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS, + }, + contextSource: { + inclusions: "", + exclusions: "", + webUrls: "", + youtubeUrls: "", + }, + created: Date.now(), + UsageTimestamps: Date.now(), + } ); // URL items derived from formData for UrlTagInput @@ -310,27 +316,10 @@ function AddProjectModalContent({ > { - const value = e.target.value; - const selectedModel = settings.activeModels.find( - (m) => m.enabled && getModelKeyFromModel(m) === value - ); - if (!selectedModel) return; - - const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings); - if (!hasApiKey && errorNotice) { - // Keep selection allowed; error will surface in chat on send - } - handleInputChange("projectModelKey", value); - }} + onChange={(e) => handleInputChange("projectModelKey", e.target.value)} onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))} placeholder="Select a model" - options={settings.activeModels - .filter((m) => m.enabled && m.projectEnabled) - .map((model) => ({ - label: getModelDisplayWithIcons(model), - value: getModelKeyFromModel(model), - }))} + options={chatModelOptions} /> diff --git a/src/components/quick-ask/QuickAskPanel.tsx b/src/components/quick-ask/QuickAskPanel.tsx index 630e2a6c..35ed1228 100644 --- a/src/components/quick-ask/QuickAskPanel.tsx +++ b/src/components/quick-ask/QuickAskPanel.tsx @@ -15,6 +15,7 @@ import type { ResizeDirection } from "@/hooks/use-resizable"; import { useSettingsValue, updateSetting } from "@/settings/model"; import { cleanMessageForCopy } from "@/utils"; import { ModelSelector } from "@/components/ui/ModelSelector"; +import { useChatModelPicker } from "@/components/chat-components/useChatModelPicker"; import { Checkbox } from "@/components/ui/checkbox"; import { HelpTooltip } from "@/components/ui/help-tooltip"; import { useQuickAskSession } from "./useQuickAskSession"; @@ -66,7 +67,6 @@ export function QuickAskPanel({ selectedText, selectedModelKey, includeNoteContext, - settings, }); // Derived state @@ -240,10 +240,13 @@ export function QuickAskPanel({ [messages, replaceGuard, onClose] ); - const handleModelChange = useCallback((modelKey: string) => { - updateSetting("quickCommandModelKey", modelKey); + const handleModelChange = useCallback((configuredModelId: string) => { + updateSetting("quickCommandModelKey", configuredModelId); }, []); + // Chat-backend picker entries for the model selector. + const chatPicker = useChatModelPicker({ value: selectedModelKey, onChange: handleModelChange }); + const handleIncludeNoteContextChange = useCallback((checked: boolean) => { setIncludeNoteContext(checked); updateSetting("quickCommandIncludeNoteContext", checked); @@ -337,8 +340,9 @@ export function QuickAskPanel({ diff --git a/src/components/quick-ask/useQuickAskSession.ts b/src/components/quick-ask/useQuickAskSession.ts index 79a7f228..20161ff6 100644 --- a/src/components/quick-ask/useQuickAskSession.ts +++ b/src/components/quick-ask/useQuickAskSession.ts @@ -17,16 +17,15 @@ import { } from "@/commands/quickCommandPrompts"; import { processCommandPrompt } from "@/commands/customCommandUtils"; import { useApp } from "@/context"; -import { findCustomModel } from "@/utils"; -import { logError, logWarn } from "@/logger"; +import { useResolvedChatBackendModel } from "@/hooks/useResolvedChatBackendModel"; +import { logError } from "@/logger"; import type { QuickAskMessage } from "./types"; -import type { CopilotSettings } from "@/settings/model"; interface UseQuickAskSessionParams { selectedText: string; + /** Selected model — a `configuredModelId` in the chat backend. */ selectedModelKey: string; includeNoteContext: boolean; - settings: CopilotSettings; } interface QuickAskSessionApi { @@ -42,7 +41,7 @@ interface QuickAskSessionApi { */ export function useQuickAskSession(params: UseQuickAskSessionParams): QuickAskSessionApi { const app = useApp(); - const { selectedText, selectedModelKey, includeNoteContext, settings } = params; + const { selectedText, selectedModelKey, includeNoteContext } = params; // Message history (completed messages only) const [messages, setMessages] = useState([]); @@ -59,22 +58,8 @@ export function useQuickAskSession(params: UseQuickAskSessionParams): QuickAskSe }; }, []); - // Safely resolve the selected model with fallback to first enabled model - const resolvedModel = useMemo(() => { - try { - const model = findCustomModel(selectedModelKey, settings.activeModels); - if (!model.enabled) { - logWarn("Selected model is disabled; falling back to first enabled model.", { - selectedModelKey, - }); - return settings.activeModels.find((m) => m.enabled) ?? null; - } - return model; - } catch { - logWarn("Selected model not found; falling back to first enabled model."); - return settings.activeModels.find((m) => m.enabled) ?? null; - } - }, [selectedModelKey, settings.activeModels]); + // Resolve the selected chat-backend model (preferred id → first enabled → null). + const resolvedModel = useResolvedChatBackendModel(app, selectedModelKey); // Use shared streaming hook const { diff --git a/src/components/ui/ModelParametersEditor.tsx b/src/components/ui/ModelParametersEditor.tsx deleted file mode 100644 index 712f6b63..00000000 --- a/src/components/ui/ModelParametersEditor.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React from "react"; -import { CustomModel } from "@/aiParams"; -import { FormField } from "@/components/ui/form-field"; -import { ParameterControl } from "@/components/ui/parameter-controls"; -import { - ChatModelProviders, - DEFAULT_MODEL_SETTING, - DEFAULT_OLLAMA_NUM_CTX, - ModelCapability, - ReasoningEffort, -} from "@/constants"; -import { CopilotSettings } from "@/settings/model"; -import { - getDefaultReasoningEffort, - getDefaultVerbosity, - REASONING_EFFORT_OPTIONS, - VERBOSITY_OPTIONS, -} from "@/utils/modelParamsHelper"; - -/** - * Parameter range configuration - */ -const PARAM_RANGES = { - temperature: { min: 0, max: 2, step: 0.01, default: DEFAULT_MODEL_SETTING.TEMPERATURE }, - topP: { min: 0, max: 1, step: 0.05, default: 0.9 }, - frequencyPenalty: { min: 0, max: 2, step: 0.05, default: 0 }, - maxTokens: { min: 100, max: 128000, step: 100, default: DEFAULT_MODEL_SETTING.MAX_TOKENS }, - numCtx: { min: 0, max: DEFAULT_OLLAMA_NUM_CTX, step: 1024, default: DEFAULT_OLLAMA_NUM_CTX }, -}; - -interface ModelParametersEditorProps { - model: CustomModel; - settings: CopilotSettings; - onChange: (field: keyof CustomModel, value: CustomModel[keyof CustomModel]) => void; - onReset?: (field: keyof CustomModel) => void; - showTokenLimit?: boolean; // Whether to show Token limit, defaults to true -} - -/** - * Shared model parameters editor component. - * Used by ChatSettingsPopover and ModelEditDialog. - */ -export function ModelParametersEditor({ - model, - settings, - onChange, - onReset, - showTokenLimit = true, -}: ModelParametersEditorProps) { - const provider = model.provider as ChatModelProviders; - const isOllamaModel = provider === ChatModelProviders.OLLAMA; - - // Parameter values: model.xxx ?? settings.xxx - const temperature = model.temperature ?? settings.temperature; - const maxTokens = model.maxTokens ?? settings.maxTokens; - const topP = model.topP; - const frequencyPenalty = model.frequencyPenalty; - // Reason: Ollama defaults to a small num_ctx (2048), so we default to 131072 - // for backward compatibility. Users can adjust via slider. - const numCtx = isOllamaModel ? (model.numCtx ?? PARAM_RANGES.numCtx.default) : model.numCtx; - const reasoningEffort = model.reasoningEffort; - const verbosity = model.verbosity; - - // Check if this is an OpenAI native reasoning model - const isOpenAIReasoningModel = - (model.name.startsWith("o1") || - model.name.startsWith("o3") || - model.name.startsWith("o4") || - model.name.startsWith("gpt-5")) && - provider === ChatModelProviders.OPENAI; - - // Check if model has REASONING capability enabled - const hasReasoningCapability = model.capabilities?.includes(ModelCapability.REASONING) ?? false; - - // Show reasoning effort for: OpenAI reasoning models, OpenRouter, LM Studio, or any model with REASONING capability - const showReasoningEffort = - isOpenAIReasoningModel || - provider === ChatModelProviders.OPENROUTERAI || - model.provider === "lm_studio" || - provider === ChatModelProviders.LM_STUDIO || - hasReasoningCapability; - const showVerbosity = model.name.startsWith("gpt-5") && provider === ChatModelProviders.OPENAI; - - return ( -
- {/* Token limit */} - {showTokenLimit && ( - - onChange("maxTokens", value)} - max={PARAM_RANGES.maxTokens.max} - min={PARAM_RANGES.maxTokens.min} - step={PARAM_RANGES.maxTokens.step} - defaultValue={PARAM_RANGES.maxTokens.default} - helpText={ - <> -

- The maximum number of output tokens to generate. Default is{" "} - {PARAM_RANGES.maxTokens.default}. -

- - This number plus the length of your prompt (input tokens) must be smaller than the - context window of the model. - - - } - /> -
- )} - - {/* num_ctx - Ollama only */} - {isOllamaModel && ( - - onChange("numCtx", value)} - min={PARAM_RANGES.numCtx.min} - max={PARAM_RANGES.numCtx.max} - step={PARAM_RANGES.numCtx.step} - defaultValue={PARAM_RANGES.numCtx.default} - helpText={ - <> -

- The num_ctx parameter sent to Ollama. Controls how many tokens the - model can use as context. Default is {DEFAULT_OLLAMA_NUM_CTX}. -

- - Lower this value to reduce VRAM usage on GPUs with limited memory. Ollama will cap - this at the model's actual maximum. - - - } - /> -
- )} - - {/* Temperature */} - - onChange("temperature", value)} - min={PARAM_RANGES.temperature.min} - max={PARAM_RANGES.temperature.max} - step={PARAM_RANGES.temperature.step} - defaultValue={PARAM_RANGES.temperature.default} - helpText={`Default is ${PARAM_RANGES.temperature.default}. Higher values will result in more creativeness, but also more mistakes. Set to 0 for no randomness.`} - /> - - - {/* Top-P */} - - onChange("topP", value)} - disableFn={onReset ? () => onReset("topP") : undefined} - min={PARAM_RANGES.topP.min} - max={PARAM_RANGES.topP.max} - step={PARAM_RANGES.topP.step} - defaultValue={PARAM_RANGES.topP.default} - helpText={`Default value is ${PARAM_RANGES.topP.default}, the smaller the value, the less variety in the answers, the easier to understand, the larger the value, the larger the range of the AI's vocabulary, the more diverse`} - /> - - - {/* Frequency Penalty */} - - onChange("frequencyPenalty", value)} - disableFn={onReset ? () => onReset("frequencyPenalty") : undefined} - min={PARAM_RANGES.frequencyPenalty.min} - max={PARAM_RANGES.frequencyPenalty.max} - step={PARAM_RANGES.frequencyPenalty.step} - defaultValue={PARAM_RANGES.frequencyPenalty.default} - helpText={ - <> -

- The frequency penalty parameter tells the model not to repeat a word that has - already been used multiple times in the conversation. -

- The higher the value, the more the model is penalized for repeating words. - - } - /> -
- - {/* Reasoning Effort - For models with reasoning capability */} - {showReasoningEffort && ( - - onChange("reasoningEffort", value)} - disableFn={onReset ? () => onReset("reasoningEffort") : undefined} - defaultValue={settings.reasoningEffort ?? getDefaultReasoningEffort()} - options={[ - ...(model.name.startsWith("gpt-5") && model.provider === "openai" - ? [{ value: ReasoningEffort.MINIMAL, label: "Minimal" }] - : []), - ...REASONING_EFFORT_OPTIONS.filter( - (opt) => - opt.value !== ReasoningEffort.MINIMAL && opt.value !== ReasoningEffort.XHIGH - ), - ...(model.name.startsWith("gpt-5.4") && model.provider === "openai" - ? [{ value: ReasoningEffort.XHIGH, label: "Extra High" }] - : []), - ]} - helpText={ - <> -

- Controls the amount of reasoning effort the model uses. Higher effort provides - more thorough reasoning but takes longer. -

-
    - {model.name.startsWith("gpt-5") && model.provider === "openai" && ( -
  • Minimal: Fastest responses, minimal reasoning (GPT-5 only)
  • - )} -
  • Low: Faster responses, basic reasoning (default)
  • -
  • Medium: Balanced performance
  • -
  • High: Thorough reasoning, slower responses
  • - {model.name.startsWith("gpt-5.4") && model.provider === "openai" && ( -
  • Extra High: Maximum reasoning depth (GPT-5.4 only)
  • - )} -
- {!hasReasoningCapability && !isOpenAIReasoningModel && ( -

- Enable the "Reasoning" capability above to use this feature. -

- )} - - } - /> -
- )} - - {/* Verbosity - Only for GPT-5 models */} - {showVerbosity && ( - - onChange("verbosity", value)} - disableFn={onReset ? () => onReset("verbosity") : undefined} - defaultValue={settings.verbosity ?? getDefaultVerbosity()} - options={VERBOSITY_OPTIONS} - helpText={ - <> -

Controls the length and detail of the model responses.

-
    -
  • Low: Concise, brief responses
  • -
  • Medium: Balanced detail
  • -
  • High: Detailed, comprehensive responses
  • -
- - } - /> -
- )} -
- ); -} diff --git a/src/hooks/use-streaming-chat-session.ts b/src/hooks/use-streaming-chat-session.ts index 0bb28427..1438ab9f 100644 --- a/src/hooks/use-streaming-chat-session.ts +++ b/src/hooks/use-streaming-chat-session.ts @@ -81,11 +81,6 @@ interface ChainAndMemory { memory: BaseChatMemory; } -/** Returns a stable key for caching chain per model. */ -function getModelKey(model: CustomModel): string { - return `${model.name}|${model.provider}`; -} - /** Returns true if an aborted signal should skip persistence/commit. */ function shouldSkipPersistOnAbort(signal: AbortSignal): boolean { if (!signal.aborted) return false; @@ -134,10 +129,10 @@ export function useStreamingChatSession( const currentModelKeyRef = useRef(null); const currentSystemPromptRef = useRef(null); - const modelKey = useMemo(() => { - if (!model) return null; - return getModelKey(model); - }, [model]); + // Stable per-model cache key for chain recreation. Bridged chat-backend + // models always carry a configuredModelId; anything without one is treated + // as no usable model (the runTurn/getOrCreateChain guards fall back to onNoModel). + const modelKey = useMemo(() => model?.configuredModelId ?? null, [model]); const setStreamingTextThrottled = useRafThrottledCallback((text: string) => { if (!isMountedRef.current) return; diff --git a/src/hooks/useChatBackendModelOptions.ts b/src/hooks/useChatBackendModelOptions.ts new file mode 100644 index 00000000..df0a79f5 --- /dev/null +++ b/src/hooks/useChatBackendModelOptions.ts @@ -0,0 +1,37 @@ +import { backendPickerAtomFamily, resolveChatModelSelectionId } from "@/modelManagement"; +import { settingsStore } from "@/settings/model"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; + +export interface ChatBackendModelOption { + label: string; + value: string; +} + +export interface ChatBackendModelOptions { + options: ChatBackendModelOption[]; + resolveSelectionId: (selection: string | undefined) => string | undefined; +} + +/** Native-select-ready chat backend options plus legacy-key compatibility resolution. */ +export function useChatBackendModelOptions(): ChatBackendModelOptions { + const entries = useAtomValue(backendPickerAtomFamily("chat"), { store: settingsStore }); + const options = useMemo(() => { + const result: ChatBackendModelOption[] = []; + for (const entry of entries) { + if (entry.state !== "ok") continue; + result.push({ + label: entry.configuredModel.info.displayName || entry.configuredModel.info.id, + value: entry.configuredModelId, + }); + } + return result; + }, [entries]); + + const resolveSelectionId = useCallback( + (selection: string | undefined) => resolveChatModelSelectionId(entries, selection), + [entries] + ); + + return { options, resolveSelectionId }; +} diff --git a/src/hooks/useResolvedChatBackendModel.ts b/src/hooks/useResolvedChatBackendModel.ts new file mode 100644 index 00000000..8ed6272b --- /dev/null +++ b/src/hooks/useResolvedChatBackendModel.ts @@ -0,0 +1,39 @@ +import type { App } from "obsidian"; +import { useMemo } from "react"; +import { useAtomValue } from "jotai"; + +import type { CustomModel } from "@/aiParams"; +import { + backendPickerAtomFamily, + configuredModelToCustomModel, + findChatBackendEntry, +} from "@/modelManagement"; +import { KeychainService } from "@/services/keychainService"; +import { settingsStore } from "@/settings/model"; + +/** + * Resolve the chat backend's selected model into a runnable `CustomModel` for + * surfaces mounted outside the `ModelManagementProvider` (Quick Ask, custom- + * command modals). Mirrors `resolveChatBackendModel`'s policy — preferred id if + * enabled, else first enabled, else `null` — but reads the keychain directly via + * `app` so it needs no model-management React context. Synchronous: keychain + * reads are in-memory. + */ +export function useResolvedChatBackendModel( + app: App, + configuredModelId: string | undefined +): CustomModel | null { + const entries = useAtomValue(backendPickerAtomFamily("chat"), { store: settingsStore }); + return useMemo(() => { + const target = findChatBackendEntry(entries, configuredModelId); + if (!target) return null; + const apiKey = target.provider.apiKeyKeychainId + ? KeychainService.getInstance(app).getSecretById(target.provider.apiKeyKeychainId) + : null; + return configuredModelToCustomModel({ + provider: target.provider, + configuredModel: target.configuredModel, + apiKey, + }); + }, [entries, configuredModelId, app]); +} diff --git a/src/modelManagement/chatModel/chatModelSelection.test.ts b/src/modelManagement/chatModel/chatModelSelection.test.ts new file mode 100644 index 00000000..28b9e277 --- /dev/null +++ b/src/modelManagement/chatModel/chatModelSelection.test.ts @@ -0,0 +1,104 @@ +import { ChatModelProviders } from "@/constants"; +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; +import type { EnabledBackendEntry } from "@/modelManagement/types/runtime"; + +import { findChatBackendEntry, resolveChatModelSelectionId } from "./chatModelSelection"; + +function provider(id: string, overrides: Partial = {}): Provider { + return { + providerId: id, + providerType: "openai-compatible", + displayName: id, + origin: { kind: "byok", catalogProviderId: "openai" }, + addedAt: 0, + apiKeyKeychainId: null, + ...overrides, + }; +} + +function entry( + configuredModelId: string, + wireId: string, + prov: Provider +): Extract { + const configuredModel: ConfiguredModel = { + configuredModelId, + providerId: prov.providerId, + info: { id: wireId, displayName: wireId }, + configuredAt: 0, + }; + return { configuredModelId, state: "ok", configuredModel, provider: prov }; +} + +describe("chatModelSelection", () => { + it("resolves configured-model ids", () => { + const p = provider("p1"); + const entries = [entry("a", "gpt-4o", p), entry("b", "gpt-5", p)]; + + expect(findChatBackendEntry(entries, "b")?.configuredModelId).toBe("b"); + expect(resolveChatModelSelectionId(entries, "b")).toBe("b"); + }); + + it("resolves legacy name|provider keys", () => { + const p = provider("p1"); + const target = entry("a", "gpt-4o", p); + + expect(resolveChatModelSelectionId([target], `gpt-4o|${ChatModelProviders.OPENAI}`)).toBe("a"); + }); + + it("resolves legacy Copilot Plus keys to the Plus configured model", () => { + const plus = provider("plus", { + origin: { kind: "copilot-plus" }, + requiresApiKey: false, + }); + const byok = entry("byok", "gpt-4o", provider("p1")); + const plusEntry = entry("plus-model", "copilot-plus-flash", plus); + + expect( + resolveChatModelSelectionId( + [byok, plusEntry], + `copilot-plus-flash|${ChatModelProviders.COPILOT_PLUS}` + ) + ).toBe("plus-model"); + }); + + it("resolves legacy local-provider aliases after migration to openai-compatible", () => { + const ollama = provider("ollama", { + displayName: "Ollama", + baseUrl: "http://localhost:11434/v1", + origin: { kind: "byok" }, + requiresApiKey: false, + }); + + expect( + resolveChatModelSelectionId( + [entry("ollama-model", "qwen3", ollama)], + `qwen3|${ChatModelProviders.OLLAMA}` + ) + ).toBe("ollama-model"); + }); + + it("resolves a legacy xAI key when a custom endpoint uses the OpenAI-format constructor", () => { + const xai = provider("xai", { + baseUrl: "https://proxy.example.com/v1", + origin: { kind: "byok", catalogProviderId: "xai" }, + }); + + expect( + resolveChatModelSelectionId( + [entry("xai-model", "grok-4", xai)], + `grok-4|${ChatModelProviders.XAI}` + ) + ).toBe("xai-model"); + }); + + it("falls back to the first valid entry for stale selections", () => { + const p = provider("p1"); + const entries: EnabledBackendEntry[] = [ + { configuredModelId: "broken", state: "broken" }, + entry("a", "gpt-4o", p), + ]; + + expect(resolveChatModelSelectionId(entries, "gone")).toBe("a"); + }); +}); diff --git a/src/modelManagement/chatModel/chatModelSelection.ts b/src/modelManagement/chatModel/chatModelSelection.ts new file mode 100644 index 00000000..505891a7 --- /dev/null +++ b/src/modelManagement/chatModel/chatModelSelection.ts @@ -0,0 +1,73 @@ +import { ChatModelProviders } from "@/constants"; +import type { EnabledBackendEntry } from "@/modelManagement/types/runtime"; + +import { + CATALOG_ID_TO_CHAT_PROVIDER, + mapProviderTypeToChatModelProvider, +} from "./configuredModelToCustomModel"; + +export type ResolvedChatBackendEntry = Extract; + +const DISPLAY_NAME_TO_LEGACY_PROVIDER: Record = { + ollama: ChatModelProviders.OLLAMA, + "lm studio": ChatModelProviders.LM_STUDIO, + "openai format": ChatModelProviders.OPENAI_FORMAT, + cohere: ChatModelProviders.COHEREAI, + siliconflow: ChatModelProviders.SILICONFLOW, +}; + +/** + * Legacy selections used `wireModelId|ChatModelProviders`; keep them resolvable + * during migration. A model may have been persisted under different provider + * spellings depending on how it was selected, so enumerate every plausible form. + */ +function getLegacyChatModelKeys(entry: ResolvedChatBackendEntry): readonly string[] { + const providers = new Set([ + mapProviderTypeToChatModelProvider(entry.provider), + ]); + if (entry.provider.origin.kind === "copilot-plus") { + providers.add(ChatModelProviders.COPILOT_PLUS); + } + if (entry.provider.origin.kind === "byok" && entry.provider.origin.catalogProviderId) { + const catalogProvider = CATALOG_ID_TO_CHAT_PROVIDER[entry.provider.origin.catalogProviderId]; + if (catalogProvider) providers.add(catalogProvider); + } + const displayProvider = DISPLAY_NAME_TO_LEGACY_PROVIDER[entry.provider.displayName.toLowerCase()]; + if (displayProvider) providers.add(displayProvider); + + return [...providers].map((provider) => `${entry.configuredModel.info.id}|${provider}`); +} + +export function isChatModelSelectionForEntry( + entry: ResolvedChatBackendEntry, + selection: string +): boolean { + return entry.configuredModelId === selection || getLegacyChatModelKeys(entry).includes(selection); +} + +/** + * Resolve a persisted chat selection. New writes are configured-model IDs, while legacy + * `name|provider` keys remain readable for settings, project files, and command frontmatter. + */ +export function findChatBackendEntry( + entries: readonly EnabledBackendEntry[], + preferredSelection: string | undefined +): ResolvedChatBackendEntry | undefined { + const okEntries = entries.filter( + (entry): entry is ResolvedChatBackendEntry => entry.state === "ok" + ); + if (!preferredSelection) return okEntries[0]; + + return ( + okEntries.find((entry) => isChatModelSelectionForEntry(entry, preferredSelection)) ?? + okEntries[0] + ); +} + +/** Return the configured-model ID represented by either a new or legacy selection. */ +export function resolveChatModelSelectionId( + entries: readonly EnabledBackendEntry[], + selection: string | undefined +): string | undefined { + return findChatBackendEntry(entries, selection)?.configuredModelId; +} diff --git a/src/modelManagement/chatModel/configuredModelToCustomModel.test.ts b/src/modelManagement/chatModel/configuredModelToCustomModel.test.ts new file mode 100644 index 00000000..67f2dd32 --- /dev/null +++ b/src/modelManagement/chatModel/configuredModelToCustomModel.test.ts @@ -0,0 +1,204 @@ +import { ChatModelProviders, ModelCapability } from "@/constants"; +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; + +import { + configuredModelToCustomModel, + mapProviderTypeToChatModelProvider, +} from "./configuredModelToCustomModel"; + +function provider(overrides: Partial = {}): Provider { + return { + providerId: "p1", + providerType: "openai-compatible", + displayName: "P", + origin: { kind: "byok" }, + addedAt: 0, + apiKeyKeychainId: null, + ...overrides, + }; +} + +function configuredModel(overrides: Partial = {}): ConfiguredModel { + return { + configuredModelId: "cm1", + providerId: "p1", + info: { id: "gpt-5", displayName: "GPT-5" }, + configuredAt: 0, + ...overrides, + }; +} + +describe("mapProviderTypeToChatModelProvider", () => { + it("maps the dedicated providerTypes directly", () => { + expect(mapProviderTypeToChatModelProvider(provider({ providerType: "anthropic" }))).toBe( + ChatModelProviders.ANTHROPIC + ); + expect(mapProviderTypeToChatModelProvider(provider({ providerType: "google" }))).toBe( + ChatModelProviders.GOOGLE + ); + expect(mapProviderTypeToChatModelProvider(provider({ providerType: "azure" }))).toBe( + ChatModelProviders.AZURE_OPENAI + ); + expect(mapProviderTypeToChatModelProvider(provider({ providerType: "bedrock" }))).toBe( + ChatModelProviders.AMAZON_BEDROCK + ); + }); + + it("refines openai-compatible via the BYOK catalog provider id", () => { + const cases: Array<[string, ChatModelProviders]> = [ + ["openai", ChatModelProviders.OPENAI], + ["groq", ChatModelProviders.GROQ], + ["mistral", ChatModelProviders.MISTRAL], + ["openrouter", ChatModelProviders.OPENROUTERAI], + ["deepseek", ChatModelProviders.DEEPSEEK], + ["xai", ChatModelProviders.XAI], + ["cohere", ChatModelProviders.COHEREAI], + ["siliconflow", ChatModelProviders.SILICONFLOW], + ]; + for (const [catalogProviderId, expected] of cases) { + expect( + mapProviderTypeToChatModelProvider( + provider({ origin: { kind: "byok", catalogProviderId } }) + ) + ).toBe(expected); + } + }); + + it("falls back to OPENAI_FORMAT for unknown / catalog-less openai-compatible providers", () => { + // Together / Fireworks / arbitrary proxies, and the Ollama / LM Studio + // built-in templates (no catalogProviderId, /v1 base URL) all route here. + expect( + mapProviderTypeToChatModelProvider( + provider({ origin: { kind: "byok", catalogProviderId: "together" } }) + ) + ).toBe(ChatModelProviders.OPENAI_FORMAT); + expect( + mapProviderTypeToChatModelProvider( + provider({ baseUrl: "http://localhost:11434/v1", origin: { kind: "byok" } }) + ) + ).toBe(ChatModelProviders.OPENAI_FORMAT); + }); + + it("maps Copilot Plus providers to the dedicated Plus constructor", () => { + expect( + mapProviderTypeToChatModelProvider( + provider({ origin: { kind: "copilot-plus" }, requiresApiKey: false }) + ) + ).toBe(ChatModelProviders.COPILOT_PLUS); + }); + + it("routes custom xAI endpoints through the OpenAI-format constructor", () => { + expect( + mapProviderTypeToChatModelProvider( + provider({ + baseUrl: "https://proxy.example.com/v1", + origin: { kind: "byok", catalogProviderId: "xai" }, + }) + ) + ).toBe(ChatModelProviders.OPENAI_FORMAT); + }); +}); + +describe("configuredModelToCustomModel", () => { + it("uses the wire id as the model name and the snapshot display name", () => { + const custom = configuredModelToCustomModel({ + provider: provider({ providerType: "anthropic" }), + configuredModel: configuredModel({ + info: { id: "claude-sonnet-4-5", displayName: "Claude Sonnet 4.5" }, + }), + apiKey: "sk-ant", + }); + expect(custom.name).toBe("claude-sonnet-4-5"); + expect(custom.displayName).toBe("Claude Sonnet 4.5"); + expect(custom.provider).toBe(ChatModelProviders.ANTHROPIC); + expect(custom.apiKey).toBe("sk-ant"); + expect(custom.enabled).toBe(true); + expect(custom.configuredModelId).toBe("cm1"); + }); + + it("passes the resolved key through and carries the provider base URL", () => { + const custom = configuredModelToCustomModel({ + provider: provider({ baseUrl: "https://api.example.com/v1" }), + configuredModel: configuredModel(), + apiKey: "key-123", + }); + expect(custom.apiKey).toBe("key-123"); + expect(custom.baseUrl).toBe("https://api.example.com/v1"); + }); + + it("substitutes a placeholder key only for keyless providers", () => { + const keyless = configuredModelToCustomModel({ + provider: provider({ requiresApiKey: false, baseUrl: "http://localhost:11434/v1" }), + configuredModel: configuredModel(), + apiKey: null, + }); + expect(keyless.apiKey).toBe("default-key"); + + const requiresKey = configuredModelToCustomModel({ + provider: provider({ requiresApiKey: true }), + configuredModel: configuredModel(), + apiKey: null, + }); + expect(requiresKey.apiKey).toBeUndefined(); + }); + + it("does not substitute a placeholder key for Copilot Plus", () => { + const custom = configuredModelToCustomModel({ + provider: provider({ origin: { kind: "copilot-plus" }, requiresApiKey: false }), + configuredModel: configuredModel(), + apiKey: null, + }); + expect(custom.apiKey).toBeUndefined(); + }); + + it("derives capabilities from the model snapshot", () => { + const custom = configuredModelToCustomModel({ + provider: provider({ providerType: "anthropic" }), + configuredModel: configuredModel({ + info: { + id: "claude-sonnet-4-5", + displayName: "Claude Sonnet 4.5", + reasoning: true, + modalities: { input: ["text", "image"] }, + }, + }), + apiKey: "k", + }); + expect(custom.capabilities).toEqual([ModelCapability.REASONING, ModelCapability.VISION]); + }); + + it("maps provider extras onto the matching CustomModel fields", () => { + const azure = configuredModelToCustomModel({ + provider: provider({ + providerType: "azure", + extras: { + azureInstanceName: "my-instance", + azureDeploymentName: "my-deploy", + azureApiVersion: "2024-05-01-preview", + }, + }), + configuredModel: configuredModel(), + apiKey: "azure-key", + }); + expect(azure.azureOpenAIApiInstanceName).toBe("my-instance"); + expect(azure.azureOpenAIApiDeploymentName).toBe("my-deploy"); + expect(azure.azureOpenAIApiVersion).toBe("2024-05-01-preview"); + + const bedrock = configuredModelToCustomModel({ + provider: provider({ providerType: "bedrock", extras: { bedrockRegion: "us-west-2" } }), + configuredModel: configuredModel(), + apiKey: "aws-key", + }); + expect(bedrock.bedrockRegion).toBe("us-west-2"); + + const openai = configuredModelToCustomModel({ + provider: provider({ + origin: { kind: "byok", catalogProviderId: "openai" }, + extras: { openAIOrgId: "org-1" }, + }), + configuredModel: configuredModel(), + apiKey: "sk", + }); + expect(openai.openAIOrgId).toBe("org-1"); + }); +}); diff --git a/src/modelManagement/chatModel/configuredModelToCustomModel.ts b/src/modelManagement/chatModel/configuredModelToCustomModel.ts new file mode 100644 index 00000000..16de9555 --- /dev/null +++ b/src/modelManagement/chatModel/configuredModelToCustomModel.ts @@ -0,0 +1,154 @@ +/** + * Bridge: a model-management `ConfiguredModel` (+ its `Provider`) → the legacy + * `CustomModel` shape `ChatModelManager` instantiates from. + * + * The v4 `ChatModelFactory` (which would build a LangChain client directly from + * a `ConfiguredModel`) is still a stub. Rather than implement every provider + * adapter, the chat backend reuses the proven `ChatModelManager` engine by + * mapping a selected `ConfiguredModel` back into a `CustomModel`. Selection data + * lives in the registries; instantiation stays on the battle-tested path. + * + * Pure — no registry/keychain access. The caller resolves the API key + * (`resolveChatBackendModel`) and passes it in. + */ + +import { CustomModel } from "@/aiParams"; +import { ChatModelProviders, ModelCapability, ProviderInfo } from "@/constants"; +import { logWarn } from "@/logger"; +import { providerRequiresApiKey } from "@/modelManagement/providers/providerRequiresApiKey"; +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; + +/** + * Canonical `models.dev` catalog-id → legacy `ChatModelProviders` table. Two consumers: + * - `mapProviderTypeToChatModelProvider` refines an `openai-compatible` provider into its + * dedicated `ChatModelManager` constructor. Everything else (the Ollama / LM Studio + * built-in templates, whose base URLs already carry `/v1`, and arbitrary custom proxies) + * routes through `OPENAI_FORMAT` — generic `ChatOpenAI` driven by the provider's + * `baseUrl`. Falling through is safe: it loses provider-specific niceties (e.g. OpenRouter + * reasoning/caching headers) but still serves chat. + * - `getLegacyChatModelKeys` (in `chatModelSelection`) enumerates legacy `name|provider` + * selection keys. + * `anthropic`/`google` are included for the legacy-key path; the chat path reaches them via + * the `providerType` switch *before* this lookup, so they're never consulted in the + * `openai-compatible` branch. + */ +export const CATALOG_ID_TO_CHAT_PROVIDER: Record = { + openai: ChatModelProviders.OPENAI, + groq: ChatModelProviders.GROQ, + mistral: ChatModelProviders.MISTRAL, + openrouter: ChatModelProviders.OPENROUTERAI, + deepseek: ChatModelProviders.DEEPSEEK, + xai: ChatModelProviders.XAI, + cohere: ChatModelProviders.COHEREAI, + siliconflow: ChatModelProviders.SILICONFLOW, + anthropic: ChatModelProviders.ANTHROPIC, + google: ChatModelProviders.GOOGLE, +}; + +function normalizeBaseUrl(value: string | undefined): string { + return (value ?? "").trim().replace(/\/+$/, "").toLowerCase(); +} + +/** + * Pick the `ChatModelProviders` value `ChatModelManager` dispatches on. This is + * the load-bearing decision — it selects the LangChain class. `providerType` is + * the coarse dispatch; for `openai-compatible` we refine via the BYOK origin's + * `catalogProviderId` so the dedicated constructors (Groq, DeepSeek, …) are used + * when known. + */ +export function mapProviderTypeToChatModelProvider(provider: Provider): ChatModelProviders { + if (provider.origin.kind === "copilot-plus") { + return ChatModelProviders.COPILOT_PLUS; + } + + switch (provider.providerType) { + case "anthropic": + return ChatModelProviders.ANTHROPIC; + case "google": + return ChatModelProviders.GOOGLE; + case "azure": + return ChatModelProviders.AZURE_OPENAI; + case "bedrock": + return ChatModelProviders.AMAZON_BEDROCK; + case "openai-compatible": { + const catalogId = + provider.origin.kind === "byok" ? provider.origin.catalogProviderId : undefined; + if ( + catalogId === "xai" && + provider.baseUrl && + normalizeBaseUrl(provider.baseUrl) !== + normalizeBaseUrl(ProviderInfo[ChatModelProviders.XAI].host) + ) { + return ChatModelProviders.OPENAI_FORMAT; + } + const mapped = catalogId ? CATALOG_ID_TO_CHAT_PROVIDER[catalogId] : undefined; + return mapped ?? ChatModelProviders.OPENAI_FORMAT; + } + default: { + // `ProviderType` is a closed union, so this is unreachable today; the + // `never` binding makes a future member without a mapping a compile + // error rather than a silent OpenAI-format fallback. + const unknownType: never = provider.providerType; + logWarn( + `[chatBridge] unknown providerType "${String(unknownType)}"; defaulting to OpenAI-format` + ); + return ChatModelProviders.OPENAI_FORMAT; + } + } +} + +function extraString(extras: Record, key: string): string | undefined { + const value = extras[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Build the `CustomModel` for a resolved chat-backend selection. + * + * Per-model tuning (temperature / maxTokens / reasoning effort) is left unset: + * `ChatModelManager.getModelConfig` falls back to the global settings defaults, + * and reasoning/thinking behavior is derived from the wire model id + * (`getModelInfo`). Re-homing per-model overrides onto `ConfiguredModel` is a + * follow-up. + */ +export function configuredModelToCustomModel(params: { + provider: Provider; + configuredModel: ConfiguredModel; + /** Plaintext key resolved from the keychain; `null` when none stored. */ + apiKey: string | null; +}): CustomModel { + const { provider, configuredModel, apiKey } = params; + const info = configuredModel.info; + const extras = provider.extras ?? {}; + + const trimmedKey = apiKey && apiKey.length > 0 ? apiKey : undefined; + // Keyless providers (Ollama, LM Studio, unauthenticated proxies) still need a + // non-empty placeholder so the OpenAI-format client constructs — the legacy + // path used the same "default-key" sentinel. When a key IS required but + // missing, leave it undefined so credential validation fails loudly. + const resolvedApiKey = + trimmedKey ?? + (provider.origin.kind === "copilot-plus" || providerRequiresApiKey(provider) + ? undefined + : "default-key"); + + const capabilities: ModelCapability[] = []; + if (info.reasoning) capabilities.push(ModelCapability.REASONING); + if (info.modalities?.input?.includes("image")) capabilities.push(ModelCapability.VISION); + + return { + configuredModelId: configuredModel.configuredModelId, + name: info.id, + provider: mapProviderTypeToChatModelProvider(provider), + displayName: info.displayName, + enabled: true, + baseUrl: provider.baseUrl, + apiKey: resolvedApiKey, + capabilities, + openAIOrgId: extraString(extras, "openAIOrgId"), + azureOpenAIApiInstanceName: extraString(extras, "azureInstanceName"), + azureOpenAIApiDeploymentName: extraString(extras, "azureDeploymentName"), + azureOpenAIApiVersion: extraString(extras, "azureApiVersion"), + bedrockRegion: extraString(extras, "bedrockRegion"), + }; +} diff --git a/src/modelManagement/chatModel/resolveChatBackendModel.test.ts b/src/modelManagement/chatModel/resolveChatBackendModel.test.ts new file mode 100644 index 00000000..1eac4514 --- /dev/null +++ b/src/modelManagement/chatModel/resolveChatBackendModel.test.ts @@ -0,0 +1,104 @@ +import type { ModelManagementApi } from "@/modelManagement/createModelManagement"; +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; +import type { EnabledBackendEntry } from "@/modelManagement/types/runtime"; + +import { resolveChatBackendModel } from "./resolveChatBackendModel"; + +function provider(id: string, overrides: Partial = {}): Provider { + return { + providerId: id, + providerType: "anthropic", + displayName: id, + origin: { kind: "byok" }, + addedAt: 0, + apiKeyKeychainId: null, + ...overrides, + }; +} + +function okEntry(configuredModelId: string, prov: Provider): EnabledBackendEntry { + const configuredModel: ConfiguredModel = { + configuredModelId, + providerId: prov.providerId, + info: { id: `${configuredModelId}-wire`, displayName: configuredModelId }, + configuredAt: 0, + }; + return { configuredModelId, state: "ok", configuredModel, provider: prov }; +} + +/** Minimal api stub exposing only what the resolver reads. */ +function makeApi( + entries: readonly EnabledBackendEntry[], + keyByProvider: Record = {} +): Pick { + return { + backendConfigRegistry: { + resolveEnabled: (backend: string) => (backend === "chat" ? entries : []), + } as unknown as ModelManagementApi["backendConfigRegistry"], + providerRegistry: { + getApiKey: async (providerId: string) => keyByProvider[providerId] ?? null, + } as unknown as ModelManagementApi["providerRegistry"], + }; +} + +describe("resolveChatBackendModel", () => { + it("resolves the preferred model when it is enabled", async () => { + const p = provider("p1"); + const api = makeApi([okEntry("a", p), okEntry("b", p)], { p1: "key" }); + + const result = await resolveChatBackendModel(api, "b"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.configuredModelId).toBe("b"); + expect(result.customModel.apiKey).toBe("key"); + } + }); + + it("falls back to the first enabled model when the preferred id is gone", async () => { + const p = provider("p1"); + const api = makeApi([okEntry("a", p), okEntry("b", p)], { p1: "key" }); + + const result = await resolveChatBackendModel(api, "stale-uuid"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.configuredModelId).toBe("a"); + }); + + it("resolves a legacy name|provider selection", async () => { + const p = provider("p1"); + const api = makeApi([okEntry("a", p)], { p1: "key" }); + + const result = await resolveChatBackendModel(api, "a-wire|anthropic"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.configuredModelId).toBe("a"); + }); + + it("falls back to the first enabled model when no preference is given", async () => { + const p = provider("p1"); + const api = makeApi([okEntry("a", p)], { p1: "key" }); + + const result = await resolveChatBackendModel(api, undefined); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.configuredModelId).toBe("a"); + }); + + it("returns empty when nothing is enabled", async () => { + const api = makeApi([]); + const result = await resolveChatBackendModel(api, "anything"); + expect(result).toEqual({ ok: false, reason: "empty" }); + }); + + it("skips broken refs and resolves the first ok entry", async () => { + const p = provider("p1"); + const broken: EnabledBackendEntry = { configuredModelId: "x", state: "broken" }; + const api = makeApi([broken, okEntry("a", p)], { p1: "key" }); + + const result = await resolveChatBackendModel(api, "x"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.configuredModelId).toBe("a"); + }); +}); diff --git a/src/modelManagement/chatModel/resolveChatBackendModel.ts b/src/modelManagement/chatModel/resolveChatBackendModel.ts new file mode 100644 index 00000000..cc981074 --- /dev/null +++ b/src/modelManagement/chatModel/resolveChatBackendModel.ts @@ -0,0 +1,49 @@ +/** + * Resolve the chat backend's selected model into a runnable `CustomModel`. + * + * Single entry point shared by every chat surface (main chat, project, vault + * QA, quick command, quick ask) so they all apply the same selection + fallback + * policy: + * - the passed `configuredModelId` if it's still enabled in `backends.chat`; + * - otherwise the first enabled chat model (stale/removed selection); + * - otherwise `{ ok: false, reason: "empty" }` — nothing enabled, UI prompts + * the user to enable a model under Settings → Agents → Quick Chat. + */ + +import { CustomModel } from "@/aiParams"; +import { logWarn } from "@/logger"; +import type { ModelManagementApi } from "@/modelManagement/createModelManagement"; + +import { findChatBackendEntry, isChatModelSelectionForEntry } from "./chatModelSelection"; +import { configuredModelToCustomModel } from "./configuredModelToCustomModel"; + +export type ChatBackendResolution = + | { ok: true; configuredModelId: string; customModel: CustomModel } + | { ok: false; reason: "empty" }; + +export async function resolveChatBackendModel( + api: Pick, + preferredConfiguredModelId: string | undefined +): Promise { + const enabled = api.backendConfigRegistry.resolveEnabled("chat"); + const target = findChatBackendEntry(enabled, preferredConfiguredModelId); + if (!target) return { ok: false, reason: "empty" }; + + if ( + preferredConfiguredModelId && + !isChatModelSelectionForEntry(target, preferredConfiguredModelId) + ) { + logWarn( + `[chatBridge] chat model "${preferredConfiguredModelId}" is not enabled; ` + + `falling back to configuredModelId="${target.configuredModelId}"` + ); + } + + const apiKey = await api.providerRegistry.getApiKey(target.provider.providerId); + const customModel = configuredModelToCustomModel({ + provider: target.provider, + configuredModel: target.configuredModel, + apiKey, + }); + return { ok: true, configuredModelId: target.configuredModelId, customModel }; +} diff --git a/src/modelManagement/index.ts b/src/modelManagement/index.ts index ffeb4bb6..81b7dd83 100644 --- a/src/modelManagement/index.ts +++ b/src/modelManagement/index.ts @@ -37,6 +37,18 @@ export { providerRequiresApiKey } from "./providers/providerRequiresApiKey"; export { ConfiguredModelRegistry } from "./models/ConfiguredModelRegistry"; export { BackendConfigRegistry } from "./backends/BackendConfigRegistry"; export { ChatModelFactory } from "./chatModel/ChatModelFactory"; +export { + configuredModelToCustomModel, + mapProviderTypeToChatModelProvider, +} from "./chatModel/configuredModelToCustomModel"; +export { + findChatBackendEntry, + isChatModelSelectionForEntry, + resolveChatModelSelectionId, +} from "./chatModel/chatModelSelection"; +export type { ResolvedChatBackendEntry } from "./chatModel/chatModelSelection"; +export { resolveChatBackendModel } from "./chatModel/resolveChatBackendModel"; +export type { ChatBackendResolution } from "./chatModel/resolveChatBackendModel"; // --------------------------------------------------------------------------- // Provider adapter contract diff --git a/src/plusUtils.ts b/src/plusUtils.ts index a7a7cced..6b09e621 100644 --- a/src/plusUtils.ts +++ b/src/plusUtils.ts @@ -81,6 +81,13 @@ export function isSelfHostModeValid(): boolean { /** Check if the model key is a Copilot Plus model. */ export function isPlusModel(modelKey: string): boolean { + const settings = getSettings(); + const configuredModel = settings.configuredModels.find( + (model) => model.configuredModelId === modelKey + ); + if (configuredModel) { + return settings.providers[configuredModel.providerId]?.origin.kind === "copilot-plus"; + } return ( (modelKey.split("|")[1] as EmbeddingModelProviders) === EmbeddingModelProviders.COPILOT_PLUS ); @@ -336,9 +343,20 @@ export async function refreshSelfHostModeValidation(): Promise { * as the automatic detection doesn't work reliably in all scenarios. */ export function applyPlusSettings(): void { - const defaultModelKey = DEFAULT_COPILOT_PLUS_CHAT_MODEL_KEY; + const settings = getSettings(); + const plusProviderIds = new Set( + Object.values(settings.providers) + .filter((provider) => provider.origin.kind === "copilot-plus") + .map((provider) => provider.providerId) + ); + const defaultModelKey = + settings.configuredModels.find( + (model) => + plusProviderIds.has(model.providerId) && + model.info.id === (DEFAULT_COPILOT_PLUS_CHAT_MODEL as string) + )?.configuredModelId ?? DEFAULT_COPILOT_PLUS_CHAT_MODEL_KEY; const embeddingModelKey = DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL_KEY; - const previousEmbeddingModelKey = getSettings().embeddingModelKey; + const previousEmbeddingModelKey = settings.embeddingModelKey; logInfo("applyPlusSettings: Changing embedding model", { from: previousEmbeddingModelKey, diff --git a/src/settings/v2/components/AgentSettings.tsx b/src/settings/v2/components/AgentSettings.tsx index 0c730c70..3abcff0d 100644 --- a/src/settings/v2/components/AgentSettings.tsx +++ b/src/settings/v2/components/AgentSettings.tsx @@ -14,6 +14,7 @@ import { setSettings, useSettingsValue } from "@/settings/model"; import { formatBinaryPathForDisplay } from "@/utils/binaryPath"; import { Platform } from "obsidian"; import React from "react"; +import { ChatModelEnableList } from "./ChatModelEnableList"; import { ConfiguredModelEnableList } from "./ConfiguredModelEnableList"; /** @@ -67,11 +68,33 @@ export const AgentSettings: React.FC = () => { {orderedDescriptors.map((descriptor) => ( ))} + +
); }; +/** + * Quick Chat curation card: which models appear in the (non-agent) chat model + * picker. Lives under Agents per the model-management design (chat is a + * first-class curation backend alongside the agents). Models come from the + * BYOK / Plus registries — chat doesn't own providers. + */ +const QuickChatSection: React.FC = () => { + return ( +
+
+ Quick Chat models + + Models shown in the chat model picker. Add providers on the Models (BYOK) tab. + +
+ +
+ ); +}; + /** * One per-backend block: heading, binary install panel, and the model enable * list. If the backend is installed but no catalog is cached yet, it kicks a diff --git a/src/settings/v2/components/BasicSettings.tsx b/src/settings/v2/components/BasicSettings.tsx index 96ac8f53..dfbf7067 100644 --- a/src/settings/v2/components/BasicSettings.tsx +++ b/src/settings/v2/components/BasicSettings.tsx @@ -2,15 +2,15 @@ import { ChainType } from "@/chainType"; import { Button } from "@/components/ui/button"; import { HelpTooltip } from "@/components/ui/help-tooltip"; import { Input } from "@/components/ui/input"; -import { getModelDisplayWithIcons } from "@/components/ui/model-display"; import { SettingItem } from "@/components/ui/setting-item"; import { DEFAULT_OPEN_AREA, PLUS_UTM_MEDIUMS, SEND_SHORTCUT } from "@/constants"; import { cn } from "@/lib/utils"; import { createPlusPageUrl } from "@/plusUtils"; -import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model"; +import { updateSetting, useSettingsValue } from "@/settings/model"; import { PlusSettings } from "@/settings/v2/components/PlusSettings"; -import { checkModelApiKey, formatDateTime } from "@/utils"; +import { formatDateTime } from "@/utils"; import { isSortStrategy } from "@/utils/recentUsageManager"; +import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions"; import { Loader2 } from "lucide-react"; import { Notice } from "obsidian"; import React, { useState } from "react"; @@ -79,15 +79,11 @@ export const BasicSettings: React.FC = () => { } }; - const defaultModelActivated = !!settings.activeModels.find( - (m) => m.enabled && getModelKeyFromModel(m) === settings.defaultModelKey - ); - const enableActivatedModels = settings.activeModels - .filter((m) => m.enabled) - .map((model) => ({ - label: getModelDisplayWithIcons(model), - value: getModelKeyFromModel(model), - })); + // Default chat model now comes from the model-management "chat" backend + // (the Quick Chat list under Agents), keyed by configuredModelId. + const { options: chatModelOptions, resolveSelectionId } = useChatBackendModelOptions(); + const resolvedDefaultModelId = resolveSelectionId(settings.defaultModelKey); + const defaultModelActivated = resolvedDefaultModelId !== undefined; return (
@@ -106,35 +102,24 @@ export const BasicSettings: React.FC = () => { -
- Default model is OpenRouter Gemini 2.5 Flash -
- Set your OpenRouter API key in 'API keys' to use this model, or - select a different model from another provider. + Chat models are curated in the Quick Chat list under the Agents tab. Add + providers on the Models (BYOK) tab to populate it.
} /> } - value={defaultModelActivated ? settings.defaultModelKey : "Select Model"} + value={resolvedDefaultModelId ?? "Select Model"} onChange={(value) => { - const selectedModel = settings.activeModels.find( - (m) => m.enabled && getModelKeyFromModel(m) === value - ); - if (!selectedModel) return; - - const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings); - if (!hasApiKey && errorNotice) { - // Keep selection allowed; error will surface in chat on send - } + if (value === "Select Model") return; updateSetting("defaultModelKey", value); }} options={ defaultModelActivated - ? enableActivatedModels - : [{ label: "Select Model", value: "Select Model" }, ...enableActivatedModels] + ? chatModelOptions + : [{ label: "Select Model", value: "Select Model" }, ...chatModelOptions] } placeholder="Model" /> diff --git a/src/settings/v2/components/ChatModelEnableList.tsx b/src/settings/v2/components/ChatModelEnableList.tsx new file mode 100644 index 00000000..b0831065 --- /dev/null +++ b/src/settings/v2/components/ChatModelEnableList.tsx @@ -0,0 +1,75 @@ +import { ModelEnableList, type ModelEnableGroup } from "@/agentMode"; +import { logError } from "@/logger"; +import { + backendsAtom, + configuredModelsAtom, + providersAtom, + useModelManagement, +} from "@/modelManagement"; +import { settingsStore } from "@/settings/model"; +import { useAtomValue } from "jotai"; +import React from "react"; +import { buildModelEnableGroups, partitionChatCandidates } from "./configuredModelGrouping"; + +/** Frozen empty fallback so the untouched chat backend's enabled set is stable. */ +const EMPTY_ENABLED: readonly string[] = Object.freeze([]); + +/** + * Curation list for the non-agent "chat" backend (Quick Chat). Sources every + * BYOK / Copilot Plus configured chat model from the registry and toggles + * `backends.chat` through `BackendConfigRegistry`. Reuses the shared + * `ModelEnableList` UI and grouping helpers; agent-origin models are excluded + * because the chat backend instantiates via LangChain, not an agent CLI. + */ +export const ChatModelEnableList: React.FC = () => { + const api = useModelManagement(); + + const configuredModels = useAtomValue(configuredModelsAtom, { store: settingsStore }); + const providers = useAtomValue(providersAtom, { store: settingsStore }); + const backends = useAtomValue(backendsAtom, { store: settingsStore }); + + const [query, setQuery] = React.useState(""); + + const enabledIds = React.useMemo( + () => new Set(backends.chat?.enabledModels ?? EMPTY_ENABLED), + [backends] + ); + + const partition = React.useMemo( + () => partitionChatCandidates(configuredModels, providers, enabledIds), + [configuredModels, providers, enabledIds] + ); + + const groups = React.useMemo( + () => buildModelEnableGroups(partition, false, query), + [partition, query] + ); + + const handleToggle = React.useCallback( + (id: string, enabled: boolean) => { + const run = enabled + ? api.backendConfigRegistry.enableModel("chat", id) + : api.backendConfigRegistry.disableModel("chat", id); + run.catch((err) => logError(`[QuickChat] toggle model ${id} failed`, err)); + }, + [api] + ); + + const emptyState = ( + + No models configured yet. Add a provider on the{" "} + Models (BYOK) tab to populate Quick Chat. + + ); + + return ( + + ); +}; diff --git a/src/settings/v2/components/ModelEditDialog.tsx b/src/settings/v2/components/ModelEditDialog.tsx deleted file mode 100644 index bea5f7db..00000000 --- a/src/settings/v2/components/ModelEditDialog.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { CustomModel } from "@/aiParams"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { FormField } from "@/components/ui/form-field"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; - -import { HelpTooltip } from "@/components/ui/help-tooltip"; -import { - ChatModelProviders, - EmbeddingModelProviders, - MODEL_CAPABILITIES, - ModelCapability, - ProviderMetadata, - SettingKeyProviders, -} from "@/constants"; -import { getSettings } from "@/settings/model"; -import { debounce, getProviderInfo, getProviderLabel } from "@/utils"; -import { getApiKeyForProvider } from "@/utils/modelUtils"; -import { App, Modal, Platform } from "obsidian"; -import React, { useCallback, useMemo, useState } from "react"; -import { Root } from "react-dom/client"; -import { createPluginRoot } from "@/utils/react/createPluginRoot"; -import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor"; - -interface ModelEditModalContentProps { - model: CustomModel; - isEmbeddingModel: boolean; - onUpdate: ( - isEmbeddingModel: boolean, - originalModel: CustomModel, - updatedModel: CustomModel - ) => void; - onCancel: () => void; -} - -const ModelEditModalContent: React.FC = ({ - model, - onUpdate, - isEmbeddingModel, - onCancel, -}) => { - // Reason: `model` is passed in once when ModelEditModal renders its React root - // (see class below) and never changes for the lifetime of this component — the - // modal unmounts on close and a new instance is constructed for each edit. - // No prop→state sync is needed; `useState(model)` at mount is sufficient. - const [localModel, setLocalModel] = useState(model); - const originalModel = model; - const providerInfo = useMemo( - () => (model.provider ? getProviderInfo(model.provider) : ({} as ProviderMetadata)), - [model.provider] - ); - const settings = getSettings(); - const isBedrockProvider = - (localModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK; - - // Debounce the onUpdate callback - const debouncedOnUpdate = useMemo( - () => - debounce((currentOriginalModel: CustomModel, updatedModel: CustomModel) => { - onUpdate(isEmbeddingModel, currentOriginalModel, updatedModel); - }, 500), - [isEmbeddingModel, onUpdate] - ); - - // Function to update local state immediately - const handleLocalUpdate = useCallback( - (field: keyof CustomModel, value: CustomModel[keyof CustomModel]) => { - setLocalModel((prevModel) => { - const updatedModel = { - ...prevModel, - [field]: value, - }; - // Call the debounced update function, passing the stable originalModel and the new updatedModel - debouncedOnUpdate(originalModel, updatedModel); - return updatedModel; // Return the updated model for immediate state update - }); - }, - [originalModel, debouncedOnUpdate] - ); - - const handleLocalReset = useCallback( - (field: keyof CustomModel) => { - setLocalModel((prevModel) => { - const updatedModel = { ...prevModel }; - delete updatedModel[field]; - // Call the debounced update function, passing the stable originalModel and the new updatedModel - debouncedOnUpdate(originalModel, updatedModel); - return updatedModel; // Return the updated model for immediate state update - }); - }, - [debouncedOnUpdate, originalModel] - ); - - if (!localModel) return null; - - const getPlaceholderUrl = () => { - if (!localModel || !localModel.provider || localModel.provider !== "azure-openai") { - return providerInfo.host || "https://api.example.com/v1"; - } - - const instanceName = localModel.azureOpenAIApiInstanceName || "[instance]"; - const deploymentName = localModel.isEmbeddingModel - ? localModel.azureOpenAIApiEmbeddingDeploymentName || "[deployment]" - : localModel.azureOpenAIApiDeploymentName || "[deployment]"; - const apiVersion = localModel.azureOpenAIApiVersion || "[api-version]"; - const endpoint = localModel.isEmbeddingModel ? "embeddings" : "chat/completions"; - - return `https://${instanceName}.openai.azure.com/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}`; - }; - - const capabilityOptions = Object.entries(MODEL_CAPABILITIES).map(([id, description]) => ({ - id, - label: id.charAt(0).toUpperCase() + id.slice(1), - description, - })) as Array<{ id: ModelCapability; label: string; description: string }>; - - const displayApiKey = getApiKeyForProvider( - localModel.provider as SettingKeyProviders, - localModel - ); - const showOtherParameters = - !isEmbeddingModel && - (localModel.provider as EmbeddingModelProviders) !== EmbeddingModelProviders.COPILOT_PLUS_JINA; - - return ( -
-
- - handleLocalUpdate("name", e.target.value)} - placeholder="Enter model name" - /> - - - - Display Name - -
Suggested format:
-
[Source]-[Payment]:[Pretty Model Name]
-
- Example: -
  • Direct-Paid:Ds-r1
  • -
  • OpenRouter-Paid:Ds-r1
  • -
  • Perplexity-Paid:lg
  • -
    -
    - } - contentClassName="tw-max-w-96" - /> -
    - } - > - handleLocalUpdate("displayName", e.target.value)} - /> - - - - - - - - handleLocalUpdate("baseUrl", e.target.value)} - /> - - - {isBedrockProvider && ( - - handleLocalUpdate("bedrockRegion", e.target.value)} - /> - - )} - - - handleLocalUpdate("apiKey", value)} - /> - {providerInfo.keyManagementURL && ( -

    - - Get {providerInfo.label} API Key - -

    - )} -
    - - {/* Prompt Caching Toggle for OpenRouter */} - {(localModel.provider as ChatModelProviders) === ChatModelProviders.OPENROUTERAI && ( -
    - handleLocalUpdate("enablePromptCaching", checked)} - /> - - - Disable if your OpenRouter endpoint uses Zero Data Retention (ZDR), which does not - support prompt caching. -
    - } - /> - - )} - - {showOtherParameters && ( - <> - - Model Capabilities - - Only used to display model capabilities, does not affect model functionality - - } - contentClassName="tw-max-w-96" - /> - - } - > -
    - {capabilityOptions.map(({ id, label, description }) => ( -
    - { - const newCapabilities = localModel.capabilities || []; - const value = checked - ? [...newCapabilities, id] - : newCapabilities.filter((cap) => cap !== id); - handleLocalUpdate("capabilities", value); - }} - /> - - - -
    - ))} -
    -
    - - {/* Stream Usage Toggle for OpenAI-format providers */} - {((localModel.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT || - (localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && ( - -
    - handleLocalUpdate("streamUsage", checked)} - /> - - Enable if your provider supports stream_options for token usage tracking. - Disable for providers that do not support it (e.g., Databricks, MLFlow). -
    - } - > - - - -
    - )} - - {/* Responses API Toggle for LM Studio */} - {(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO && ( - -
    - handleLocalUpdate("useResponsesApi", checked)} - /> - - Use /v1/responses instead of /v1/chat/completions. Patches compatibility - issues with LM Studio (text.format, tool definitions). Requires LM Studio - 0.3.6+. -
    - } - > - - - -
    - )} - - {/* Model Parameters Editor */} - - - )} - - -
    - -
    - - ); -}; - -export class ModelEditModal extends Modal { - private root: Root; - - constructor( - app: App, - private model: CustomModel, - private isEmbeddingModel: boolean, - private onUpdate: ( - isEmbeddingModel: boolean, - originalModel: CustomModel, - updatedModel: CustomModel - ) => void - ) { - super(app); - // @ts-ignore - this.setTitle(`Model Settings - ${this.model.name}`); - } - - onOpen() { - const { contentEl, modalEl } = this; - // It occupies only 80% of the height, leaving a clickable blank area to prevent the close icon from malfunctioning. - if (Platform.isMobile) { - modalEl.addClass("tw-h-4/5"); - } - this.root = createPluginRoot(contentEl, this.app); - - const handleUpdate = ( - isEmbeddingModel: boolean, - originalModel: CustomModel, - updatedModel: CustomModel - ) => { - this.onUpdate(isEmbeddingModel, originalModel, updatedModel); - }; - - const handleCancel = () => { - this.close(); - }; - - this.root.render( - - ); - } - - onClose() { - this.root.unmount(); - } -} diff --git a/src/settings/v2/components/configuredModelGrouping.ts b/src/settings/v2/components/configuredModelGrouping.ts index 5dd40587..9ebf060f 100644 --- a/src/settings/v2/components/configuredModelGrouping.ts +++ b/src/settings/v2/components/configuredModelGrouping.ts @@ -61,6 +61,34 @@ export function partitionCandidates( return { byokPlusCandidates, agentOriginCandidates }; } +/** + * Partition for the non-agent "chat" backend (Quick Chat). Every BYOK / + * Copilot Plus configured chat model is a candidate, bucketed one-group-per- + * provider by `buildModelEnableGroups`. Agent-origin models are excluded — the + * chat backend instantiates via LangChain (`ChatModelManager`), which can't + * drive an agent CLI's models — and embedding models are excluded since they + * aren't chat models. + */ +export function partitionChatCandidates( + configuredModels: readonly ConfiguredModel[], + providers: Readonly>, + enabledIds: ReadonlySet +): CandidatePartition { + const byokPlusCandidates: Candidate[] = []; + for (const configuredModel of configuredModels) { + if (configuredModel.info.isEmbedding) continue; + const provider = providers[configuredModel.providerId]; + if (!provider) continue; + if (provider.origin.kind === "agent") continue; + byokPlusCandidates.push({ + configuredModel, + provider, + enabled: enabledIds.has(configuredModel.configuredModelId), + }); + } + return { byokPlusCandidates, agentOriginCandidates: [] }; +} + /** * The opencode-only sub-group label: the wire-id prefix (e.g. * `opencode/big-pickle` → `opencode`). All opencode-only models live under one