Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
02bc319648 fix(providers): don't silently enable rate-limit protection on PATCH unless persisted (#11278)
PUT /api/providers/[id] unconditionally called enableRateLimitProtection(id)
whenever the request body included rateLimitOverrides, even null. Since
EditConnectionModal.tsx sends rateLimitOverrides on every connection save
regardless of whether the operator touched that section, saving any
connection silently started queuing its requests through Bottleneck while
the DB (rate_limit_protection column) and the dashboard toggle both still
showed the feature off.

Only (re)enable the in-memory limiter when updated.rateLimitProtection is
actually true (mapped from the persisted DB row), and explicitly disable it
otherwise so runtime state can never drift ahead of the DB.
2026-08-23 21:14:36 -03:00
5 changed files with 161 additions and 129 deletions

View File

@@ -20,7 +20,6 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
import {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
} from "./chatCore/claudeClassifierCompat.ts";
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
@@ -779,12 +778,11 @@ export async function handleChatCore({
classifierSettings.claudeClassifierCompat as string | undefined
)
) {
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
log?.warn?.(
"CHAT",
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
);
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
return buildDefaultAllowClaudeMessage(requestedModel);
}
}

View File

@@ -24,19 +24,14 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
/** The two synthetic-response shapes Claude Code's classifier can expect. */
export type ClaudeClassifierFormat = "block" | "severity";
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
const system = body?.system;
if (typeof system === "string") return [system];
if (Array.isArray(system)) {
return system
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
? ((part as { text: string }).text)
: ""))
.filter(Boolean);
}
return [];
@@ -65,29 +60,6 @@ export function shouldDefaultAllowClassifier(
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}
/**
* Detect which synthetic-response shape the classifier request expects.
*
* Newer Claude Code builds send a "severity classifier" variant of the same internal
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
* stages and then fails closed — the same "blocking it for safety" failure this compat
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
* the request is the classifier (via the system-prompt marker), so an unrelated app
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
*/
export function detectClassifierFormat(
body: Record<string, unknown> | null | undefined
): ClaudeClassifierFormat {
const stopSequences = body?.stop_sequences;
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
return "severity";
}
return "block";
}
/**
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
* body (matching the upstream reference implementation) — Claude Code's classifier
@@ -95,10 +67,7 @@ export function detectClassifierFormat(
* satisfies both streaming and non-streaming callers without needing to plumb a
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
*/
export function buildDefaultAllowClaudeMessage(
model?: string | null,
format: ClaudeClassifierFormat = "block"
): {
export function buildDefaultAllowClaudeMessage(model?: string | null): {
success: true;
response: Response;
} {
@@ -107,12 +76,7 @@ export function buildDefaultAllowClaudeMessage(
type: "message",
role: "assistant",
model: model || "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
},
],
content: [{ type: "text", text: "<block>no</block>" }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },

View File

@@ -29,6 +29,7 @@ import { canUpdateProviderApiKey } from "@/shared/providers/webSessionCredential
import {
refreshConnectionRateLimits,
enableRateLimitProtection,
disableRateLimitProtection,
} from "@/../open-sse/services/rateLimitManager";
import {
finalizeValidatedChatGptWebCodexSecrets,
@@ -342,10 +343,18 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
// If rateLimitOverrides was included in the request, refresh the in-memory
// rate limiter state so the change takes effect without a server restart.
// Also ensure rate limit protection is active so the limiter is enforced.
// Only (re)enable enforcement when rate limit protection is actually
// persisted for this connection — this route never lets a caller flip
// `rateLimitProtection` itself, so any drift here would silently start
// queuing requests through Bottleneck for a connection whose DB row (and
// the dashboard toggle reading it) both still say "off" (#11278).
if (rateLimitOverrides !== undefined) {
refreshConnectionRateLimits(id, updated?.rateLimitOverrides ?? null);
enableRateLimitProtection(id);
if (updated?.rateLimitProtection === true) {
enableRateLimitProtection(id);
} else {
disableRateLimitProtection(id);
}
}
// Hide sensitive fields

View File

@@ -25,8 +25,9 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const originalFetch = globalThis.fetch;
@@ -57,14 +58,6 @@ const CLASSIFIER_BODY = {
max_tokens: 8,
};
// Newer Claude Code builds send a "severity classifier" variant of the same internal
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
const SEVERITY_CLASSIFIER_BODY = {
...CLASSIFIER_BODY,
stop_sequences: ["</severity>"],
};
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
@@ -130,12 +123,7 @@ test("detector: always does NOT fire for normal chat without classifier marker (
test("detector: always fires when classifier marker is present", () => {
const classifier = {
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
},
],
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
stop_sequences: ["</block>"],
};
assert.equal(
@@ -145,21 +133,6 @@ test("detector: always fires when classifier marker is present", () => {
);
});
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
});
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
});
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
assert.equal(detectClassifierFormat({}), "block");
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
});
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
@@ -182,16 +155,6 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
});
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
content: Array<{ type: string; text?: string }>;
};
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(text, "<severity>0</severity>");
});
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
@@ -233,44 +196,3 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
globalThis.fetch = originalFetch;
}
});
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(
text,
"<severity>0</severity>",
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,139 @@
// Regression guard for #11278 — PATCH/PUT /api/providers/[id] silently enabled
// runtime rate-limit protection (Bottleneck queuing) for ANY connection whose
// request body included the `rateLimitOverrides` key, even `null`, regardless
// of whether `rate_limit_protection` was actually persisted as on for that
// connection in the DB.
//
// Root cause: src/app/api/providers/[id]/route.ts unconditionally called
// enableRateLimitProtection(id) whenever `rateLimitOverrides !== undefined`
// in the validated body. `EditConnectionModal.tsx` sends `rateLimitOverrides`
// on every save regardless of whether the operator touched that section, so
// saving ANY connection silently started queuing its requests through
// Bottleneck — with the DB (`rate_limit_protection` column) and the dashboard
// toggle both still showing the feature as off.
//
// Fix: only (re)enable the in-memory limiter when the persisted connection
// (`updated.rateLimitProtection`, mapped from the DB row) is actually `true`;
// otherwise explicitly disable it so runtime state can't drift ahead of the
// DB. `rateLimitProtection` is never itself part of updateProviderConnectionSchema,
// so this route can only read it from the persisted row — never set it.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11278-ratelimit-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.APP_LOG_TO_FILE = "false";
process.env.JWT_SECRET = "test-jwt-secret-11278-ratelimit";
process.env.INITIAL_PASSWORD = "admin-secret";
const core = await import("../../src/lib/db/core.ts");
const { createProviderConnection, getProviderConnectionById } =
await import("../../src/lib/db/providers.ts");
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
async function createConnection(rateLimitProtection: boolean) {
return createProviderConnection({
provider: "openai",
authType: "apikey",
name: "OpenAI key",
apiKey: "sk-test-key-value",
priority: 1,
isActive: true,
testStatus: "active",
rateLimitProtection,
});
}
test(
"PUT /api/providers/[id] does NOT enable rate-limit protection just because " +
"rateLimitOverrides is present, when protection is off in the DB (#11278 RED->GREEN)",
async () => {
const connection = (await createConnection(false)) as Record<string, unknown>;
assert.equal(connection.rateLimitProtection, false);
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), false);
// Mirrors EditConnectionModal.tsx's handleSubmit(): it always sends
// `rateLimitOverrides` on every save, even when the operator never
// touched that section of the form.
const payload = {
name: connection.name,
priority: connection.priority,
rateLimitOverrides: null,
};
const request = await makeManagementSessionRequest(
`http://localhost/api/providers/${connection.id}`,
{ method: "PUT", body: payload }
);
const response = await providerByIdRoute.PUT(request, {
params: Promise.resolve({ id: connection.id as string }),
});
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
string,
unknown
>;
assert.equal(
persisted.rateLimitProtection,
false,
"DB row must still show protection off — this route never sets rateLimitProtection"
);
assert.equal(
rateLimitManager.isRateLimitEnabled(connection.id as string),
false,
"in-memory limiter must not silently diverge from the persisted DB state"
);
}
);
test(
"PUT /api/providers/[id] keeps rate-limit protection ENABLED when it is " +
"actually persisted as on in the DB",
async () => {
const connection = (await createConnection(true)) as Record<string, unknown>;
assert.equal(connection.rateLimitProtection, true);
const payload = {
name: connection.name,
priority: connection.priority,
rateLimitOverrides: { rpm: 30 },
};
const request = await makeManagementSessionRequest(
`http://localhost/api/providers/${connection.id}`,
{ method: "PUT", body: payload }
);
const response = await providerByIdRoute.PUT(request, {
params: Promise.resolve({ id: connection.id as string }),
});
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
string,
unknown
>;
assert.equal(persisted.rateLimitProtection, true);
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), true);
}
);