diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 4c014a20ff..b985a28760 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -3351,6 +3351,231 @@ async function validateAdaptaWebProvider({ apiKey, providerSpecificData = {} }: } } +async function validateClaudeWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const cookieHeader = normalizeSessionCookieHeader(String(apiKey || ""), "sessionKey"); + if (!cookieHeader) { + return { valid: false, error: "Paste your sessionKey cookie from claude.ai" }; + } + + const { tlsFetchClaude, TlsClientUnavailableError } = await import( + "@omniroute/open-sse/services/claudeTlsClient.ts" + ); + + let response: { status: number; text: string | null }; + try { + response = await tlsFetchClaude("https://claude.ai/api/organizations", { + method: "GET", + headers: applyCustomUserAgent( + { + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Cookie: cookieHeader, + Origin: "https://claude.ai", + Pragma: "no-cache", + Referer: "https://claude.ai/new", + "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/120.0.0.0 Safari/537.36", + "anthropic-client-platform": "web_claude_ai", + }, + providerSpecificData + ), + timeoutMs: 30_000, + }); + } catch (err: any) { + if (err instanceof TlsClientUnavailableError) { + return { + valid: false, + error: `${err.message} (claude-web requires this — without it, Cloudflare blocks every request)`, + }; + } + throw err; + } + + if (response.status === 200) { + return { valid: true, error: null }; + } + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: "Invalid or expired session cookie — re-paste sessionKey from claude.ai DevTools → Cookies", + }; + } + + if (response.status === 429) { + return { valid: true, error: null }; + } + + if (response.status >= 500) { + return { valid: false, error: `Claude.ai unavailable (${response.status})` }; + } + + return { valid: false, error: `Claude.ai validation failed (${response.status})` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// ── Gemini Web cookie validator ── +async function validateGeminiWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const raw = String(apiKey || "").trim(); + if (!raw) { + return { valid: false, error: "Paste your __Secure-1PSID cookie from gemini.google.com" }; + } + + // Accept full cookie blob or bare value + let cookieHeader = raw; + if (!raw.includes("=")) { + cookieHeader = `__Secure-1PSID=${raw}`; + } + + const response = await validationRead("https://gemini.google.com/app", { + headers: applyCustomUserAgent( + { + Accept: "text/html,application/xhtml+xml", + Cookie: cookieHeader, + Origin: "https://gemini.google.com", + Referer: "https://gemini.google.com/", + }, + providerSpecificData + ), + }); + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: "Invalid or expired __Secure-1PSID cookie — re-paste from gemini.google.com DevTools → Cookies", + }; + } + + // 200/302 = valid, anything < 500 that isn't auth failure is acceptable + if (response.status < 500) { + return { valid: true, error: null }; + } + + return { valid: false, error: `Gemini validation failed (${response.status})` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// ── Copilot Web token validator ── +async function validateCopilotWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const raw = String(apiKey || "").trim(); + if (!raw) { + return { + valid: false, + error: "Paste your access_token from copilot.microsoft.com DevTools → Cookies", + }; + } + + // Extract token — may be bare JWT, cookie string with access_token=, or Bearer prefix + const { extractAccessToken } = await import("@omniroute/open-sse/executors/copilot-web.ts"); + const token = extractAccessToken(raw); + if (!token) { + return { valid: false, error: "Could not extract access_token from input" }; + } + + // Probe Copilot's conversation API to verify token + const response = await validationWrite( + "https://copilot.microsoft.com/c/api/conversations?language=en", + { + method: "GET", + headers: applyCustomUserAgent( + { + Accept: "application/json", + Authorization: `Bearer ${token}`, + Origin: "https://copilot.microsoft.com", + Referer: "https://copilot.microsoft.com/", + }, + providerSpecificData + ), + } + ); + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: "Invalid or expired access_token — re-paste from copilot.microsoft.com DevTools → Cookies", + }; + } + + if (response.status >= 500) { + return { valid: false, error: `Copilot unavailable (${response.status})` }; + } + + // 200, 400, 404 etc. all indicate the token was accepted + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// ── t3.chat Web cookie validator ── +async function validateT3WebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const raw = String(apiKey || "").trim(); + if (!raw) { + return { + valid: false, + error: "Paste your Cookie header and convex-session-id from t3.chat", + }; + } + + // The cookie field may contain "cookies=\nconvexSessionId=" + // or just the Cookie header value. Try to parse. + let cookieHeader = raw; + let convexSessionId = ""; + + if (raw.includes("convexSessionId") || raw.includes("convex-session-id")) { + // Structured format: "cookies=...; convexSessionId=..." + const parts = raw.split(/[,;\n]/).map((s: string) => s.trim()); + const cookieParts: string[] = []; + for (const part of parts) { + if (part.startsWith("convexSessionId=") || part.startsWith("convex-session-id=")) { + convexSessionId = part.split("=").slice(1).join("="); + } else if (part.startsWith("cookies=")) { + cookieParts.push(part.slice("cookies=".length)); + } else if (part.includes("=")) { + cookieParts.push(part); + } + } + if (cookieParts.length) cookieHeader = cookieParts.join("; "); + } + + // Build final cookie with convex-session-id if found + const finalCookie = convexSessionId + ? `${cookieHeader}; convex-session-id=${convexSessionId}` + : cookieHeader; + + const response = await validationRead("https://t3.chat", { + headers: applyCustomUserAgent( + { + Accept: "text/html", + Cookie: finalCookie, + }, + providerSpecificData + ), + }); + + // t3.chat returns 200/302/404 for valid sessions, 5xx for down + if (response.status >= 500) { + return { valid: false, error: `t3.chat unavailable (${response.status})` }; + } + + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + /** Jules API — GET /v1alpha/sources with X-Goog-Api-Key (see developers.google.com/jules/api). */ async function validateJulesProvider({ apiKey }: { apiKey: string }) { try { @@ -3550,6 +3775,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "muse-spark-web": validateMuseSparkWebProvider, "inner-ai": validateInnerAiProvider, "adapta-web": validateAdaptaWebProvider, + "claude-web": validateClaudeWebProvider, + "gemini-web": validateGeminiWebProvider, + "copilot-web": validateCopilotWebProvider, + "t3-web": validateT3WebProvider, "azure-openai": validateAzureOpenAIProvider, "azure-ai": validateAzureAiProvider, "voyage-ai": ({ apiKey, providerSpecificData }: any) => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 130534c3c8..d5d8ddc67c 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2033,6 +2033,309 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a }); }); +// ─── claude-web validator ──────────────────────────────────────────────────── + +const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } = + await import("../../open-sse/services/claudeTlsClient.ts"); + +function makeClaudeTlsResponse(status: number, body: string, headers: Record = {}): any { + const h = new Headers(); + for (const [k, v] of Object.entries(headers)) h.set(k, v); + return { status, ok: status >= 200 && status < 300, headers: h, text: body, body: null }; +} + +test("claude-web validator: 200 from /api/organizations → valid", async () => { + let captured: { url: string; opts: any } | null = null; + __setClaudeTlsFetchOverride(async (url, opts) => { + captured = { url, opts }; + return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] })); + }); + + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=sk-ant-sid02-test-session-key", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(captured?.url, "https://claude.ai/api/organizations"); + assert.match((captured?.opts.headers as Record).Cookie || "", /sessionKey=sk-ant-sid02-test-session-key/); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: full cookie blob passes through verbatim", async () => { + let capturedCookie = ""; + __setClaudeTlsFetchOverride(async (_url, opts) => { + capturedCookie = (opts.headers as Record).Cookie || ""; + return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] })); + }); + + const blob = + "__cf_bm=abc123; sessionKey=sk-ant-sid02-test; intercom-device-id-lupk8zyo=xyz; __stripe_mid=stripe123"; + await validateProviderApiKey({ provider: "claude-web", apiKey: blob }); + assert.equal(capturedCookie, blob); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: 401 → invalid session cookie", async () => { + __setClaudeTlsFetchOverride(async () => + makeClaudeTlsResponse(401, JSON.stringify({ error: "unauthorized" })) + ); + + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=expired-key", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid or expired session cookie/i); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: 429 → valid (rate limited means auth passed)", async () => { + __setClaudeTlsFetchOverride(async () => + makeClaudeTlsResponse(429, JSON.stringify({ error: "rate limited" })) + ); + + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=sk-ant-sid02-good-key", + }); + + assert.equal(result.valid, true); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: 500 → Claude.ai unavailable", async () => { + __setClaudeTlsFetchOverride(async () => + makeClaudeTlsResponse(500, "internal server error") + ); + + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=sk-ant-sid02-any-key", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Claude\.ai unavailable \(500\)/i); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: TLS client unavailable → clear error", async () => { + const { TlsClientUnavailableError } = await import("../../open-sse/services/claudeTlsClient.ts"); + __setClaudeTlsFetchOverride(async () => { + throw new TlsClientUnavailableError("tls-client-node not installed"); + }); + + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=sk-ant-sid02-any-key", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /tls-client-node not installed/i); + __setClaudeTlsFetchOverride(null); +}); + +test("claude-web validator: bare sessionKey value gets prefixed", async () => { + let capturedCookie = ""; + __setClaudeTlsFetchOverride(async (_url, opts) => { + capturedCookie = (opts.headers as Record).Cookie || ""; + return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] })); + }); + + await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sk-ant-sid02-bare-value", + }); + assert.equal(capturedCookie, "sessionKey=sk-ant-sid02-bare-value"); + __setClaudeTlsFetchOverride(null); +}); + +// ─── gemini-web validator ──────────────────────────────────────────────────── + +test("gemini-web validator: 200 from gemini.google.com → valid", async () => { + globalThis.fetch = async (url, init = {}) => { + const target = String(url); + const headers = init.headers || {}; + if (target.includes("gemini.google.com/app")) { + assert.match((headers as Record).Cookie || "", /__Secure-1PSID=eyJPSID/); + return new Response("ok", { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }; + + const result = await validateProviderApiKey({ + provider: "gemini-web", + apiKey: "__Secure-1PSID=eyJPSID", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("gemini-web validator: bare value gets __Secure-1PSID prefix", async () => { + let capturedCookie = ""; + globalThis.fetch = async (url, init = {}) => { + if (String(url).includes("gemini.google.com")) { + capturedCookie = ((init.headers as Record) || {}).Cookie || ""; + return new Response("ok", { status: 200 }); + } + throw new Error(`unexpected fetch: ${String(url)}`); + }; + + await validateProviderApiKey({ provider: "gemini-web", apiKey: "eyJbarevalue" }); + assert.equal(capturedCookie, "__Secure-1PSID=eyJbarevalue"); +}); + +test("gemini-web validator: 401 → invalid cookie", async () => { + globalThis.fetch = async () => new Response("unauthorized", { status: 401 }); + + const result = await validateProviderApiKey({ + provider: "gemini-web", + apiKey: "__Secure-1PSID=expired", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid or expired __Secure-1PSID/i); +}); + +test("gemini-web validator: 500 → unavailable", async () => { + globalThis.fetch = async () => new Response("down", { status: 500 }); + + const result = await validateProviderApiKey({ + provider: "gemini-web", + apiKey: "__Secure-1PSID=eyJany", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Gemini validation failed \(500\)/i); +}); + +// ─── copilot-web validator ─────────────────────────────────────────────────── + +test("copilot-web validator: valid access_token → 200", async () => { + globalThis.fetch = async (url, init = {}) => { + const target = String(url); + if (target.includes("copilot.microsoft.com/c/api/conversations")) { + assert.match( + ((init.headers as Record) || {}).Authorization || "", + /Bearer eyJhbGci/ + ); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }; + + const result = await validateProviderApiKey({ + provider: "copilot-web", + apiKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("copilot-web validator: cookie with access_token= is extracted", async () => { + let capturedAuth = ""; + globalThis.fetch = async (url, init = {}) => { + if (String(url).includes("copilot.microsoft.com")) { + capturedAuth = ((init.headers as Record) || {}).Authorization || ""; + return new Response(JSON.stringify({}), { status: 200 }); + } + throw new Error(`unexpected fetch: ${String(url)}`); + }; + + await validateProviderApiKey({ + provider: "copilot-web", + apiKey: "access_token=eyJhbGciOiJIUzI1NiJ9.payload.sig; other_cookie=foo", + }); + assert.equal(capturedAuth, "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"); +}); + +test("copilot-web validator: 401 → invalid token", async () => { + globalThis.fetch = async () => new Response("unauthorized", { status: 401 }); + + const result = await validateProviderApiKey({ + provider: "copilot-web", + apiKey: "bad-token", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid or expired access_token/i); +}); + +test("copilot-web validator: 500 → unavailable", async () => { + globalThis.fetch = async () => new Response("down", { status: 500 }); + + const result = await validateProviderApiKey({ + provider: "copilot-web", + apiKey: "any-token", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Copilot unavailable \(500\)/i); +}); + +test("copilot-web validator: empty input → paste prompt", async () => { + globalThis.fetch = async () => { + throw new Error("should not fetch"); + }; + + const result = await validateProviderApiKey({ provider: "copilot-web", apiKey: "" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Paste your access_token/i); +}); + +// ─── t3-web validator ──────────────────────────────────────────────────────── + +test("t3-web validator: valid cookies → valid", async () => { + globalThis.fetch = async (url, init = {}) => { + if (String(url).includes("t3.chat")) { + return new Response("ok", { status: 200 }); + } + throw new Error(`unexpected fetch: ${String(url)}`); + }; + + const result = await validateProviderApiKey({ + provider: "t3-web", + apiKey: "cookies=__session=abc123; convexSessionId=def456", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("t3-web validator: 500 → unavailable", async () => { + globalThis.fetch = async () => new Response("down", { status: 500 }); + + const result = await validateProviderApiKey({ + provider: "t3-web", + apiKey: "cookies=__session=abc", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /t3\.chat unavailable \(500\)/i); +}); + +test("t3-web validator: valid cookies → passes through", async () => { + globalThis.fetch = async (url, init = {}) => { + if (String(url).includes("t3.chat")) { + return new Response("ok", { status: 200 }); + } + throw new Error(`unexpected fetch: ${String(url)}`); + }; + + const result = await validateProviderApiKey({ + provider: "t3-web", + apiKey: "__session=abc123; convex-session-id=def456", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + test("llama-cpp is classified as a self-hosted chat provider", async () => { const { isSelfHostedChatProvider, isLocalProvider, providerAllowsOptionalApiKey } = await import("../../src/shared/constants/providers.ts");