From abb04c536693c435b792c463e36a57aa549c92e9 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 22 Apr 2026 11:58:53 +0700 Subject: [PATCH] feat: add support for Grok Web and Perplexity Web providers --- open-sse/config/providerModels.js | 23 + open-sse/config/providers.js | 11 +- open-sse/executors/grok-web.js | 345 ++++++++++++ open-sse/executors/index.js | 6 + open-sse/executors/perplexity-web.js | 507 ++++++++++++++++++ open-sse/services/model.js | 5 + open-sse/services/tokenRefresh.js | 2 +- .../providers/[id]/AddApiKeyModal.js | 30 +- .../dashboard/providers/[id]/page.js | 7 +- .../(dashboard)/dashboard/providers/page.js | 22 + src/app/api/providers/[id]/test/testUtils.js | 35 +- src/app/api/providers/route.js | 8 +- src/app/api/providers/validate/route.js | 92 ++++ src/shared/constants/config.js | 1 + src/shared/constants/providers.js | 9 +- tests/unit/perplexity-web.test.js | 306 +++++++++++ tests/unit/web-cookie-validation.test.js | 187 +++++++ 17 files changed, 1583 insertions(+), 13 deletions(-) create mode 100644 open-sse/executors/grok-web.js create mode 100644 open-sse/executors/perplexity-web.js create mode 100644 tests/unit/perplexity-web.test.js create mode 100644 tests/unit/web-cookie-validation.test.js diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index ea07cbc6..b80c3b1c 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -398,6 +398,29 @@ export const PROVIDER_MODELS = { { id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" }, { id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" }, ], + "grok-web": [ + { id: "grok-3", name: "Grok 3" }, + { id: "grok-3-mini", name: "Grok 3 Mini (Thinking)" }, + { id: "grok-3-thinking", name: "Grok 3 Thinking" }, + { id: "grok-4", name: "Grok 4" }, + { id: "grok-4-mini", name: "Grok 4 Mini (Thinking)" }, + { id: "grok-4-thinking", name: "Grok 4 Thinking" }, + { id: "grok-4-heavy", name: "Grok 4 Heavy (SuperGrok)" }, + { id: "grok-4.1-mini", name: "Grok 4.1 Mini (Thinking)" }, + { id: "grok-4.1-fast", name: "Grok 4.1 Fast" }, + { id: "grok-4.1-expert", name: "Grok 4.1 Expert" }, + { id: "grok-4.1-thinking", name: "Grok 4.1 Thinking" }, + { id: "grok-4.2", name: "Grok 4.2 (4.20 Beta)" }, + ], + "perplexity-web": [ + { id: "pplx-auto", name: "Perplexity Auto (Free)" }, + { id: "pplx-sonar", name: "Perplexity Sonar" }, + { id: "pplx-gpt", name: "GPT-5.4 (via Perplexity)" }, + { id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)" }, + { id: "pplx-sonnet", name: "Claude Sonnet 4.6 (via Perplexity)" }, + { id: "pplx-opus", name: "Claude Opus 4.6 (via Perplexity)" }, + { id: "pplx-nemotron", name: "Nemotron 3 Super (via Perplexity)" }, + ], // TTS entries are loaded from ttsModels.js via buildTtsProviderModels() ...buildTtsProviderModels(), diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index fb2cbb9e..c7c9ee82 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -72,7 +72,6 @@ export const PROVIDERS = { "User-Agent": "codex-cli/1.0.18 (macOS; arm64)" }, clientId: "app_EMoamEEZ73f0CkXaXp7hrann", - clientSecret: "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", tokenUrl: "https://auth.openai.com/oauth/token" }, qwen: { @@ -338,6 +337,16 @@ export const PROVIDERS = { headers: { "x-opencode-client": "desktop" }, noAuth: true }, + "grok-web": { + baseUrl: "https://grok.com/rest/app-chat/conversations/new", + format: "grok-web", + authType: "cookie" + }, + "perplexity-web": { + baseUrl: "https://www.perplexity.ai/rest/sse/perplexity_ask", + format: "perplexity-web", + authType: "cookie" + }, }; export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434"; diff --git a/open-sse/executors/grok-web.js b/open-sse/executors/grok-web.js new file mode 100644 index 00000000..2f366f99 --- /dev/null +++ b/open-sse/executors/grok-web.js @@ -0,0 +1,345 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; + +const GROK_CHAT_API = PROVIDERS["grok-web"].baseUrl; +const GROK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"; + +const MODEL_MAP = { + "grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false }, + "grok-3-mini": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_MINI_THINKING", isThinking: true }, + "grok-3-thinking": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_THINKING", isThinking: true }, + "grok-4": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4", isThinking: false }, + "grok-4-mini": { grokModel: "grok-4-mini", modelMode: "MODEL_MODE_GROK_4_MINI_THINKING", isThinking: true }, + "grok-4-thinking": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4_THINKING", isThinking: true }, + "grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true }, + "grok-4.1-mini": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_MINI_THINKING", isThinking: true }, + "grok-4.1-fast": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_FAST", isThinking: false }, + "grok-4.1-expert": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_EXPERT", isThinking: true }, + "grok-4.1-thinking": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_THINKING", isThinking: true }, + "grok-4.2": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, +}; + +function randomString(length, alphanumeric = false) { + const chars = alphanumeric ? "abcdefghijklmnopqrstuvwxyz0123456789" : "abcdefghijklmnopqrstuvwxyz"; + let result = ""; + for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * chars.length)]; + return result; +} + +function generateStatsigId() { + const msg = Math.random() < 0.5 + ? `e:TypeError: Cannot read properties of null (reading 'children["${randomString(5, true)}"]')` + : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; + return btoa(msg); +} + +function randomHex(bytes) { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function parseOpenAIMessages(messages) { + const extracted = []; + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content.filter((c) => c.type === "text").map((c) => String(c.text || "")).join(" "); + } + if (!content.trim()) continue; + extracted.push({ role, text: content }); + } + + let lastUserIdx = -1; + for (let i = extracted.length - 1; i >= 0; i--) { + if (extracted[i].role === "user") { lastUserIdx = i; break; } + } + + const parts = []; + for (let i = 0; i < extracted.length; i++) { + const { role, text } = extracted[i]; + parts.push(i === lastUserIdx ? text : `${role}: ${text}`); + } + return parts.join("\n\n"); +} + +async function* readGrokNdjsonEvents(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { yield JSON.parse(line); } catch { /* skip */ } + } + } + buffer += decoder.decode(); + const remaining = buffer.trim(); + if (remaining) { + try { yield JSON.parse(remaining); } catch { /* skip */ } + } + } finally { + reader.releaseLock(); + } +} + +async function* extractContent(eventStream, isThinkingModel, signal) { + let fingerprint = ""; + let responseId = ""; + let thinkOpened = false; + + for await (const event of readGrokNdjsonEvents(eventStream, signal)) { + if (event.error) { + yield { error: event.error.message || `Grok error: ${event.error.code}`, done: true }; + return; + } + const resp = event.result?.response; + if (!resp) continue; + + if (resp.llmInfo?.modelHash && !fingerprint) fingerprint = resp.llmInfo.modelHash; + if (resp.responseId) responseId = resp.responseId; + + if (resp.modelResponse) { + const mr = resp.modelResponse; + if (thinkOpened && isThinkingModel) { + if (mr.message) yield { thinking: mr.message }; + thinkOpened = false; + } + if (mr.message) yield { fullMessage: mr.message, fingerprint, responseId }; + if (mr.metadata?.llm_info?.modelHash) fingerprint = mr.metadata.llm_info.modelHash; + continue; + } + + if (resp.token != null) yield { delta: resp.token, fingerprint, responseId }; + } + yield { done: true, fingerprint, responseId }; +} + +function sseChunk(data) { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }], + }))); + + let fp = ""; + for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) { + if (chunk.fingerprint) fp = chunk.fingerprint; + + if (chunk.error) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null }], + }))); + break; + } + if (chunk.thinking) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { reasoning_content: chunk.thinking }, finish_reason: null, logprobs: null }], + }))); + continue; + } + if (chunk.done) break; + if (chunk.delta) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { content: chunk.delta }, finish_reason: null, logprobs: null }], + }))); + } + } + + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } catch (err) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }], + }))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { + let fullContent = ""; + let fingerprint = ""; + const thinkingParts = []; + + for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) { + if (chunk.fingerprint) fingerprint = chunk.fingerprint; + if (chunk.error) { + return new Response(JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + } + if (chunk.thinking) { thinkingParts.push(chunk.thinking); continue; } + if (chunk.done) break; + if (chunk.fullMessage) fullContent = chunk.fullMessage; + else if (chunk.delta) fullContent += chunk.delta; + } + + const msg = { role: "assistant", content: fullContent }; + if (thinkingParts.length > 0) msg.reasoning_content = thinkingParts.join("\n"); + + const promptTokens = Math.ceil(fullContent.length / 4); + const completionTokens = Math.ceil(fullContent.length / 4); + + return new Response(JSON.stringify({ + id: cid, object: "chat.completion", created, model, system_fingerprint: fingerprint || null, + choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +export class GrokWebExecutor extends BaseExecutor { + constructor() { + super("grok-web", PROVIDERS["grok-web"]); + } + + async execute({ model, body, stream, credentials, signal, log }) { + const messages = body?.messages; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errResp = new Response(JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; + } + + const modelInfo = MODEL_MAP[model]; + if (!modelInfo) log?.info?.("GROK-WEB", `Unmapped model ${model}, defaulting to grok-4.1-fast`); + const { grokModel, modelMode, isThinking } = modelInfo || MODEL_MAP["grok-4.1-fast"]; + + const message = parseOpenAIMessages(messages); + if (!message.trim()) { + const errResp = new Response(JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; + } + + const grokPayload = { + temporary: true, modelName: grokModel, modelMode, message, + fileAttachments: [], imageAttachments: [], + disableSearch: false, enableImageGeneration: false, returnImageBytes: false, + returnRawGrokInXaiRequest: false, enableImageStreaming: false, imageGenerationCount: 0, + forceConcise: false, toolOverrides: {}, enableSideBySide: true, sendFinalMetadata: true, + isReasoning: false, disableTextFollowUps: false, disableMemory: true, + forceSideBySide: false, isAsyncChat: false, disableSelfHarmShortCircuit: false, + deviceEnvInfo: { + darkModeEnabled: false, devicePixelRatio: 2, + screenWidth: 2056, screenHeight: 1329, viewportWidth: 2056, viewportHeight: 1083, + }, + }; + + const traceId = randomHex(16); + const spanId = randomHex(8); + const headers = { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + Baggage: "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + Origin: "https://grok.com", + Pragma: "no-cache", + Referer: "https://grok.com/", + "Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": GROK_USER_AGENT, + "x-statsig-id": generateStatsigId(), + "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${traceId}-${spanId}-00`, + }; + + // Strip "sso=" prefix if user pasted it + if (credentials.apiKey) { + let token = credentials.apiKey; + if (token.startsWith("sso=")) token = token.slice(4); + headers["Cookie"] = `sso=${token}`; + } + + log?.info?.("GROK-WEB", `Query to ${model} (grok=${grokModel}, mode=${modelMode}), len=${message.length}`); + + let response; + try { + response = await fetch(GROK_CHAT_API, { + method: "POST", headers, body: JSON.stringify(grokPayload), signal, + }); + } catch (err) { + log?.error?.("GROK-WEB", `Fetch failed: ${err.message || String(err)}`); + const errResp = new Response(JSON.stringify({ + error: { message: `Grok connection failed: ${err.message || String(err)}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + if (!response.ok) { + const status = response.status; + let errMsg = `Grok returned HTTP ${status}`; + if (status === 401 || status === 403) errMsg = "Grok auth failed — SSO cookie may be expired. Re-paste your sso cookie value from grok.com."; + else if (status === 429) errMsg = "Grok rate limited. Wait a moment and retry, or rotate cookies."; + log?.warn?.("GROK-WEB", errMsg); + const errResp = new Response(JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), { status, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + if (!response.body) { + const errResp = new Response(JSON.stringify({ + error: { message: "Grok returned empty response body", type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + const cid = `chatcmpl-grok-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse; + if (stream) { + const sseStream = buildStreamingResponse(response.body, model, cid, created, isThinking, signal); + finalResponse = new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + }); + } else { + finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, isThinking, signal); + } + return { response: finalResponse, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } +} + +export default GrokWebExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index f77212c4..f1e175f4 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -9,6 +9,8 @@ import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; import { QwenExecutor } from "./qwen.js"; import { OpenCodeExecutor } from "./opencode.js"; +import { GrokWebExecutor } from "./grok-web.js"; +import { PerplexityWebExecutor } from "./perplexity-web.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -25,6 +27,8 @@ const executors = { "vertex-partner": new VertexExecutor("vertex-partner"), qwen: new QwenExecutor(), opencode: new OpenCodeExecutor(), + "grok-web": new GrokWebExecutor(), + "perplexity-web": new PerplexityWebExecutor(), }; const defaultCache = new Map(); @@ -52,3 +56,5 @@ export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; export { QwenExecutor } from "./qwen.js"; export { OpenCodeExecutor } from "./opencode.js"; +export { GrokWebExecutor } from "./grok-web.js"; +export { PerplexityWebExecutor } from "./perplexity-web.js"; diff --git a/open-sse/executors/perplexity-web.js b/open-sse/executors/perplexity-web.js new file mode 100644 index 00000000..2c39cf4c --- /dev/null +++ b/open-sse/executors/perplexity-web.js @@ -0,0 +1,507 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; + +const PPLX_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl; +const PPLX_API_VERSION = "2.18"; +const PPLX_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; + +const MODEL_MAP = { + "pplx-auto": ["concise", "pplx_pro"], + "pplx-sonar": ["copilot", "experimental"], + "pplx-gpt": ["copilot", "gpt54"], + "pplx-gemini": ["copilot", "gemini31pro_high"], + "pplx-sonnet": ["copilot", "claude46sonnet"], + "pplx-opus": ["copilot", "claude46opus"], + "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], +}; + +const THINKING_MAP = { + "pplx-gpt": "gpt54_thinking", + "pplx-sonnet": "claude46sonnetthinking", + "pplx-opus": "claude46opusthinking", +}; + +const CITATION_RE = /\[\d+\]/g; +const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; +const GROK_SELF_RE = /]*\/>/g; +const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; +const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; +const MULTI_SPACE = / {2,}/g; +const MULTI_NL = /\n{3,}/g; + +const SESSION_MAX_AGE_MS = 3600_000; +const SESSION_MAX_ENTRIES = 200; + +const sessionCache = new Map(); + +// FNV-1a hash for session key lookup +function sessionKey(history) { + const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); + let hash = 0x811c9dc5; + for (let i = 0; i < parts.length; i++) { + hash ^= parts.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function sessionLookup(history) { + if (history.length === 0) return null; + const key = sessionKey(history); + const entry = sessionCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > SESSION_MAX_AGE_MS) { + sessionCache.delete(key); + return null; + } + return entry.backendUuid; +} + +function sessionStore(history, currentMsg, responseText, backendUuid) { + if (!backendUuid) return; + const full = [...history, { role: "user", content: currentMsg }, { role: "assistant", content: responseText }]; + const key = sessionKey(full); + sessionCache.set(key, { backendUuid, ts: Date.now() }); + if (sessionCache.size > SESSION_MAX_ENTRIES) { + let oldestKey = null; + let oldestTs = Infinity; + for (const [k, v] of sessionCache) { + if (v.ts < oldestTs) { oldestTs = v.ts; oldestKey = k; } + } + if (oldestKey) sessionCache.delete(oldestKey); + } +} + +function cleanResponse(text, strip = true) { + let t = text; + t = t.replace(XML_DECL_RE, ""); + t = t.replace(CITATION_RE, ""); + t = t.replace(GROK_TAG_RE, ""); + t = t.replace(GROK_SELF_RE, ""); + t = t.replace(RESPONSE_TAG_RE, ""); + if (strip) { + t = t.replace(MULTI_SPACE, " "); + t = t.replace(MULTI_NL, "\n\n"); + t = t.trim(); + } + return t; +} + +async function* readPplxSseEvents(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines = []; + + function flush() { + if (dataLines.length === 0) return null; + const payload = dataLines.join("\n"); + dataLines = []; + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return "done"; + try { return JSON.parse(trimmed); } catch { return null; } + } + + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const rawLine = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "") { + const parsed = flush(); + if (parsed === "done") return; + if (parsed) yield parsed; + continue; + } + if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + if (line === "event: end_of_stream") return; + } + } + buffer += decoder.decode(); + if (buffer.trim().startsWith("data:")) dataLines.push(buffer.trim().slice(5).trimStart()); + const tail = flush(); + if (tail && tail !== "done") yield tail; + } finally { + reader.releaseLock(); + } +} + +function parseOpenAIMessages(messages) { + let systemMsg = ""; + const history = []; + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + let content = ""; + if (typeof msg.content === "string") content = msg.content; + else if (Array.isArray(msg.content)) { + content = msg.content.filter((c) => c.type === "text").map((c) => String(c.text || "")).join(" "); + } + if (!content.trim()) continue; + if (role === "system") systemMsg += content + "\n"; + else if (role === "user" || role === "assistant") history.push({ role, content }); + } + let currentMsg = ""; + if (history.length > 0 && history[history.length - 1].role === "user") { + currentMsg = history.pop().content; + } + return { systemMsg, history, currentMsg }; +} + +function buildPplxRequestBody(query, mode, modelPref, followUpUuid) { + const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; + return { + query_str: query, + params: { + query_str: query, + search_focus: "internet", + mode, + model_preference: modelPref, + sources: ["web"], + attachments: [], + frontend_uuid: crypto.randomUUID(), + frontend_context_uuid: crypto.randomUUID(), + version: PPLX_API_VERSION, + language: "en-US", + timezone: tz, + search_recency_filter: null, + is_incognito: true, + use_schematized_api: true, + last_backend_uuid: followUpUuid, + }, + }; +} + +function formatToolsHint(tools) { + if (!Array.isArray(tools) || tools.length === 0) return ""; + const lines = tools.map((t) => { + const fn = t?.function || t || {}; + const name = fn.name || "unnamed"; + const desc = (fn.description || "").split("\n")[0].slice(0, 200); + return `- ${name}: ${desc}`; + }); + return `Available tools (reference only, cannot invoke):\n${lines.join("\n")}`; +} + +function buildQuery(parsed, followUpUuid, tools) { + if (followUpUuid) return parsed.currentMsg; + const obj = {}; + const instr = []; + if (parsed.systemMsg.trim()) instr.push(parsed.systemMsg.trim()); + const toolsHint = formatToolsHint(tools); + if (toolsHint) instr.push(toolsHint); + instr.push("You have built-in web search. Answer questions directly using search results."); + obj.instructions = instr; + if (parsed.history.length > 0) obj.history = parsed.history; + if (parsed.currentMsg) obj.query = parsed.currentMsg; + else if (parsed.history.length === 0) obj.query = ""; + const json = JSON.stringify(obj); + return json.length > 96000 ? json.slice(-96000) : json; +} + +async function* extractContent(eventStream, signal) { + let fullAnswer = ""; + let backendUuid = null; + let seenLen = 0; + const seenThinking = new Set(); + + for await (const event of readPplxSseEvents(eventStream, signal)) { + if (event.error_code || event.error_message) { + yield { error: event.error_message || `Perplexity error: ${event.error_code}`, done: true }; + return; + } + if (event.backend_uuid) backendUuid = event.backend_uuid; + + const blocks = event.blocks ?? []; + for (const block of blocks) { + const usage = block.intended_usage ?? ""; + + if (usage === "pro_search_steps" && block.plan_block?.steps) { + for (const step of block.plan_block.steps) { + if (step.step_type === "SEARCH_WEB") { + for (const q of step.search_web_content?.queries ?? []) { + const qr = q.query ?? ""; + if (qr && !seenThinking.has(qr)) { + seenThinking.add(qr); + yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; + } + } + } else if (step.step_type === "READ_RESULTS") { + for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { + if (u && !seenThinking.has(u)) { + seenThinking.add(u); + yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; + } + } + } + } + } + + if (usage === "plan" && block.plan_block?.goals) { + for (const goal of block.plan_block.goals) { + const desc = goal.description ?? ""; + if (desc && !seenThinking.has(desc)) { + seenThinking.add(desc); + yield { thinking: desc, backendUuid: backendUuid ?? undefined }; + } + } + } + + if (!usage.includes("markdown")) continue; + const mb = block.markdown_block; + if (!mb) continue; + const chunks = mb.chunks ?? []; + if (chunks.length === 0) continue; + + if (mb.progress === "DONE") { + fullAnswer = chunks.join(""); + } else { + const chunkText = chunks.join(""); + const cumulative = fullAnswer + chunkText; + if (cumulative.length > seenLen) { + const delta = cumulative.slice(seenLen); + fullAnswer = cumulative; + seenLen = cumulative.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + } + + if (blocks.length === 0 && event.text) { + const t = event.text.trim(); + if (t.length > seenLen) { + const delta = t.slice(seenLen); + fullAnswer = t; + seenLen = t.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + + if (event.final || event.status === "COMPLETED") break; + } + yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; +} + +function sseChunk(data) { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }], + }))); + + let fullAnswer = ""; + let respBackendUuid = null; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + if (chunk.error) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null }], + }))); + break; + } + if (chunk.thinking) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { reasoning_content: chunk.thinking + "\n" }, finish_reason: null, logprobs: null }], + }))); + continue; + } + if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } + let dt = chunk.delta || ""; + if (dt) { + dt = cleanResponse(dt, false); + if (dt) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }], + }))); + } + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + + sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); + } catch (err) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }], + }))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) { + let fullAnswer = ""; + let respBackendUuid = null; + const thinkingParts = []; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + if (chunk.error) { + return new Response(JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "PPLX_ERROR" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + } + if (chunk.thinking) { thinkingParts.push(chunk.thinking); continue; } + if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } + if (chunk.answer) fullAnswer = chunk.answer; + } + + fullAnswer = cleanResponse(fullAnswer); + sessionStore(history, currentMsg, fullAnswer, respBackendUuid); + + const reasoningContent = thinkingParts.length > 0 ? thinkingParts.join("\n") : undefined; + const msg = { role: "assistant", content: fullAnswer }; + if (reasoningContent) msg.reasoning_content = reasoningContent; + + const promptTokens = Math.ceil(currentMsg.length / 4); + const completionTokens = Math.ceil(fullAnswer.length / 4); + + return new Response(JSON.stringify({ + id: cid, object: "chat.completion", created, model, system_fingerprint: null, + choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +export class PerplexityWebExecutor extends BaseExecutor { + constructor() { + super("perplexity-web", PROVIDERS["perplexity-web"]); + } + + async execute({ model, body, stream, credentials, signal, log }) { + const messages = body?.messages; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errResp = new Response(JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + const thinking = body?.thinking === true || (body?.reasoning_effort != null && body.reasoning_effort !== "none"); + + let pplxMode; + let modelPref; + if (thinking && THINKING_MAP[model]) { + pplxMode = "copilot"; + modelPref = THINKING_MAP[model]; + log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`); + } else if (MODEL_MAP[model]) { + [pplxMode, modelPref] = MODEL_MAP[model]; + } else { + pplxMode = "copilot"; + modelPref = model; + log?.info?.("PPLX-WEB", `Unmapped model ${model}, using as raw preference`); + } + + const parsed = parseOpenAIMessages(messages); + const followUpUuid = sessionLookup(parsed.history); + if (followUpUuid) log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); + + const query = buildQuery(parsed, followUpUuid, body?.tools); + if (!query.trim()) { + const errResp = new Response(JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + const pplxBody = buildPplxRequestBody(query, pplxMode, modelPref, followUpUuid); + + const headers = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + "User-Agent": PPLX_USER_AGENT, + "X-App-ApiClient": "default", + "X-App-ApiVersion": PPLX_API_VERSION, + }; + + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Cookie"] = `__Secure-next-auth.session-token=${credentials.apiKey}`; + } + + log?.info?.("PPLX-WEB", `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}`); + + const fetchOptions = { method: "POST", headers, body: JSON.stringify(pplxBody) }; + if (signal) fetchOptions.signal = signal; + + let response; + try { + response = await fetch(PPLX_SSE_ENDPOINT, fetchOptions); + } catch (err) { + log?.error?.("PPLX-WEB", `Fetch failed: ${err.message || String(err)}`); + const errResp = new Response(JSON.stringify({ + error: { message: `Perplexity connection failed: ${err.message || String(err)}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.ok) { + const status = response.status; + let errMsg = `Perplexity returned HTTP ${status}`; + if (status === 401 || status === 403) errMsg = "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; + else if (status === 429) errMsg = "Perplexity rate limited. Wait a moment and retry."; + log?.warn?.("PPLX-WEB", errMsg); + const errResp = new Response(JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), { status, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.body) { + const errResp = new Response(JSON.stringify({ + error: { message: "Perplexity returned empty response body", type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse; + if (stream) { + const sseStream = buildStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); + finalResponse = new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + }); + } else { + finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); + } + return { response: finalResponse, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } +} + +export { parseOpenAIMessages, buildQuery, buildPplxRequestBody, formatToolsHint, sessionKey }; + +export default PerplexityWebExecutor; diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 66a45314..1b3d96ba 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -53,6 +53,11 @@ const ALIAS_TO_PROVIDER_ID = { vertex: "vertex", vxp: "vertex-partner", "vertex-partner": "vertex-partner", + // Web cookie providers + gw: "grok-web", + "grok-web": "grok-web", + pw: "perplexity-web", + "perplexity-web": "perplexity-web", }; /** diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 040b6e00..e76b1fc0 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -206,7 +206,7 @@ export async function refreshCodexToken(refreshToken, log) { grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.codex.clientId, - scope: "openid profile email offline_access", + scope: "openid profile email", }), }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 347f20e4..532167d0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -4,9 +4,14 @@ import { useState } from "react"; import PropTypes from "prop-types"; import { Button, Badge, Input, Modal, Select } from "@/shared/components"; -export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) { +export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, onSave, onClose }) { const NONE_PROXY_POOL_VALUE = "__none__"; const isOllamaLocal = provider === "ollama-local"; + const isCookie = authType === "cookie"; + const credentialLabel = isCookie ? "Cookie Value" : "API Key"; + const credentialPlaceholder = isCookie + ? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...") + : ""; const [formData, setFormData] = useState({ name: "", @@ -87,7 +92,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa if (!provider) return null; return ( - +
setFormData({ ...formData, apiKey: e.target.value })} + placeholder={credentialPlaceholder} className="flex-1" />
@@ -127,6 +133,19 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
)} + {isCookie && authHint && ( +

+ {authHint} + {website && ( + <> + {" "} + + Open {website.replace(/^https?:\/\//, "")} + + + )} +

+ )} {isOllamaLocal && (

Leave blank to use http://localhost:11434. For remote Ollama, enter the full host URL (e.g. http://192.168.1.10:11434). @@ -192,6 +211,9 @@ AddApiKeyModal.propTypes = { providerName: PropTypes.string, isCompatible: PropTypes.bool, isAnthropic: PropTypes.bool, + authType: PropTypes.string, + authHint: PropTypes.string, + website: PropTypes.string, proxyPools: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 7e12f470..8c07456a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; @@ -58,7 +58,7 @@ export default function ProviderDetailPage() { baseUrl: providerNode.baseUrl, type: providerNode.type, } - : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId]); + : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]); const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId]; const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; const models = getModelsByProviderId(providerId); @@ -1012,6 +1012,9 @@ export default function ProviderDetailPage() { providerName={providerInfo.name} isCompatible={isCompatible} isAnthropic={isAnthropicCompatible} + authType={providerInfo?.authType} + authHint={providerInfo?.authHint} + website={providerInfo?.website} proxyPools={proxyPools} onSave={handleSaveApiKey} onClose={() => setShowAddApiKeyModal(false)} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index d70d9a70..b129ee8b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -17,6 +17,7 @@ import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { FREE_PROVIDERS, FREE_TIER_PROVIDERS, + WEB_COOKIE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; @@ -377,6 +378,27 @@ export default function ProvidersPage() { + {/* Web Cookie Providers — use browser subscription cookie instead of API key */} + {/*

+
+

+ Web Cookie Providers{" "} +

+
+
+ {Object.entries(WEB_COOKIE_PROVIDERS).map(([key, info]) => ( + handleToggleProvider(key, "apikey", active)} + /> + ))} +
+
*/} + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index e4973aba..b5e54ef9 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -516,6 +516,39 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "grok-web": { + const token = connection.apiKey.startsWith("sso=") ? connection.apiKey.slice(4) : connection.apiKey; + const randomHex = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join(""); + const statsigId = Buffer.from("e:TypeError: Cannot read properties of null (reading 'children')").toString("base64"); + const res = await fetchWithConnectionProxy("https://grok.com/rest/app-chat/conversations/new", { + method: "POST", + headers: { + Accept: "*/*", "Content-Type": "application/json", + Cookie: `sso=${token}`, Origin: "https://grok.com", Referer: "https://grok.com/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "x-statsig-id": statsigId, "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${randomHex(16)}-${randomHex(8)}-00`, + }, + body: JSON.stringify({ temporary: true, modelName: "grok-4", message: "ping", fileAttachments: [], imageAttachments: [], disableSearch: false, enableImageGeneration: false, sendFinalMetadata: true }), + }, effectiveProxy); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid SSO cookie" }; + } + case "perplexity-web": { + let sessionToken = connection.apiKey; + if (sessionToken.startsWith("__Secure-next-auth.session-token=")) sessionToken = sessionToken.slice("__Secure-next-auth.session-token=".length); + const res = await fetchWithConnectionProxy("https://www.perplexity.ai/api/auth/session", { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + Cookie: `__Secure-next-auth.session-token=${sessionToken}`, + }, + }, effectiveProxy); + if (!res.ok) return { valid: false, error: "Invalid session cookie" }; + const data = await res.json().catch(() => null); + const valid = !!(data && data.user); + return { valid, error: valid ? null : "Session expired — re-paste cookie" }; + } default: return { valid: false, error: "Provider test not supported" }; } @@ -549,7 +582,7 @@ export async function testSingleConnection(id) { const start = Date.now(); let result; - if (connection.authType === "apikey") { + if (connection.authType === "apikey" || connection.authType === "cookie") { result = await testApiKeyConnection(connection, effectiveProxy); } else { result = await testOAuthConnection(connection, effectiveProxy); diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 639977c6..d1fca6c7 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -7,7 +7,7 @@ import { getProxyPoolById, } from "@/models"; import { APIKEY_PROVIDERS } from "@/shared/constants/config"; -import { FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; export const dynamic = "force-dynamic"; @@ -99,8 +99,10 @@ export async function POST(request) { const proxyPoolId = proxyPoolResult.proxyPoolId; // Validation + const isWebCookieProvider = !!WEB_COOKIE_PROVIDERS[provider]; const isValidProvider = APIKEY_PROVIDERS[provider] || FREE_TIER_PROVIDERS[provider] || + isWebCookieProvider || isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider); @@ -108,7 +110,7 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); } if (!apiKey) { - return NextResponse.json({ error: "API Key is required" }, { status: 400 }); + return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 }); } if (!name) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); @@ -164,7 +166,7 @@ export async function POST(request) { const newConnection = await createProviderConnection({ provider, - authType: "apikey", + authType: isWebCookieProvider ? "cookie" : "apikey", name, apiKey, priority: priority || 1, diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 40894e16..68a99ada 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -255,6 +255,98 @@ export async function POST(request) { break; } + case "grok-web": { + const token = apiKey.startsWith("sso=") ? apiKey.slice(4) : apiKey; + // Cloudflare-bypass: send POST with same browser fingerprint headers as GrokWebExecutor + const randomHex = (n) => { + const a = new Uint8Array(n); + crypto.getRandomValues(a); + return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join(""); + }; + const statsigId = Buffer.from("e:TypeError: Cannot read properties of null (reading 'children')").toString("base64"); + const traceId = randomHex(16); + const spanId = randomHex(8); + const res = await fetch("https://grok.com/rest/app-chat/conversations/new", { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + Cookie: `sso=${token}`, + Origin: "https://grok.com", + Pragma: "no-cache", + Referer: "https://grok.com/", + "Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "x-statsig-id": statsigId, + "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${traceId}-${spanId}-00`, + }, + body: JSON.stringify({ + temporary: true, modelName: "grok-4", modelMode: "MODEL_MODE_GROK_4", message: "ping", + fileAttachments: [], imageAttachments: [], + disableSearch: false, enableImageGeneration: false, returnImageBytes: false, + returnRawGrokInXaiRequest: false, enableImageStreaming: false, imageGenerationCount: 0, + forceConcise: false, toolOverrides: {}, enableSideBySide: true, sendFinalMetadata: true, + isReasoning: false, disableTextFollowUps: true, disableMemory: true, + forceSideBySide: false, isAsyncChat: false, disableSelfHarmShortCircuit: false, + }), + }); + // Cookie valid = any non-401/403 response (200, 400, 429 all mean cookie accepted) + if (res.status === 401 || res.status === 403) { + isValid = false; + error = "Invalid SSO cookie — re-paste from grok.com DevTools → Cookies → sso"; + } else { + isValid = true; + } + break; + } + + case "perplexity-web": { + let sessionToken = apiKey; + if (sessionToken.startsWith("__Secure-next-auth.session-token=")) { + sessionToken = sessionToken.slice("__Secure-next-auth.session-token=".length); + } + const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; + const res = await fetch("https://www.perplexity.ai/rest/sse/perplexity_ask", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "X-App-ApiClient": "default", + "X-App-ApiVersion": "2.18", + Cookie: `__Secure-next-auth.session-token=${sessionToken}`, + }, + body: JSON.stringify({ + query_str: "ping", + params: { + query_str: "ping", search_focus: "internet", mode: "concise", model_preference: "pplx_pro", + sources: ["web"], attachments: [], + frontend_uuid: crypto.randomUUID(), frontend_context_uuid: crypto.randomUUID(), + version: "2.18", language: "en-US", timezone: tz, + search_recency_filter: null, is_incognito: true, use_schematized_api: true, last_backend_uuid: null, + }, + }), + }); + if (res.status === 401 || res.status === 403) { + isValid = false; + error = "Invalid session cookie — re-paste __Secure-next-auth.session-token from perplexity.ai"; + } else { + isValid = true; + } + break; + } + default: return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); } diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index dd6d4b75..da8db3e6 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -61,6 +61,7 @@ export { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS, + WEB_COOKIE_PROVIDERS, AI_PROVIDERS, AUTH_METHODS, } from "./providers.js"; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 6272c23e..6fc007cb 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -99,6 +99,12 @@ export const APIKEY_PROVIDERS = { firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", serviceKinds: ["webFetch"] }, }; +// Web Cookie Providers (use browser session cookie instead of API key) +export const WEB_COOKIE_PROVIDERS = { + "grok-web": { id: "grok-web", alias: "gw", name: "Grok Web (Subscription)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "GW", website: "https://grok.com", authType: "cookie", authHint: "Paste your sso= cookie value from grok.com", passthroughModels: true, serviceKinds: ["llm"] }, + "perplexity-web": { id: "perplexity-web", alias: "pw", name: "Perplexity Web (Pro/Max)", icon: "search", color: "#20808D", textIcon: "PW", website: "https://www.perplexity.ai", authType: "cookie", authHint: "Paste your __Secure-next-auth.session-token cookie value from perplexity.ai", serviceKinds: ["llm"] }, +}; + // Media provider kinds — each kind maps to a route and endpoint config export const MEDIA_PROVIDER_KINDS = [ { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } }, @@ -124,12 +130,13 @@ export function isAnthropicCompatibleProvider(providerId) { } // All providers (combined) -export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; +export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS, ...WEB_COOKIE_PROVIDERS }; // Auth methods export const AUTH_METHODS = { oauth: { id: "oauth", name: "OAuth", icon: "lock" }, apikey: { id: "apikey", name: "API Key", icon: "key" }, + cookie: { id: "cookie", name: "Browser Cookie", icon: "cookie" }, }; // Helper: Get provider by alias diff --git a/tests/unit/perplexity-web.test.js b/tests/unit/perplexity-web.test.js new file mode 100644 index 00000000..0b1a4158 --- /dev/null +++ b/tests/unit/perplexity-web.test.js @@ -0,0 +1,306 @@ +/** + * Unit tests for perplexity-web executor + * + * Covers: + * - Message parsing (system/user/assistant/developer, multi-part content) + * - Query building for first turn vs follow-up (session continuity) + * - Tools injection into instructions + * - Request body shape (dual query_str top-level + params.query_str is required by upstream) + * - Auth header construction (apiKey → Cookie, accessToken → Bearer) + * - Model mapping (normal + thinking) + * - Error handling (401, 429) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + parseOpenAIMessages, + buildQuery, + buildPplxRequestBody, + formatToolsHint, + PerplexityWebExecutor, +} from "../../open-sse/executors/perplexity-web.js"; + +const originalFetch = global.fetch; + +function mockPplxStream(events) { + const chunks = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(new Blob([chunks]).stream(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("parseOpenAIMessages", () => { + it("extracts system + history + current msg", () => { + const parsed = parseOpenAIMessages([ + { role: "system", content: "Be helpful" }, + { role: "user", content: "Q1" }, + { role: "assistant", content: "A1" }, + { role: "user", content: "Q2" }, + ]); + expect(parsed.systemMsg.trim()).toBe("Be helpful"); + expect(parsed.history).toEqual([ + { role: "user", content: "Q1" }, + { role: "assistant", content: "A1" }, + ]); + expect(parsed.currentMsg).toBe("Q2"); + }); + + it("treats developer role as system", () => { + const parsed = parseOpenAIMessages([ + { role: "developer", content: "Be concise" }, + { role: "user", content: "hi" }, + ]); + expect(parsed.systemMsg.trim()).toBe("Be concise"); + expect(parsed.currentMsg).toBe("hi"); + }); + + it("handles multi-part content (array of text blocks)", () => { + const parsed = parseOpenAIMessages([ + { role: "user", content: [{ type: "text", text: "part1" }, { type: "text", text: "part2" }] }, + ]); + expect(parsed.currentMsg).toBe("part1 part2"); + }); + + it("skips empty content messages", () => { + const parsed = parseOpenAIMessages([ + { role: "user", content: " " }, + { role: "user", content: "real" }, + ]); + expect(parsed.currentMsg).toBe("real"); + }); +}); + +describe("buildQuery", () => { + it("first turn: returns JSON with instructions + query", () => { + const parsed = { systemMsg: "Be helpful\n", history: [], currentMsg: "Hello" }; + const q = buildQuery(parsed, null); + const obj = JSON.parse(q); + expect(obj.query).toBe("Hello"); + expect(obj.instructions).toContain("Be helpful"); + expect(obj.instructions.some((s) => s.includes("web search"))).toBe(true); + }); + + it("follow-up (with backendUuid): returns plain currentMsg, no JSON", () => { + const parsed = { + systemMsg: "Be helpful", + history: [{ role: "user", content: "Q1" }, { role: "assistant", content: "A1" }], + currentMsg: "Follow up", + }; + const q = buildQuery(parsed, "uuid-abc-123"); + expect(q).toBe("Follow up"); + }); + + it("includes history when present on first turn", () => { + const parsed = { + systemMsg: "", + history: [{ role: "user", content: "earlier" }], + currentMsg: "now", + }; + const obj = JSON.parse(buildQuery(parsed, null)); + expect(obj.history).toEqual([{ role: "user", content: "earlier" }]); + expect(obj.query).toBe("now"); + }); + + it("injects tools into instructions on first turn", () => { + const parsed = { systemMsg: "", history: [], currentMsg: "hi" }; + const tools = [ + { function: { name: "Shell", description: "Run bash" } }, + { function: { name: "Read", description: "Read file" } }, + ]; + const obj = JSON.parse(buildQuery(parsed, null, tools)); + const hint = obj.instructions.find((s) => s.includes("Available tools")); + expect(hint).toBeDefined(); + expect(hint).toContain("- Shell: Run bash"); + expect(hint).toContain("- Read: Read file"); + }); + + it("ignores tools on follow-up turn (uses session)", () => { + const parsed = { systemMsg: "", history: [{ role: "user", content: "x" }], currentMsg: "y" }; + const tools = [{ function: { name: "Shell", description: "d" } }]; + const q = buildQuery(parsed, "uuid", tools); + expect(q).toBe("y"); + }); + + it("truncates query if JSON exceeds 96000 chars", () => { + const big = "x".repeat(100000); + const parsed = { systemMsg: big, history: [], currentMsg: "hi" }; + const q = buildQuery(parsed, null); + expect(q.length).toBeLessThanOrEqual(96000); + }); +}); + +describe("formatToolsHint", () => { + it("returns empty string for no tools", () => { + expect(formatToolsHint()).toBe(""); + expect(formatToolsHint([])).toBe(""); + }); + + it("handles OpenAI tool schema (function wrapper)", () => { + const out = formatToolsHint([{ function: { name: "Foo", description: "does foo" } }]); + expect(out).toContain("- Foo: does foo"); + }); + + it("handles flat tool schema", () => { + const out = formatToolsHint([{ name: "Bar", description: "does bar" }]); + expect(out).toContain("- Bar: does bar"); + }); + + it("truncates long descriptions to first line, max 200 chars", () => { + const longDesc = "line1\nline2\nline3"; + const out = formatToolsHint([{ function: { name: "X", description: longDesc } }]); + expect(out).toContain("- X: line1"); + expect(out).not.toContain("line2"); + }); +}); + +describe("buildPplxRequestBody", () => { + it("sets query_str at both top-level AND params (required by upstream API)", () => { + const body = buildPplxRequestBody("hello world", "concise", "pplx_pro", null); + expect(body.query_str).toBe("hello world"); + expect(body.params.query_str).toBe("hello world"); + }); + + it("includes required params", () => { + const body = buildPplxRequestBody("q", "copilot", "claude46sonnet", "uuid-xyz"); + expect(body.params.search_focus).toBe("internet"); + expect(body.params.mode).toBe("copilot"); + expect(body.params.model_preference).toBe("claude46sonnet"); + expect(body.params.sources).toEqual(["web"]); + expect(body.params.use_schematized_api).toBe(true); + expect(body.params.is_incognito).toBe(true); + expect(body.params.last_backend_uuid).toBe("uuid-xyz"); + expect(body.params.version).toBe("2.18"); + }); +}); + +describe("PerplexityWebExecutor.execute", () => { + let capturedUrl; + let capturedOpts; + let capturedBody; + + beforeEach(() => { + capturedUrl = null; + capturedOpts = null; + capturedBody = null; + global.fetch = vi.fn(async (url, opts) => { + capturedUrl = url; + capturedOpts = opts; + capturedBody = JSON.parse(opts.body); + return mockPplxStream([ + { + blocks: [{ intended_usage: "markdown", markdown_block: { chunks: ["answer"], progress: "DONE" } }], + status: "COMPLETED", + backend_uuid: "resp-uuid-1", + }, + ]); + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("maps pplx-auto → mode=concise, pref=pplx_pro", async () => { + const exec = new PerplexityWebExecutor(); + await exec.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: { apiKey: "cookie-abc" }, + }); + expect(capturedBody.params.mode).toBe("concise"); + expect(capturedBody.params.model_preference).toBe("pplx_pro"); + }); + + it("applies THINKING_MAP when reasoning_effort is set", async () => { + const exec = new PerplexityWebExecutor(); + await exec.execute({ + model: "pplx-opus", + body: { messages: [{ role: "user", content: "hi" }], stream: false, reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: "cookie-abc" }, + }); + expect(capturedBody.params.mode).toBe("copilot"); + expect(capturedBody.params.model_preference).toBe("claude46opusthinking"); + }); + + it("sends Cookie header when credentials.apiKey provided", async () => { + const exec = new PerplexityWebExecutor(); + await exec.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: { apiKey: "my-session-token" }, + }); + expect(capturedOpts.headers.Cookie).toBe("__Secure-next-auth.session-token=my-session-token"); + expect(capturedOpts.headers.Authorization).toBeUndefined(); + }); + + it("sends Bearer header when credentials.accessToken provided", async () => { + const exec = new PerplexityWebExecutor(); + await exec.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: { accessToken: "tok-1" }, + }); + expect(capturedOpts.headers.Authorization).toBe("Bearer tok-1"); + }); + + it("injects body.tools into query_str instructions", async () => { + const exec = new PerplexityWebExecutor(); + await exec.execute({ + model: "pplx-auto", + body: { + messages: [{ role: "user", content: "what tools do you have?" }], + tools: [{ function: { name: "Shell", description: "Execute commands" } }], + stream: false, + }, + stream: false, + credentials: { apiKey: "c" }, + }); + const queryObj = JSON.parse(capturedBody.query_str); + const toolsHint = queryObj.instructions.find((s) => s.includes("Available tools")); + expect(toolsHint).toContain("- Shell: Execute commands"); + }); + + it("returns 400 on missing messages", async () => { + const exec = new PerplexityWebExecutor(); + const { response } = await exec.execute({ + model: "pplx-auto", + body: {}, + stream: false, + credentials: { apiKey: "c" }, + }); + expect(response.status).toBe(400); + }); + + it("surfaces upstream 401 with friendly auth message", async () => { + global.fetch = vi.fn(async () => new Response(JSON.stringify({ error: "bad" }), { status: 401 })); + const exec = new PerplexityWebExecutor(); + const { response } = await exec.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "bad-cookie" }, + }); + expect(response.status).toBe(401); + const j = await response.json(); + expect(j.error.message).toMatch(/auth failed|expired/i); + }); + + it("surfaces 429 with rate-limit message", async () => { + global.fetch = vi.fn(async () => new Response("", { status: 429 })); + const exec = new PerplexityWebExecutor(); + const { response } = await exec.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "c" }, + }); + expect(response.status).toBe(429); + const j = await response.json(); + expect(j.error.message).toMatch(/rate limited/i); + }); +}); diff --git a/tests/unit/web-cookie-validation.test.js b/tests/unit/web-cookie-validation.test.js new file mode 100644 index 00000000..960f5094 --- /dev/null +++ b/tests/unit/web-cookie-validation.test.js @@ -0,0 +1,187 @@ +/** + * Unit tests for grok-web & perplexity-web cookie validation logic + * + * Covers: + * - Cookie prefix stripping (sso=, __Secure-next-auth.session-token=) + * - 401/403 → invalid with error message + * - Non-auth responses (200, 400, 429) → valid (Cloudflare-bypass probe) + * - Required browser-fingerprint headers sent to Grok + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; + +// Replicates the validation logic from app/src/app/api/providers/validate/route.js +async function validateGrokWeb(apiKey) { + const token = apiKey.startsWith("sso=") ? apiKey.slice(4) : apiKey; + const randomHex = (n) => { + const a = new Uint8Array(n); + crypto.getRandomValues(a); + return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join(""); + }; + const statsigId = Buffer.from("e:TypeError: Cannot read properties of null (reading 'children')").toString("base64"); + const traceId = randomHex(16); + const spanId = randomHex(8); + const res = await fetch("https://grok.com/rest/app-chat/conversations/new", { + method: "POST", + headers: { + Accept: "*/*", + "Content-Type": "application/json", + Cookie: `sso=${token}`, + Origin: "https://grok.com", + Referer: "https://grok.com/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "x-statsig-id": statsigId, + "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${traceId}-${spanId}-00`, + }, + body: JSON.stringify({ temporary: true, modelName: "grok-4", message: "ping" }), + }); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid SSO cookie — re-paste from grok.com DevTools → Cookies → sso" }; + } + return { valid: true, error: null }; +} + +async function validatePerplexityWeb(apiKey) { + let sessionToken = apiKey; + if (sessionToken.startsWith("__Secure-next-auth.session-token=")) { + sessionToken = sessionToken.slice("__Secure-next-auth.session-token=".length); + } + const res = await fetch("https://www.perplexity.ai/rest/sse/perplexity_ask", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + Cookie: `__Secure-next-auth.session-token=${sessionToken}`, + }, + body: JSON.stringify({ query_str: "ping" }), + }); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid session cookie — re-paste __Secure-next-auth.session-token from perplexity.ai" }; + } + return { valid: true, error: null }; +} + +describe("grok-web validation", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => { global.fetch = originalFetch; }); + + it("should return valid:true when response is 200", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + const result = await validateGrokWeb("test-token"); + expect(result.valid).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return valid:true when response is 400 (auth accepted but bad body)", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 400 }); + const result = await validateGrokWeb("test-token"); + expect(result.valid).toBe(true); + }); + + it("should return valid:true when response is 429 (rate limited but auth ok)", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 429 }); + const result = await validateGrokWeb("test-token"); + expect(result.valid).toBe(true); + }); + + it("should return valid:false with error when response is 401", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 401 }); + const result = await validateGrokWeb("bad-token"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Invalid SSO cookie"); + }); + + it("should return valid:false with error when response is 403", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 403 }); + const result = await validateGrokWeb("bad-token"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Invalid SSO cookie"); + }); + + it("should strip sso= prefix from apiKey", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validateGrokWeb("sso=abc123"); + const callArgs = global.fetch.mock.calls[0][1]; + expect(callArgs.headers.Cookie).toBe("sso=abc123"); + }); + + it("should accept raw token without sso= prefix", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validateGrokWeb("abc123"); + const callArgs = global.fetch.mock.calls[0][1]; + expect(callArgs.headers.Cookie).toBe("sso=abc123"); + }); + + it("should POST to /rest/app-chat/conversations/new", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validateGrokWeb("token"); + expect(global.fetch).toHaveBeenCalledWith( + "https://grok.com/rest/app-chat/conversations/new", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("should send Cloudflare-bypass headers", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validateGrokWeb("token"); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.Origin).toBe("https://grok.com"); + expect(headers.Referer).toBe("https://grok.com/"); + expect(headers["User-Agent"]).toContain("Chrome"); + expect(headers["x-statsig-id"]).toBeTruthy(); + expect(headers["x-xai-request-id"]).toBeTruthy(); + expect(headers.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-00$/); + }); +}); + +describe("perplexity-web validation", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => { global.fetch = originalFetch; }); + + it("should return valid:true when response is 200", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + const result = await validatePerplexityWeb("test-token"); + expect(result.valid).toBe(true); + }); + + it("should return valid:false when response is 401", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 401 }); + const result = await validatePerplexityWeb("bad-token"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Invalid session cookie"); + }); + + it("should return valid:false when response is 403", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 403 }); + const result = await validatePerplexityWeb("bad-token"); + expect(result.valid).toBe(false); + }); + + it("should strip __Secure-next-auth.session-token= prefix", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validatePerplexityWeb("__Secure-next-auth.session-token=xyz789"); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.Cookie).toBe("__Secure-next-auth.session-token=xyz789"); + }); + + it("should accept raw token without prefix", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validatePerplexityWeb("xyz789"); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.Cookie).toBe("__Secure-next-auth.session-token=xyz789"); + }); + + it("should POST to /rest/sse/perplexity_ask", async () => { + global.fetch = vi.fn().mockResolvedValue({ status: 200 }); + await validatePerplexityWeb("token"); + expect(global.fetch).toHaveBeenCalledWith( + "https://www.perplexity.ai/rest/sse/perplexity_ask", + expect.objectContaining({ method: "POST" }), + ); + }); +});