diff --git a/changelog.d/fixes/10329-zai-web-auth-semantics.md b/changelog.d/fixes/10329-zai-web-auth-semantics.md new file mode 100644 index 0000000000..c4e6703112 --- /dev/null +++ b/changelog.d/fixes/10329-zai-web-auth-semantics.md @@ -0,0 +1 @@ +- **fix(providers):** validate Z.ai web Local Storage sessions against the authenticated user-settings endpoint and preserve exact upstream status codes ([#10329](https://github.com/diegosouzapw/OmniRoute/pull/10329)) — thanks @Zartharas diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2d05ece895..5efff93cb9 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -81,6 +81,7 @@ import { validatePoeProvider, } from "./validation/audioMiscProviders"; import { validateChatGptWebCodexProvider } from "./validation/chatgptWebCodex"; +import { validateZaiWebProvider } from "./validation/zaiWeb"; import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders"; import { validateClarifaiProvider, @@ -227,7 +228,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi error: "Modal requires a Base URL pointing to your OpenAI-compatible Modal app " + "(e.g. https://--.modal.run/v1). " + - "Fill in the \"Base URL override\" field.", + 'Fill in the "Base URL override" field.', }; } return validateOpenAILikeProvider({ @@ -249,6 +250,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi snowflake: validateSnowflakeProvider, gigachat: validateGigachatProvider, "deepseek-web": validateDeepSeekWebProvider, + "zai-web": validateZaiWebProvider, "grok-web": validateGrokWebProvider, "qwen-web": validateQwenWebProvider, "kimi-web": validateKimiWebProvider, diff --git a/src/lib/providers/validation/zaiWeb.ts b/src/lib/providers/validation/zaiWeb.ts new file mode 100644 index 0000000000..6d151d4130 --- /dev/null +++ b/src/lib/providers/validation/zaiWeb.ts @@ -0,0 +1,52 @@ +import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts"; +import { toValidationErrorResult, validationRead } from "./transport"; + +const ZAI_SESSION_PROBE_URL = "https://chat.z.ai/api/v1/users/user/settings"; + +export async function validateZaiWebProvider({ apiKey }: { apiKey?: string }) { + const token = extractZaiToken(String(apiKey || "")); + + if (!token) { + return { + valid: false, + error: + 'Invalid Z.ai web-session credential — copy the "token" value from chat.z.ai Local Storage.', + }; + } + + try { + const response = await validationRead(ZAI_SESSION_PROBE_URL, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + Authorization: `Bearer ${token}`, + Origin: "https://chat.z.ai", + Referer: "https://chat.z.ai/", + }, + }); + + if (response.status >= 200 && response.status < 300) { + return { + valid: true, + error: null, + }; + } + + if (response.status === 401) { + return { + valid: false, + error: + 'Invalid or expired Z.ai web-session credential — copy a fresh "token" value from chat.z.ai Local Storage.', + statusCode: 401, + }; + } + + return { + valid: false, + error: `Z.ai session validation returned HTTP ${response.status}`, + statusCode: response.status, + }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} diff --git a/tests/unit/zai-web-auth-semantics.test.ts b/tests/unit/zai-web-auth-semantics.test.ts new file mode 100644 index 0000000000..ff0561f718 --- /dev/null +++ b/tests/unit/zai-web-auth-semantics.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const originalFetch = globalThis.fetch; + +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +let nextStatus = 200; +let lastRequest: { + url: string; + method: string; + authorization: string; + cookie: string; +} | null = null; + +let lastResponse: Response | null = null; + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + + lastRequest = { + url: String(input), + method: String(init?.method || "GET"), + authorization: headers.get("authorization") || "", + cookie: headers.get("cookie") || "", + }; + + lastResponse = new Response( + JSON.stringify({ + secret: "response-body-must-not-be-consumed", + }), + { + status: nextStatus, + headers: { + "content-type": "application/json", + }, + } + ); + + return lastResponse; +}) as typeof fetch; + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +async function validate(status: number) { + nextStatus = status; + lastRequest = null; + lastResponse = null; + + return validateProviderApiKey({ + provider: "zai-web", + apiKey: "synthetic-zai-token", + providerSpecificData: {}, + }); +} + +test("zai-web uses the token-only authenticated user-settings GET", async () => { + const result = await validate(200); + + assert.equal(result.valid, true); + + assert.equal(lastRequest?.url, "https://chat.z.ai/api/v1/users/user/settings"); + + assert.equal(lastRequest?.method, "GET"); + + assert.equal(lastRequest?.authorization, "Bearer synthetic-zai-token"); + + assert.equal(lastRequest?.cookie, ""); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves exact 401 as credential rejection", async () => { + const result = await validate(401); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 401); + + assert.match(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves 403 without calling it expired", async () => { + const result = await validate(403); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 403); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves rate-limit and server statuses", async () => { + for (const status of [429, 503]) { + const result = await validate(status); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, status); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); + } +});