mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
Validated in merge-train 2026-07-18 @ 9084b408b: 12198/12201 pass; single red = earlyStreamKeepalive timer test, confirmed load-flake (6/6 green isolated on release tip AND the merged tree; no boarded PR touches keepalive)
This commit is contained in:
committed by
GitHub
parent
2cca081b3c
commit
b28331307e
1
changelog.d/features/6879-default-reasoning-effort.md
Normal file
1
changelog.d/features/6879-default-reasoning-effort.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): per-model default `reasoning_effort` (`ModelSpec.defaultReasoningEffort`, injected only when the request carries no reasoning field) and make `no-think/` express `reasoning_effort:"none"` instead of deleting the field on the OpenAI path, so thinks-by-default models actually stop thinking (#6879)
|
||||
@@ -109,6 +109,7 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
||||
import { normalizeMimoThinking } from "../services/mimoThinking.ts";
|
||||
import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts";
|
||||
import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts";
|
||||
import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts";
|
||||
import { echoModelInObject } from "../services/responseModelEcho.ts";
|
||||
import {
|
||||
stripGpt5SamplingWhenReasoning,
|
||||
@@ -2044,6 +2045,14 @@ export async function handleChatCore({
|
||||
// model substitution. Mirrors upstream 9router 401d93bd5. See
|
||||
// services/claudeHaikuConstraints.ts.
|
||||
translatedBody = normalizeClaudeHaikuConstraints(translatedBody, finalModelToUpstream);
|
||||
// #6879: per-model default reasoning_effort, injected only when the request
|
||||
// carries no reasoning field of any shape — an explicit client/combo-leg value
|
||||
// always wins. Scoped to the OpenAI Chat Completions dispatch shape (the shape
|
||||
// `reasoning_effort` is native to); unset ModelSpec.defaultReasoningEffort is a
|
||||
// no-op. See open-sse/services/defaultReasoningEffort.ts.
|
||||
if (targetFormat === FORMATS.OPENAI) {
|
||||
translatedBody = applyDefaultReasoningEffort(translatedBody, finalModelToUpstream);
|
||||
}
|
||||
}
|
||||
|
||||
// Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and
|
||||
|
||||
42
open-sse/services/defaultReasoningEffort.ts
Normal file
42
open-sse/services/defaultReasoningEffort.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Per-model default reasoning effort (#6879, "Ask 1"). Many models think by
|
||||
// default with no client-visible way to turn it off (measured:
|
||||
// gemini-flash-lite-latest burns ~277 reasoning tokens on a plain request with
|
||||
// no reasoning params). ModelSpec.defaultReasoningEffort lets an operator
|
||||
// configure a strip-by-default (or steer-by-default) value fleet-wide without
|
||||
// patching every client.
|
||||
//
|
||||
// Semantics: applied ONLY when the request carries no reasoning field of any
|
||||
// shape (`reasoning_effort`, `reasoning`, `thinking`) — an explicit client
|
||||
// value, including one forwarded verbatim through a combo leg, always wins
|
||||
// and this is a no-op. Models without a configured default are untouched
|
||||
// (regression-safe). Wired at the OpenAI-format dispatch chokepoint in
|
||||
// chatCore.ts, after model resolution, so the *upstream* model's default is
|
||||
// used even when a combo/route substituted it.
|
||||
import { getModelSpec } from "@/shared/constants/modelSpecs.ts";
|
||||
|
||||
/** True when `body` already expresses a reasoning-effort choice, in any known shape. */
|
||||
function hasExplicitReasoningField(body: Record<string, unknown>): boolean {
|
||||
return (
|
||||
body.reasoning_effort !== undefined ||
|
||||
body.reasoning !== undefined ||
|
||||
body.thinking !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the resolved model's `defaultReasoningEffort` as `reasoning_effort` when the
|
||||
* request has no reasoning field. Returns `body` unchanged (same reference) when there
|
||||
* is nothing to inject, so callers can chain it without extra guards.
|
||||
*/
|
||||
export function applyDefaultReasoningEffort<T extends Record<string, unknown>>(
|
||||
body: T,
|
||||
modelId: string
|
||||
): T {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (hasExplicitReasoningField(body)) return body;
|
||||
|
||||
const defaultEffort = getModelSpec(modelId)?.defaultReasoningEffort;
|
||||
if (!defaultEffort) return body;
|
||||
|
||||
return { ...body, reasoning_effort: defaultEffort };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
recordReplay,
|
||||
requiresReasoningReplay,
|
||||
} from "../services/reasoningCache.ts";
|
||||
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
|
||||
|
||||
bootstrapTranslatorRegistry();
|
||||
export { register } from "./registry.ts";
|
||||
@@ -61,10 +62,37 @@ function normalizeResponsesInputItem(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Promote a stray top-level Chat-Completions-shaped `reasoning_effort` into the
|
||||
// Responses-shaped `reasoning:{effort}` object, in place, removing the top-level key.
|
||||
// No-op when `reasoning` is already present (an explicit Responses-shaped value always
|
||||
// wins) or when `reasoning_effort` is absent.
|
||||
//
|
||||
// This exists for the SAME-FORMAT lane (source === target === OPENAI_RESPONSES), where
|
||||
// translateRequest's hub-and-spoke translation block is skipped entirely (#7631): a
|
||||
// caller that lands a top-level `reasoning_effort` there — e.g. applyNoThinkingAlias
|
||||
// on the OpenAI path, which runs upstream of model-format resolution and cannot know
|
||||
// yet whether the target lane is Responses-native — would otherwise reach the upstream
|
||||
// with BOTH an unrecognized top-level field AND no `reasoning.effort`, so suppression
|
||||
// silently does not take effect. The cross-format path (openai -> openai-responses)
|
||||
// already performs the equivalent promotion in toResponses.ts; this covers the lane
|
||||
// that promotion never runs on.
|
||||
function promoteStrayReasoningEffort(body) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (body.reasoning !== undefined) return body;
|
||||
if (body.reasoning_effort === undefined) return body;
|
||||
|
||||
const effort = normalizeResponsesReasoningEffort(body.reasoning_effort);
|
||||
if (effort) {
|
||||
body.reasoning = { effort };
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
return body;
|
||||
}
|
||||
|
||||
function normalizeOpenAIResponsesRequest(body) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
const normalized = { ...body };
|
||||
const normalized = promoteStrayReasoningEffort({ ...body });
|
||||
|
||||
if (typeof normalized.input === "string") {
|
||||
normalized.input = [
|
||||
|
||||
@@ -10,9 +10,17 @@
|
||||
*
|
||||
* When such an id arrives on a request we strip the prefix back to the real
|
||||
* `<provider>/<model>` and suppress reasoning (`thinking:{type:"disabled"}` for the
|
||||
* Claude/Messages path; drop `reasoning`/`reasoning_effort` for the OpenAI path).
|
||||
* Claude/Messages path; `reasoning_effort:"none"` for the OpenAI path — #6879: a
|
||||
* thinks-by-default OpenAI-shape model left with no reasoning field at all keeps
|
||||
* thinking with its provider default, so the alias must express "none" rather than
|
||||
* merely deleting the field. The `reasoning` object is still dropped, since a
|
||||
* Responses-shaped client's `reasoning:{...}` cannot itself express "none" and the
|
||||
* translator promotes `reasoning_effort` into it downstream when absent).
|
||||
* The existing `normalizeThinkingForModel()` still runs downstream, so models that
|
||||
* reject `disabled` are handled exactly as before.
|
||||
* reject `disabled` are handled exactly as before, and the per-lane
|
||||
* unsupported-param strip (open-sse/translator/paramSupport.ts) still removes
|
||||
* `reasoning_effort` for lanes known to reject it, falling back to today's
|
||||
* delete-only behavior for those.
|
||||
*
|
||||
* Catalog visibility is gated (see `shouldExposeNoThinkingAlias`): we only advertise
|
||||
* the variant for Claude-family models that actually support thinking AND honor
|
||||
@@ -61,8 +69,15 @@ export function applyNoThinkingAlias(
|
||||
body.model = realModel;
|
||||
if (opts.claudeFormat === true) {
|
||||
body.thinking = { type: "disabled" };
|
||||
delete body.reasoning_effort;
|
||||
} else {
|
||||
// #6879: express "none" instead of deleting, so a thinks-by-default model
|
||||
// actually stops thinking instead of falling back to its provider default.
|
||||
// Lanes that reject reasoning_effort are still cleaned up downstream by the
|
||||
// per-lane unsupported-param strip (paramSupport.ts), which removes it just
|
||||
// like it would have been removed here — same end state, correct on more lanes.
|
||||
body.reasoning_effort = "none";
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
delete body.reasoning;
|
||||
return { applied: true, realModel };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ export interface ModelSpec {
|
||||
// Claude-family thinking-capable models that honor `disabled`. Set `true` to force the
|
||||
// variant on for any other model, or `false` to suppress it. See open-sse/utils/noThinkingAlias.ts.
|
||||
noThinkingAlias?: boolean;
|
||||
// Per-model default reasoning effort (#6879). When the incoming request carries no
|
||||
// `reasoning_effort` / `reasoning` / `thinking` field of any shape, the resolved
|
||||
// upstream model's `defaultReasoningEffort` is injected as `reasoning_effort` on the
|
||||
// OpenAI-format dispatch path before the request leaves the gateway. An explicit
|
||||
// client value — including one forwarded verbatim through a combo leg — always wins;
|
||||
// this is a no-op for it. Unset preserves current behavior (no injection). Lets an
|
||||
// operator strip-by-default a thinks-by-default model (measured: gemini-flash-lite
|
||||
// burns ~277 reasoning tokens on a plain request; `reasoning_effort:"none"` → 0)
|
||||
// without patching every client. See open-sse/services/defaultReasoningEffort.ts.
|
||||
defaultReasoningEffort?: "none" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [
|
||||
|
||||
206
tests/unit/default-reasoning-effort-6879.test.ts
Normal file
206
tests/unit/default-reasoning-effort-6879.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* #6879 — per-model default `reasoning_effort` + `no-think/` expresses "none" on the
|
||||
* OpenAI path (instead of deleting the field).
|
||||
*
|
||||
* Ask 1: `ModelSpec.defaultReasoningEffort` is injected as `reasoning_effort` only
|
||||
* when the request carries no reasoning field of any shape (`reasoning_effort`,
|
||||
* `reasoning`, `thinking`); an explicit client value always wins.
|
||||
*
|
||||
* Ask 2: `applyNoThinkingAlias` sets `reasoning_effort:"none"` on the OpenAI path
|
||||
* (instead of deleting the field), so a thinks-by-default model actually stops
|
||||
* thinking rather than falling back to its provider default. The Claude/Messages
|
||||
* path is unchanged (`thinking:{type:"disabled"}`). Lanes known to reject
|
||||
* `reasoning_effort` still end up with the field removed via the existing
|
||||
* per-lane unsupported-param strip (open-sse/translator/paramSupport.ts) — same
|
||||
* end state as today's delete-only behavior, just correct on more lanes.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyDefaultReasoningEffort } from "../../open-sse/services/defaultReasoningEffort.ts";
|
||||
import { applyNoThinkingAlias } from "../../open-sse/utils/noThinkingAlias.ts";
|
||||
import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts";
|
||||
import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts";
|
||||
|
||||
const FIXTURE_MODEL_ID = "__test_6879_default_reasoning_effort_model__";
|
||||
|
||||
test.before(() => {
|
||||
MODEL_SPECS[FIXTURE_MODEL_ID] = { defaultReasoningEffort: "none" };
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete MODEL_SPECS[FIXTURE_MODEL_ID];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ask 1: applyDefaultReasoningEffort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("applyDefaultReasoningEffort: injects the model's default when no reasoning field is present", () => {
|
||||
const body = { model: FIXTURE_MODEL_ID, messages: [] };
|
||||
const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID);
|
||||
assert.equal(result.reasoning_effort, "none");
|
||||
});
|
||||
|
||||
test("applyDefaultReasoningEffort: an explicit reasoning_effort always wins over the model default", () => {
|
||||
const body = { model: FIXTURE_MODEL_ID, messages: [], reasoning_effort: "high" };
|
||||
const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID);
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
});
|
||||
|
||||
test("applyDefaultReasoningEffort: an explicit reasoning object always wins (Responses shape)", () => {
|
||||
const body = { model: FIXTURE_MODEL_ID, messages: [], reasoning: { effort: "medium" } };
|
||||
const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID);
|
||||
assert.deepEqual(result.reasoning, { effort: "medium" });
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
});
|
||||
|
||||
test("applyDefaultReasoningEffort: an explicit thinking block always wins (Claude legacy shape)", () => {
|
||||
const body = { model: FIXTURE_MODEL_ID, messages: [], thinking: { type: "enabled" } };
|
||||
const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID);
|
||||
assert.deepEqual(result.thinking, { type: "enabled" });
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
});
|
||||
|
||||
test("applyDefaultReasoningEffort: no injection when the model has no configured default (regression)", () => {
|
||||
const body = { model: "gpt-5.4-sol", messages: [] };
|
||||
const result = applyDefaultReasoningEffort(body, "gpt-5.4-sol");
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
assert.equal(result, body); // same reference — no allocation when nothing to inject
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ask 2: applyNoThinkingAlias on the OpenAI path expresses "none"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("applyNoThinkingAlias: OpenAI path sets reasoning_effort:none instead of deleting the field", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "no-think/gemini/gemini-flash-lite-latest",
|
||||
messages: [],
|
||||
};
|
||||
const result = applyNoThinkingAlias(body, { claudeFormat: false });
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(result.realModel, "gemini/gemini-flash-lite-latest");
|
||||
assert.equal(body.model, "gemini/gemini-flash-lite-latest");
|
||||
assert.equal(body.reasoning_effort, "none");
|
||||
assert.equal("reasoning" in body, false);
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias: OpenAI path overrides a client-supplied reasoning_effort with none (alias always wins)", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "no-think/gemini/gemini-flash-lite-latest",
|
||||
messages: [],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const result = applyNoThinkingAlias(body, { claudeFormat: false });
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(body.reasoning_effort, "none");
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias: Claude/Messages path is unchanged (thinking:disabled, no reasoning_effort field)", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "no-think/claude/claude-opus-4-6",
|
||||
messages: [],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const result = applyNoThinkingAlias(body, { claudeFormat: true });
|
||||
assert.equal(result.applied, true);
|
||||
assert.deepEqual(body.thinking, { type: "disabled" });
|
||||
assert.equal("reasoning_effort" in body, false);
|
||||
assert.equal("reasoning" in body, false);
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias: non-alias model is untouched (regression)", () => {
|
||||
const body: Record<string, unknown> = { model: "gemini/gemini-flash-lite-latest", messages: [] };
|
||||
const result = applyNoThinkingAlias(body, { claudeFormat: false });
|
||||
assert.equal(result.applied, false);
|
||||
assert.equal("reasoning_effort" in body, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ask 2, fallback: a lane that rejects reasoning_effort still ends up with the
|
||||
// field removed, via the pre-existing per-lane unsupported-param strip — same
|
||||
// end state as today's delete-only behavior, correct on more lanes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("a lane known to reject reasoning_effort still drops it downstream (delete-fallback preserved)", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "no-think/github/claude-3-5-sonnet",
|
||||
messages: [],
|
||||
};
|
||||
const alias = applyNoThinkingAlias(body, { claudeFormat: false });
|
||||
assert.equal(alias.applied, true);
|
||||
assert.equal(body.reasoning_effort, "none"); // set by the alias itself
|
||||
|
||||
// github/claude (non-4.6) is a known-unsupported lane for both thinking and
|
||||
// reasoning_effort (paramSupport.ts STRIP_RULES) — the field ends up removed
|
||||
// before dispatch, exactly like the old delete-only behavior.
|
||||
const stripped = stripUnsupportedParams("github", "claude-3-5-sonnet", body);
|
||||
assert.equal("reasoning_effort" in stripped, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #7631: the same-format /v1/responses lane (source === target === OPENAI_RESPONSES)
|
||||
// skips translateRequest's hub-and-spoke translation block entirely, so a stray
|
||||
// top-level `reasoning_effort` (set upstream by applyNoThinkingAlias on the OpenAI
|
||||
// path, before model-format resolution knows the target lane is Responses-native)
|
||||
// must still be promoted into the Responses-shaped `reasoning.effort`, or thinking
|
||||
// suppression silently does not take effect on that lane.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("7631: translateRequest promotes a stray top-level reasoning_effort into reasoning.effort on the same-format OPENAI_RESPONSES lane", async () => {
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5.1-codex",
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
reasoning_effort: "none",
|
||||
};
|
||||
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
body
|
||||
);
|
||||
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
assert.deepEqual(result.reasoning, { effort: "none" });
|
||||
});
|
||||
|
||||
test("7631: same-format OPENAI_RESPONSES lane leaves an explicit reasoning object untouched (no double-promotion)", async () => {
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5.1-codex",
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
};
|
||||
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
body
|
||||
);
|
||||
|
||||
assert.deepEqual(result.reasoning, { effort: "high", summary: "auto" });
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
});
|
||||
|
||||
test("7631: cross-format openai -> openai-responses promotion is unchanged (no regression)", async () => {
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
reasoning_effort: "none",
|
||||
};
|
||||
|
||||
const result = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, "gpt-5.1-codex", body);
|
||||
|
||||
assert.equal("reasoning_effort" in result, false);
|
||||
assert.deepEqual(result.reasoning, { effort: "none" });
|
||||
});
|
||||
@@ -59,7 +59,7 @@ test("applyNoThinkingAlias rewrites the model and disables thinking (Claude form
|
||||
assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped");
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias strips reasoning fields without a thinking block (OpenAI format)", () => {
|
||||
test("applyNoThinkingAlias expresses reasoning_effort:none without a thinking block (OpenAI format)", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "no-think/openai/gpt-5.4",
|
||||
reasoning_effort: "high",
|
||||
@@ -70,8 +70,11 @@ test("applyNoThinkingAlias strips reasoning fields without a thinking block (Ope
|
||||
assert.equal(res.applied, true);
|
||||
assert.equal(body.model, "openai/gpt-5.4");
|
||||
assert.ok(!("thinking" in body), "no Claude thinking block on an OpenAI body");
|
||||
assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped");
|
||||
assert.ok(!("reasoning" in body), "reasoning must be stripped");
|
||||
// #6879: a thinks-by-default OpenAI-shape model must carry reasoning_effort:"none"
|
||||
// explicitly (not merely have the field deleted), so suppression actually takes
|
||||
// effect downstream; the Responses-shaped `reasoning` object is still dropped.
|
||||
assert.equal(body.reasoning_effort, "none", "reasoning_effort must express none, not be stripped");
|
||||
assert.ok(!("reasoning" in body), "reasoning object must be dropped");
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias is a no-op for plain models", () => {
|
||||
|
||||
Reference in New Issue
Block a user