mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
5c8298fa66
commit
89823e1d03
39 changed files with 1482 additions and 1111 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
44
src/LLMProviders/chatModelManager.bridge.test.ts
Normal file
44
src/LLMProviders/chatModelManager.bridge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
108
src/components/chat-components/useChatModelPicker.ts
Normal file
108
src/components/chat-components/useChatModelPicker.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'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 "Reasoning" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
37
src/hooks/useChatBackendModelOptions.ts
Normal file
37
src/hooks/useChatBackendModelOptions.ts
Normal 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 };
|
||||
}
|
||||
39
src/hooks/useResolvedChatBackendModel.ts
Normal file
39
src/hooks/useResolvedChatBackendModel.ts
Normal 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]);
|
||||
}
|
||||
104
src/modelManagement/chatModel/chatModelSelection.test.ts
Normal file
104
src/modelManagement/chatModel/chatModelSelection.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
73
src/modelManagement/chatModel/chatModelSelection.ts
Normal file
73
src/modelManagement/chatModel/chatModelSelection.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
154
src/modelManagement/chatModel/configuredModelToCustomModel.ts
Normal file
154
src/modelManagement/chatModel/configuredModelToCustomModel.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
104
src/modelManagement/chatModel/resolveChatBackendModel.test.ts
Normal file
104
src/modelManagement/chatModel/resolveChatBackendModel.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
49
src/modelManagement/chatModel/resolveChatBackendModel.ts
Normal file
49
src/modelManagement/chatModel/resolveChatBackendModel.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 'API keys' to use this model, or
|
||||
select a different model from another provider.
|
||||
Chat models are curated in the Quick Chat list under the Agents tab. Add
|
||||
providers on the Models (BYOK) tab to populate it.
|
||||
</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"
|
||||
/>
|
||||
|
|
|
|||
75
src/settings/v2/components/ChatModelEnableList.tsx
Normal file
75
src/settings/v2/components/ChatModelEnableList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue