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:
Nelson Love 2026-06-16 17:38:17 -04:00
parent 2d647205e0
commit fdbbe30d70

View file

@ -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";