diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4488b5cb..8806afa554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ --- -## [3.7.2] — 2026-04-27 +## [3.7.2] — 2026-04-28 ### ✨ New Features @@ -25,6 +25,9 @@ - **fix(stream):** fail zombie SSE streams before accepting response — returns 504 instead of hanging indefinitely, enables combo fallback (#1693) - **fix(combo):** complete context truncation hotfix — cache getCombos() with 10s TTL, pass allCombosData to resolveComboTargets() for nested combo resolution, consolidate duplicated context overflow regex patterns (#1685) - **fix(codex):** raise default quota threshold from 90% to 99% to avoid premature account blocking when usable quota remains (#1697) +- **fix(memory):** use `user` role for GLM/ZAI/Qianfan providers — providers with strict role constraints (no `system` role) now correctly receive memory context as a `user` message instead of a `system` message, preventing 422 validation errors (#1701) +- **fix(oauth):** target specific connection by ID on re-auth token exchange — prevents duplicate account creation when re-authenticating an existing OAuth connection (#1702 — thanks @namhhitvn) +- **feat(email-privacy):** integrate email visibility toggle in RequestLoggerV2 — log detail modal now respects global email privacy state, hiding email addresses by default (#1700 — thanks @namhhitvn) - **fix(combo):** trigger fallback on Anthropic `Invalid signature in thinking block` errors instead of returning 400 directly (#1696) - **fix:** combo retry loop stops immediately on client disconnect (499) (#1681) - **fix(search):** support optional bearer auth for SearXNG (#1683) diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index f5061e4fa1..daff51f457 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -135,8 +135,6 @@ export function googApiClientHeader(): string { export { ANTIGRAVITY_VERSION, - GEMINI_CLI_VERSION, - GEMINI_SDK_VERSION, LOAD_CODE_ASSIST_USER_AGENT as ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, LOAD_CODE_ASSIST_API_CLIENT as ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, }; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 00d31d9d3e..e74aef792e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -77,18 +77,18 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ /unsupported model/i, /model.*has no provider/i, // Function calling format error — model doesn't support this capability - /function\.?arguments.*(must be|should be|必须).*(json|JSON)/i, + /function\.?arguments.*(must be|should be|\u5fc5\u987b).*(json|JSON)/i, /tool.*arguments.*invalid/i, /function.*parameter.*(invalid|format)/i, // Input length range error — model-specific context limit /range of input length/i, /input length should be/i, // Transient 400 errors from upstream — should fallback to next combo target - /服务遇到了一点小状况/i, // ModelScope/Qwen transient error - /抱歉.*?敏感内容.*?请检查/i, // ModelScope/Qwen content moderation with context - /内容.*?敏感.*?(?:无法|过滤)/i, // Content sensitivity block - /无法响应.*?请求/i, // "unable to respond to request" - /稍后重试/i, // "retry later" in Chinese + /\u670d\u52a1\u9047\u5230\u4e86\u4e00\u70b9\u5c0f\u72b6\u51b5/i, // ModelScope/Qwen transient error + /\u62b1\u6b49.*?\u654f\u611f\u5185\u5bb9.*?\u8bf7\u68c0\u67e5/i, // ModelScope/Qwen content moderation with context + /\u5185\u5bb9.*?\u654f\u611f.*?(?:\u65e0\u6cd5|\u8fc7\u6ee4)/i, // Content sensitivity block + /\u65e0\u6cd5\u54cd\u5e94.*?\u8bf7\u6c42/i, // "unable to respond to request" + /\u7a0d\u540e\u91cd\u8bd5/i, // "retry later" in Chinese /temporary.*error/i, /transient.*error/i, /service.*unavailable/i, @@ -96,7 +96,7 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ // Rate limit errors — some providers return 400 instead of 429 /\brate.?-?limit.?(?:exceeded|reached|hit)/i, /too many requests/i, - /请求过于频繁/i, // Chinese rate limit message + /\u8bf7\u6c42\u8fc7\u4e8e\u9891\u7e41/i, // Chinese rate limit message // Tool call function name errors — model-specific, try next combo target /\bfunction'?s? name (?:can't|can not|is|has) (?:blank|empty|missing)/i, /function.*name.*(?:blank|empty|missing)/i, diff --git a/open-sse/services/roleNormalizer.ts b/open-sse/services/roleNormalizer.ts index 8a946496ce..0b1a12ee72 100644 --- a/open-sse/services/roleNormalizer.ts +++ b/open-sse/services/roleNormalizer.ts @@ -30,7 +30,8 @@ const PROVIDERS_WITHOUT_SYSTEM_ROLE = new Set([ * Uses prefix matching (e.g., "glm-" matches "glm-4.7", "glm-4.5", etc.) */ const MODELS_WITHOUT_SYSTEM_ROLE = [ - "glm-", // ZhipuAI GLM models + "glm-", // ZhipuAI GLM models (prefix: glm-5.1, glm-4.7, etc.) + "glm", // Exact match for model id "glm" (e.g., Pollinations) "ernie-", // Baidu ERNIE models ]; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index ea6b9a7fb8..cdcfa5dfdd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -969,7 +969,8 @@ export default function ProviderDetailPage() { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); - const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showOAuthModal, _setShowOAuthModal] = useState(false); + const [reauthConnection, setReauthConnection] = useState(null); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); @@ -1021,6 +1022,11 @@ export default function ProviderDetailPage() { const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; + const setShowOAuthModal = (show: boolean, connectionRow?: ConnectionRowConnection) => { + _setShowOAuthModal(show); + setReauthConnection(show && connectionRow ? connectionRow : null); + }; + const providerInfo = resolveDashboardProviderInfo(providerId, { providerNode, compatibleLabels: { @@ -2928,7 +2934,9 @@ export default function ProviderDetailPage() { }} onDelete={() => handleDelete(conn.id)} onReauth={ - conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined + conn.authType === "oauth" + ? () => setShowOAuthModal(true, conn) + : undefined } onRefreshToken={ conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined @@ -3048,7 +3056,7 @@ export default function ProviderDetailPage() { onDelete={() => handleDelete(conn.id)} onReauth={ conn.authType === "oauth" - ? () => setShowOAuthModal(true) + ? () => setShowOAuthModal(true, conn) : undefined } onRefreshToken={ @@ -3183,6 +3191,7 @@ export default function ProviderDetailPage() { (providerId === "kiro" || providerId === "amazon-q" ? ( { @@ -3192,6 +3201,7 @@ export default function ProviderDetailPage() { ) : providerId === "cursor" ? ( { setShowOAuthModal(false); @@ -3200,6 +3210,7 @@ export default function ProviderDetailPage() { ) : ( 0 ? state : undefined; const providerData = getProvider(provider); @@ -278,6 +278,7 @@ export async function POST( if (tokenData.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections @@ -322,7 +323,7 @@ export async function POST( } if (action === "poll") { - const { deviceCode, codeVerifier, extraData } = body; + const { deviceCode, connectionId, codeVerifier, extraData } = body; // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(provider); @@ -364,6 +365,7 @@ export async function POST( if (result.tokens.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-8/9) if (!safeEqual(c.email, result.tokens.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections @@ -418,6 +420,8 @@ export async function POST( } if (action === "poll-callback") { + const { connectionId } = body; + // Poll for Codex callback server result if (provider !== "codex") { return NextResponse.json( @@ -489,6 +493,7 @@ export async function POST( if (tokenData.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections diff --git a/src/lib/memory/injection.ts b/src/lib/memory/injection.ts index 8d1704e93c..283bb28bd2 100644 --- a/src/lib/memory/injection.ts +++ b/src/lib/memory/injection.ts @@ -35,7 +35,16 @@ export interface ChatRequest { * Providers known NOT to support a top-level system-role message. * These receive memories injected as the first user message instead. */ -const PROVIDERS_WITHOUT_SYSTEM_MESSAGE = new Set(["o1", "o1-mini", "o1-preview"]); +const PROVIDERS_WITHOUT_SYSTEM_MESSAGE = new Set([ + "o1", + "o1-mini", + "o1-preview", + "glm", // GLM/ZhipuAI rejects system role (#1701) + "glmt", // GLM Thinking variant + "glm-cn", // GLM China variant + "zai", // Z.AI uses same GLM backend + "qianfan", // Baidu ERNIE rejects system role +]); /** * Returns true when the given provider accepts a system-role message. diff --git a/src/shared/components/CursorAuthModal.tsx b/src/shared/components/CursorAuthModal.tsx index f27057613b..aaf2ff4f54 100644 --- a/src/shared/components/CursorAuthModal.tsx +++ b/src/shared/components/CursorAuthModal.tsx @@ -11,7 +11,7 @@ import Input from "./Input"; * Cursor Auth Modal * Auto-detect and import token from Cursor IDE's local SQLite database */ -export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { +export default function CursorAuthModal({ isOpen, onSuccess, onClose, reauthConnection: _ }) { const t = useTranslations("cursorAuthModal"); const [accessToken, setAccessToken] = useState(""); const [machineId, setMachineId] = useState(""); diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index eb3ad6c747..dde999aa81 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -10,7 +10,13 @@ import KiroSocialOAuthModal from "./KiroSocialOAuthModal"; * Kiro OAuth Wrapper * Orchestrates between method selection, device code flow, and social login flow */ -export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onClose }) { +export default function KiroOAuthWrapper({ + isOpen, + providerInfo, + onSuccess, + onClose, + reauthConnection, +}) { const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" const [socialProvider, setSocialProvider] = useState(null); // "google" | "github" const [idcConfig, setIdcConfig] = useState(null); @@ -78,6 +84,7 @@ export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onCl provider={oauthProviderId} providerInfo={providerInfo} onSuccess={handleDeviceSuccess} + reauthConnection={reauthConnection} onClose={handleBack} idcConfig={idcConfig} /> diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index e6e39cca97..841147ccf7 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -17,6 +17,7 @@ type OAuthModalProps = { onSuccess?: () => void; onClose: () => void; idcConfig?: unknown; + reauthConnection?: null | { id?: string }; }; /** @@ -31,6 +32,7 @@ export default function OAuthModal({ onSuccess, onClose, idcConfig, + reauthConnection, }: OAuthModalProps) { const t = useTranslations("oauthModal"); const [step, setStep] = useState("waiting"); // waiting | input | success | error @@ -93,6 +95,7 @@ export default function OAuthModal({ body: JSON.stringify({ code, redirectUri: authData.redirectUri, + connectionId: reauthConnection?.id, codeVerifier: authData.codeVerifier, ...(normalizedState ? { state: normalizedState } : {}), }), @@ -141,7 +144,7 @@ export default function OAuthModal({ setStep("error"); } }, - [authData, provider, onSuccess] + [authData, provider, onSuccess, reauthConnection] ); // Poll for device code token @@ -157,7 +160,12 @@ export default function OAuthModal({ const res = await fetch(`/api/oauth/${provider}/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceCode, codeVerifier, extraData }), + body: JSON.stringify({ + deviceCode, + connectionId: reauthConnection?.id, + codeVerifier, + extraData, + }), }); const data = await res.json(); @@ -188,7 +196,7 @@ export default function OAuthModal({ setStep("error"); setPolling(false); }, - [provider, onSuccess] + [provider, onSuccess, reauthConnection] ); // Start OAuth flow @@ -271,7 +279,7 @@ export default function OAuthModal({ const pollRes = await fetch(`/api/oauth/codex/poll-callback`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ connectionId: reauthConnection?.id }), }); const pollData = await pollRes.json(); @@ -371,7 +379,7 @@ export default function OAuthModal({ setError(err.message); setStep("error"); } - }, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]); + }, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess, reauthConnection]); // Reset guard when modal closes useEffect(() => { diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 343c12b656..e072697553 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -17,6 +17,7 @@ import { maskAccount, formatApiKeyLabel, } from "@/shared/utils/formatting"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; // Quick filter categories - status-based only (providers are dynamic from data) const STATUS_FILTERS = [ @@ -114,6 +115,7 @@ function getCacheSourceMeta(cacheSource: unknown) { export default function RequestLoggerV2() { const t = useTranslations("requestLogger"); + const { emailsVisible } = useEmailPrivacyStore(); // Get translated status filters const statusFilters = useMemo( @@ -795,7 +797,7 @@ export default function RequestLoggerV2() { className="px-3 py-2 text-text-muted truncate max-w-[120px]" title={log.account} > - {maskAccount(log.account)} + {maskAccount(log.account, emailsVisible)} )} {visibleColumns.apiKey && ( diff --git a/src/shared/utils/formatting.ts b/src/shared/utils/formatting.ts index 2eba7ed2b2..7ae2ba40e8 100644 --- a/src/shared/utils/formatting.ts +++ b/src/shared/utils/formatting.ts @@ -65,12 +65,14 @@ export function maskSegment(value, start = 2, end = 2) { /** * Mask an email or account string for display. * @param {string} account - Account identifier (email or username) + * @param {boolean} emailsVisible - Whether to show full email (true) or mask it (false) * @returns {string} */ -export function maskAccount(account) { +export function maskAccount(account, emailsVisible) { if (!account || account === "-") return "-"; const atIdx = account.indexOf("@"); if (atIdx > 3) { + if (emailsVisible) return account; return account.slice(0, 3) + "***" + account.slice(atIdx); } if (account.length > 8) { diff --git a/tests/unit/memory-glm-injection.test.ts b/tests/unit/memory-glm-injection.test.ts new file mode 100644 index 0000000000..5840548a6d --- /dev/null +++ b/tests/unit/memory-glm-injection.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for #1701: GLM providers rejecting system role from memory injection. + * + * Validates that injectMemory() uses the user role fallback for GLM/ZAI/Qianfan + * providers that do not support the system role, while still using system role + * for standard providers like OpenAI. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { injectMemory, providerSupportsSystemMessage } from "../../src/lib/memory/injection.ts"; +import type { ChatRequest } from "../../src/lib/memory/injection.ts"; +import type { Memory } from "../../src/lib/memory/types.ts"; +import { normalizeSystemRole } from "../../open-sse/services/roleNormalizer.ts"; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const baseRequest: ChatRequest = { + model: "glm-5.1", + messages: [{ role: "user", content: "Hello" }], +}; + +const testMemories: Memory[] = [ + { + id: "mem-1", + content: "User prefers dark mode", + type: "factual" as any, + apiKeyId: "test-key", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + importance: 0.5, + }, +]; + +// ── providerSupportsSystemMessage ────────────────────────────────────────────── + +describe("providerSupportsSystemMessage — GLM providers (#1701)", () => { + it("should return false for glm", () => { + assert.equal(providerSupportsSystemMessage("glm"), false); + }); + + it("should return false for glmt", () => { + assert.equal(providerSupportsSystemMessage("glmt"), false); + }); + + it("should return false for glm-cn", () => { + assert.equal(providerSupportsSystemMessage("glm-cn"), false); + }); + + it("should return false for zai", () => { + assert.equal(providerSupportsSystemMessage("zai"), false); + }); + + it("should return false for qianfan", () => { + assert.equal(providerSupportsSystemMessage("qianfan"), false); + }); + + it("should return false for o1 (existing behavior)", () => { + assert.equal(providerSupportsSystemMessage("o1"), false); + }); + + it("should return true for openai (regression check)", () => { + assert.equal(providerSupportsSystemMessage("openai"), true); + }); + + it("should return true for anthropic (regression check)", () => { + assert.equal(providerSupportsSystemMessage("anthropic"), true); + }); + + it("should return true for null/undefined (safe default)", () => { + assert.equal(providerSupportsSystemMessage(null), true); + assert.equal(providerSupportsSystemMessage(undefined), true); + }); +}); + +// ── injectMemory with GLM providers ──────────────────────────────────────────── + +describe("injectMemory — GLM providers use user role (#1701)", () => { + it("should inject as user role for provider=glm", () => { + const result = injectMemory(baseRequest, testMemories, "glm"); + assert.equal(result.messages[0].role, "user"); + assert.ok(result.messages[0].content.includes("Memory context:")); + }); + + it("should inject as user role for provider=glmt", () => { + const result = injectMemory(baseRequest, testMemories, "glmt"); + assert.equal(result.messages[0].role, "user"); + }); + + it("should inject as user role for provider=glm-cn", () => { + const result = injectMemory(baseRequest, testMemories, "glm-cn"); + assert.equal(result.messages[0].role, "user"); + }); + + it("should inject as user role for provider=zai", () => { + const result = injectMemory(baseRequest, testMemories, "zai"); + assert.equal(result.messages[0].role, "user"); + }); + + it("should inject as user role for provider=qianfan", () => { + const result = injectMemory(baseRequest, testMemories, "qianfan"); + assert.equal(result.messages[0].role, "user"); + }); + + it("should inject as system role for provider=openai (regression)", () => { + const result = injectMemory(baseRequest, testMemories, "openai"); + assert.equal(result.messages[0].role, "system"); + }); + + it("should inject as system role for provider=anthropic (regression)", () => { + const result = injectMemory(baseRequest, testMemories, "anthropic"); + assert.equal(result.messages[0].role, "system"); + }); + + it("should not modify original request", () => { + const original = { ...baseRequest, messages: [...baseRequest.messages] }; + injectMemory(baseRequest, testMemories, "glm"); + assert.equal(baseRequest.messages.length, original.messages.length); + }); +}); + +// ── normalizeSystemRole — GLM model names ────────────────────────────────────── + +describe("normalizeSystemRole — GLM model names (#1701)", () => { + it("should convert system→user for glm-5.1", () => { + const messages = [ + { role: "system", content: "Memory context: test" }, + { role: "user", content: "Hello" }, + ]; + const result = normalizeSystemRole(messages, "glm", "glm-5.1"); + assert.ok(Array.isArray(result)); + const roles = (result as { role: string }[]).map((m) => m.role); + assert.ok(!roles.includes("system"), "system role should be converted"); + assert.ok(roles.includes("user"), "should have user role"); + }); + + it("should convert system→user for glm-4.7", () => { + const messages = [ + { role: "system", content: "Instructions" }, + { role: "user", content: "Hello" }, + ]; + const result = normalizeSystemRole(messages, "glm", "glm-4.7"); + const roles = (result as { role: string }[]).map((m) => m.role); + assert.ok(!roles.includes("system")); + }); + + it("should convert system→user for exact model id 'glm'", () => { + const messages = [ + { role: "system", content: "Memory" }, + { role: "user", content: "Hello" }, + ]; + const result = normalizeSystemRole(messages, "pollinations", "glm"); + const roles = (result as { role: string }[]).map((m) => m.role); + assert.ok(!roles.includes("system")); + }); + + it("should convert system→user for ernie models", () => { + const messages = [ + { role: "system", content: "Memory" }, + { role: "user", content: "Hello" }, + ]; + const result = normalizeSystemRole(messages, "qianfan", "ernie-4.5-turbo-128k"); + const roles = (result as { role: string }[]).map((m) => m.role); + assert.ok(!roles.includes("system")); + }); + + it("should preserve system role for openai models (regression)", () => { + const messages = [ + { role: "system", content: "Instructions" }, + { role: "user", content: "Hello" }, + ]; + const result = normalizeSystemRole(messages, "openai", "gpt-5.4"); + const roles = (result as { role: string }[]).map((m) => m.role); + assert.ok(roles.includes("system"), "system role should be preserved"); + }); +});