feat(chat): power legacy chat from the model-management chat backend (#2556)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-06-07 05:52:50 +08:00 committed by GitHub
parent 5c8298fa66
commit 89823e1d03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1482 additions and 1111 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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**.
---

View file

@ -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.
---

View file

@ -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<void> {
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()}`);
}
}

View file

@ -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<string> {
// 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);

View file

@ -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<string> {
// 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);

View file

@ -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> = {}): 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();
});
});

View file

@ -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<ModelConfig> {
private async getModelConfig(
customModel: CustomModel,
allowLegacyCredentialFallback: boolean = true
): Promise<ModelConfig> {
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<string> {
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<BedrockChatModelFields> {
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<BaseChatModel> {
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<void> {
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<void> {
try {
ChatModelManager.chatModel = await this.createModelInstanceFromBridged(model);
ChatModelManager.activeModel = model;
ChatModelManager.activeModelSource = "bridged";
} catch (error) {
logError(error);
throw error;
}
}
async createModelInstance(model: CustomModel): Promise<BaseChatModel> {
// 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<BaseChatModel> {
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<BaseChatModel> {
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");
}
}

View file

@ -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(),

View file

@ -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;

View file

@ -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}

View file

@ -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<FormErrors>({});
const handleUpdate = (field: keyof CustomCommand, value: CustomCommand[keyof CustomCommand]) => {

View file

@ -37,7 +37,11 @@ export async function createChatChain(
systemPrompt: string,
memory: BaseChatMemory
): Promise<RunnableSequence> {
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.";

View file

@ -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<ChatProps & { chatInput: ReturnType<typeof useChatI
const eventTarget = useContext(EventTargetContext);
const { messages: chatHistory, addMessage: rawAddMessage } = useChatManager(chatUIState);
const [currentModelKey] = useModelKey();
const [currentModelKey, setCurrentModelKey] = useModelKey();
const [currentChain] = useChainType();
const currentProject = useSyncExternalStore(subscribeToProjectChange, getCurrentProject);
// Non-agent chat picker sourced from the model-management "chat" backend.
const chatModelPicker = useChatModelPicker({
value:
currentChain === ChainType.PROJECT_CHAIN
? currentProject?.projectModelKey || currentModelKey
: currentModelKey,
onChange: setCurrentModelKey,
});
const [currentAiMessage, setCurrentAiMessage] = useState("");
const [inputMessage, setInputMessage] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
@ -933,6 +952,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
onAddImage={handleAddImage}
setSelectedImages={setSelectedImages}
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
modelPickerOverride={chatModelPicker}
selectedTextContexts={selectedTextContexts}
onRemoveSelectedText={handleRemoveSelectedText}
showProgressCard={() => {

View file

@ -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<CustomModel | undefined>(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 (
<Popover onOpenChange={handleOpenChange}>
<Tooltip>
@ -282,17 +181,6 @@ export function ChatSettingsPopover() {
</div>
</div>
{/* Model Parameters Editor */}
<ModelParametersEditor
model={localModel}
settings={settings}
onChange={handleParamChange}
onReset={handleParamReset}
showTokenLimit={true}
/>
<Separator />
{/* Disable Builtin System Prompt */}
<div className="tw-space-y-3">
<div className="tw-space-y-1.5">
@ -372,9 +260,7 @@ export function ChatSettingsPopover() {
<div className="tw-flex tw-flex-row tw-flex-wrap">
<span className="tw-text-xs tw-text-normal">
<span className=" tw-italic">System Prompt and Disable Builtin System Prompt</span>{" "}
<strong>apply to this chat session only</strong>;
<br />
other settings are <strong>bound to the current model</strong>.
<strong>apply to this chat session only</strong>.
</span>
</div>
</div>

View file

@ -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<TokenLimitWarningProps> = ({ 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 (

View file

@ -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<string, string>();
const idToModelKey = new Map<string, string>();
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 };
}

View file

@ -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 && (

View file

@ -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<ProjectConfig>(
() =>
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<ProjectConfig>(() =>
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({
>
<ObsidianNativeSelect
value={formData.projectModelKey}
onChange={(e) => {
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}
/>
</FormField>

View file

@ -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({
<ModelSelector
size="sm"
variant="ghost"
value={selectedModelKey}
onChange={handleModelChange}
value={chatPicker.value}
models={chatPicker.models}
onChange={chatPicker.onChange}
disabled={isStreaming}
/>

View file

@ -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<QuickAskMessage[]>([]);
@ -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 {

View file

@ -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 (
<div className="tw-space-y-4">
{/* Token limit */}
{showTokenLimit && (
<FormField>
<ParameterControl
type="slider"
optional={false}
label="Token limit"
value={maxTokens}
onChange={(value) => onChange("maxTokens", value)}
max={PARAM_RANGES.maxTokens.max}
min={PARAM_RANGES.maxTokens.min}
step={PARAM_RANGES.maxTokens.step}
defaultValue={PARAM_RANGES.maxTokens.default}
helpText={
<>
<p>
The maximum number of <em>output tokens</em> to generate. Default is{" "}
{PARAM_RANGES.maxTokens.default}.
</p>
<em>
This number plus the length of your prompt (input tokens) must be smaller than the
context window of the model.
</em>
</>
}
/>
</FormField>
)}
{/* num_ctx - Ollama only */}
{isOllamaModel && (
<FormField>
<ParameterControl
type="slider"
optional={false}
label="num_ctx"
value={numCtx}
onChange={(value) => onChange("numCtx", value)}
min={PARAM_RANGES.numCtx.min}
max={PARAM_RANGES.numCtx.max}
step={PARAM_RANGES.numCtx.step}
defaultValue={PARAM_RANGES.numCtx.default}
helpText={
<>
<p>
The <code>num_ctx</code> parameter sent to Ollama. Controls how many tokens the
model can use as context. Default is {DEFAULT_OLLAMA_NUM_CTX}.
</p>
<em>
Lower this value to reduce VRAM usage on GPUs with limited memory. Ollama will cap
this at the model&apos;s actual maximum.
</em>
</>
}
/>
</FormField>
)}
{/* Temperature */}
<FormField>
<ParameterControl
type="slider"
optional={false}
label="Temperature"
value={temperature}
onChange={(value) => 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.`}
/>
</FormField>
{/* Top-P */}
<FormField>
<ParameterControl
type="slider"
optional={true}
label="Top-P"
value={topP}
onChange={(value) => 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`}
/>
</FormField>
{/* Frequency Penalty */}
<FormField>
<ParameterControl
type="slider"
optional={true}
label="Frequency Penalty"
value={frequencyPenalty}
onChange={(value) => 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={
<>
<p>
The frequency penalty parameter tells the model not to repeat a word that has
already been used multiple times in the conversation.
</p>
<em>The higher the value, the more the model is penalized for repeating words.</em>
</>
}
/>
</FormField>
{/* Reasoning Effort - For models with reasoning capability */}
{showReasoningEffort && (
<FormField>
<ParameterControl
type="select"
optional={true}
label="Reasoning Effort"
value={reasoningEffort}
onChange={(value) => 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={
<>
<p>
Controls the amount of reasoning effort the model uses. Higher effort provides
more thorough reasoning but takes longer.
</p>
<ul className="tw-mt-2 tw-space-y-1 tw-text-xs">
{model.name.startsWith("gpt-5") && model.provider === "openai" && (
<li>Minimal: Fastest responses, minimal reasoning (GPT-5 only)</li>
)}
<li>Low: Faster responses, basic reasoning (default)</li>
<li>Medium: Balanced performance</li>
<li>High: Thorough reasoning, slower responses</li>
{model.name.startsWith("gpt-5.4") && model.provider === "openai" && (
<li>Extra High: Maximum reasoning depth (GPT-5.4 only)</li>
)}
</ul>
{!hasReasoningCapability && !isOpenAIReasoningModel && (
<p className="tw-mt-2 tw-text-warning">
Enable the &quot;Reasoning&quot; capability above to use this feature.
</p>
)}
</>
}
/>
</FormField>
)}
{/* Verbosity - Only for GPT-5 models */}
{showVerbosity && (
<FormField>
<ParameterControl
type="select"
optional={true}
label="Verbosity"
value={verbosity}
onChange={(value) => onChange("verbosity", value)}
disableFn={onReset ? () => onReset("verbosity") : undefined}
defaultValue={settings.verbosity ?? getDefaultVerbosity()}
options={VERBOSITY_OPTIONS}
helpText={
<>
<p>Controls the length and detail of the model responses.</p>
<ul className="tw-mt-2 tw-space-y-1 tw-text-xs">
<li>Low: Concise, brief responses</li>
<li>Medium: Balanced detail</li>
<li>High: Detailed, comprehensive responses</li>
</ul>
</>
}
/>
</FormField>
)}
</div>
);
}

View file

@ -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<string | null>(null);
const currentSystemPromptRef = useRef<string | null>(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;

View file

@ -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 };
}

View file

@ -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]);
}

View file

@ -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> = {}): 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<EnabledBackendEntry, { state: "ok" }> {
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");
});
});

View file

@ -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<EnabledBackendEntry, { state: "ok" }>;
const DISPLAY_NAME_TO_LEGACY_PROVIDER: Record<string, ChatModelProviders> = {
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<ChatModelProviders>([
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;
}

View file

@ -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> = {}): Provider {
return {
providerId: "p1",
providerType: "openai-compatible",
displayName: "P",
origin: { kind: "byok" },
addedAt: 0,
apiKeyKeychainId: null,
...overrides,
};
}
function configuredModel(overrides: Partial<ConfiguredModel> = {}): 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");
});
});

View file

@ -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<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,
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<string, unknown>, 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"),
};
}

View file

@ -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> = {}): 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<string, string | null> = {}
): Pick<ModelManagementApi, "backendConfigRegistry" | "providerRegistry"> {
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");
});
});

View file

@ -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<ModelManagementApi, "backendConfigRegistry" | "providerRegistry">,
preferredConfiguredModelId: string | undefined
): Promise<ChatBackendResolution> {
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 };
}

View file

@ -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

View file

@ -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<void> {
* 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,

View file

@ -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) => (
<BackendSection key={descriptor.id} descriptor={descriptor} plugin={plugin} />
))}
<QuickChatSection />
</div>
</section>
);
};
/**
* 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 (
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
<div className="tw-flex tw-min-w-0 tw-flex-col">
<span className="tw-text-base tw-font-semibold">Quick Chat models</span>
<span className="tw-text-xs tw-text-muted">
Models shown in the chat model picker. Add providers on the Models (BYOK) tab.
</span>
</div>
<ChatModelEnableList />
</div>
);
};
/**
* 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

View file

@ -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 (
<div className="tw-space-y-4">
@ -106,35 +102,24 @@ export const BasicSettings: React.FC = () => {
<HelpTooltip
content={
<div className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2 tw-py-4">
<div className="tw-text-sm tw-font-medium tw-text-accent">
Default model is OpenRouter Gemini 2.5 Flash
</div>
<div className="tw-text-xs tw-text-muted">
Set your OpenRouter API key in &apos;API keys&apos; 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.
</div>
</div>
}
/>
</div>
}
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"
/>

View file

@ -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<ModelEnableGroup[]>(
() => 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 = (
<span>
No models configured yet. Add a provider on the{" "}
<span className="tw-font-medium">Models (BYOK)</span> tab to populate Quick Chat.
</span>
);
return (
<ModelEnableList
groups={groups}
onToggle={handleToggle}
query={query}
onQueryChange={setQuery}
searchPlaceholder="Search chat models…"
emptyState={emptyState}
/>
);
};

View file

@ -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<ModelEditModalContentProps> = ({
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<CustomModel>(model);
const originalModel = model;
const providerInfo = useMemo<ProviderMetadata>(
() => (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 (
<div className="tw-space-y-3 tw-p-4">
<div className="tw-space-y-3">
<FormField label="Model Name" required>
<Input
type="text"
disabled={localModel.core}
value={localModel.name}
onChange={(e) => handleLocalUpdate("name", e.target.value)}
placeholder="Enter model name"
/>
</FormField>
<FormField
label={
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-leading-none">Display Name</span>
<HelpTooltip
content={
<div className="tw-flex tw-flex-col tw-gap-0.5 tw-text-sm tw-text-muted">
<div className="tw-text-[12px] tw-font-bold">Suggested format:</div>
<div className="tw-text-accent">[Source]-[Payment]:[Pretty Model Name]</div>
<div className="tw-text-[12px]">
Example:
<li>Direct-Paid:Ds-r1</li>
<li>OpenRouter-Paid:Ds-r1</li>
<li>Perplexity-Paid:lg</li>
</div>
</div>
}
contentClassName="tw-max-w-96"
/>
</div>
}
>
<Input
type="text"
placeholder="Custom display name (optional)"
value={localModel.displayName || ""}
onChange={(e) => handleLocalUpdate("displayName", e.target.value)}
/>
</FormField>
<FormField label="Provider">
<Input type="text" value={getProviderLabel(localModel.provider)} disabled />
</FormField>
<FormField label="Base URL" description="Leave it blank, unless you are using a proxy.">
<Input
type="text"
placeholder={getPlaceholderUrl()}
value={localModel.baseUrl || ""}
onChange={(e) => handleLocalUpdate("baseUrl", e.target.value)}
/>
</FormField>
{isBedrockProvider && (
<FormField
label="Region (optional)"
description="Defaults to us-east-1 when left blank. With inference profiles (global., us., eu., apac.), region is auto-managed."
>
<Input
type="text"
placeholder="Enter AWS region (e.g. us-east-1)"
value={localModel.bedrockRegion || ""}
onChange={(e) => handleLocalUpdate("bedrockRegion", e.target.value)}
/>
</FormField>
)}
<FormField label="API Key">
<PasswordInput
placeholder={`Enter ${providerInfo.label || "Provider"} API Key`}
value={displayApiKey}
onChange={(value) => handleLocalUpdate("apiKey", value)}
/>
{providerInfo.keyManagementURL && (
<p className="tw-text-xs tw-text-muted">
<a href={providerInfo.keyManagementURL} target="_blank" rel="noopener noreferrer">
Get {providerInfo.label} API Key
</a>
</p>
)}
</FormField>
{/* Prompt Caching Toggle for OpenRouter */}
{(localModel.provider as ChatModelProviders) === ChatModelProviders.OPENROUTERAI && (
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="enable-prompt-caching"
checked={localModel.enablePromptCaching !== false}
onCheckedChange={(checked) => handleLocalUpdate("enablePromptCaching", checked)}
/>
<Label htmlFor="enable-prompt-caching" className="tw-cursor-pointer tw-text-sm">
Prompt Caching
</Label>
<HelpTooltip
content={
<div className="tw-text-sm tw-text-muted">
Disable if your OpenRouter endpoint uses Zero Data Retention (ZDR), which does not
support prompt caching.
</div>
}
/>
</div>
)}
{showOtherParameters && (
<>
<FormField
label={
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-leading-none">Model Capabilities</span>
<HelpTooltip
content={
<div className="tw-text-sm tw-text-muted">
Only used to display model capabilities, does not affect model functionality
</div>
}
contentClassName="tw-max-w-96"
/>
</div>
}
>
<div className="tw-flex tw-items-center tw-gap-4">
{capabilityOptions.map(({ id, label, description }) => (
<div key={id} className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id={id}
checked={localModel.capabilities?.includes(id)}
onCheckedChange={(checked) => {
const newCapabilities = localModel.capabilities || [];
const value = checked
? [...newCapabilities, id]
: newCapabilities.filter((cap) => cap !== id);
handleLocalUpdate("capabilities", value);
}}
/>
<HelpTooltip content={description}>
<Label htmlFor={id} className="tw-text-sm">
{label}
</Label>
</HelpTooltip>
</div>
))}
</div>
</FormField>
{/* Stream Usage Toggle for OpenAI-format providers */}
{((localModel.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT ||
(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && (
<FormField label="Stream Options">
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="stream-usage"
checked={localModel.streamUsage || false}
onCheckedChange={(checked) => handleLocalUpdate("streamUsage", checked)}
/>
<HelpTooltip
content={
<div className="tw-text-sm tw-text-muted">
Enable if your provider supports stream_options for token usage tracking.
Disable for providers that do not support it (e.g., Databricks, MLFlow).
</div>
}
>
<Label htmlFor="stream-usage" className="tw-cursor-pointer tw-text-sm">
Stream Usage
</Label>
</HelpTooltip>
</div>
</FormField>
)}
{/* Responses API Toggle for LM Studio */}
{(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO && (
<FormField label="Responses API">
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="use-responses-api"
checked={localModel.useResponsesApi !== false}
onCheckedChange={(checked) => handleLocalUpdate("useResponsesApi", checked)}
/>
<HelpTooltip
content={
<div className="tw-text-sm tw-text-muted">
Use /v1/responses instead of /v1/chat/completions. Patches compatibility
issues with LM Studio (text.format, tool definitions). Requires LM Studio
0.3.6+.
</div>
}
>
<Label htmlFor="use-responses-api" className="tw-cursor-pointer tw-text-sm">
Use Responses API (faster inference)
</Label>
</HelpTooltip>
</div>
</FormField>
)}
{/* Model Parameters Editor */}
<ModelParametersEditor
model={localModel}
settings={settings}
onChange={handleLocalUpdate}
onReset={handleLocalReset}
showTokenLimit={true}
/>
</>
)}
</div>
<div className="tw-mt-6 tw-flex tw-justify-end tw-gap-2 tw-border-t tw-border-border tw-pt-4">
<Button variant="secondary" onClick={onCancel}>
Close
</Button>
</div>
</div>
);
};
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(
<ModelEditModalContent
model={this.model}
isEmbeddingModel={this.isEmbeddingModel}
onUpdate={handleUpdate}
onCancel={handleCancel}
/>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -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<Record<string, Provider>>,
enabledIds: ReadonlySet<string>
): 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