feat: add support for Grok Web and Perplexity Web providers
This commit is contained in:
@@ -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 (
|
||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} API Key`} onClose={onClose}>
|
||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} ${credentialLabel}`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
@@ -114,10 +119,11 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
{!isOllamaLocal && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
label={credentialLabel}
|
||||
type={isCookie ? "text" : "password"}
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder={credentialPlaceholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
@@ -127,6 +133,19 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCookie && authHint && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{authHint}
|
||||
{website && (
|
||||
<>
|
||||
{" "}
|
||||
<a href={website} target="_blank" rel="noopener noreferrer" className="text-primary underline">
|
||||
Open {website.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isOllamaLocal && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Leave blank to use <code>http://localhost:11434</code>. For remote Ollama, enter the full host URL (e.g. <code>http://192.168.1.10:11434</code>).
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web Cookie Providers — use browser subscription cookie instead of API key */}
|
||||
{/* <div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
Web Cookie Providers{" "}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(WEB_COOKIE_PROVIDERS).map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export {
|
||||
FREE_PROVIDERS,
|
||||
OAUTH_PROVIDERS,
|
||||
APIKEY_PROVIDERS,
|
||||
WEB_COOKIE_PROVIDERS,
|
||||
AI_PROVIDERS,
|
||||
AUTH_METHODS,
|
||||
} from "./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
|
||||
|
||||
Reference in New Issue
Block a user