diff --git a/__mocks__/obsidian.js b/__mocks__/obsidian.js index f683a649..f6ffe6a2 100644 --- a/__mocks__/obsidian.js +++ b/__mocks__/obsidian.js @@ -1,10 +1,25 @@ // __mocks__/obsidian.js import { parse as parseYamlString } from "yaml"; +// Per-test overrides set via the exported `__setRequestUrlImpl` helper. +// Default: empty success response. Tests that exercise network paths should +// install their own implementation. +let requestUrlImpl = jest.fn().mockResolvedValue({ + status: 200, + text: "", + json: undefined, + arrayBuffer: new ArrayBuffer(0), + headers: {}, +}); + module.exports = { // Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests normalizePath: jest.fn().mockImplementation((p) => p), moment: jest.requireActual("moment"), + requestUrl: (...args) => requestUrlImpl(...args), + __setRequestUrlImpl: (impl) => { + requestUrlImpl = impl; + }, Vault: jest.fn().mockImplementation(() => { return { getMarkdownFiles: jest.fn().mockImplementation(() => { diff --git a/src/LLMProviders/CustomOpenAIEmbeddings.ts b/src/LLMProviders/CustomOpenAIEmbeddings.ts index 34243db1..cba18fb7 100644 --- a/src/LLMProviders/CustomOpenAIEmbeddings.ts +++ b/src/LLMProviders/CustomOpenAIEmbeddings.ts @@ -1,3 +1,4 @@ +import { safeFetchNoThrow } from "@/utils"; import { OpenAIEmbeddings } from "@langchain/openai"; export class CustomOpenAIEmbeddings extends OpenAIEmbeddings { @@ -35,7 +36,7 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings { const baseURL = configuration?.baseURL || "https://api.openai.com/v1"; const url = `${baseURL}/embeddings`; const apiKey = this.customConfig.apiKey as string; - const fetchFn = configuration?.fetch || fetch; + const fetchFn = configuration?.fetch || safeFetchNoThrow; const response = await fetchFn(url, { method: "POST", diff --git a/src/LLMProviders/brevilabsClient.ts b/src/LLMProviders/brevilabsClient.ts index 7b13653b..94776acd 100644 --- a/src/LLMProviders/brevilabsClient.ts +++ b/src/LLMProviders/brevilabsClient.ts @@ -4,8 +4,86 @@ import { MissingPlusLicenseError } from "@/error"; import { logInfo } from "@/logger"; import { turnOffPlus, turnOnPlus } from "@/plusUtils"; import { getSettings } from "@/settings/model"; -import { safeFetchNoThrow } from "@/utils"; import { arrayBufferToBase64 } from "@/utils/base64"; +import { requestUrl } from "obsidian"; + +/** + * Build a multipart/form-data body buffer from a FormData instance. + * Returned as an ArrayBuffer suitable for passing to Obsidian's requestUrl. + * + * @param formData - FormData containing strings and/or File/Blob entries. + * @returns The serialized multipart body and the Content-Type header (including boundary). + */ +async function buildMultipartFromFormData( + formData: FormData +): Promise<{ body: ArrayBuffer; contentType: string }> { + const boundary = `----CopilotBoundary${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; + const encoder = new TextEncoder(); + const parts: Uint8Array[] = []; + + for (const [name, value] of formData.entries()) { + parts.push(encoder.encode(`--${boundary}\r\n`)); + if (value instanceof Blob) { + const filename = value instanceof File ? value.name : "blob"; + const contentType = value.type || "application/octet-stream"; + parts.push( + encoder.encode( + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n` + ) + ); + const buf = await value.arrayBuffer(); + parts.push(new Uint8Array(buf)); + parts.push(encoder.encode("\r\n")); + } else { + parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`)); + parts.push(encoder.encode(String(value))); + parts.push(encoder.encode("\r\n")); + } + } + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + const totalLength = parts.reduce((sum, p) => sum + p.byteLength, 0); + const out = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return { + body: out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +/** + * Normalize a requestUrl response into the {data, error} shape used by Brevilabs API methods. + * Handles the case where `response.json` is a raw string (non-JSON body, e.g. HTML error page). + */ +function parseBrevilabsResponse( + response: { status: number; json: unknown }, + endpoint: string +): { data: T | null; error?: Error } { + let data: unknown = response.json; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + // Non-JSON body — fall through to status-based error. + } + } + if (response.status < 200 || response.status >= 300) { + const detail = (data as { detail?: { reason?: string; error?: string } } | null)?.detail; + if (detail?.reason) { + const error = new Error(detail.reason); + if (detail.error) error.name = detail.error; + return { data: null, error }; + } + return { data: null, error: new Error(`HTTP error: ${response.status}`) }; + } + logInfo(`[API ${endpoint} request]:`, data); + return { data: data as T }; +} export interface RerankResponse { response: { @@ -123,25 +201,14 @@ export class BrevilabsClient { if (!excludeAuthHeader) { headers.Authorization = `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`; } - const response = await safeFetchNoThrow(url.toString(), { + const response = await requestUrl({ + url: url.toString(), method, headers, ...(method === "POST" && { body: JSON.stringify(body) }), + throw: false, }); - const data = await response.json(); - if (!response.ok) { - try { - const errorDetail = data.detail; - const error = new Error(errorDetail.reason as string); - error.name = errorDetail.error as string; - return { data: null, error }; - } catch { - return { data: null, error: new Error("Unknown error") }; - } - } - logInfo(`[API ${endpoint} request]:`, data); - - return { data }; + return parseBrevilabsResponse(response, endpoint); } private async makeFormDataRequest( @@ -159,29 +226,21 @@ export class BrevilabsClient { const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`); try { - const response = await fetch(url.toString(), { + // Build multipart body manually for requestUrl (does not natively support FormData). + const { body, contentType } = await buildMultipartFromFormData(formData); + + const response = await requestUrl({ + url: url.toString(), method: "POST", headers: { - // No Content-Type header - browser will set it automatically with boundary + "Content-Type": contentType, Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`, "X-Client-Version": this.pluginVersion, }, - body: formData, + body, + throw: false, }); - - const data = await response.json(); - if (!response.ok) { - try { - const errorDetail = data.detail; - const error = new Error(errorDetail.reason as string); - error.name = errorDetail.error as string; - return { data: null, error }; - } catch { - return { data: null, error: new Error(`HTTP error: ${response.status}`) }; - } - } - logInfo(`[API ${endpoint} form-data request]:`, data); - return { data }; + return parseBrevilabsResponse(response, `${endpoint} form-data`); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)) }; } diff --git a/src/LLMProviders/selfHostServices.test.ts b/src/LLMProviders/selfHostServices.test.ts index 7f3c73a4..9ee9d9e4 100644 --- a/src/LLMProviders/selfHostServices.test.ts +++ b/src/LLMProviders/selfHostServices.test.ts @@ -17,9 +17,15 @@ jest.mock("@/logger", () => ({ logWarn: jest.fn(), })); -// Mock global fetch +// Mock safeFetchNoThrow (the requestUrl-backed wrapper used in place of fetch) const mockFetch = jest.fn(); -window.fetch = mockFetch; +jest.mock("@/utils", () => { + const actual = jest.requireActual>("@/utils"); + return { + ...actual, + safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options), + }; +}); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/LLMProviders/selfHostServices.ts b/src/LLMProviders/selfHostServices.ts index c7246fc8..2a4cfe4e 100644 --- a/src/LLMProviders/selfHostServices.ts +++ b/src/LLMProviders/selfHostServices.ts @@ -2,6 +2,7 @@ import { type Youtube4llmResponse } from "@/LLMProviders/brevilabsClient"; import { getDecryptedKey } from "@/encryptionService"; import { logError, logInfo } from "@/logger"; import { getSettings } from "@/settings/model"; +import { safeFetchNoThrow } from "@/utils"; const FIRECRAWL_SEARCH_URL = "https://api.firecrawl.dev/v2/search"; const PERPLEXITY_CHAT_URL = "https://api.perplexity.ai/chat/completions"; @@ -45,7 +46,7 @@ export function hasSelfHostSearchKey(): boolean { async function firecrawlSearch(query: string, apiKey: string): Promise { const startTime = Date.now(); - const response = await fetch(FIRECRAWL_SEARCH_URL, { + const response = await safeFetchNoThrow(FIRECRAWL_SEARCH_URL, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, @@ -95,7 +96,7 @@ async function perplexitySonarSearch( query: string, apiKey: string ): Promise { - const response = await fetch(PERPLEXITY_CHAT_URL, { + const response = await safeFetchNoThrow(PERPLEXITY_CHAT_URL, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, @@ -144,7 +145,7 @@ export async function selfHostYoutube4llm(url: string): Promise window.setTimeout(resolve, SUPADATA_POLL_INTERVAL)); - const pollResponse = await fetch(pollUrl, { + const pollResponse = await safeFetchNoThrow(pollUrl, { method: "GET", headers: { "x-api-key": apiKey, diff --git a/src/settings/v2/utils/modelActions.ts b/src/settings/v2/utils/modelActions.ts index 171315e9..7673a4c0 100644 --- a/src/settings/v2/utils/modelActions.ts +++ b/src/settings/v2/utils/modelActions.ts @@ -3,7 +3,7 @@ import { ChatModelProviders, SettingKeyProviders } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; import { GitHubCopilotProvider } from "@/LLMProviders/githubCopilot/GitHubCopilotProvider"; import ProjectManager from "@/LLMProviders/projectManager"; -import { logError, logWarn } from "@/logger"; +import { logError } from "@/logger"; import { parseModelsResponse, StandardModel } from "@/settings/providerModels"; import { err2String, getProviderInfo, safeFetch } from "@/utils"; import { getApiKeyForProvider } from "@/utils/modelUtils"; @@ -66,43 +66,18 @@ export async function fetchModelsForProvider( }; } - const tryFetch = async (useSafeFetch: boolean) => { - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), 3000); - - try { - const response = await (useSafeFetch ? safeFetch : fetch)(url, { - headers, - signal: controller.signal, - method: "GET", - }); - - if (!response.ok) { - const msg = err2String(await response.json()); - logError(msg); - throw new Error(`Failed to fetch models: ${response.statusText} \n detail: ` + msg); - } - return response; - } finally { - window.clearTimeout(timeoutId); - } - }; - - let response; - try { - response = await tryFetch(false); - } catch (firstError) { - logWarn("First fetch attempt failed, trying with safeFetch..."); - try { - response = await tryFetch(true); - } catch (error) { - const msg = - "\nwithout CORS Error: " + - err2String(firstError) + - "\nwith CORS Error: " + - err2String(error); - throw new Error(msg); - } + // Use safeFetch (requestUrl) to bypass CORS on desktop and mobile. safeFetch + // does not honor AbortSignal, so bound the call manually via Promise.race. + const response = await Promise.race([ + safeFetch(url, { headers, method: "GET" }), + new Promise((_, reject) => + window.setTimeout(() => reject(new Error("Request timed out after 10s")), 10000) + ), + ]); + if (!response.ok) { + const msg = err2String(await response.json()); + logError(msg); + throw new Error(`Failed to fetch models: ${response.statusText} \n detail: ` + msg); } const rawData = await response.json();