A browser extension that lets you summarize any webpage and ask questions using AI.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Harden OpenAI-compatible API (URL, Azure auth, streaming) and clarify base URL help

+175 -40
+6 -1
options/options.html
··· 100 100 <p class="help"> 101 101 Ollama: 102 102 <code>http://localhost:11434</code> &nbsp;·&nbsp; 103 - OpenAI: <code>https://api.openai.com/v1</code> 103 + OpenAI: <code>https://api.openai.com/v1</code> or the full 104 + <code>…/v1/chat/completions</code> URL (either works). 105 + Azure OpenAI uses the 106 + <code>…/openai/deployments/&lt;name&gt;</code> base plus 107 + <code>?api-version=…</code>; the key is sent as 108 + <code>api-key</code> automatically. 104 109 </p> 105 110 </div> 106 111
+168 -39
scripts/background.js
··· 330 330 return data; 331 331 } 332 332 333 + /** 334 + * OpenAI-compatible APIs differ: `message.content` may be a string, a parts array (Responses / some 335 + * gateways), or text may appear on `choices[0].text` or `output_text`. 336 + */ 337 + function normalizeMessageContent(content) { 338 + if (content == null) return ""; 339 + if (typeof content === "string") return content; 340 + if (Array.isArray(content)) { 341 + let out = ""; 342 + for (const part of content) { 343 + if (typeof part === "string") { 344 + out += part; 345 + } else if (part && typeof part === "object") { 346 + if (typeof part.text === "string") out += part.text; 347 + else if (typeof part.content === "string") out += part.content; 348 + } 349 + } 350 + return out; 351 + } 352 + return ""; 353 + } 354 + 355 + function extractOpenAIChatCompletionText(data) { 356 + if (!data || typeof data !== "object") return ""; 357 + const c0 = data.choices?.[0]; 358 + if (!c0) return ""; 359 + const fromMsg = normalizeMessageContent(c0.message?.content); 360 + if (fromMsg) return fromMsg; 361 + if (typeof c0.text === "string" && c0.text) return c0.text; 362 + if (typeof data.output_text === "string" && data.output_text) return data.output_text; 363 + return ""; 364 + } 365 + 366 + function normalizeOpenAIChatResponse(data) { 367 + const merged = extractOpenAIChatCompletionText(data); 368 + if (!merged || !data?.choices?.[0]) return data; 369 + const first = data.choices[0]; 370 + return { 371 + ...data, 372 + choices: [ 373 + { 374 + ...first, 375 + message: { 376 + ...(first.message || {}), 377 + role: first.message?.role || "assistant", 378 + content: merged, 379 + }, 380 + }, 381 + ...data.choices.slice(1), 382 + ], 383 + }; 384 + } 385 + 386 + /** Extract one streaming text fragment from an SSE JSON payload (delta.content string or parts array). */ 387 + function extractOpenAIStreamDeltaChunk(parsed) { 388 + if (!parsed || typeof parsed !== "object") return ""; 389 + const c0 = parsed.choices?.[0]; 390 + if (!c0) return ""; 391 + 392 + const delta = c0.delta; 393 + if (delta && typeof delta === "object") { 394 + const fromDelta = normalizeMessageContent(delta.content); 395 + if (fromDelta) return fromDelta; 396 + if (typeof delta.reasoning_content === "string" && delta.reasoning_content) { 397 + return delta.reasoning_content; 398 + } 399 + } 400 + 401 + if (typeof c0.text === "string" && c0.text) return c0.text; 402 + 403 + if (c0.message?.content) { 404 + const t = normalizeMessageContent(c0.message.content); 405 + if (t) return t; 406 + } 407 + 408 + return ""; 409 + } 410 + 411 + /** 412 + * Resolve API base URL to POST /.../chat/completions once. Avoids broken URLs when the user pastes 413 + * a full endpoint, uses Azure (?api-version=…), or Google-style /v1beta/openai (substring "v1" 414 + * must not trigger a bogus extra /v1 segment). 415 + */ 416 + function resolveOpenAICompatChatUrl(raw) { 417 + const trimmed = String(raw || "").trim(); 418 + if (!trimmed) return trimmed; 419 + try { 420 + const u = new URL(trimmed); 421 + let path = (u.pathname || "").replace(/\/+$/, "") || ""; 422 + 423 + if (/\/chat\/completions$/i.test(path)) { 424 + return u.toString(); 425 + } 426 + 427 + if (/\/openai\/deployments\/[^/]+$/i.test(path)) { 428 + u.pathname = `${path}/chat/completions`; 429 + return u.toString(); 430 + } 431 + 432 + if (/\/v1$/i.test(path)) { 433 + u.pathname = `${path}/chat/completions`; 434 + return u.toString(); 435 + } 436 + 437 + if (/\/v1beta\/openai$/i.test(path)) { 438 + u.pathname = `${path}/chat/completions`; 439 + return u.toString(); 440 + } 441 + 442 + if (!/\bv1\b/i.test(path)) { 443 + const base = path || ""; 444 + u.pathname = `${base}/v1/chat/completions`.replace(/\/{2,}/g, "/"); 445 + return u.toString(); 446 + } 447 + 448 + u.pathname = `${path}/chat/completions`.replace(/\/{2,}/g, "/"); 449 + return u.toString(); 450 + } catch { 451 + return trimmed; 452 + } 453 + } 454 + 455 + function openAICompatFetchHeaders(apiKey, urlString) { 456 + const headers = { "Content-Type": "application/json" }; 457 + if (!apiKey) return headers; 458 + try { 459 + const host = new URL(urlString).hostname; 460 + if (/\.openai\.azure\.com$/i.test(host)) { 461 + headers["api-key"] = apiKey; 462 + } else { 463 + headers.Authorization = `Bearer ${apiKey}`; 464 + } 465 + } catch { 466 + headers.Authorization = `Bearer ${apiKey}`; 467 + } 468 + return headers; 469 + } 470 + 333 471 async function handleChatRequest(data) { 334 472 const { 335 473 tabId, ··· 365 503 keepAlive, 366 504 ); 367 505 } else { 368 - return await callOpenAICompatible( 506 + const raw = await callOpenAICompatible( 369 507 apiBaseUrl, 370 508 model, 371 509 apiKey, ··· 373 511 tokenCap, 374 512 signal, 375 513 ); 514 + return normalizeOpenAIChatResponse(raw); 376 515 } 377 516 } catch (error) { 378 517 if (error.name === "AbortError" || signal.aborted) { ··· 528 667 maxTokens = CONFIG.API.MAX_TOKENS, 529 668 signal, 530 669 ) { 531 - let url = baseUrl.replace(/\/$/, ""); 532 - 533 - if (!url.includes("/v1")) { 534 - url = url + "/v1"; 535 - } 536 - 537 - url = url + "/chat/completions"; 670 + const url = resolveOpenAICompatChatUrl(baseUrl); 538 671 539 672 const fetchOpts = { 540 673 method: "POST", 541 - headers: { 542 - "Content-Type": "application/json", 543 - ...(apiKey && { Authorization: `Bearer ${apiKey}` }), 544 - }, 674 + headers: openAICompatFetchHeaders(apiKey, url), 545 675 body: JSON.stringify({ 546 676 model: model, 547 677 messages: messages, ··· 746 876 messages, 747 877 signal, 748 878 ) { 749 - let url = baseUrl.replace(/\/$/, ""); 750 - 751 - if (!url.includes("/v1")) { 752 - url = url + "/v1"; 753 - } 754 - 755 - url = url + "/chat/completions"; 879 + const url = resolveOpenAICompatChatUrl(baseUrl); 756 880 757 881 resetStreamChunkBatching(); 758 882 let hitMaxChars = false; ··· 760 884 try { 761 885 const response = await fetch(url, { 762 886 method: "POST", 763 - headers: { 764 - "Content-Type": "application/json", 765 - ...(apiKey && { Authorization: `Bearer ${apiKey}` }), 766 - }, 887 + headers: openAICompatFetchHeaders(apiKey, url), 767 888 body: JSON.stringify({ 768 889 model: model, 769 890 messages: messages, ··· 826 947 buffer = lines.pop() || ""; 827 948 828 949 for (const line of lines) { 829 - if (line.trim() && line.startsWith("data: ")) { 830 - const data = line.slice(6); 831 - if (data === "[DONE]") continue; 832 - try { 833 - const json = JSON.parse(data); 834 - const content = json.choices?.[0]?.delta?.content; 835 - if (content) { 836 - if (streamedChars + content.length > maxChars) { 837 - hitMaxChars = true; 838 - reader.cancel().catch(() => {}); 839 - break; 840 - } 841 - streamedChars += content.length; 842 - queueStreamChunk(content); 950 + const trimmed = line.trim(); 951 + if (!trimmed) continue; 952 + let payload = null; 953 + if (trimmed.startsWith("data:")) { 954 + payload = trimmed.slice(5).replace(/^\uFEFF/, "").trimStart(); 955 + if (payload === "[DONE]") continue; 956 + } else if (trimmed.startsWith("{") && /"choices"\s*:/i.test(trimmed)) { 957 + // Some OpenAI-compatible proxies stream NDJSON without a `data:` prefix. 958 + payload = trimmed; 959 + } else { 960 + continue; 961 + } 962 + try { 963 + const json = JSON.parse(payload); 964 + const content = extractOpenAIStreamDeltaChunk(json); 965 + if (content) { 966 + if (streamedChars + content.length > maxChars) { 967 + hitMaxChars = true; 968 + reader.cancel().catch(() => {}); 969 + break; 843 970 } 844 - } catch (e) { 845 - // Skip invalid JSON lines 971 + streamedChars += content.length; 972 + queueStreamChunk(content); 846 973 } 974 + } catch (e) { 975 + // Skip invalid JSON lines 847 976 } 848 977 } 849 978
+1
scripts/config.js
··· 101 101 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: 102 102 - What are some key quotes? 103 103 - Explain this simply 104 + - What information the most important? 104 105 105 106 Return only the 2 questions, one per line, no numbering or bullet points.`, 106 107 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.`,