mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Other OpenAI-compatible providers (LM Studio, OpenAI-format, SiliconFlow, etc.) already pass a fetch override so requests can go through Obsidian's requestUrl API. The Ollama branch was missing this wiring, so toggling "Enable CORS" on an Ollama model had no effect and mobile (WKWebView) requests to http:// Ollama hosts failed with "Load failed" due to CORS / mixed-content blocking. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
943 lines
35 KiB
TypeScript
943 lines
35 KiB
TypeScript
import { CustomModel, getModelKey, ModelConfig } from "@/aiParams";
|
|
import {
|
|
BREVILABS_MODELS_BASE_URL,
|
|
BUILTIN_CHAT_MODELS,
|
|
ChatModelProviders,
|
|
DEFAULT_OLLAMA_NUM_CTX,
|
|
ModelCapability,
|
|
ProviderInfo,
|
|
} from "@/constants";
|
|
import { getDecryptedKey } from "@/encryptionService";
|
|
import { logError, logInfo } from "@/logger";
|
|
import { isPlusEnabled } from "@/plusUtils";
|
|
import {
|
|
CopilotSettings,
|
|
getModelKeyFromModel,
|
|
getSettings,
|
|
subscribeToSettingsChange,
|
|
} from "@/settings/model";
|
|
import {
|
|
err2String,
|
|
findCustomModel,
|
|
getModelInfo,
|
|
ModelInfo,
|
|
safeFetch,
|
|
safeFetchNoThrow,
|
|
} from "@/utils";
|
|
import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
|
|
import { ChatAnthropic } from "@langchain/anthropic";
|
|
import { ChatCohere } from "@langchain/cohere";
|
|
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
|
import { BaseLanguageModel } from "@langchain/core/language_models/base";
|
|
import { ChatDeepSeek } from "@langchain/deepseek";
|
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
|
import { ChatGroq } from "@langchain/groq";
|
|
import { ChatMistralAI } from "@langchain/mistralai";
|
|
import { ChatOllama } from "@langchain/ollama";
|
|
import { ChatOpenAI } from "@langchain/openai";
|
|
import { ChatXAI } from "@langchain/xai";
|
|
import { MissingApiKeyError, MissingPlusLicenseError } from "@/error";
|
|
import { Notice } from "obsidian";
|
|
import { ChatOpenRouter } from "./ChatOpenRouter";
|
|
import { ChatLMStudio } from "./ChatLMStudio";
|
|
import { BedrockChatModel, type BedrockChatModelFields } from "./BedrockChatModel";
|
|
import { GitHubCopilotChatModel } from "@/LLMProviders/githubCopilot/GitHubCopilotChatModel";
|
|
|
|
// Patch BaseLanguageModel.prototype.getNumTokens once at module load to prevent
|
|
// tiktoken CDN fetches. LangChain's default getNumTokens() downloads a ~3MB BPE
|
|
// vocabulary from tiktoken.pages.dev, which blocks all LLM calls when the CDN is
|
|
// unreachable. This char/4 estimation is the same fallback LangChain uses internally
|
|
// before tiktoken loads. Actual token usage comes from API response metadata.
|
|
(BaseLanguageModel.prototype as any).getNumTokens = async (
|
|
content: string | Array<{ type: string; text?: string }>
|
|
) => {
|
|
const text =
|
|
typeof content === "string"
|
|
? content
|
|
: content.map((item: any) => (typeof item === "string" ? item : (item.text ?? ""))).join("");
|
|
return Math.ceil(text.length / 4);
|
|
};
|
|
|
|
type ChatConstructorType = {
|
|
new (config: any): any;
|
|
};
|
|
|
|
const CHAT_PROVIDER_CONSTRUCTORS = {
|
|
[ChatModelProviders.OPENAI]: ChatOpenAI,
|
|
[ChatModelProviders.AZURE_OPENAI]: ChatOpenAI,
|
|
[ChatModelProviders.ANTHROPIC]: ChatAnthropic,
|
|
[ChatModelProviders.COHEREAI]: ChatCohere,
|
|
[ChatModelProviders.GOOGLE]: ChatGoogleGenerativeAI,
|
|
[ChatModelProviders.XAI]: ChatXAI,
|
|
[ChatModelProviders.OPENROUTERAI]: ChatOpenRouter,
|
|
[ChatModelProviders.OLLAMA]: ChatOllama,
|
|
[ChatModelProviders.LM_STUDIO]: ChatOpenRouter,
|
|
[ChatModelProviders.GROQ]: ChatGroq,
|
|
[ChatModelProviders.OPENAI_FORMAT]: ChatOpenAI,
|
|
[ChatModelProviders.SILICONFLOW]: ChatOpenAI,
|
|
[ChatModelProviders.COPILOT_PLUS]: ChatOpenRouter,
|
|
[ChatModelProviders.MISTRAL]: ChatMistralAI,
|
|
[ChatModelProviders.DEEPSEEK]: ChatDeepSeek,
|
|
[ChatModelProviders.AMAZON_BEDROCK]: BedrockChatModel,
|
|
[ChatModelProviders.GITHUB_COPILOT]: GitHubCopilotChatModel,
|
|
} as const;
|
|
|
|
type ChatProviderConstructMap = typeof CHAT_PROVIDER_CONSTRUCTORS;
|
|
|
|
/**
|
|
* Normalize an Azure URL that a user may have pasted in full.
|
|
* Strips trailing `/chat/completions` or `/embeddings` and extracts
|
|
* `api-version` from query parameters so the OpenAI client can
|
|
* construct the correct final URL.
|
|
*/
|
|
export function normalizeAzureUrl(raw: string | undefined): {
|
|
baseUrl: string | undefined;
|
|
apiVersion: string | undefined;
|
|
} {
|
|
if (!raw) return { baseUrl: undefined, apiVersion: undefined };
|
|
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw);
|
|
} catch {
|
|
return { baseUrl: raw, apiVersion: undefined };
|
|
}
|
|
|
|
const apiVersion = url.searchParams.get("api-version") || undefined;
|
|
url.search = "";
|
|
let baseUrl = url.toString().replace(/\/+$/, "");
|
|
|
|
// Strip paths that the OpenAI client appends automatically
|
|
baseUrl = baseUrl.replace(/\/(chat\/completions|embeddings)$/, "");
|
|
|
|
return { baseUrl, apiVersion };
|
|
}
|
|
|
|
export default class ChatModelManager {
|
|
private static instance: ChatModelManager;
|
|
private static chatModel: BaseChatModel | null;
|
|
private static modelMap: Record<
|
|
string,
|
|
{
|
|
hasApiKey: boolean;
|
|
AIConstructor: ChatConstructorType;
|
|
vendor: string;
|
|
}
|
|
>;
|
|
|
|
private static readonly ANTHROPIC_THINKING_BUDGET_TOKENS = 2048;
|
|
|
|
private readonly providerApiKeyMap: Record<ChatModelProviders, () => string> = {
|
|
[ChatModelProviders.OPENAI]: () => getSettings().openAIApiKey,
|
|
[ChatModelProviders.GOOGLE]: () => getSettings().googleApiKey,
|
|
[ChatModelProviders.AZURE_OPENAI]: () => getSettings().azureOpenAIApiKey,
|
|
[ChatModelProviders.ANTHROPIC]: () => getSettings().anthropicApiKey,
|
|
[ChatModelProviders.COHEREAI]: () => getSettings().cohereApiKey,
|
|
[ChatModelProviders.OPENROUTERAI]: () => getSettings().openRouterAiApiKey,
|
|
[ChatModelProviders.GROQ]: () => getSettings().groqApiKey,
|
|
[ChatModelProviders.XAI]: () => getSettings().xaiApiKey,
|
|
[ChatModelProviders.OLLAMA]: () => "default-key",
|
|
[ChatModelProviders.LM_STUDIO]: () => "default-key",
|
|
[ChatModelProviders.OPENAI_FORMAT]: () => "default-key",
|
|
[ChatModelProviders.COPILOT_PLUS]: () => getSettings().plusLicenseKey,
|
|
[ChatModelProviders.MISTRAL]: () => getSettings().mistralApiKey,
|
|
[ChatModelProviders.DEEPSEEK]: () => getSettings().deepseekApiKey,
|
|
[ChatModelProviders.AMAZON_BEDROCK]: () => getSettings().amazonBedrockApiKey,
|
|
[ChatModelProviders.SILICONFLOW]: () => getSettings().siliconflowApiKey,
|
|
[ChatModelProviders.GITHUB_COPILOT]: () =>
|
|
getSettings().githubCopilotToken || getSettings().githubCopilotAccessToken,
|
|
} as const;
|
|
|
|
private constructor() {
|
|
this.buildModelMap();
|
|
subscribeToSettingsChange(() => {
|
|
this.buildModelMap();
|
|
this.validateCurrentModel();
|
|
});
|
|
}
|
|
|
|
static getInstance(): ChatModelManager {
|
|
if (!ChatModelManager.instance) {
|
|
ChatModelManager.instance = new ChatModelManager();
|
|
}
|
|
return ChatModelManager.instance;
|
|
}
|
|
|
|
private static readonly REASONING_MODEL_TEMPERATURE = 1;
|
|
|
|
/**
|
|
* Determines the appropriate temperature for a model
|
|
* @returns temperature value or undefined if temperature should not be set
|
|
*/
|
|
private getTemperatureForModel(
|
|
modelInfo: ModelInfo,
|
|
customModel: CustomModel,
|
|
settings: CopilotSettings
|
|
): number | undefined {
|
|
// Thinking-enabled models don't accept temperature
|
|
if (modelInfo.isThinkingEnabled) {
|
|
return undefined;
|
|
}
|
|
|
|
// O-series and GPT-5 models require temperature = 1
|
|
if (modelInfo.isOSeries || modelInfo.isGPT5) {
|
|
return ChatModelManager.REASONING_MODEL_TEMPERATURE;
|
|
}
|
|
|
|
// All other models use configured temperature
|
|
return customModel.temperature ?? settings.temperature;
|
|
}
|
|
|
|
private async getModelConfig(customModel: CustomModel): Promise<ModelConfig> {
|
|
const settings = getSettings();
|
|
|
|
const modelName = customModel.name;
|
|
const modelInfo = getModelInfo(modelName);
|
|
const { isThinkingEnabled } = modelInfo;
|
|
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
|
|
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
|
|
|
|
// Base config - temperature will be handled by provider-specific methods
|
|
const baseConfig: Omit<ModelConfig, "maxTokens" | "maxCompletionTokens"> = {
|
|
modelName: modelName,
|
|
streaming: customModel.stream ?? true,
|
|
maxRetries: 3,
|
|
maxConcurrency: 3,
|
|
enableCors: customModel.enableCors,
|
|
// Add temperature for normal models (will be overridden by special configs if needed)
|
|
...(!isThinkingEnabled && resolvedTemperature !== undefined
|
|
? { temperature: resolvedTemperature }
|
|
: {}),
|
|
};
|
|
|
|
const providerConfig: {
|
|
[K in keyof ChatProviderConstructMap]: ConstructorParameters<ChatProviderConstructMap[K]>[0];
|
|
} = {
|
|
[ChatModelProviders.OPENAI]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey),
|
|
configuration: {
|
|
baseURL: customModel.baseUrl,
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
organization: await getDecryptedKey(customModel.openAIOrgId || settings.openAIOrgId),
|
|
},
|
|
...this.getOpenAISpecialConfig(
|
|
modelName,
|
|
customModel.maxTokens ?? settings.maxTokens,
|
|
customModel.temperature ?? settings.temperature,
|
|
customModel
|
|
),
|
|
},
|
|
[ChatModelProviders.ANTHROPIC]: {
|
|
anthropicApiKey: await getDecryptedKey(customModel.apiKey || settings.anthropicApiKey),
|
|
model: modelName,
|
|
anthropicApiUrl: customModel.baseUrl,
|
|
clientOptions: {
|
|
// Required to bypass CORS restrictions
|
|
defaultHeaders: {
|
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
},
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
},
|
|
...(isThinkingEnabled && {
|
|
thinking: {
|
|
type: "enabled",
|
|
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
|
|
},
|
|
}),
|
|
},
|
|
[ChatModelProviders.AZURE_OPENAI]: await (async () => {
|
|
const azureUrl = normalizeAzureUrl(customModel.baseUrl);
|
|
return {
|
|
modelName: customModel.baseUrl
|
|
? modelName
|
|
: customModel.azureOpenAIApiDeploymentName || settings.azureOpenAIApiDeploymentName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey),
|
|
configuration: {
|
|
baseURL:
|
|
azureUrl.baseUrl ||
|
|
`https://${customModel.azureOpenAIApiInstanceName || settings.azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${customModel.azureOpenAIApiDeploymentName || settings.azureOpenAIApiDeploymentName}`,
|
|
defaultQuery: {
|
|
"api-version":
|
|
azureUrl.apiVersion ||
|
|
customModel.azureOpenAIApiVersion ||
|
|
settings.azureOpenAIApiVersion ||
|
|
"2024-05-01-preview",
|
|
},
|
|
defaultHeaders: {
|
|
"Content-Type": "application/json",
|
|
"api-key": await getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey),
|
|
},
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
},
|
|
...this.getOpenAISpecialConfig(
|
|
modelName,
|
|
customModel.maxTokens ?? settings.maxTokens,
|
|
customModel.temperature ?? settings.temperature,
|
|
customModel
|
|
),
|
|
};
|
|
})(),
|
|
[ChatModelProviders.COHEREAI]: {
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey),
|
|
model: modelName,
|
|
},
|
|
[ChatModelProviders.GOOGLE]: {
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
|
|
model: modelName,
|
|
safetySettings: [
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
],
|
|
baseUrl: customModel.baseUrl,
|
|
},
|
|
[ChatModelProviders.XAI]: {
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.xaiApiKey),
|
|
model: modelName,
|
|
// This langchainjs XAI client does not support baseURL override
|
|
},
|
|
[ChatModelProviders.OPENROUTERAI]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.openRouterAiApiKey),
|
|
configuration: {
|
|
baseURL: customModel.baseUrl || "https://openrouter.ai/api/v1",
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
defaultHeaders: {
|
|
"HTTP-Referer": "https://obsidiancopilot.com",
|
|
"X-Title": "Obsidian Copilot",
|
|
},
|
|
},
|
|
// Enable reasoning if the model has the reasoning capability
|
|
enableReasoning: customModel.capabilities?.includes(ModelCapability.REASONING) ?? false,
|
|
// Pass reasoning effort if configured and reasoning capability is enabled
|
|
reasoningEffort:
|
|
customModel.capabilities?.includes(ModelCapability.REASONING) &&
|
|
customModel.reasoningEffort
|
|
? customModel.reasoningEffort
|
|
: undefined,
|
|
// Enable prompt caching by default; can be turned off for ZDR endpoints
|
|
enablePromptCaching: customModel.enablePromptCaching ?? true,
|
|
},
|
|
[ChatModelProviders.GROQ]: {
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.groqApiKey),
|
|
model: modelName,
|
|
},
|
|
[ChatModelProviders.OLLAMA]: {
|
|
// ChatOllama has `model` instead of `modelName`!!
|
|
model: modelName,
|
|
// MUST NOT use /v1 in the baseUrl for ollama
|
|
baseUrl: customModel.baseUrl || "http://localhost:11434",
|
|
headers: {
|
|
Authorization: `Bearer ${await getDecryptedKey(customModel.apiKey || "default-key")}`,
|
|
},
|
|
// Route through Obsidian's requestUrl (safeFetch) to bypass CORS / mixed-content
|
|
// restrictions — required on mobile (WKWebView) when calling http:// Ollama hosts.
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
// Enable thinking for models with REASONING capability (e.g., qwen3, deepseek-r1)
|
|
// Thinking content goes to additional_kwargs.reasoning_content
|
|
think: customModel.capabilities?.includes(ModelCapability.REASONING) ?? false,
|
|
// Reduce repetition in local models (1.1 = slight penalty, helps with hallucination loops)
|
|
repeatPenalty: 1.1,
|
|
numCtx: customModel.numCtx ?? DEFAULT_OLLAMA_NUM_CTX,
|
|
},
|
|
[ChatModelProviders.LM_STUDIO]: {
|
|
modelName: modelName,
|
|
apiKey: customModel.apiKey || "default-key",
|
|
streamUsage: customModel.streamUsage ?? false,
|
|
configuration: {
|
|
baseURL: customModel.baseUrl || "http://localhost:1234/v1",
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
},
|
|
// Enable reasoning extraction for models with REASONING capability
|
|
enableReasoning: customModel.capabilities?.includes(ModelCapability.REASONING) ?? false,
|
|
// Pass reasoning effort if configured and reasoning capability is enabled
|
|
reasoningEffort:
|
|
customModel.capabilities?.includes(ModelCapability.REASONING) &&
|
|
customModel.reasoningEffort
|
|
? customModel.reasoningEffort
|
|
: undefined,
|
|
},
|
|
[ChatModelProviders.OPENAI_FORMAT]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey),
|
|
streamUsage: customModel.streamUsage ?? false,
|
|
configuration: {
|
|
baseURL: customModel.baseUrl,
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
defaultHeaders: { "dangerously-allow-browser": "true" },
|
|
},
|
|
...this.getOpenAISpecialConfig(
|
|
modelName,
|
|
customModel.maxTokens ?? settings.maxTokens,
|
|
customModel.temperature ?? settings.temperature,
|
|
customModel
|
|
),
|
|
},
|
|
[ChatModelProviders.SILICONFLOW]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.siliconflowApiKey),
|
|
configuration: {
|
|
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.SILICONFLOW].host,
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
},
|
|
...this.getOpenAISpecialConfig(
|
|
modelName,
|
|
customModel.maxTokens ?? settings.maxTokens,
|
|
customModel.temperature ?? settings.temperature,
|
|
customModel
|
|
),
|
|
},
|
|
[ChatModelProviders.COPILOT_PLUS]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(settings.plusLicenseKey),
|
|
configuration: {
|
|
baseURL: BREVILABS_MODELS_BASE_URL,
|
|
fetch: safeFetch,
|
|
},
|
|
},
|
|
[ChatModelProviders.MISTRAL]: {
|
|
model: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.mistralApiKey),
|
|
serverURL: customModel.baseUrl,
|
|
},
|
|
[ChatModelProviders.DEEPSEEK]: {
|
|
modelName: modelName,
|
|
apiKey: await getDecryptedKey(customModel.apiKey || settings.deepseekApiKey),
|
|
configuration: {
|
|
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.DEEPSEEK].host,
|
|
fetch: customModel.enableCors ? safeFetch : undefined,
|
|
},
|
|
},
|
|
[ChatModelProviders.AMAZON_BEDROCK]: {} as BedrockChatModelFields,
|
|
[ChatModelProviders.GITHUB_COPILOT]: {
|
|
modelName: modelName,
|
|
// Use safeFetchNoThrow for CORS bypass on mobile platforms.
|
|
// This doesn't throw on HTTP errors so 401 retry logic works correctly.
|
|
// WARNING: AbortSignal/timeout will NOT work when enableCors is true
|
|
// because Obsidian's requestUrl doesn't support cancellation.
|
|
// Reason: fetchImplementation is passed to the authed fetch wrapper inside
|
|
// GitHubCopilotChatModel, which injects Copilot token and headers per request.
|
|
fetchImplementation: customModel.enableCors ? safeFetchNoThrow : undefined,
|
|
},
|
|
};
|
|
|
|
let selectedProviderConfig =
|
|
providerConfig[customModel.provider as keyof typeof providerConfig] || {};
|
|
|
|
if (customModel.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
|
selectedProviderConfig = await this.buildBedrockConfig(
|
|
customModel,
|
|
modelName,
|
|
settings,
|
|
maxTokens,
|
|
resolvedTemperature
|
|
);
|
|
}
|
|
|
|
// Get provider-specific parameters (like topP, frequencyPenalty) that the provider supports
|
|
const providerSpecificParams = this.getProviderSpecificParams(
|
|
customModel.provider as ChatModelProviders,
|
|
customModel
|
|
);
|
|
|
|
// LangChain 0.6.6 handles token configuration for special models internally
|
|
const tokenConfig = {
|
|
maxTokens,
|
|
};
|
|
|
|
const finalConfig = {
|
|
...baseConfig,
|
|
...selectedProviderConfig,
|
|
...providerSpecificParams,
|
|
...tokenConfig,
|
|
};
|
|
|
|
return finalConfig as ModelConfig;
|
|
}
|
|
|
|
/**
|
|
* Adds special configuration for OpenAI models that support reasoning
|
|
* LangChain 0.6.6+ handles most of the token/temperature logic internally
|
|
*
|
|
* NOTE: GPT-5 models require Responses API for verbosity parameter to work.
|
|
* The useResponsesApi flag is set automatically in createModelInstance() for GPT-5.
|
|
*/
|
|
private getOpenAISpecialConfig(
|
|
modelName: string,
|
|
maxTokens: number,
|
|
_temperature: number | undefined,
|
|
customModel?: CustomModel
|
|
) {
|
|
const settings = getSettings();
|
|
const modelInfo = getModelInfo(modelName);
|
|
const resolvedTemperature = this.getTemperatureForModel(
|
|
modelInfo,
|
|
customModel || ({} as CustomModel),
|
|
settings
|
|
);
|
|
|
|
const config: any = {
|
|
maxTokens,
|
|
temperature: resolvedTemperature,
|
|
};
|
|
|
|
// Add reasoning parameters for O-series and GPT-5 models
|
|
// LangChain 0.6.6 will handle the endpoint routing and parameter conversion
|
|
if ((modelInfo.isOSeries || modelInfo.isGPT5) && customModel?.reasoningEffort) {
|
|
config.reasoning = {
|
|
effort: customModel.reasoningEffort,
|
|
};
|
|
|
|
// Add verbosity for GPT-5 models (Responses API only).
|
|
// Azure does not support Responses API so skip verbosity there;
|
|
// useResponsesApi is only enabled for OPENAI / OPENAI_FORMAT in createModelInstance().
|
|
if (
|
|
modelInfo.isGPT5 &&
|
|
customModel?.verbosity &&
|
|
customModel?.provider !== ChatModelProviders.AZURE_OPENAI
|
|
) {
|
|
const verbosityValue = customModel.verbosity;
|
|
// For Responses API, verbosity is nested under 'text' parameter
|
|
config.text = {
|
|
verbosity: verbosityValue,
|
|
};
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Builds configuration for Amazon Bedrock models by merging custom overrides with global defaults.
|
|
* @param customModel - The model definition provided by the user.
|
|
* @param modelName - The resolved Bedrock model identifier to invoke.
|
|
* @param settings - Current Copilot settings.
|
|
* @param maxTokens - Maximum completion tokens requested for the invocation.
|
|
* @param temperature - Optional temperature override for the invocation.
|
|
*/
|
|
private async buildBedrockConfig(
|
|
customModel: CustomModel,
|
|
modelName: string,
|
|
settings: CopilotSettings,
|
|
maxTokens: number,
|
|
temperature: number | undefined
|
|
): Promise<BedrockChatModelFields> {
|
|
const apiKeySource = customModel.apiKey || settings.amazonBedrockApiKey;
|
|
if (!apiKeySource) {
|
|
throw new Error(
|
|
"Amazon Bedrock API key is not configured. Provide a key in Settings > API Keys or the model definition."
|
|
);
|
|
}
|
|
|
|
const apiKey = await getDecryptedKey(apiKeySource);
|
|
|
|
const explicitRegion = customModel.bedrockRegion?.trim();
|
|
const settingsRegion = settings.amazonBedrockRegion?.trim();
|
|
const resolvedRegion = explicitRegion || settingsRegion || "us-east-1";
|
|
const baseUrlInput = customModel.baseUrl?.trim();
|
|
const baseUrl = baseUrlInput ? baseUrlInput.replace(/\/+$/, "") : undefined;
|
|
const endpointBase = baseUrl || `https://bedrock-runtime.${resolvedRegion}.amazonaws.com`;
|
|
|
|
const encodedModel = encodeURIComponent(modelName);
|
|
const endpoint = `${endpointBase}/model/${encodedModel}/invoke`;
|
|
const streamEndpoint = `${endpointBase}/model/${encodedModel}/invoke-with-response-stream`;
|
|
const fetchImplementation = customModel.enableCors ? safeFetch : undefined;
|
|
// Inference profiles prefix Anthropic identifiers (e.g. global.anthropic.*), so look for the segment anywhere.
|
|
const requiresAnthropicVersion = /(^|\.)anthropic\./.test(modelName);
|
|
const anthropicVersion = requiresAnthropicVersion ? "bedrock-2023-05-31" : undefined;
|
|
// Only enable thinking mode if user has explicitly enabled REASONING capability
|
|
const enableThinking = customModel.capabilities?.includes(ModelCapability.REASONING) ?? false;
|
|
|
|
return {
|
|
modelName,
|
|
modelId: modelName,
|
|
apiKey,
|
|
endpoint,
|
|
streamEndpoint,
|
|
defaultMaxTokens: maxTokens,
|
|
defaultTemperature: temperature,
|
|
defaultTopP: customModel.topP,
|
|
anthropicVersion,
|
|
enableThinking,
|
|
fetchImplementation,
|
|
streaming: customModel.stream ?? true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns provider-specific parameters (like topP, frequencyPenalty) based on what the provider supports
|
|
* This prevents passing undefined values to providers that don't support them
|
|
*/
|
|
private getProviderSpecificParams(provider: ChatModelProviders, customModel: CustomModel) {
|
|
const params: Record<string, any> = {};
|
|
|
|
// Add topP only if defined
|
|
if (customModel.topP !== undefined) {
|
|
// These providers support topP
|
|
if (
|
|
[
|
|
ChatModelProviders.OPENAI,
|
|
ChatModelProviders.AZURE_OPENAI,
|
|
ChatModelProviders.ANTHROPIC,
|
|
ChatModelProviders.GOOGLE,
|
|
ChatModelProviders.OPENROUTERAI,
|
|
ChatModelProviders.OLLAMA,
|
|
ChatModelProviders.LM_STUDIO,
|
|
ChatModelProviders.OPENAI_FORMAT,
|
|
ChatModelProviders.MISTRAL,
|
|
ChatModelProviders.DEEPSEEK,
|
|
ChatModelProviders.SILICONFLOW,
|
|
].includes(provider)
|
|
) {
|
|
params.topP = customModel.topP;
|
|
}
|
|
}
|
|
|
|
// Add frequencyPenalty only if defined
|
|
if (customModel.frequencyPenalty !== undefined) {
|
|
// These providers support frequencyPenalty
|
|
if (
|
|
[
|
|
ChatModelProviders.OPENAI,
|
|
ChatModelProviders.AZURE_OPENAI,
|
|
ChatModelProviders.OPENROUTERAI,
|
|
ChatModelProviders.OLLAMA,
|
|
ChatModelProviders.LM_STUDIO,
|
|
ChatModelProviders.OPENAI_FORMAT,
|
|
ChatModelProviders.MISTRAL,
|
|
ChatModelProviders.DEEPSEEK,
|
|
ChatModelProviders.SILICONFLOW,
|
|
].includes(provider)
|
|
) {
|
|
params.frequencyPenalty = customModel.frequencyPenalty;
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
// Build a map of modelKey to model config
|
|
public buildModelMap() {
|
|
const activeModels = getSettings().activeModels;
|
|
ChatModelManager.modelMap = {};
|
|
const modelMap = ChatModelManager.modelMap;
|
|
|
|
const allModels = activeModels ?? BUILTIN_CHAT_MODELS;
|
|
|
|
allModels.forEach((model) => {
|
|
if (model.enabled) {
|
|
if (!Object.values(ChatModelProviders).contains(model.provider as ChatModelProviders)) {
|
|
console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`);
|
|
return;
|
|
}
|
|
|
|
const constructor = this.getProviderConstructor(model);
|
|
const hasCredentials = this.hasProviderCredentials(model);
|
|
const modelKey = getModelKeyFromModel(model);
|
|
modelMap[modelKey] = {
|
|
hasApiKey: hasCredentials,
|
|
AIConstructor: constructor,
|
|
vendor: model.provider,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if a model has the necessary credentials configured for its provider.
|
|
* @param model - The custom model definition.
|
|
* @returns True when the provider requirements are satisfied, otherwise false.
|
|
*/
|
|
private hasProviderCredentials(model: CustomModel): boolean {
|
|
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
|
const settings = getSettings();
|
|
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
|
|
// Region defaults to us-east-1 if not specified, so API key is the only requirement
|
|
return Boolean(apiKey);
|
|
}
|
|
|
|
const getDefaultApiKey = this.providerApiKeyMap[model.provider as ChatModelProviders];
|
|
if (!getDefaultApiKey) {
|
|
return Boolean(model.apiKey);
|
|
}
|
|
|
|
return Boolean(model.apiKey || getDefaultApiKey());
|
|
}
|
|
|
|
getProviderConstructor(model: CustomModel): ChatConstructorType {
|
|
const constructor: ChatConstructorType =
|
|
CHAT_PROVIDER_CONSTRUCTORS[model.provider as ChatModelProviders];
|
|
if (!constructor) {
|
|
console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`);
|
|
throw new Error(`Unknown provider: ${model.provider} for model: ${model.name}`);
|
|
}
|
|
return constructor;
|
|
}
|
|
|
|
getChatModel(): BaseChatModel {
|
|
if (!ChatModelManager.chatModel) {
|
|
throw new Error("No valid chat model available. Please check your API key settings.");
|
|
}
|
|
return ChatModelManager.chatModel;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private isModelConfigValid(model: CustomModel, settings: CopilotSettings): boolean {
|
|
const modelKey = getModelKeyFromModel(model);
|
|
const modelInfo = ChatModelManager.modelMap[modelKey];
|
|
|
|
// Check if model exists in map and has API key
|
|
if (!modelInfo || !modelInfo.hasApiKey) {
|
|
return false;
|
|
}
|
|
|
|
// Check Copilot Plus entitlement requirements (bypassed in self-host mode)
|
|
if (model.plusExclusive && !isPlusEnabled()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolves the active chat model for temperature override operations.
|
|
* Uses a single source of truth: getModelKey() -> findCustomModel()
|
|
* Falls back to first valid model in settings.activeModels if current selection is invalid.
|
|
*
|
|
* Note: believerExclusive models are trusted if explicitly selected by the user,
|
|
* but skipped in fallback to avoid selecting them for non-Believer users.
|
|
*/
|
|
private resolveModelForTemperatureOverride(): CustomModel {
|
|
const settings = getSettings();
|
|
|
|
// Try to get the user's currently selected model
|
|
try {
|
|
const currentModelKey = getModelKey();
|
|
if (currentModelKey) {
|
|
const model = findCustomModel(currentModelKey, settings.activeModels);
|
|
|
|
// Validate it (trust believerExclusive if user selected it)
|
|
if (this.isModelConfigValid(model, settings)) {
|
|
return model;
|
|
}
|
|
}
|
|
} catch {
|
|
// Model not found or invalid, fall through to fallback
|
|
}
|
|
|
|
// Fallback: Find first valid model in settings.activeModels
|
|
// Skip believerExclusive models in fallback to avoid selecting them for non-Believer users
|
|
for (const model of settings.activeModels) {
|
|
if (model.enabled && !model.believerExclusive && this.isModelConfigValid(model, settings)) {
|
|
return model;
|
|
}
|
|
}
|
|
|
|
// No valid model found
|
|
throw new Error(
|
|
"No valid chat model available for temperature override. " +
|
|
"Please check your API key settings and ensure at least one model is properly configured."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* langchain 1.0 TypeScript doesn't support temperature override in BaseChatModelCallOptions,
|
|
* so we need to create a new model instance with the specified temperature.
|
|
*/
|
|
async getChatModelWithTemperature(temperature: number): Promise<BaseChatModel> {
|
|
const modelConfig = this.resolveModelForTemperatureOverride();
|
|
|
|
// Create a temporary model config with overridden temperature
|
|
const modelWithTempOverride: CustomModel = {
|
|
...modelConfig,
|
|
temperature,
|
|
};
|
|
|
|
return await this.createModelInstance(modelWithTempOverride);
|
|
}
|
|
|
|
async setChatModel(model: CustomModel): Promise<void> {
|
|
try {
|
|
const modelInstance = await this.createModelInstance(model);
|
|
ChatModelManager.chatModel = modelInstance;
|
|
|
|
// Log if Responses API is enabled for GPT-5
|
|
const modelInfo = getModelInfo(model.name);
|
|
if (
|
|
modelInfo.isGPT5 &&
|
|
(model.provider === ChatModelProviders.OPENAI ||
|
|
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
|
) {
|
|
logInfo(`Chat model set with Responses API for GPT-5: ${model.name}`);
|
|
}
|
|
} catch (error) {
|
|
logError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createModelInstance(model: CustomModel): Promise<BaseChatModel> {
|
|
// Create and return the appropriate model
|
|
const modelKey = getModelKeyFromModel(model);
|
|
const selectedModel = ChatModelManager.modelMap[modelKey];
|
|
if (!selectedModel) {
|
|
throw new Error(`No model found for: ${modelKey}`);
|
|
}
|
|
if (!selectedModel.hasApiKey) {
|
|
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
|
|
if (model.provider === 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(errorMessage);
|
|
}
|
|
|
|
const modelConfig = await this.getModelConfig(model);
|
|
const modelInfo = getModelInfo(model.name);
|
|
|
|
// For GPT-5 models, automatically use Responses API for proper verbosity support
|
|
const constructorConfig: any = { ...modelConfig };
|
|
if (
|
|
modelInfo.isGPT5 &&
|
|
(selectedModel.vendor === ChatModelProviders.OPENAI ||
|
|
selectedModel.vendor === ChatModelProviders.OPENAI_FORMAT)
|
|
) {
|
|
constructorConfig.useResponsesApi = true;
|
|
logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`);
|
|
}
|
|
|
|
// For LM Studio, use ChatLMStudio by default for Responses API compatibility.
|
|
// Opt out by setting useResponsesApi to false.
|
|
if (model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
|
|
const lmStudioInstance = new ChatLMStudio(constructorConfig);
|
|
logInfo(`[ChatModelManager] Using Responses API for LM Studio model: ${model.name}`);
|
|
return lmStudioInstance;
|
|
}
|
|
|
|
const newModelInstance = new selectedModel.AIConstructor(constructorConfig);
|
|
|
|
return newModelInstance;
|
|
}
|
|
|
|
validateChatModel(chatModel: BaseChatModel): boolean {
|
|
if (chatModel === undefined || chatModel === null) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Custom token estimation function for fallback when model is unknown
|
|
private estimateTokens(text: string): number {
|
|
if (!text) return 0;
|
|
// This is a simple approximation: ~4 chars per token for English text
|
|
// More accurate than using word count, but still a decent estimation
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
|
|
async countTokens(inputStr: string): Promise<number> {
|
|
return ChatModelManager.chatModel?.getNumTokens(inputStr) ?? this.estimateTokens(inputStr);
|
|
}
|
|
|
|
private validateCurrentModel(): void {
|
|
if (!ChatModelManager.chatModel) return;
|
|
|
|
const currentModelKey = getModelKey();
|
|
if (!currentModelKey) return;
|
|
|
|
// Get the model configuration
|
|
const selectedModel = ChatModelManager.modelMap[currentModelKey];
|
|
|
|
// If API key is missing or model doesn't exist in map
|
|
if (!selectedModel?.hasApiKey) {
|
|
// Clear the current chat model
|
|
ChatModelManager.chatModel = null;
|
|
logInfo("Failed to reinitialize model due to missing API key");
|
|
}
|
|
}
|
|
|
|
async ping(model: CustomModel): Promise<boolean> {
|
|
const tryPing = async (enableCors: boolean) => {
|
|
const modelToTest = { ...model, enableCors };
|
|
const modelConfig = await this.getModelConfig(modelToTest);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { streaming, maxTokens, maxCompletionTokens, ...pingConfig } = modelConfig;
|
|
|
|
// Check model capabilities to determine appropriate maxTokens
|
|
const modelInfo = getModelInfo(model.name);
|
|
|
|
// For thinking-enabled models, maxTokens must be greater than thinking.budget_tokens (2048)
|
|
// For other models, use minimal tokens for a fast ping
|
|
const pingMaxTokens = modelInfo.isThinkingEnabled ? 4096 : 30;
|
|
const tokenConfig = { maxTokens: pingMaxTokens };
|
|
|
|
const constructorConfig: any = {
|
|
...pingConfig,
|
|
...tokenConfig,
|
|
};
|
|
|
|
if (
|
|
modelInfo.isGPT5 &&
|
|
(model.provider === ChatModelProviders.OPENAI ||
|
|
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
|
) {
|
|
constructorConfig.useResponsesApi = true;
|
|
}
|
|
|
|
// For LM Studio with Responses API, ping via ChatLMStudio so the
|
|
// connectivity check hits the same /v1/responses endpoint used in chats.
|
|
const testModel =
|
|
model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
|
|
? new ChatLMStudio(constructorConfig)
|
|
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
|
|
await testModel.invoke([{ role: "user", content: "hello" }], {
|
|
timeout: 8000,
|
|
});
|
|
};
|
|
|
|
try {
|
|
// First try without CORS
|
|
await tryPing(false);
|
|
return true;
|
|
} catch (firstError) {
|
|
console.log("First ping attempt failed, trying with CORS...");
|
|
try {
|
|
// Second try with CORS
|
|
await tryPing(true);
|
|
new Notice(
|
|
"Connection successful, but requires CORS to be enabled. Please enable CORS for this model once you add it above."
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
const msg =
|
|
"\nwithout CORS Error: " +
|
|
err2String(firstError) +
|
|
"\nwith CORS Error: " +
|
|
err2String(error);
|
|
throw new Error(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
findModelByName(modelName: string): CustomModel | undefined {
|
|
const settings = getSettings();
|
|
return settings.activeModels.find((model) => model.name === modelName);
|
|
}
|
|
}
|