diff --git a/src/lib/ai-proxy.ts b/src/lib/ai-proxy.ts index b05e925..84b15e6 100644 --- a/src/lib/ai-proxy.ts +++ b/src/lib/ai-proxy.ts @@ -15,6 +15,8 @@ import type { ProxyPool, SessionProxyPool } from "./proxy-pool"; import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, type FetchWithRetryResult } from "./fetch-utils"; import { getJwt, invalidateJwt } from "./mimo-auth"; +import * as aichatAuth from "./aichat-auth"; + // --- Types ------------------------------------------------------------------- @@ -46,6 +48,38 @@ export interface BackendConfig { adaptStreamLine?: (line: string, req: OpenAIRequest) => string | null; } +// --- Shared aichat.org backend config (all models use the same backend) ------ + +/** Shared backend config for all aichat.org model routes. */ +const aichatConfig: BackendConfig = { + provider: "aichat", + url: "https://aichat.org/api/chat", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Referer: "https://aichat.org/chat", + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + }, + adaptRequest: (req: OpenAIRequest) => ({ + model: req.model, + messages: req.messages, + }), +}; + +/** All aichat.org model IDs discovered from the chat UI. */ +export const AICHAT_MODELS: readonly string[] = [ + "deepseek/deepseek-v4-flash", + "openai/gpt-4o-mini", + "anthropic/claude-haiku-4-5", + "google/gemini-2.0-flash-001", + "x-ai/grok-3-mini-beta", + "deepseek/deepseek-chat-v3-0324", + "qwen/qwen-2.5-72b-instruct", + "moonshotai/moonlight-16k", + "perplexity/sonar", +]; + // --- Model routing table ------------------------------------------------------- /** Map of model name -> backend configuration. */ @@ -121,65 +155,19 @@ export const MODEL_ROUTES: Record = { }), }, - // -- deep-seek.ai (custom format) -------------------------------------------- - "deepseek/deepseek-v4-flash": { - provider: "deepseek", - url: "https://deep-seek.ai/api/chat", - headers: { - Accept: "*/*", - "Accept-Language": "en-US,en;q=0.8", - "Content-Type": "application/json", - Origin: "https://deep-seek.ai", - Referer: "https://deep-seek.ai/chat", - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", - }, - adaptStreamLine: (line) => { - if (!line || line.trim().length === 0) return null; - if (line.startsWith("data: ")) { - try { - const parsed = JSON.parse(line.slice(6)); - parsed.id = `chatcmpl-${Date.now()}`; - parsed.object = "chat.completion.chunk"; - parsed.created = Math.floor(Date.now() / 1000); - parsed.model = "deepseek/deepseek-v4-flash"; - return `data: ${JSON.stringify(parsed)}`; - } catch { - return line; - } - } - return `data: ${JSON.stringify({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: "deepseek/deepseek-v4-flash", - choices: [ - { - index: 0, - delta: { content: line }, - finish_reason: null, - }, - ], - })}`; - }, - adaptResponse: (raw: any) => ({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: "deepseek/deepseek-v4-flash", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "", - }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }), - }, + // -- aichat.org (OpenAI-compatible, relay via session auth) ------------------ + // All models share the same backend config. aichat.org's /api/chat + // proxies to OpenRouter internally and accepts any OpenRouter model ID. + + "deepseek/deepseek-v4-flash": aichatConfig, + "openai/gpt-4o-mini": aichatConfig, + "anthropic/claude-haiku-4-5": aichatConfig, + "google/gemini-2.0-flash-001": aichatConfig, + "x-ai/grok-3-mini-beta": aichatConfig, + "deepseek/deepseek-chat-v3-0324": aichatConfig, + "qwen/qwen-2.5-72b-instruct": aichatConfig, + "moonshotai/moonlight-16k": aichatConfig, + "perplexity/sonar": aichatConfig, // -- Xiaomi MiMo Free (OpenAI-compatible, JWT bootstrap auth) ----------------- "mimo-auto": { @@ -419,6 +407,16 @@ export async function handleChatCompletion( }; } + // -- aichat.org: inject session cookies + CSRF header ---------------------- + if (config.provider === "aichat") { + const aichat = await aichatAuth.getAichatSession(); + init.headers = { + ...init.headers, + Cookie: aichat.cookies, + "X-CSRF-TOKEN": aichat.csrfToken, + }; + } + // -- Execute with session-aware or standard retry -------------------------- let result: FetchWithRetryResult = sessionPool && sessionId @@ -443,6 +441,25 @@ export async function handleChatCompletion( : await fetchWithRetry(url, init, proxyPool, `openai:${req.model}`); } + // -- aichat.org: session expiry → invalidate session and retry once --------- + if ( + config.provider === "aichat" && + result.response && + result.response.status === 401 + ) { + aichatAuth.invalidateAichatSession(); + const aichat = await aichatAuth.getAichatSession(); + init.headers = { + ...init.headers, + Cookie: aichat.cookies, + "X-CSRF-TOKEN": aichat.csrfToken, + }; + result = + sessionPool && sessionId + ? await fetchWithSessionRetry(url, init, sessionPool, sessionId, `openai:${req.model}`) + : await fetchWithRetry(url, init, proxyPool, `openai:${req.model}`); + } + if (result.errorClassification) { return new Response( JSON.stringify({ @@ -463,6 +480,11 @@ export async function handleChatCompletion( const response = result.response!; + // -- aichat.org: refresh session cookies from every response ----------------- + if (config.provider === "aichat") { + aichatAuth.updateAichatSessionFromResponse(response); + } + // -- Handle error responses from backend ------------------------------------ if (!response.ok) { const status = response.status; @@ -475,7 +497,7 @@ export async function handleChatCompletion( const contentType = response.headers.get("content-type") ?? ""; const isNativeStream = contentType.includes("text/event-stream"); - if (isNativeStream && (config.provider === "opencode" || config.provider === "mimo-free")) { + if (isNativeStream && (config.provider === "opencode" || config.provider === "aichat" || config.provider === "mimo-free")) { // Passthrough for OpenAI-compatible SSE const headers: Record = { "Content-Type": "text/event-stream", diff --git a/src/lib/aichat-auth.ts b/src/lib/aichat-auth.ts new file mode 100644 index 0000000..40e36bc --- /dev/null +++ b/src/lib/aichat-auth.ts @@ -0,0 +1,160 @@ +/** + * aichat.org — session & CSRF token manager. + * + * aichat.org uses Laravel-style session cookies (XSRF-TOKEN + ai_chat_session) + * with a CSRF double-submit pattern. The session expires after 2 hours. + * + * Flow: + * 1. GET https://aichat.org/chat → extract CSRF from meta tag + capture cookies + * 2. Cache cookies + CSRF for subsequent API requests + * 3. On 401 → invalidate session, re-bootstrap, retry once + */ + +const AICHAT_CHAT_URL = "https://aichat.org/chat"; +const SESSION_REFRESH_MS = 3_600_000; // refresh every hour (session lasts 2h) +const BOOTSTRAP_MAX_RETRIES = 3; +const BOOTSTRAP_BASE_DELAY_MS = 1_000; // 1s, 2s, 4s + +interface AichatSession { + cookies: string; // "XSRF-TOKEN=...; ai_chat_session=..." + csrfToken: string; // value of + fetchedAt: number; // epoch ms +} + +// --- Module-level cache ------------------------------------------------------ + +let session: AichatSession | null = null; + +// --- Session bootstrap ------------------------------------------------------ + +/** + * Fetch the chat page, parse the CSRF token, and capture session cookies. + * + * Retries up to BOOTSTRAP_MAX_RETRIES times with exponential backoff on + * network errors or non-2xx responses. This prevents transient failures + * (aichat.org temporarily down, network blip) from becoming user-visible errors. + */ +async function bootstrapSession(attempt = 1): Promise { + const resp = await fetch(AICHAT_CHAT_URL, { + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + }, + }); + + if (!resp.ok) { + if (attempt < BOOTSTRAP_MAX_RETRIES) { + const delay = BOOTSTRAP_BASE_DELAY_MS * Math.pow(2, attempt - 1); + await new Promise((r) => setTimeout(r, delay)); + return bootstrapSession(attempt + 1); + } + throw new Error(`aichat.org session bootstrap failed (after ${BOOTSTRAP_MAX_RETRIES} attempts): ${resp.status}`); + } + + const html = await resp.text(); + + // Extract CSRF token from meta tag + const csrfMatch = html.match( + / setTimeout(r, delay)); + return bootstrapSession(attempt + 1); + } + throw new Error(`aichat.org: CSRF token not found in bootstrap response (after ${BOOTSTRAP_MAX_RETRIES} attempts)`); + } + const csrfToken = csrfMatch[1]; + + // Capture Set-Cookie headers + const cookieParts: string[] = []; + for (const [key, val] of resp.headers.entries()) { + if (key.toLowerCase() === "set-cookie") { + cookieParts.push(val.split(";")[0]!); + } + } + + if ( + cookieParts.length === 0 || + (!cookieParts.some((c) => c.startsWith("XSRF-TOKEN")) && + !cookieParts.some((c) => c.startsWith("ai_chat_session"))) + ) { + cookieParts.push(`XSRF-TOKEN=${encodeURIComponent(csrfToken)}`); + } + + session = { + cookies: cookieParts.join("; "), + csrfToken, + fetchedAt: Date.now(), + }; + + return session; +} + +// --- Public API -------------------------------------------------------------- + +/** + * Get the current session's cookies + CSRF token. + * + * Automatically refreshes the session if it is stale (fetched > 1h ago). + */ +export async function getAichatSession(): Promise<{ + cookies: string; + csrfToken: string; +}> { + if (session && Date.now() - session.fetchedAt < SESSION_REFRESH_MS) { + return { cookies: session.cookies, csrfToken: session.csrfToken }; + } + const fresh = await bootstrapSession(); + return { cookies: fresh.cookies, csrfToken: fresh.csrfToken }; +} + +/** + * Update the session from API response headers. + * + * aichat.org sends new Set-Cookie on every response — call this after + * a successful API call so the cached session stays fresh. + */ +export function updateAichatSessionFromResponse(response: Response): void { + if (!session) return; + + const cookieParts: string[] = []; + for (const [key, val] of response.headers.entries()) { + if (key.toLowerCase() === "set-cookie") { + cookieParts.push(val.split(";")[0]!); + } + } + + if (cookieParts.length === 0) return; + + const newXsrf = cookieParts.find((c) => c.startsWith("XSRF-TOKEN=")); + const newSession = cookieParts.find((c) => c.startsWith("ai_chat_session=")); + + if (newXsrf || newSession) { + // Merge updated cookies — keep the other one if missing + const oldParts = session.cookies.split("; ").filter(Boolean); + const keepXsrf = !newXsrf ? oldParts.find((c) => c.startsWith("XSRF-TOKEN=")) : undefined; + const keepSess = !newSession ? oldParts.find((c) => c.startsWith("ai_chat_session=")) : undefined; + + session.cookies = [newXsrf ?? keepXsrf, newSession ?? keepSess] + .filter(Boolean) + .join("; "); + + // XSRF-TOKEN in cookie is the encrypted value, CSRF meta is the raw. + // These differ (Laravel encrypts the cookie). Only update CSRF from + // bootstrap, not from response cookies — the meta tag value stays valid + // as long as the session is alive. + session.fetchedAt = Date.now(); + } +} + +/** + * Invalidate the cached session. + * + * Call after receiving a 401 from aichat.org so the next request + * bootstraps a fresh session. + */ +export function invalidateAichatSession(): void { + session = null; +}