{"repo": "arkin26/why-join", "files": {"extension/lib/llm.js": {"f0785d2b7fbac5852891412449716ccd3dca283d": {"t": 1785746707.122372, "n": 13281, "h": "11905bd708991f09", "c": "/**\n * Browser-safe multi-provider LLM client (fetch only — no Node SDKs).\n * Auto-detects from model name + API key shape.\n */\n\nconst GEMINI_API_ROOT = \"https://generativelanguage.googleapis.com/v1beta\";\n\nconst KNOWN_BASE_URLS = {\n groq: \"https://api.groq.com/openai/v1\",\n openrouter: \"https://openrouter.ai/api/v1\",\n deepseek: \"https://api.deepseek.com\",\n openai: \"https://api.openai.com/v1\",\n};\n\nexport function detectProviderAndBaseUrl({\n model,\n apiKey,\n provider,\n baseURL,\n} = {}) {\n const explicit = (provider || \"\").toLowerCase().trim();\n if (explicit && explicit !== \"auto\") {\n return {\n provider: explicit === \"google\" ? \"gemini\" : explicit,\n baseURL: baseURL || undefined,\n };\n }\n\n const m = String(model || \"\").toLowerCase().trim();\n const k = String(apiKey || \"\").trim();\n\n // Google AI Studio keys are usually AIza…; some newer keys use AQ.…\n if (k.startsWith(\"AIza\") || k.startsWith(\"AQ.\")) {\n return { provider: \"gemini\", baseURL: undefined };\n }\n if (k.startsWith(\"sk-ant-\")) {\n return { provider: \"anthropic\", baseURL: undefined };\n }\n if (k.startsWith(\"gsk_\")) {\n return { provider: \"compatible\", baseURL: baseURL || KNOWN_BASE_URLS.groq };\n }\n if (k.startsWith(\"sk-or-\")) {\n return {\n provider: \"compatible\",\n baseURL: baseURL || KNOWN_BASE_URLS.openrouter,\n };\n }\n\n if (m.startsWith(\"gemini\") || m.startsWith(\"gemma\") || m.includes(\"gemini-\")) {\n return { provider: \"gemini\", baseURL: undefined };\n }\n if (m.startsWith(\"claude\")) {\n return { provider: \"anthropic\", baseURL: undefined };\n }\n if (m.startsWith(\"deepseek\")) {\n return {\n provider: \"compatible\",\n baseURL: baseURL || KNOWN_BASE_URLS.deepseek,\n };\n }\n if (m.includes(\"/\")) {\n return {\n provider: \"compatible\",\n baseURL: baseURL || KNOWN_BASE_URLS.openrouter,\n };\n }\n if (\n m.startsWith(\"gpt-\") ||\n m.startsWith(\"chatgpt-\") ||\n m.startsWith(\"o1\") ||\n m.startsWith(\"o3\") ||\n m.startsWith(\"o4\")\n ) {\n return { provider: \"openai\", baseURL: undefined };\n }\n if (\n m.includes(\"llama\") ||\n m.includes(\"mixtral\") ||\n m.includes(\"gemma2\") ||\n m.includes(\"qwen\")\n ) {\n return { provider: \"compatible\", baseURL: baseURL || KNOWN_BASE_URLS.groq };\n }\n\n if (k.startsWith(\"sk-\")) {\n return { provider: \"openai\", baseURL: baseURL || undefined };\n }\n\n throw new Error(\n `Could not detect provider from model \"${model || \"(empty)\"}\". ` +\n `Try a clear name like gemini-3.6-flash, gpt-4o-mini, or claude-sonnet-4-20250514.`\n );\n}\n\nfunction normalizeGeminiModelId(model) {\n return String(model || \"\")\n .trim()\n .replace(/^models\\//, \"\");\n}\n\nfunction sleep(ms) {\n return new Promise((r) => setTimeout(r, ms));\n}\n\nexport function isRateLimitError(err) {\n const status = err?.status || err?.response?.status || err?.httpStatus;\n const body = err?.error?.message || err?.message || String(err);\n return (\n status === 429 ||\n /429|rate.?limit|quota|RESOURCE_EXHAUSTED|too many requests/i.test(body)\n );\n}\n\nasync function withRateLimitRetry(fn) {\n const delaysMs = [0, 8_000, 20_000];\n let lastErr;\n for (let i = 0; i < delaysMs.length; i++) {\n if (delaysMs[i] > 0) await sleep(delaysMs[i]);\n try {\n return await fn();\n } catch (err) {\n lastErr = err;\n if (!isRateLimitError(err) || i === delaysMs.length - 1) throw err;\n }\n }\n throw lastErr;\n}\n\n/** Never echo the raw API key in user-facing errors. */\nfunction redactSecrets(text, apiKey) {\n let out = String(text || \"\");\n if (apiKey && apiKey.length > 8) {\n out = out.split(apiKey).join(\"[redacted-key]\");\n }\n return out;\n}\n\nexport function formatLlmError(err, { provider, model, baseURL, apiKey }) {\n const status = err?.status || err?.response?.status || err?.httpStatus;\n const body = redactSecrets(\n err?.error?.message ||\n err?.message ||\n (typeof err === \"string\" ? err : String(err)),\n apiKey\n );\n\n const isOrFree =\n String(model || \"\").includes(\":free\") ||\n (baseURL || \"\").includes(\"openrouter.ai\");\n\n if (status === 404 || /404/.test(body)) {\n const hint =\n provider === \"gemini\"\n ? ` \"${model}\" isn’t available for this Google key. Try gemini-3.6-flash, gemini-3.5-flash, or gemini-flash-latest.`\n : ` Check that the model name matches your key’s provider.`;\n return new Error(`Model not found (404): ${body}.${hint}`);\n }\n\n if (isRateLimitError(err)) {\n const hint = isOrFree\n ? \" OpenRouter free tier is ~20/min and ~200/day. Wait, or use a Gemini / OpenAI key.\"\n : \" Wait 30–60 seconds and try again.\";\n return new Error(`Rate limit / quota (429): ${body}.${hint}`);\n }\n\n if (status === 401 || status === 403) {\n return new Error(\n `Auth failed (${status}): check your API key for ${provider}. ${body}`\n );\n }\n\n if (status === 400) {\n return new Error(`Bad request (400) [${provider}/${model}]: ${body}`);\n }\n\n return new Error(\n `LLM error${status ? ` ${status}` : \"\"} [${provider}/${model}]: ${body}`\n );\n}\n\nasync function geminiGenerateContent(apiKey, model, prompt, maxTokens) {\n const modelId = normalizeGeminiModelId(model);\n const url = `${GEMINI_API_ROOT}/models/${encodeURIComponent(modelId)}:generateContent`;\n const isGemini3 = /gemini-3/i.test(modelId);\n\n const generationConfig = {\n maxOutputTokens: isGemini3 ? Math.max(maxTokens, 1024) : maxTokens,\n };\n\n if (isGemini3) {\n generationConfig.thinkingConfig = { thinkingLevel: \"minimal\" };\n } else {\n generationConfig.temperature = 0.7;\n }\n\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": apiKey,\n },\n body: JSON.stringify({\n contents: [{ role: \"user\", parts: [{ text: prompt }] }],\n generationConfig,\n }),\n });\n\n const raw = await res.text();\n let data = {};\n try {\n data = raw ? JSON.parse(raw) : {};\n } catch {\n data = { raw };\n }\n\n if (!res.ok) {\n const msg =\n data?.error?.message ||\n (raw && raw.slice(0, 300)) ||\n `${res.status} ${res.statusText}`;\n const err = new Error(msg);\n err.httpStatus = res.status;\n throw err;\n }\n\n const parts = data?.candidates?.[0]?.content?.parts || [];\n const text = parts\n .filter((p) => !p.thought)\n .map((p) => p.text || \"\")\n .join(\"\")\n .trim();\n\n if (!text) {\n const block = data?.candidates?.[0]?.finishReason;\n throw new Error(\n `Gemini returned empty text${block ? ` (finishReason: ${block})` : \"\"}`\n );\n }\n\n return text;\n}\n\nasync function anthropicComplete(apiKey, model, prompt, maxTokens) {\n const res = await fetch(\"https://api.anthropic.com/v1/messages\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": apiKey,\n \"anthropic-version\": \"2023-06-01\",\n \"anthropic-dangerous-direct-browser-access\": \"true\",\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n messages: [{ role: \"user\", content: prompt }],\n }),\n });\n\n const raw = await res.text();\n let data = {};\n try {\n data = raw ? JSON.parse(raw) : {};\n } catch {\n data = { raw };\n }\n\n if (!res.ok) {\n const msg =\n data?.error?.message ||\n (raw && raw.slice(0, 300)) ||\n `${res.status} ${res.statusText}`;\n const err = new Error(msg);\n err.httpStatus = res.status;\n throw err;\n }\n\n const textBlock = (data.content || []).find((b) => b.type === \"text\");\n if (!textBlock?.text) throw new Error(\"Anthropic returned no text content\");\n return textBlock.text.trim();\n}\n\nfunction extractChatText(completion) {\n const choice = completion?.choices?.[0];\n const message = choice?.message || {};\n\n if (typeof message.content === \"string\" && message.content.trim()) {\n return message.content.trim();\n }\n\n if (Array.isArray(message.content)) {\n const joined = message.content\n .map((part) => {\n if (typeof part === \"string\") return part;\n if (part?.text) return part.text;\n return \"\";\n })\n .join(\"\")\n .trim();\n if (joined) return joined;\n }\n\n if (typeof message.reasoning === \"string\" && message.reasoning.trim()) {\n return message.reasoning.trim();\n }\n if (\n typeof message.reasoning_content === \"string\" &&\n message.reasoning_content.trim()\n ) {\n return message.reasoning_content.trim();\n }\n\n return \"\";\n}\n\nfunction emptyCompletionError(completion) {\n const choice = completion?.choices?.[0];\n const finish = choice?.finish_reason;\n const nestedErr = choice?.error?.message || completion?.error?.message;\n const parts = [\n \"API returned no text\",\n finish ? `finish_reason=${finish}` : null,\n nestedErr ? `provider_error=${nestedErr}` : null,\n \"Retry once, or pick a different model.\",\n ].filter(Boolean);\n return new Error(parts.join(\". \"));\n}\n\nasync function openAiCompatibleComplete({\n apiKey,\n model,\n prompt,\n maxTokens,\n baseURL,\n}) {\n const root = (baseURL || KNOWN_BASE_URLS.openai).replace(/\\/$/, \"\");\n const url = `${root}/chat/completions`;\n const isOpenRouter = root.includes(\"openrouter.ai\");\n\n const headers = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n };\n if (isOpenRouter) {\n headers[\"HTTP-Referer\"] = \"https://github.com/wellfound-bot\";\n headers[\"X-Title\"] = \"Why Join Extension\";\n }\n\n const request = async () => {\n const res = await fetch(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature: 0.7,\n messages: [{ role: \"user\", content: prompt }],\n }),\n });\n\n const raw = await res.text();\n let data = {};\n try {\n data = raw ? JSON.parse(raw) : {};\n } catch {\n data = { raw };\n }\n\n if (!res.ok) {\n const msg =\n data?.error?.message ||\n (raw && raw.slice(0, 300)) ||\n `${res.status} ${res.statusText}`;\n const err = new Error(msg);\n err.httpStatus = res.status;\n throw err;\n }\n return data;\n };\n\n let completion = await request();\n let text = extractChatText(completion);\n\n if (!text && isOpenRouter) {\n await sleep(1500);\n completion = await request();\n text = extractChatText(completion);\n }\n\n if (!text) throw emptyCompletionError(completion);\n return text;\n}\n\nfunction defaultModelFor(provider) {\n if (provider === \"gemini\") return \"gemini-3.6-flash\";\n if (provider === \"anthropic\") return \"claude-sonnet-4-20250514\";\n if (provider === \"openai\") return \"gpt-4o-mini\";\n return \"\";\n}\n\n/**\n * Guess provider label for UI without throwing.\n */\nexport function peekProvider({ apiKey, model } = {}) {\n try {\n return detectProviderAndBaseUrl({ apiKey, model, provider: \"auto\" })\n .provider;\n } catch {\n return \"auto\";\n }\n}\n\nexport function createLlmClient(overrides = {}) {\n const cleaned = Object.fromEntries(\n Object.entries(overrides).filter(\n ([, v]) => v !== undefined && v !== null && String(v).trim() !== \"\"\n )\n );\n\n const apiKey = String(cleaned.apiKey || \"\").trim();\n const modelInput = String(cleaned.model || \"\").trim();\n\n if (!apiKey) {\n throw new Error(\n \"Add your API key first (step 1). Keys stay on this device — never uploaded to us.\"\n );\n }\n if (!modelInput) {\n throw new Error(\n \"Enter a model name (step 2), e.g. gemini-3.6-flash, gpt-4o-mini, or claude-sonnet-4-20250514.\"\n );\n }\n\n const detected = detectProviderAndBaseUrl({\n apiKey,\n model: modelInput,\n provider: cleaned.provider || \"auto\",\n baseURL: cleaned.baseURL,\n });\n\n const cfg = {\n apiKey,\n model: modelInput || defaultModelFor(detected.provider),\n provider: detected.provider,\n baseURL: cleaned.baseURL || detected.baseURL,\n };\n\n if (cfg.provider === \"gemini\") {\n const model = normalizeGeminiModelId(cfg.model);\n return {\n provider: \"gemini\",\n model,\n async complete(prompt, { maxTokens = 500 } = {}) {\n try {\n return await withRateLimitRetry(() =>\n geminiGenerateContent(cfg.apiKey, model, prompt, maxTokens)\n );\n } catch (err) {\n throw formatLlmError(err, { ...cfg, model });\n }\n },\n };\n }\n\n if (cfg.provider === \"anthropic\") {\n return {\n provider: \"anthropic\",\n model: cfg.model,\n async complete(prompt, { maxTokens = 500 } = {}) {\n try {\n return await withRateLimitRetry(() =>\n anthropicComplete(cfg.apiKey, cfg.model, prompt, maxTokens)\n );\n } catch (err) {\n throw formatLlmError(err, cfg);\n }\n },\n };\n }\n\n if (cfg.provider === \"openai\" || cfg.provider === \"compatible\") {\n return {\n provider: cfg.provider,\n model: cfg.model,\n async complete(prompt, { maxTokens = 800 } = {}) {\n try {\n return await withRateLimitRetry(() =>\n openAiCompatibleComplete({\n apiKey: cfg.apiKey,\n model: cfg.model,\n prompt,\n maxTokens,\n baseURL: cfg.baseURL,\n })\n );\n } catch (err) {\n throw formatLlmError(err, cfg);\n }\n },\n };\n }\n\n throw new Error(`Unknown provider \"${cfg.provider}\" after auto-detect.`);\n}\n"}}}}