mirror of
https://github.com/ahmetildirim/obsidian-inscribe.git
synced 2026-07-22 05:44:10 +00:00
fix(openai-compat): use Obsidian requestUrl() to bypass renderer CORS
The OpenAI SDK's fetch() path is blocked by Electron's CORS gate for any endpoint that doesn't echo a permissive Access-Control-Allow-Origin header. This makes the openai_compatible provider unusable against Anthropic's OpenAI-compatibility layer at https://api.anthropic.com/v1/ and any other self-hosted backend that doesn't opt into browser CORS — requests fail with `Failed to fetch` before they leave the renderer. Switch the openai-compat provider to Obsidian's requestUrl() API, which runs in the main process and bypasses CORS. Tradeoffs: - requestUrl() does not stream response bodies, so the provider now sends `stream: false` and yields the full completion in a single chunk. The Provider interface yields cumulative strings, so this is interface-compatible with the streaming providers — no caller changes needed. For inline autocompletion of short snippets the perceived latency difference is small. - abort() can no longer cancel an in-flight request; it sets a flag and the post-response yield is skipped instead. Additional fixes: - connectionTest() now performs a tiny chat.completions ping (max_tokens: 1) instead of GET /v1/models. Anthropic's compat layer and several self-hosted runtimes don't implement /v1/models, so the old probe always reported the provider as unconfigured even when chat/completions worked perfectly. - fetchModels() falls back to the user's configured models list when /v1/models returns a non-2xx, instead of throwing and leaving the settings UI broken. The openai and ollama providers are untouched: openai.com sets permissive CORS so the SDK path works there, and the ollama provider talks to localhost.
This commit is contained in:
parent
2d647205e0
commit
fdbbe30d70
1 changed files with 92 additions and 48 deletions
|
|
@ -1,80 +1,127 @@
|
|||
import { Provider } from "..";
|
||||
import { Editor } from "obsidian";
|
||||
import { Editor, requestUrl } from "obsidian";
|
||||
import { OpenAICompatibleSettings } from ".";
|
||||
import { ProfileOptions } from "src/settings/settings";
|
||||
import OpenAI from "openai";
|
||||
|
||||
// Uses Obsidian's requestUrl() rather than the OpenAI SDK's fetch path so the
|
||||
// plugin can reach endpoints that don't enable browser CORS (notably
|
||||
// Anthropic's OpenAI-compatibility layer at https://api.anthropic.com/v1/).
|
||||
// Tradeoff: requestUrl() does not stream, so completions arrive as a single
|
||||
// buffered response. The Provider interface yields cumulative strings, so one
|
||||
// final yield is interface-compatible with the streaming providers.
|
||||
export class OpenAICompatibleProvider implements Provider {
|
||||
client: OpenAI;
|
||||
settings: OpenAICompatibleSettings;
|
||||
aborted: boolean = false;
|
||||
abortcontroller?: AbortController;
|
||||
|
||||
constructor(settings: OpenAICompatibleSettings) {
|
||||
this.settings = settings;
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.settings.baseUrl,
|
||||
apiKey: this.settings.apiKey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
async *generate(editor: Editor, prompt: string, options: ProfileOptions): AsyncGenerator<string> {
|
||||
this.aborted = false;
|
||||
const abortcontroller = new AbortController();
|
||||
this.abortcontroller = abortcontroller;
|
||||
|
||||
const initialPosition = editor.getCursor();
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{ role: "system", content: options.systemPrompt },
|
||||
{ role: "user", content: prompt }
|
||||
],
|
||||
temperature: options.temperature,
|
||||
stream: true,
|
||||
...this.settings.extraParams,
|
||||
}, { signal: abortcontroller.signal });
|
||||
|
||||
let completion = "";
|
||||
for await (const chunk of stream) {
|
||||
if (this.aborted) {
|
||||
return;
|
||||
}
|
||||
if (this.cursorMoved(editor, initialPosition)) {
|
||||
this.abort();
|
||||
return;
|
||||
}
|
||||
const response = await requestUrl({
|
||||
url: this.endpoint("chat/completions"),
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{ role: "system", content: options.systemPrompt },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
temperature: options.temperature,
|
||||
stream: false,
|
||||
...this.settings.extraParams,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
completion += content;
|
||||
if (this.aborted || this.cursorMoved(editor, initialPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error("Inscribe: openai-compat request failed", response.status, response.text);
|
||||
return;
|
||||
}
|
||||
|
||||
const completion = response.json?.choices?.[0]?.message?.content ?? "";
|
||||
if (completion) {
|
||||
yield completion;
|
||||
}
|
||||
}
|
||||
|
||||
async abort() {
|
||||
if (this.aborted) return;
|
||||
// requestUrl() responses are buffered; the in-flight request cannot
|
||||
// be cancelled. Flag the abort so any yield after the response
|
||||
// returns is skipped.
|
||||
this.aborted = true;
|
||||
this.abortcontroller?.abort();
|
||||
}
|
||||
|
||||
async fetchModels(): Promise<string[]> {
|
||||
if (!this.client) {
|
||||
return this.settings.models;
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: this.endpoint("models"),
|
||||
method: "GET",
|
||||
headers: this.authHeaders(),
|
||||
throw: false,
|
||||
});
|
||||
if (response.status >= 200 && response.status < 300 && Array.isArray(response.json?.data)) {
|
||||
return response.json.data.map((m: { id: string }) => m.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Inscribe: openai-compat fetchModels failed", error);
|
||||
}
|
||||
|
||||
const models = await this.client.models.list();
|
||||
return models.data.map(model => model.id);
|
||||
// Many OpenAI-compatible endpoints (Anthropic, some self-hosted
|
||||
// runtimes) don't implement /v1/models. Fall back to the user's
|
||||
// configured list so the model picker isn't empty.
|
||||
return this.settings.models;
|
||||
}
|
||||
|
||||
async connectionTest(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.models.list();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error testing connection:", error);
|
||||
if (!this.settings.baseUrl || !this.settings.apiKey) {
|
||||
return false;
|
||||
}
|
||||
const model = this.settings.models[0];
|
||||
if (!model) {
|
||||
console.error("Inscribe: openai-compat connectionTest requires at least one configured model");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: this.endpoint("chat/completions"),
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return true;
|
||||
}
|
||||
console.error("Inscribe: openai-compat connection test failed", response.status, response.text);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Inscribe: openai-compat connection test error", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private endpoint(path: string): string {
|
||||
const base = this.settings.baseUrl.endsWith("/") ? this.settings.baseUrl : `${this.settings.baseUrl}/`;
|
||||
return base + (path.startsWith("/") ? path.slice(1) : path);
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return { "Authorization": `Bearer ${this.settings.apiKey}` };
|
||||
}
|
||||
|
||||
private cursorMoved(editor: Editor, initialPosition: { line: number, ch: number }): boolean {
|
||||
|
|
@ -82,6 +129,3 @@ export class OpenAICompatibleProvider implements Provider {
|
|||
return currentPosition.line !== initialPosition.line || currentPosition.ch !== initialPosition.ch;
|
||||
}
|
||||
}
|
||||
|
||||
// Import this at the end to avoid circular dependency issues
|
||||
import preparePrompt from "src/completions/prompt";
|
||||
|
|
|
|||
Loading…
Reference in a new issue