logancyang_obsidian-copilot/src/utils.ts
Logan Yang 3f12c14444
fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ (#2471)
* fix(anthropic): use thinking.type=adaptive for claude-opus-4-7+

claude-opus-4-7 rejects the legacy thinking shape with a 400:
  `thinking.type.enabled` is not supported for this model.
  Use `thinking.type.adaptive` and `output_config.effort`.

Detect opus-4 minor version in getModelInfo and emit
{ type: "adaptive" } for >=7, keeping { type: "enabled", budget_tokens }
for opus-4-6 and earlier, sonnet-4, and 3-7-sonnet.

Bumps @langchain/anthropic ^1.0.0 -> ^1.3.29 to pick up the adaptive
ThinkingConfigParam variant and an updated tool-schema typing that lets
BedrockChatModel.convertTools drop its now-redundant Record cast.

Fixes #2361. Replaces stale PR #2362.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): use thinking.type=adaptive for claude-opus-4-7+

Same 400 from claude-opus-4-7+ as the direct Anthropic path (fixed in
the previous commit), but the Bedrock provider has its own payload
builder in BedrockChatModel.buildRequestBody that was unaffected.

Add the same minor-version detection inside that payload builder.
Uses an unanchored regex /claude-opus-4-(\d+)/ because Bedrock model
IDs carry a provider/profile prefix (e.g.
"global.anthropic.claude-opus-4-7-20260115-v1:0").

Fixes #2384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: use real sonnet model IDs

claude-sonnet-4-7 doesn't exist; tests were asserting against a
fabricated ID. Switch the non-opus negative cases to a real
claude-sonnet-4-5 (and keep the existing claude-3-7-sonnet test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): set output_config.display=summarized for claude-opus-4-7+

Adaptive thinking on Opus 4.7+ alone restores the request shape but
leaves the UI empty — Anthropic flipped the default for
output_config.display from "summarized" to "omitted" on this model
line, so the API stops emitting visible thinking content blocks.
Pre-4.7 models still default to "summarized" server-side.

Set display: "summarized" alongside thinking: { type: "adaptive" } on
both the direct Anthropic and Bedrock paths so opus-4-7 renders
thinking summaries the same way sonnet 4.6 does today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): nest display=summarized inside thinking config

The Anthropic API places `display: 'summarized' | 'omitted'` on the
thinking config itself (ThinkingConfigAdaptive / ThinkingConfigEnabled),
not on a top-level `output_config`. The previous commit set
`outputConfig: { display: "summarized" }` on the ChatAnthropic config and
`output_config: { display: "summarized" }` on the Bedrock request body,
which broke the TS build (the SDK's OutputConfig only exposes
effort/format) and would not have produced the intended server behavior
for the adaptive thinking summarization toggle.

- chatModelManager.ts: emit `thinking: { type: "adaptive", display: "summarized" }`
- BedrockChatModel.ts: same, on the request payload
- BedrockChatModel.test.ts: update assertions to the new shape

* fix(thinking): constrain Opus minor regex so dated snapshots don't match

The previous `^claude-opus-4-(\d+)` (Anthropic) and unanchored
`claude-opus-4-(\d+)` (Bedrock) regex captured the date portion of
snapshot IDs like `claude-opus-4-20250514` as if it were the minor
version, so `usesAdaptiveThinking` evaluated as `true` (20250514 >= 7)
for pre-4.7 Opus models. That would send `thinking: { type: "adaptive" }`
for the legacy 4.0 snapshot and could trigger 400s on REASONING-enabled
runs.

Constrain the minor capture to 1-2 digits followed by `-`/`.` or end of
string. Real Anthropic minor versions are single/double digit; dated
snapshots have an 8-digit date that this regex now rejects.

- src/utils.ts: anchored fix for the Anthropic path
- src/LLMProviders/BedrockChatModel.ts: unanchored fix for Bedrock IDs
- regression tests for dated 4.0/4.1 snapshots in both src/utils.test.ts
  and src/LLMProviders/BedrockChatModel.test.ts

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:29:36 -07:00

1421 lines
46 KiB
TypeScript

// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild
// so the same Buffer code path works on desktop (Electron) and mobile (WebView).
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from "buffer";
import { ChainType } from "@/chainType";
import {
ALLOWED_NOTE_CONTEXT_EXTENSIONS,
ChatModelProviders,
EmbeddingModelProviders,
NOMIC_EMBED_TEXT,
Provider,
ProviderInfo,
ProviderMetadata,
SettingKeyProviders,
TEXT_READABLE_EXTENSIONS,
} from "@/constants";
import { logInfo, logWarn } from "@/logger";
import { CopilotSettings } from "@/settings/model";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { Document } from "@langchain/core/documents";
import { MemoryVariables } from "@langchain/core/memory";
import { DateTime } from "luxon";
import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
import { getApiKeyForProvider } from "@/utils/modelUtils";
export { err2String } from "@/errorFormat";
/**
* Unified type for fetch implementation.
* Used for dependency injection of fetch (e.g., safeFetch for CORS bypass).
*/
export type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Extract domain from URL, removing 'www.' prefix.
* Returns the original URL string if parsing fails.
* @param url - The URL to extract domain from
* @returns Domain without 'www.' prefix, or original string on parse failure
*/
export function getDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// Add custom error type at the top of the file
interface APIError extends Error {
json?: unknown;
}
// Error message constants
const ERROR_MESSAGES = {
INVALID_LICENSE_KEY_USER:
"Invalid Copilot Plus license key. Please check your license key in settings.",
UNKNOWN_ERROR: "An unknown error occurred",
REQUEST_FAILED: (status: number) => `Request failed, status ${status}`,
} as const;
// Error handling utilities
interface ErrorDetail {
status?: number;
message?: string;
reason?: string;
}
function extractErrorDetail(error: unknown): ErrorDetail {
const err = error as Record<string, unknown> | null | undefined;
const errorDetail: ErrorDetail = (err?.detail as ErrorDetail) || {};
return {
status: errorDetail.status,
message: errorDetail.message || (err?.message as string | undefined),
reason: errorDetail.reason,
};
}
function isLicenseKeyError(error: unknown): boolean {
const errorDetail = extractErrorDetail(error);
const err = error as Record<string, unknown> | null | undefined;
const message = err?.message as string | undefined;
return Boolean(
errorDetail.reason === "Invalid license key" ||
message === "Invalid license key" ||
message?.includes("status 403") ||
errorDetail.status === 403
);
}
export function getApiErrorMessage(error: unknown): string {
const errorDetail = extractErrorDetail(error);
if (isLicenseKeyError(error)) {
return ERROR_MESSAGES.INVALID_LICENSE_KEY_USER;
}
return (
errorDetail.message ||
(errorDetail.reason ? `Error: ${errorDetail.reason}` : ERROR_MESSAGES.UNKNOWN_ERROR)
);
}
export const isFolderMatch = (fileFullpath: string, inputPath: string): boolean => {
const fileSegments = fileFullpath.split("/").map((segment) => segment.toLowerCase());
return fileSegments.includes(inputPath.toLowerCase());
};
/** TODO: Rewrite with app.vault.getAbstractFileByPath() */
export const getNotesFromPath = (vault: Vault, path: string): TFile[] => {
const files = vault.getMarkdownFiles();
// Special handling for the root path '/'
if (path === "/") {
return files;
}
// Normalize the input path
const normalizedPath = path.toLowerCase().replace(/^\/|\/$/g, "");
return files.filter((file) => {
// Normalize the file path
const normalizedFilePath = file.path.toLowerCase();
const filePathParts = normalizedFilePath.split("/");
const pathParts = normalizedPath.split("/");
// Check if the file path contains all parts of the input path in order
let filePathIndex = 0;
for (const pathPart of pathParts) {
while (filePathIndex < filePathParts.length) {
if (filePathParts[filePathIndex] === pathPart) {
break;
}
filePathIndex++;
}
if (filePathIndex >= filePathParts.length) {
return false;
}
}
return true;
});
};
/**
* @param tag - The tag to strip the hash symbol from.
* @returns The tag without the hash symbol.
*/
export function stripHash(tag: string): string {
return tag.replace(/^#/, "").trim();
}
/**
* Options for {@link stripFrontmatter}.
*/
interface StripFrontmatterOptions {
/**
* When true (default), trims leading whitespace after the frontmatter block.
* When false, preserves leading whitespace from the body, but still removes
* the single newline that immediately follows the closing frontmatter marker.
*/
trimStart?: boolean;
}
/**
* Strip YAML frontmatter from markdown content.
* @param content - The markdown content to strip frontmatter from.
* @param options - Options controlling how leading whitespace is handled.
* @returns The content without the frontmatter block.
*/
export function stripFrontmatter(content: string, options: StripFrontmatterOptions = {}): string {
const { trimStart = true } = options;
if (content.startsWith("---")) {
const end = content.indexOf("---", 3);
if (end !== -1) {
const body = content.slice(end + 3);
if (trimStart) {
return body.trimStart();
}
// Preserve body whitespace, but remove the frontmatter/body separator newline.
if (body.startsWith("\r\n")) {
return body.slice(2);
}
if (body.startsWith("\n") || body.startsWith("\r")) {
return body.slice(1);
}
return body;
}
}
return content;
}
/**
* @param file - The note file to get tags from.
* @param frontmatterOnly - Whether to only get tags from frontmatter.
* @returns An array of lowercase tags without the hash symbol.
*/
export function getTagsFromNote(file: TFile, frontmatterOnly = true): string[] {
const metadata = app.metadataCache.getFileCache(file);
const frontmatterTags = metadata?.frontmatter?.tags;
const allTags = new Set<string>();
if (!frontmatterOnly) {
const inlineTags = metadata?.tags?.map((tag) => tag.tag);
if (inlineTags) {
inlineTags.forEach((tag) => allTags.add(stripHash(tag)));
}
}
// Add frontmatter tags
if (frontmatterTags) {
if (Array.isArray(frontmatterTags)) {
frontmatterTags.forEach((tag) => {
if (typeof tag === "string") {
allTags.add(stripHash(tag));
}
});
} else if (typeof frontmatterTags === "string") {
allTags.add(stripHash(frontmatterTags));
}
}
return Array.from(allTags);
}
/**
* Get notes from tags.
* @param vault - The vault to get notes from.
* @param tags - The tags to get notes from. Tags should be with the hash symbol.
* @param noteFiles - The notes to get notes from.
* @returns An array of note files.
*/
export function getNotesFromTags(vault: Vault, tags: string[], noteFiles?: TFile[]): TFile[] {
if (tags.length === 0) {
return [];
}
tags = tags.map((tag) => stripHash(tag));
const files = noteFiles && noteFiles.length > 0 ? noteFiles : getNotesFromPath(vault, "/");
const filesWithTag = [];
for (const file of files) {
const noteTags = getTagsFromNote(file);
if (tags.some((tag) => noteTags.includes(tag))) {
filesWithTag.push(file);
}
}
return filesWithTag;
}
export interface FormattedDateTime {
fileName: string;
display: string;
epoch: number;
}
export const formatDateTime = (
now: Date,
timezone: "local" | "utc" = "local"
): FormattedDateTime => {
const dt = timezone === "utc" ? DateTime.fromJSDate(now).toUTC() : DateTime.fromJSDate(now);
return {
fileName: dt.toFormat("yyyyMMdd_HHmmss"),
display: dt.toFormat("yyyy/MM/dd HH:mm:ss"),
epoch: dt.toMillis(),
};
};
/**
* Ensure a folder path exists by creating any missing parent directories.
* Works across desktop and mobile. Safe to call repeatedly.
*
* Examples:
* - ensureFolderExists("copilot/copilot-conversations")
* - ensureFolderExists("some/deep/nested/path")
*
* Throws if any segment conflicts with an existing file.
*/
export async function ensureFolderExists(folderPath: string): Promise<void> {
const path = normalizePath(folderPath).replace(/^\/+/, "").replace(/\/+$/, "");
if (!path) return; // nothing to ensure
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = app.vault.getAbstractFileByPath(current);
if (existing) {
if (existing instanceof TFile) {
throw new Error(`Path conflict: "${current}" exists as a file, expected folder.`);
}
// If it's a folder, continue to check/create the next segment
continue;
}
// Create this level; parents are guaranteed to exist from previous iterations
await app.vault.adapter.mkdir(current);
}
}
export function stringToFormattedDateTime(timestamp: string): FormattedDateTime {
const date = DateTime.fromFormat(timestamp, "yyyy/MM/dd HH:mm:ss");
if (!date.isValid) {
// If the string is not in the expected format, return current date/time
return formatDateTime(new Date());
}
return {
fileName: date.toFormat("yyyyMMdd_HHmmss"),
display: date.toFormat("yyyy/MM/dd HH:mm:ss"),
epoch: date.toMillis(),
};
}
/**
* Check if a file has a text-readable extension (md, canvas, base).
*/
export function isTextReadableFile(file: TFile | null): boolean {
if (!file) return false;
return TEXT_READABLE_EXTENSIONS.includes(file.extension);
}
export async function getFileContent(file: TFile, vault: Vault): Promise<string | null> {
if (!isTextReadableFile(file)) return null;
return await vault.read(file);
}
export function getFileName(file: TFile): string {
return file.basename;
}
/**
* Check if a file is allowed for note context (text-readable files plus PDF).
* This does NOT include images - images are handled separately in the UI.
* @param file The file to check
* @returns true if the file is allowed for note context, false otherwise
*/
export function isAllowedFileForNoteContext(file: TFile | null): boolean {
if (!file) return false;
return ALLOWED_NOTE_CONTEXT_EXTENSIONS.includes(file.extension);
}
/**
* Checks if a chain type is a Plus mode chain (Copilot Plus or Project Chain).
* Plus mode chains have access to premium features like PDF processing and URL processing.
* @param chainType The chain type to check
* @returns true if this is a Plus mode chain, false otherwise
*/
export function isPlusChain(chainType: ChainType): boolean {
return chainType === ChainType.COPILOT_PLUS_CHAIN || chainType === ChainType.PROJECT_CHAIN;
}
/**
* Checks if a file extension is allowed for context based on the chain type.
* All chains support text-readable files (md, canvas, base).
* Plus chains additionally support PDF, EPUB, PPT, DOCX, etc.
* @param file The file to check
* @param chainType The current chain type
* @returns true if the file is allowed for this chain type, false otherwise
*/
export function isAllowedFileForChainContext(file: TFile | null, chainType: ChainType): boolean {
if (!file) return false;
if (isTextReadableFile(file)) {
return true;
}
// Plus chains support all other file types (PDF, EPUB, PPT, DOCX, etc.)
return isPlusChain(chainType);
}
export function areEmbeddingModelsSame(
model1: string | undefined,
model2: string | undefined
): boolean {
if (!model1 || !model2) return false;
// TODO: Hacks to handle different embedding model names for the same model. Need better handling.
if (model1.includes(NOMIC_EMBED_TEXT) && model2.includes(NOMIC_EMBED_TEXT)) {
return true;
}
if (
(model1 === "small" && model2 === "cohereai") ||
(model1 === "cohereai" && model2 === "small")
) {
return true;
}
return model1 === model2;
}
export interface ChatHistoryEntry {
role: "user" | "assistant";
content: string;
}
/**
* Extract text-only chat history from memory variables.
* This function pairs messages by index (i, i+1) and returns only string content.
*
* Note: For multimodal chains (CopilotPlus, AutonomousAgent), use
* chatHistoryUtils.processRawChatHistory instead to preserve image content.
*
* @param memoryVariables Memory variables from LangChain memory
* @returns Array of text-only chat history entries
*/
// TODO: Deprecated, use chatHistoryUtils.processRawChatHistory instead
export function extractChatHistory(memoryVariables: MemoryVariables): ChatHistoryEntry[] {
const chatHistory: ChatHistoryEntry[] = [];
const { history } = memoryVariables;
for (let i = 0; i < history.length; i += 2) {
const userMessage = history[i]?.content || "";
const aiMessage = history[i + 1]?.content || "";
chatHistory.push(
{ role: "user", content: userMessage },
{ role: "assistant", content: aiMessage }
);
}
return chatHistory;
}
/**
* Core logic for extracting note files from wikilink patterns.
* Resolves note titles/paths to TFile objects, handling both unique titles and full paths.
*
* @param noteTitles - Array of note title/path strings extracted from wikilinks
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects
*/
function resolveNoteFilesFromTitles(noteTitles: string[], vault: Vault): TFile[] {
const uniqueFiles = new Map<string, TFile>();
noteTitles.forEach((noteTitle) => {
// First try to get file by full path
const file = vault.getAbstractFileByPath(noteTitle);
if (file instanceof TFile) {
// Found by path, use it directly
uniqueFiles.set(file.path, file);
} else {
// Try to find by title
const files = vault.getMarkdownFiles();
const matchingFiles = files.filter((f) => f.basename === noteTitle);
if (matchingFiles.length > 0) {
if (isNoteTitleUnique(noteTitle, vault)) {
// Only one file with this title, use it
uniqueFiles.set(matchingFiles[0].path, matchingFiles[0]);
} else {
// Multiple files with same title - this shouldn't happen
// as we should be using full paths for duplicate titles
console.warn(
`Found multiple files with title "${noteTitle}". Expected a full path for duplicate titles.`
);
}
}
}
});
return Array.from(uniqueFiles.values());
}
/**
* Extract note files from text containing wikilinks: [[note title]]
* Used by search/retrieval systems to find explicitly mentioned notes.
*
* @param query - Text containing [[...]] patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the [[...]] patterns
*/
export function extractNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in [[]]
const regex = /\[\[(.*?)\]\]/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from [[...]]
const noteTitles = matches.map((match) => match.slice(2, -2));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
/**
* Extract note files from text containing wikilinks wrapped in curly braces: {[[note title]]}
* This is specifically for custom prompt templating where only {[[...]]} syntax should trigger
* note content inclusion.
*
* @param query - Text containing {[[...]]} patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the {[[...]]} patterns
*/
export function extractTemplateNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in {[[]]}
const regex = /\{\[\[(.*?)\]\]\}/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from {[[...]]}
const noteTitles = matches.map((match) => match.slice(3, -3));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
// Helper function to check if a note title is unique in the vault
function isNoteTitleUnique(title: string, vault: Vault): boolean {
const files = vault.getMarkdownFiles();
return files.filter((f) => f.basename === title).length === 1;
}
/**
* Process the variable name to generate a note path if it's enclosed in double brackets,
* otherwise return the variable name as is.
*
* @param {string} variableName - The name of the variable to process
* @return {string} The processed note path or the variable name itself
*/
export function processVariableNameForNotePath(variableName: string): string {
variableName = variableName.trim();
// Check if the variable name is enclosed in double brackets indicating it's a note
if (variableName.startsWith("[[") && variableName.endsWith("]]")) {
// It's a note, so we remove the brackets and append '.md'
return `${variableName.slice(2, -2).trim()}.md`;
}
// It's a path, so we just return it as is
return variableName;
}
export function extractUniqueTitlesFromDocs(docs: Document[]): string[] {
const titlesSet = new Set<string>();
docs.forEach((doc) => {
if (doc.metadata?.title) {
titlesSet.add(doc.metadata.title as string);
}
});
return Array.from(titlesSet);
}
const YOUTUBE_URL_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([^\s&]+)/;
/**
* Validates a YouTube URL and returns detailed validation result
*/
export function validateYoutubeUrl(url: string): {
isValid: boolean;
error?: string;
videoId?: string;
} {
if (!url || typeof url !== "string") {
return { isValid: false, error: "URL is required" };
}
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return { isValid: false, error: "URL cannot be empty" };
}
// Extract video ID
const videoId = extractYoutubeVideoId(trimmedUrl);
if (!videoId) {
return { isValid: false, error: "Invalid YouTube URL format" };
}
// Check if video ID is valid (11 characters, alphanumeric with dashes and underscores)
if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
return { isValid: false, error: "Invalid YouTube video ID" };
}
return { isValid: true, videoId };
}
/**
* Extract YouTube video ID from various URL formats
*/
export function extractYoutubeVideoId(url: string): string | null {
try {
// Handle different YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
} catch {
return null;
}
}
/**
* Create a standard YouTube URL from video ID
*/
export function formatYoutubeUrl(videoId: string): string {
return `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* Check if a string is a valid YouTube URL (legacy function for backward compatibility)
*/
export function isYoutubeUrl(url: string): boolean {
return validateYoutubeUrl(url).isValid;
}
/**
* Check if a URL is a Twitter/X URL (e.g. tweet or post link)
*/
export function isTwitterUrl(url: string): boolean {
if (!url || typeof url !== "string") return false;
try {
const urlObj = new URL(url.trim());
return (
(urlObj.hostname === "x.com" ||
urlObj.hostname === "www.x.com" ||
urlObj.hostname === "twitter.com" ||
urlObj.hostname === "www.twitter.com") &&
urlObj.pathname.includes("/status/")
);
} catch {
return false;
}
}
/**
* Extract all YouTube URLs from text (legacy function for backward compatibility)
*/
export function extractAllYoutubeUrls(text: string): string[] {
const matches = text.matchAll(new RegExp(YOUTUBE_URL_REGEX, "g"));
return Array.from(matches, (match) => match[0]);
}
/**
* Proxy function to use in place of fetch() to bypass CORS restrictions.
* Uses Obsidian's requestUrl which bypasses browser CORS restrictions.
*
* @param url - The URL to fetch
* @param options - Fetch options (subset of RequestInit)
* @param options.throwOnHttpError - If true (default), throws on HTTP >= 400. Set to false for fetch-like behavior.
*
* @remarks
* **AbortSignal Limitation**: The `signal` option is accepted for API compatibility
* but is NOT honored by the underlying `requestUrl` implementation. Requests made
* through this function cannot be cancelled via AbortSignal. If cancellation is
* required, use native `fetch` instead (which may encounter CORS issues on mobile).
*
* **Streaming Limitation**: This function does not support true streaming responses.
* The entire response body is buffered before being returned.
*
* @see https://forum.obsidian.md/t/support-streaming-the-request-and-requesturl-response-body/87381
*/
export async function safeFetch(
url: string,
options: RequestInit & { throwOnHttpError?: boolean } = {}
): Promise<Response> {
const { throwOnHttpError = true } = options;
// Initialize headers if not provided
const normalizedHeaders = new Headers(options.headers);
const headers = Object.fromEntries(normalizedHeaders.entries());
// Remove content-length if it exists
delete (headers as Record<string, string>)["content-length"];
logInfo("safeFetch request");
const method = options.method?.toUpperCase() || "POST";
const methodsWithBody = ["POST", "PUT", "PATCH"];
const response = await requestUrl({
url,
contentType: "application/json",
headers: headers,
method: method,
...(methodsWithBody.includes(method) &&
typeof options.body === "string" && { body: options.body }),
throw: false, // Don't throw so we can get the response body
});
// Check if response is error status (only throw if throwOnHttpError is true)
if (throwOnHttpError && response.status >= 400) {
let errorJson;
try {
errorJson = typeof response.json === "string" ? JSON.parse(response.json) : response.json;
} catch {
try {
errorJson = typeof response.text === "string" ? JSON.parse(response.text) : response.text;
} catch {
errorJson = null;
}
}
// Create error with proper structure
const error = new Error(ERROR_MESSAGES.REQUEST_FAILED(response.status)) as APIError;
error.json = errorJson;
// Handle nested error structure
if (
errorJson?.detail?.reason === "Invalid license key" ||
errorJson?.reason === "Invalid license key"
) {
error.message = "Invalid license key";
} else if (errorJson?.detail?.message || errorJson?.message) {
const message = errorJson?.detail?.message || errorJson?.message;
const reason = errorJson?.detail?.reason || errorJson?.reason;
error.message = reason ? `${message}: ${reason}` : message;
} else if (errorJson?.detail) {
error.message = JSON.stringify(errorJson.detail);
} else if (errorJson) {
// for external error, add more msg
error.message += ". " + JSON.stringify(errorJson);
}
throw error;
}
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status.toString(),
headers: new Headers(response.headers),
url: url,
type: "basic" as ResponseType,
redirected: false,
bytes: () => Promise.resolve(new Uint8Array(0)),
body: createReadableStreamFromString(response.text),
bodyUsed: true,
json: (): Promise<unknown> => Promise.resolve(response.json as unknown),
text: async () => response.text,
arrayBuffer: async () => {
if (response.arrayBuffer) {
return response.arrayBuffer;
}
const base64 = response.text.replace(/^data:.*;base64,/, "");
// Reason: Buffer (from the `buffer` polyfill imported above) is the
// cross-platform path — bare global Buffer is undefined in mobile WebView.
const buf = Buffer.from(base64, "base64");
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
},
blob: () => {
throw new Error("not implemented");
},
formData: () => {
throw new Error("not implemented");
},
clone: () => {
throw new Error("not implemented");
},
};
}
/**
* Wrapper around safeFetch that doesn't throw on HTTP errors (fetch-like behavior).
* Use this when you need to check response.status for retry logic (e.g., 401 token refresh).
*
* @remarks
* Inherits all limitations from safeFetch:
* - AbortSignal is NOT honored (requests cannot be cancelled)
* - No true streaming support (response is fully buffered)
*
* @see safeFetch for full documentation
*/
export function safeFetchNoThrow(url: string, options: RequestInit = {}): Promise<Response> {
return safeFetch(url, { ...options, throwOnHttpError: false });
}
function createReadableStreamFromString(input: string) {
return new ReadableStream({
start(controller) {
// Convert the input string to a Uint8Array
const encoder = new TextEncoder();
const uint8Array = encoder.encode(input);
// Push the data to the stream
controller.enqueue(uint8Array);
// Close the stream
controller.close();
},
});
}
// err2String is now exported from '@/errorFormat' to avoid circular dependencies and duplication.
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj };
keys.forEach((key) => {
delete result[key];
});
return result;
}
export function findCustomModel(modelKey: string, activeModels: CustomModel[]): CustomModel {
const [modelName, provider] = modelKey.split("|");
const model = activeModels.find((m) => m.name === modelName && m.provider === provider);
if (!model) {
throw new Error(`No model configuration found for: ${modelKey}`);
}
return model;
}
export function getProviderInfo(provider: string): ProviderMetadata {
const info = ProviderInfo[provider as Provider];
return {
...info,
label: info.label || provider,
};
}
export function getProviderLabel(provider: string, model?: CustomModel): string {
const baseLabel = ProviderInfo[provider as Provider]?.label || provider;
return baseLabel + (model?.believerExclusive && baseLabel === "Copilot Plus" ? "(Believer)" : "");
}
/**
* Cleans a message by removing Think blocks, Action blocks (writeFile), tool call markers,
* and agent reasoning blocks for copying to clipboard or inserting at cursor.
* This is more comprehensive than removeThinkTags which is used for RAG.
*/
export function cleanMessageForCopy(message: string): string {
let cleanedMessage = message;
// First use the existing removeThinkTags function
cleanedMessage = removeThinkTags(cleanedMessage);
// Remove writeFile blocks wrapped in XML codeblocks (also handles legacy writeToFile tag)
cleanedMessage = cleanedMessage.replace(
/```xml\s*[\s\S]*?<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>[\s\S]*?```/g,
""
);
// Remove standalone writeFile/writeToFile blocks
cleanedMessage = cleanedMessage.replace(
/<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>/g,
""
);
// Remove tool call markers
// Format: <!--TOOL_CALL_START:id:toolName:displayName:emoji:confirmationMessage:isExecuting-->content<!--TOOL_CALL_END:id:result-->
cleanedMessage = cleanedMessage.replace(
/<!--TOOL_CALL_START:[^:]+:[^:]+:[^:]+:[^:]+:[^:]*:[^:]+-->[\s\S]*?<!--TOOL_CALL_END:[^:]+:[\s\S]*?-->/g,
""
);
// Remove agent reasoning blocks
// Format: <!--AGENT_REASONING:status:elapsed:["step1","step2"]-->
// Use greedy .* so we match to the real closing --> even if the JSON payload contains -->
cleanedMessage = cleanedMessage.replace(/<!--AGENT_REASONING:\w+:\d+:.*-->/g, "");
// Clean up any resulting multiple consecutive newlines (more than 2)
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, "\n\n");
// Trim leading and trailing whitespace
cleanedMessage = cleanedMessage.trim();
return cleanedMessage;
}
/**
* Inserts a message into the active markdown editor, optionally replacing the current selection.
* Uses a single CM6 transaction to avoid undo stack splitting.
* Ensures the inserted/replaced range is selected after the operation.
*/
export async function insertIntoEditor(message: string, replace: boolean = false) {
let leaf = app.workspace.getMostRecentLeaf();
if (!leaf) {
new Notice("No active leaf found.");
return;
}
if (!(leaf.view instanceof MarkdownView)) {
leaf = app.workspace.getLeaf(false);
await leaf.setViewState({ type: "markdown", state: leaf.view.getState() });
}
if (!(leaf.view instanceof MarkdownView)) {
new Notice("Failed to open a markdown view.");
return;
}
const editor = leaf.view.editor;
const cursorFrom = editor.getCursor("from");
const cursorTo = editor.getCursor("to");
// Clean the message before inserting (removes think tags, writeFile blocks, tool calls)
const cleanedMessage = cleanMessageForCopy(message);
const cleanedLines = cleanedMessage.split("\n");
/** Computes the end editor position after inserting text at start position */
const getEndPosition = (
start: { line: number; ch: number },
textLines: string[]
): { line: number; ch: number } => {
const lineDelta = textLines.length - 1;
if (lineDelta === 0) {
return { line: start.line, ch: start.ch + (textLines[0]?.length ?? 0) };
}
return { line: start.line + lineDelta, ch: textLines[textLines.length - 1]?.length ?? 0 };
};
/** Inserts via Obsidian Editor API (fallback when CM6 unavailable or dispatch fails) */
const insertWithEditorAPI = (): void => {
const changeFrom = replace ? cursorFrom : cursorTo;
editor.replaceRange(cleanedMessage, changeFrom, cursorTo);
editor.setSelection(changeFrom, getEndPosition(changeFrom, cleanedLines));
};
/** Focuses the editor and shows success notice */
const finalizeInsertion = (): void => {
editor.focus();
new Notice("Message inserted into the active note.");
};
// Check if CM6 EditorView is available and valid
const view = editor.cm;
const isCM6View = view?.state?.doc && typeof view.dispatch === "function";
if (!isCM6View) {
insertWithEditorAPI();
finalizeInsertion();
return;
}
// Use CM6 selection offsets directly (simpler than manual line/ch conversion)
const { from, to } = view.state.selection.main;
const changeFrom = replace ? from : to;
// Use CM6's toText to handle CRLF normalization correctly
// (CM6 treats \r\n as single newline, so string.length would be wrong)
const insertText = view.state.toText(cleanedMessage);
const endOffset = changeFrom + insertText.length;
try {
// Single transaction: text change + selection change
view.dispatch({
changes: { from: changeFrom, to, insert: insertText },
selection: { anchor: changeFrom, head: endOffset },
});
} catch (e) {
// Fallback to Obsidian API if CM6 dispatch fails
logWarn("CM6 dispatch failed, falling back to Obsidian API", e);
insertWithEditorAPI();
}
finalizeInsertion();
}
export { debounce } from "@/utils/debounce";
/**
* Compare two semantic version strings.
* @returns true if latest version is newer than current version
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false;
}
/**
* Check for latest version from GitHub releases.
* @returns latest version string or error message
*/
export async function checkLatestVersion(): Promise<{
version: string | null;
error: string | null;
}> {
try {
const response = await requestUrl({
url: "https://api.github.com/repos/logancyang/obsidian-copilot/releases/latest",
method: "GET",
});
const version = response.json.tag_name.replace("v", "");
return { version, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to check for updates";
return { version: null, error: errorMessage };
}
}
// Note: LangChain 0.6.6+ handles O-series and GPT-5 models automatically
// These functions are kept for backward compatibility and specific checks
export function isOSeriesModel(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4");
}
// For BaseChatModel instances
const m = model as unknown as Record<string, unknown>;
const modelName: string = (m.modelName as string) || (m.model as string) || "";
return modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4");
}
function isGPT5Model(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("gpt-5");
}
// For BaseChatModel instances
const m = model as unknown as Record<string, unknown>;
const modelName: string = (m.modelName as string) || (m.model as string) || "";
return modelName.startsWith("gpt-5");
}
/**
* Checks whether a model belongs to the Codex family.
* Codex model identifiers consistently include the "codex" token.
* @param model - Model instance or model name string.
* @returns True when the model name indicates a Codex model.
*/
function isCodexModel(model: BaseChatModel | string): boolean {
const m = model as unknown as Record<string, unknown>;
const modelName: string =
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
return modelName.toLowerCase().includes("codex");
}
/**
* Determines whether a GitHub Copilot model should use the Responses API.
* Copilot Codex models reject `/chat/completions` and must be sent to `/responses`.
* @param model - Minimal model configuration used for routing.
* @returns True when the model should be routed to `/responses`.
*/
export function shouldUseGitHubCopilotResponsesApi(
model: Pick<CustomModel, "provider" | "name" | "useResponsesApi">
): boolean {
if ((model.provider as ChatModelProviders) !== ChatModelProviders.GITHUB_COPILOT) {
return false;
}
if (model.useResponsesApi === true) {
return true;
}
return isCodexModel(model.name);
}
/**
* Utility for determining model characteristics
* Note: Most of this is handled by LangChain 0.6.6+ internally
*/
export interface ModelInfo {
isOSeries: boolean;
isGPT5: boolean;
isThinkingEnabled: boolean;
usesAdaptiveThinking: boolean;
}
export function getModelInfo(model: BaseChatModel | string): ModelInfo {
const m = model as unknown as Record<string, unknown>;
const modelName: string =
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
const isOSeries = isOSeriesModel(modelName);
const isGPT5 = isGPT5Model(modelName);
const isThinkingEnabled =
modelName.startsWith("claude-3-7-sonnet") ||
modelName.startsWith("claude-sonnet-4") ||
modelName.startsWith("claude-opus-4");
// claude-opus-4-7 and later reject the legacy { type: "enabled", budget_tokens } shape
// with a 400 and require { type: "adaptive" }. Detect by minor version on the opus-4 line.
// Constrain the minor to 1-2 digits followed by a delimiter or end-of-string so dated
// snapshot IDs (e.g. "claude-opus-4-20250514") aren't misread as Opus 4.20250514.
const opusMinorMatch = modelName.match(/^claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
return {
isOSeries,
isGPT5,
isThinkingEnabled,
usesAdaptiveThinking,
};
}
export function getMessageRole(
model: BaseChatModel | string,
defaultRole: "system" | "human" = "system"
): "system" | "human" {
return isOSeriesModel(model) ? "human" : defaultRole;
}
export function getNeedSetKeyProvider(): Provider[] {
// List of providers to exclude
const excludeProviders: Provider[] = [
ChatModelProviders.OPENAI_FORMAT,
ChatModelProviders.OLLAMA,
ChatModelProviders.LM_STUDIO,
ChatModelProviders.AZURE_OPENAI,
ChatModelProviders.GITHUB_COPILOT,
EmbeddingModelProviders.COPILOT_PLUS,
EmbeddingModelProviders.COPILOT_PLUS_JINA,
];
return (Object.keys(ProviderInfo) as Provider[]).filter((key) => !excludeProviders.includes(key));
}
export function checkModelApiKey(
model: CustomModel,
settings: Readonly<CopilotSettings>
): {
hasApiKey: boolean;
errorNotice?: string;
} {
const provider = model.provider as ChatModelProviders;
if (provider === ChatModelProviders.AMAZON_BEDROCK) {
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
if (!apiKey) {
return {
hasApiKey: false,
errorNotice:
"Amazon Bedrock API key is missing. Please add a key in Settings > API Keys or update the model configuration.",
};
}
// Region defaults to us-east-1 if not specified, so API key is the only required check
return { hasApiKey: true };
}
// GitHub Copilot uses OAuth, not API key
if (provider === ChatModelProviders.GITHUB_COPILOT) {
const hasAuth = Boolean(
model.apiKey || settings.githubCopilotToken || settings.githubCopilotAccessToken
);
if (!hasAuth) {
return {
hasApiKey: false,
errorNotice:
"GitHub Copilot is not authenticated. Please connect it in Settings > Copilot > Basic Tab > Set Keys.",
};
}
return { hasApiKey: true };
}
const needSetKeyPath = !!getNeedSetKeyProvider().find((p) => p === provider);
const hasNoApiKey = !getApiKeyForProvider(model.provider as SettingKeyProviders, model);
// For Providers that require setting a key in the dialog, an inspection is necessary.
if (needSetKeyPath && hasNoApiKey) {
const notice =
`Please configure API Key for ${model.name} in settings first.` +
"\nPath: Settings > copilot plugin > Basic Tab > Set Keys";
return {
hasApiKey: false,
errorNotice: notice,
};
}
return {
hasApiKey: true,
};
}
/**
* Extracts text content from a message chunk that could be either a string
* or an array of content objects (Claude 3.7 format)
*/
export function extractTextFromChunk(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.filter((item) => item.type === "text")
.map((item) => item.text as string)
.join("");
}
// For any other type, return empty string
return "";
}
/**
* Removes any <think> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove think tags from
* @returns The text with think tags removed
*/
export function removeThinkTags(text: unknown): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Remove complete think tags and their content
let cleanedText = plainText.replace(/<think>[\s\S]*?<\/think>/g, "");
// Remove any remaining unclosed think tags (for streaming scenarios)
cleanedText = cleanedText.replace(/<think>[\s\S]*$/g, "");
return cleanedText.trim();
}
/**
* Removes any <errorChunk> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove error tags from
* @returns The text with error tags removed
*/
export function removeErrorTags(text: unknown): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Then remove error tags
return plainText.replace(/<errorChunk>[\s\S]*?<\/errorChunk>/g, "").trim();
}
export function randomUUID() {
return crypto.randomUUID();
}
/**
* Executes a function with token counting warnings suppressed
* This can be used anywhere in the codebase where token counting warnings should be suppressed
* @param fn The function to execute without token counting warnings
* @returns The result of the function
*/
export async function withSuppressedTokenWarnings<T>(fn: () => Promise<T>): Promise<T> {
// Store original console.warn
const originalWarn = console.warn;
try {
// Replace with filtered version
console.warn = function (...args) {
// Ignore token counting warnings
if (
args[0]?.includes &&
(args[0].includes("Failed to calculate number of tokens") ||
args[0].includes("Unknown model"))
) {
return;
}
// Pass through other warnings
originalWarn.apply(console, args);
};
// Execute the provided function
return await fn();
} finally {
// Always restore original console.warn, even if an error occurs
console.warn = originalWarn;
}
}
/**
* Execute an operation with a timeout using AbortController for proper cancellation
* @param operation - Function that accepts an AbortSignal and returns a Promise
* @param timeoutMs - Timeout in milliseconds
* @param operationName - Name of the operation for error messages
* @returns Promise that resolves with the operation result or rejects with TimeoutError
*/
export async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
operationName: string = "Operation"
): Promise<T> {
const { TimeoutError } = await import("@/error");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () => {
reject(new TimeoutError(operationName, timeoutMs));
});
}),
]);
} finally {
window.clearTimeout(timeoutId);
}
}
/**
* Check if the current Obsidian editor setting is in source mode
*/
export function isSourceModeOn(): boolean {
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return true;
const state = view.getState() as { source?: boolean };
return state.source === true;
}
/**
* Calculate the UTF-8 byte length of a string.
* This is important for filesystem operations where filename limits are in bytes, not characters.
* @param str - The string to measure
* @returns The byte length when encoded as UTF-8
*/
export function getUtf8ByteLength(str: string): number {
// Use TextEncoder which always uses UTF-8 encoding
return new TextEncoder().encode(str).length;
}
/**
* Truncate a string to fit within a byte limit, ensuring UTF-8 character boundaries are respected.
* This prevents cutting multibyte UTF-8 sequences in the middle.
* @param str - The string to truncate
* @param byteLimit - Maximum number of bytes (not characters)
* @returns The truncated string that fits within the byte limit
*/
export function truncateToByteLimit(str: string, byteLimit: number): string {
if (byteLimit <= 0) {
return "";
}
// Fast path: if string already fits, return as-is
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
if (bytes.length <= byteLimit) {
return str;
}
// Binary search to find the longest prefix that fits
let low = 0;
let high = str.length;
let result = "";
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = str.substring(0, mid);
const candidateBytes = encoder.encode(candidate);
if (candidateBytes.length <= byteLimit) {
// This candidate fits, try a longer one
result = candidate;
low = mid + 1;
} else {
// This candidate is too long, try a shorter one
high = mid - 1;
}
}
return result;
}
/** Maximum filename (basename) length in bytes for most filesystems (ext4, NTFS, APFS). */
const MAX_FILENAME_BYTES = 255;
/**
* Sanitize a vault-relative file path by truncating the basename if it exceeds
* filesystem filename length limits. Preserves the file extension.
* @param filePath - Vault-relative file path
* @returns The sanitized path with basename truncated if necessary
*/
export function sanitizeFilePath(filePath: string): string {
const parts = filePath.split("/");
const basename = parts[parts.length - 1];
if (getUtf8ByteLength(basename) <= MAX_FILENAME_BYTES) {
return filePath;
}
const extIndex = basename.lastIndexOf(".");
const ext = extIndex >= 0 ? basename.substring(extIndex) : "";
const name = extIndex >= 0 ? basename.substring(0, extIndex) : basename;
const extBytes = getUtf8ByteLength(ext);
const availableBytes = MAX_FILENAME_BYTES - extBytes;
if (availableBytes <= 0) {
parts[parts.length - 1] = truncateToByteLimit(basename, MAX_FILENAME_BYTES);
} else {
parts[parts.length - 1] = truncateToByteLimit(name, availableBytes) + ext;
}
return parts.join("/");
}
/**
* Opens a file in the workspace, reusing an existing tab if the file is already open.
* @param file - The TFile to open
* @param focusIfOpen - If true, focuses the existing leaf if the file is already open (default: true)
*/
export async function openFileInWorkspace(file: TFile, focusIfOpen: boolean = true): Promise<void> {
// Check if the file is already open in any leaf
let existingLeaf = null;
app.workspace.iterateAllLeaves((leaf) => {
if (
leaf.view.getViewType() === "markdown" ||
leaf.view.getViewType() === "pdf" ||
leaf.view.getViewType() === "canvas"
) {
const viewFile = (leaf.view as unknown as Record<string, unknown>).file as
| { path: string }
| undefined;
if (viewFile && viewFile.path === file.path) {
existingLeaf = leaf;
}
}
});
if (existingLeaf && focusIfOpen) {
// File is already open, focus the existing leaf
app.workspace.setActiveLeaf(existingLeaf, { focus: true });
} else if (!existingLeaf) {
// File is not open, open it in a new tab
const leaf = app.workspace.getLeaf("tab");
await leaf.openFile(file);
}
}