Merge pull request #1672 from diegosouzapw/release/v3.7.2

Release v3.7.2
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-28 02:56:33 -03:00
committed by GitHub
13 changed files with 248 additions and 26 deletions

View File

@@ -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)

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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
];

View File

@@ -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<ConnectionRowConnection | null>(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" ? (
<KiroOAuthWrapper
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
providerInfo={{ ...providerInfo, id: providerId }}
onSuccess={handleOAuthSuccess}
onClose={() => {
@@ -3192,6 +3201,7 @@ export default function ProviderDetailPage() {
) : providerId === "cursor" ? (
<CursorAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => {
setShowOAuthModal(false);
@@ -3200,6 +3210,7 @@ export default function ProviderDetailPage() {
) : (
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}

View File

@@ -234,7 +234,7 @@ export async function POST(
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state } = body;
const { code, redirectUri, connectionId, codeVerifier, state } = body;
const normalizedState = typeof state === "string" && state.length > 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

View File

@@ -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.

View File

@@ -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("");

View File

@@ -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}
/>

View File

@@ -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(() => {

View File

@@ -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)}
</td>
)}
{visibleColumns.apiKey && (

View File

@@ -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) {

View File

@@ -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");
});
});