···100100 <p class="help">
101101 Ollama:
102102 <code>http://localhost:11434</code> ·
103103- OpenAI: <code>https://api.openai.com/v1</code>
103103+ OpenAI: <code>https://api.openai.com/v1</code> or the full
104104+ <code>…/v1/chat/completions</code> URL (either works).
105105+ Azure OpenAI uses the
106106+ <code>…/openai/deployments/<name></code> base plus
107107+ <code>?api-version=…</code>; the key is sent as
108108+ <code>api-key</code> automatically.
104109 </p>
105110 </div>
106111
+168-39
scripts/background.js
···330330 return data;
331331}
332332333333+/**
334334+ * OpenAI-compatible APIs differ: `message.content` may be a string, a parts array (Responses / some
335335+ * gateways), or text may appear on `choices[0].text` or `output_text`.
336336+ */
337337+function normalizeMessageContent(content) {
338338+ if (content == null) return "";
339339+ if (typeof content === "string") return content;
340340+ if (Array.isArray(content)) {
341341+ let out = "";
342342+ for (const part of content) {
343343+ if (typeof part === "string") {
344344+ out += part;
345345+ } else if (part && typeof part === "object") {
346346+ if (typeof part.text === "string") out += part.text;
347347+ else if (typeof part.content === "string") out += part.content;
348348+ }
349349+ }
350350+ return out;
351351+ }
352352+ return "";
353353+}
354354+355355+function extractOpenAIChatCompletionText(data) {
356356+ if (!data || typeof data !== "object") return "";
357357+ const c0 = data.choices?.[0];
358358+ if (!c0) return "";
359359+ const fromMsg = normalizeMessageContent(c0.message?.content);
360360+ if (fromMsg) return fromMsg;
361361+ if (typeof c0.text === "string" && c0.text) return c0.text;
362362+ if (typeof data.output_text === "string" && data.output_text) return data.output_text;
363363+ return "";
364364+}
365365+366366+function normalizeOpenAIChatResponse(data) {
367367+ const merged = extractOpenAIChatCompletionText(data);
368368+ if (!merged || !data?.choices?.[0]) return data;
369369+ const first = data.choices[0];
370370+ return {
371371+ ...data,
372372+ choices: [
373373+ {
374374+ ...first,
375375+ message: {
376376+ ...(first.message || {}),
377377+ role: first.message?.role || "assistant",
378378+ content: merged,
379379+ },
380380+ },
381381+ ...data.choices.slice(1),
382382+ ],
383383+ };
384384+}
385385+386386+/** Extract one streaming text fragment from an SSE JSON payload (delta.content string or parts array). */
387387+function extractOpenAIStreamDeltaChunk(parsed) {
388388+ if (!parsed || typeof parsed !== "object") return "";
389389+ const c0 = parsed.choices?.[0];
390390+ if (!c0) return "";
391391+392392+ const delta = c0.delta;
393393+ if (delta && typeof delta === "object") {
394394+ const fromDelta = normalizeMessageContent(delta.content);
395395+ if (fromDelta) return fromDelta;
396396+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content) {
397397+ return delta.reasoning_content;
398398+ }
399399+ }
400400+401401+ if (typeof c0.text === "string" && c0.text) return c0.text;
402402+403403+ if (c0.message?.content) {
404404+ const t = normalizeMessageContent(c0.message.content);
405405+ if (t) return t;
406406+ }
407407+408408+ return "";
409409+}
410410+411411+/**
412412+ * Resolve API base URL to POST /.../chat/completions once. Avoids broken URLs when the user pastes
413413+ * a full endpoint, uses Azure (?api-version=…), or Google-style /v1beta/openai (substring "v1"
414414+ * must not trigger a bogus extra /v1 segment).
415415+ */
416416+function resolveOpenAICompatChatUrl(raw) {
417417+ const trimmed = String(raw || "").trim();
418418+ if (!trimmed) return trimmed;
419419+ try {
420420+ const u = new URL(trimmed);
421421+ let path = (u.pathname || "").replace(/\/+$/, "") || "";
422422+423423+ if (/\/chat\/completions$/i.test(path)) {
424424+ return u.toString();
425425+ }
426426+427427+ if (/\/openai\/deployments\/[^/]+$/i.test(path)) {
428428+ u.pathname = `${path}/chat/completions`;
429429+ return u.toString();
430430+ }
431431+432432+ if (/\/v1$/i.test(path)) {
433433+ u.pathname = `${path}/chat/completions`;
434434+ return u.toString();
435435+ }
436436+437437+ if (/\/v1beta\/openai$/i.test(path)) {
438438+ u.pathname = `${path}/chat/completions`;
439439+ return u.toString();
440440+ }
441441+442442+ if (!/\bv1\b/i.test(path)) {
443443+ const base = path || "";
444444+ u.pathname = `${base}/v1/chat/completions`.replace(/\/{2,}/g, "/");
445445+ return u.toString();
446446+ }
447447+448448+ u.pathname = `${path}/chat/completions`.replace(/\/{2,}/g, "/");
449449+ return u.toString();
450450+ } catch {
451451+ return trimmed;
452452+ }
453453+}
454454+455455+function openAICompatFetchHeaders(apiKey, urlString) {
456456+ const headers = { "Content-Type": "application/json" };
457457+ if (!apiKey) return headers;
458458+ try {
459459+ const host = new URL(urlString).hostname;
460460+ if (/\.openai\.azure\.com$/i.test(host)) {
461461+ headers["api-key"] = apiKey;
462462+ } else {
463463+ headers.Authorization = `Bearer ${apiKey}`;
464464+ }
465465+ } catch {
466466+ headers.Authorization = `Bearer ${apiKey}`;
467467+ }
468468+ return headers;
469469+}
470470+333471async function handleChatRequest(data) {
334472 const {
335473 tabId,
···365503 keepAlive,
366504 );
367505 } else {
368368- return await callOpenAICompatible(
506506+ const raw = await callOpenAICompatible(
369507 apiBaseUrl,
370508 model,
371509 apiKey,
···373511 tokenCap,
374512 signal,
375513 );
514514+ return normalizeOpenAIChatResponse(raw);
376515 }
377516 } catch (error) {
378517 if (error.name === "AbortError" || signal.aborted) {
···528667 maxTokens = CONFIG.API.MAX_TOKENS,
529668 signal,
530669) {
531531- let url = baseUrl.replace(/\/$/, "");
532532-533533- if (!url.includes("/v1")) {
534534- url = url + "/v1";
535535- }
536536-537537- url = url + "/chat/completions";
670670+ const url = resolveOpenAICompatChatUrl(baseUrl);
538671539672 const fetchOpts = {
540673 method: "POST",
541541- headers: {
542542- "Content-Type": "application/json",
543543- ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
544544- },
674674+ headers: openAICompatFetchHeaders(apiKey, url),
545675 body: JSON.stringify({
546676 model: model,
547677 messages: messages,
···746876 messages,
747877 signal,
748878) {
749749- let url = baseUrl.replace(/\/$/, "");
750750-751751- if (!url.includes("/v1")) {
752752- url = url + "/v1";
753753- }
754754-755755- url = url + "/chat/completions";
879879+ const url = resolveOpenAICompatChatUrl(baseUrl);
756880757881 resetStreamChunkBatching();
758882 let hitMaxChars = false;
···760884 try {
761885 const response = await fetch(url, {
762886 method: "POST",
763763- headers: {
764764- "Content-Type": "application/json",
765765- ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
766766- },
887887+ headers: openAICompatFetchHeaders(apiKey, url),
767888 body: JSON.stringify({
768889 model: model,
769890 messages: messages,
···826947 buffer = lines.pop() || "";
827948828949 for (const line of lines) {
829829- if (line.trim() && line.startsWith("data: ")) {
830830- const data = line.slice(6);
831831- if (data === "[DONE]") continue;
832832- try {
833833- const json = JSON.parse(data);
834834- const content = json.choices?.[0]?.delta?.content;
835835- if (content) {
836836- if (streamedChars + content.length > maxChars) {
837837- hitMaxChars = true;
838838- reader.cancel().catch(() => {});
839839- break;
840840- }
841841- streamedChars += content.length;
842842- queueStreamChunk(content);
950950+ const trimmed = line.trim();
951951+ if (!trimmed) continue;
952952+ let payload = null;
953953+ if (trimmed.startsWith("data:")) {
954954+ payload = trimmed.slice(5).replace(/^\uFEFF/, "").trimStart();
955955+ if (payload === "[DONE]") continue;
956956+ } else if (trimmed.startsWith("{") && /"choices"\s*:/i.test(trimmed)) {
957957+ // Some OpenAI-compatible proxies stream NDJSON without a `data:` prefix.
958958+ payload = trimmed;
959959+ } else {
960960+ continue;
961961+ }
962962+ try {
963963+ const json = JSON.parse(payload);
964964+ const content = extractOpenAIStreamDeltaChunk(json);
965965+ if (content) {
966966+ if (streamedChars + content.length > maxChars) {
967967+ hitMaxChars = true;
968968+ reader.cancel().catch(() => {});
969969+ break;
843970 }
844844- } catch (e) {
845845- // Skip invalid JSON lines
971971+ streamedChars += content.length;
972972+ queueStreamChunk(content);
846973 }
974974+ } catch (e) {
975975+ // Skip invalid JSON lines
847976 }
848977 }
849978
+1
scripts/config.js
···101101 SUGGESTIONS: `Based on the summary provided, generate 2 natural follow-up questions that a reader might want to ask (besides "Why would this be worth reading?"). Keep questions short (5-8 words), like these examples:
102102- What are some key quotes?
103103- Explain this simply
104104+- What information the most important?
104105105106Return only the 2 questions, one per line, no numbering or bullet points.`,
106107 CHAT_SUGGESTIONS: `Based on this chat response, generate 2 natural follow-up questions the reader might want to ask next. Keep questions short (5-8words). Make them specific to what was just discussed. Return only the 2 questions, one per line, no numbering or bullet points.`,