diff --git a/packages/client/src/ai-direct-generate.test.mjs b/packages/client/src/ai-direct-generate.test.mjs index 40561604..a39014f9 100644 --- a/packages/client/src/ai-direct-generate.test.mjs +++ b/packages/client/src/ai-direct-generate.test.mjs @@ -54,7 +54,11 @@ describe("direct tag suggestions", () => { modelId: "meta/llama", }), { headers: { "content-type": "application/json" } }); } - if (String(url).includes("integrate.api.nvidia.com")) throw new TypeError("Failed to fetch"); + if (String(url).includes("integrate.api.nvidia.com")) { + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer edgeever-cors-probe"); + throw new TypeError("Failed to fetch"); + } return new Response(JSON.stringify({ suggestions: [{ name: "proxied", existing: false }] }), { headers: { "content-type": "application/json" }, }); diff --git a/packages/client/src/ai-direct-stream.test.mjs b/packages/client/src/ai-direct-stream.test.mjs index 7be6802f..502e3a23 100644 --- a/packages/client/src/ai-direct-stream.test.mjs +++ b/packages/client/src/ai-direct-stream.test.mjs @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createEdgeEverClient } from "./index.ts"; +import { probeAiProviderCors } from "./ai-direct-stream.ts"; describe("direct AI generation client", () => { test("prepares on the instance then streams from the model provider", async () => { @@ -56,7 +57,11 @@ describe("direct AI generation client", () => { baseUrl: "https://notes.example", tryDirectAiGeneration: true, fetch: async (url, init) => { - urls.push({ url: String(url), method: init?.method ?? "GET" }); + urls.push({ + url: String(url), + method: init?.method ?? "GET", + auth: new Headers(init?.headers).get("Authorization"), + }); if (String(url).endsWith("/direct-target")) { return new Response(JSON.stringify({ provider: "openai-compatible", @@ -65,6 +70,8 @@ describe("direct AI generation client", () => { }), { headers: { "content-type": "application/json" } }); } if (String(url).includes("integrate.api.nvidia.com")) { + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer edgeever-cors-probe"); throw new TypeError("Failed to fetch"); } return new Response("data: {\"type\":\"text-delta\",\"text\":\"proxied\"}\n\n", { @@ -78,22 +85,27 @@ describe("direct AI generation client", () => { { onEvent: (event) => events.push(event) }, ); expect(urls).toEqual([ - { url: "https://notes.example/api/v1/ai/direct-target", method: "GET" }, - { url: "https://integrate.api.nvidia.com/v1/chat/completions", method: "OPTIONS" }, - { url: "https://notes.example/api/v1/ai/generate", method: "POST" }, + { url: "https://notes.example/api/v1/ai/direct-target", method: "GET", auth: null }, + { url: "https://integrate.api.nvidia.com/v1/chat/completions", method: "POST", auth: "Bearer edgeever-cors-probe" }, + { url: "https://notes.example/api/v1/ai/generate", method: "POST", auth: null }, ]); expect(urls.some((call) => call.url.endsWith("/prepare"))).toBe(false); expect(events).toEqual([{ type: "text-delta", text: "proxied" }]); }); test("direct-connects from the browser after a successful CORS probe", async () => { - const urls = []; + const calls = []; const client = createEdgeEverClient({ baseUrl: "https://notes.example", token: "user-token", tryDirectAiGeneration: true, fetch: async (url, init) => { - urls.push(String(url)); + const headers = new Headers(init?.headers); + calls.push({ + url: String(url), + method: init?.method ?? "GET", + auth: headers.get("Authorization"), + }); if (String(url).endsWith("/direct-target")) { return new Response(JSON.stringify({ provider: "openai-compatible", @@ -101,8 +113,17 @@ describe("direct AI generation client", () => { modelId: "openai/gpt-4o-mini", }), { headers: { "content-type": "application/json" } }); } - if (String(url).includes("openrouter.ai") && (init?.method ?? "GET") === "OPTIONS") { - return new Response(null, { status: 204 }); + if (String(url).includes("openrouter.ai")) { + if (headers.get("Authorization") === "Bearer edgeever-cors-probe") { + return new Response("{}", { status: 401 }); + } + expect(headers.get("Authorization")).toBe("Bearer or-key"); + return new Response([ + `data: {"choices":[{"delta":{"content":"\\nHello"}}]}`, + `data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}`, + "data: [DONE]", + "", + ].join("\n\n"), { headers: { "content-type": "text/event-stream" } }); } if (String(url).endsWith("/prepare")) { return new Response(JSON.stringify({ @@ -116,14 +137,6 @@ describe("direct AI generation client", () => { resultBoundary: { start: "", end: "" }, }), { headers: { "content-type": "application/json" } }); } - if (String(url).includes("openrouter.ai")) { - return new Response([ - `data: {"choices":[{"delta":{"content":"\\nHello"}}]}`, - `data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}`, - "data: [DONE]", - "", - ].join("\n\n"), { headers: { "content-type": "text/event-stream" } }); - } throw new Error(`unexpected ${url}`); }, }); @@ -132,11 +145,11 @@ describe("direct AI generation client", () => { { action: "summarize", title: "Note", contentMarkdown: "Body" }, { onEvent: (event) => events.push(event) }, ); - expect(urls).toEqual([ - "https://notes.example/api/v1/ai/direct-target", - "https://openrouter.ai/api/v1/chat/completions", - "https://notes.example/api/v1/ai/generate/prepare", - "https://openrouter.ai/api/v1/chat/completions", + expect(calls).toEqual([ + { url: "https://notes.example/api/v1/ai/direct-target", method: "GET", auth: "Bearer user-token" }, + { url: "https://openrouter.ai/api/v1/chat/completions", method: "POST", auth: "Bearer edgeever-cors-probe" }, + { url: "https://notes.example/api/v1/ai/generate/prepare", method: "POST", auth: "Bearer user-token" }, + { url: "https://openrouter.ai/api/v1/chat/completions", method: "POST", auth: "Bearer or-key" }, ]); expect(events.filter((event) => event.type === "text-delta").map((event) => event.text).join("")).toBe("Hello"); }); @@ -166,3 +179,73 @@ describe("direct AI generation client", () => { expect(events).toEqual([{ type: "text-delta", text: "proxied" }]); }); }); + +describe("AI provider CORS probe", () => { + const openaiTarget = { + provider: "openai-compatible", + baseUrl: "https://api.example/v1", + modelId: "demo", + }; + + test("POSTs a dummy key and treats a readable 401 as CORS success", async () => { + let cancelled = false; + const body = new ReadableStream({ + start() {}, + cancel() { + cancelled = true; + }, + }); + const allowed = await probeAiProviderCors(openaiTarget, async (url, init) => { + expect(String(url)).toBe("https://api.example/v1/chat/completions"); + expect(init?.method).toBe("POST"); + expect(init?.mode).toBe("cors"); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer edgeever-cors-probe"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Accept")).toBe("text/event-stream"); + expect(JSON.parse(String(init?.body)).stream).toBe(true); + return new Response(body, { status: 401 }); + }); + expect(allowed).toBe(true); + expect(cancelled).toBe(true); + }); + + test("sends Anthropic and Google dummy credentials on the real provider paths", async () => { + const anthropicAllowed = await probeAiProviderCors({ + provider: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + modelId: "claude", + }, async (url, init) => { + expect(String(url)).toBe("https://api.anthropic.com/v1/messages"); + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("x-api-key")).toBe("edgeever-cors-probe"); + return new Response("{}", { status: 401 }); + }); + const googleAllowed = await probeAiProviderCors({ + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + modelId: "models/gemini-2.5-flash", + }, async (url, init) => { + expect(String(url)).toBe("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"); + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("x-goog-api-key")).toBe("edgeever-cors-probe"); + return new Response("{}", { status: 403 }); + }); + expect(anthropicAllowed).toBe(true); + expect(googleAllowed).toBe(true); + }); + + test("returns false when the browser blocks the probe", async () => { + const allowed = await probeAiProviderCors(openaiTarget, async () => { + throw new TypeError("Failed to fetch"); + }); + expect(allowed).toBe(false); + }); + + test("rethrows when the caller aborts the probe", async () => { + const signal = AbortSignal.abort(); + await expect(probeAiProviderCors(openaiTarget, async () => { + throw new DOMException("The operation was aborted.", "AbortError"); + }, signal)).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/packages/client/src/ai-direct-stream.ts b/packages/client/src/ai-direct-stream.ts index b1d1b83f..4c200d71 100644 --- a/packages/client/src/ai-direct-stream.ts +++ b/packages/client/src/ai-direct-stream.ts @@ -126,7 +126,15 @@ export const probeAiProviderCors = async ( resultBoundary: { start: "", end: "" }, }); try { - await fetchImpl(request.url, { method: "OPTIONS", mode: "cors", signal }); + // POST with the dummy key so the browser preflight matches the real direct request. + const response = await fetchImpl(request.url, { + method: "POST", + mode: "cors", + headers: request.headers, + body: request.body, + signal, + }); + await response.body?.cancel().catch(() => {}); return true; } catch (error) { if (signal?.aborted) throw error; diff --git a/packages/client/src/companion-direct-stream.test.mjs b/packages/client/src/companion-direct-stream.test.mjs index de2eb1fa..b8d0b985 100644 --- a/packages/client/src/companion-direct-stream.test.mjs +++ b/packages/client/src/companion-direct-stream.test.mjs @@ -95,6 +95,8 @@ describe("direct companion client", () => { }), { headers: { "content-type": "application/json" } }); } if (String(url).includes("integrate.api.nvidia.com")) { + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer edgeever-cors-probe"); throw new TypeError("Failed to fetch"); } return new Response("data: {\"type\":\"text-delta\",\"text\":\"proxied\"}\n\n", {