Fix Mentra nested Responses endpoints
This commit is contained in:
541
vendor/mentra-provider/src/request.rs
vendored
Normal file
541
vendor/mentra-provider/src/request.rs
vendored
Normal file
@@ -0,0 +1,541 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::ContentBlock;
|
||||
use crate::Message;
|
||||
use crate::ProviderError;
|
||||
use crate::model::ToolChoice;
|
||||
use crate::tool::ToolSpec;
|
||||
|
||||
/// Provider-neutral reasoning controls supported across multiple providers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReasoningOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<ReasoningEffort>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<ReasoningSummary>,
|
||||
}
|
||||
|
||||
/// Shared reasoning effort levels supported by Mentra's public API.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
/// Shared reasoning summary levels used by Responses-family providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningSummary {
|
||||
Auto,
|
||||
Concise,
|
||||
Detailed,
|
||||
}
|
||||
|
||||
/// Provider-neutral tool search behavior requested for a model call.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolSearchMode {
|
||||
#[default]
|
||||
Disabled,
|
||||
Hosted,
|
||||
}
|
||||
|
||||
/// Responses-compatible verbosity controls for text output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ResponsesVerbosity {
|
||||
Low,
|
||||
#[default]
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// Transport-level request compression supported by Responses-family HTTP calls.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ResponsesRequestCompression {
|
||||
#[default]
|
||||
None,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
/// Provider-side conversation state strategy for Responses-family providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponsesStateMode {
|
||||
/// Send the complete local transcript and do not attach provider-side state.
|
||||
ReplayOnly,
|
||||
/// Keep local replay as the source of truth while opportunistically chaining provider state.
|
||||
///
|
||||
/// An unknown HTTP endpoint may receive one capability probe before Hybrid
|
||||
/// learns that `previous_response_id` is unsupported. Hosts that already
|
||||
/// know this can disable the probe on [`crate::responses::ResponsesProvider`].
|
||||
#[default]
|
||||
Hybrid,
|
||||
/// Require provider-side state chaining once a previous response id is available.
|
||||
Stateful,
|
||||
}
|
||||
|
||||
impl ResponsesStateMode {
|
||||
pub fn uses_provider_state(self) -> bool {
|
||||
matches!(self, Self::Hybrid | Self::Stateful)
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming transport used by Responses-family providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponsesTransport {
|
||||
/// Standard HTTP request with Server-Sent Events response streaming.
|
||||
#[default]
|
||||
HttpSse,
|
||||
/// Long-lived WebSocket connection driven by `response.create` frames.
|
||||
WebSocket,
|
||||
}
|
||||
|
||||
/// Responses-compatible format discriminator for structured text output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponsesTextFormatType {
|
||||
#[default]
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
/// Structured text output format controls for Responses-family providers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ResponsesTextFormat {
|
||||
#[serde(default)]
|
||||
pub r#type: ResponsesTextFormatType,
|
||||
#[serde(default)]
|
||||
pub strict: bool,
|
||||
pub schema: serde_json::Value,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Responses-compatible text controls combining verbosity and output schemas.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ResponsesTextControls {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verbosity: Option<ResponsesVerbosity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<ResponsesTextFormat>,
|
||||
}
|
||||
|
||||
/// Shared Responses-family request options.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResponsesRequestOptions {
|
||||
#[serde(default)]
|
||||
pub parallel_tool_calls: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub previous_response_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state_mode: ResponsesStateMode,
|
||||
#[serde(default)]
|
||||
pub transport: ResponsesTransport,
|
||||
#[serde(default)]
|
||||
pub store: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<ResponsesTextControls>,
|
||||
#[serde(default)]
|
||||
pub compression: ResponsesRequestCompression,
|
||||
}
|
||||
|
||||
impl Default for ResponsesRequestOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parallel_tool_calls: None,
|
||||
previous_response_id: None,
|
||||
state_mode: ResponsesStateMode::Hybrid,
|
||||
transport: ResponsesTransport::HttpSse,
|
||||
store: None,
|
||||
stream: Some(true),
|
||||
include: Vec::new(),
|
||||
service_tier: None,
|
||||
prompt_cache_key: None,
|
||||
text: None,
|
||||
compression: ResponsesRequestCompression::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic-specific request options.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AnthropicRequestOptions {
|
||||
#[serde(default)]
|
||||
pub disable_parallel_tool_use: Option<bool>,
|
||||
}
|
||||
|
||||
/// Gemini-specific request options.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct GeminiRequestOptions {
|
||||
#[serde(default)]
|
||||
pub thoughts: Option<bool>,
|
||||
}
|
||||
|
||||
/// Provider-neutral session metadata and affinity hints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SessionRequestOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sticky_turn_state: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_metadata: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subagent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prefer_connection_reuse: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_affinity: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra_headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Provider-specific request options that should be forwarded on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderRequestOptions {
|
||||
#[serde(default)]
|
||||
pub tool_search_mode: ToolSearchMode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningOptions>,
|
||||
#[serde(default)]
|
||||
pub responses: ResponsesRequestOptions,
|
||||
#[serde(default)]
|
||||
pub anthropic: AnthropicRequestOptions,
|
||||
#[serde(default)]
|
||||
pub gemini: GeminiRequestOptions,
|
||||
#[serde(default)]
|
||||
pub session: SessionRequestOptions,
|
||||
}
|
||||
|
||||
/// Provider request assembled by the runtime before dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
pub model: Cow<'a, str>,
|
||||
pub system: Option<Cow<'a, str>>,
|
||||
pub messages: Cow<'a, [Message]>,
|
||||
pub tools: Cow<'a, [ToolSpec]>,
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub metadata: Cow<'a, BTreeMap<String, String>>,
|
||||
pub provider_request_options: ProviderRequestOptions,
|
||||
}
|
||||
|
||||
impl Request<'_> {
|
||||
pub fn into_owned(self) -> Request<'static> {
|
||||
Request {
|
||||
model: Cow::Owned(self.model.into_owned()),
|
||||
system: self.system.map(|system| Cow::Owned(system.into_owned())),
|
||||
messages: Cow::Owned(self.messages.into_owned()),
|
||||
tools: Cow::Owned(self.tools.into_owned()),
|
||||
tool_choice: self.tool_choice,
|
||||
temperature: self.temperature,
|
||||
max_output_tokens: self.max_output_tokens,
|
||||
metadata: Cow::Owned(self.metadata.into_owned()),
|
||||
provider_request_options: self.provider_request_options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-neutral transcript item used for history compaction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CompactionInputItem {
|
||||
UserTurn {
|
||||
content: String,
|
||||
},
|
||||
AssistantTurn {
|
||||
content: String,
|
||||
},
|
||||
ToolExchange {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request: Option<String>,
|
||||
result: String,
|
||||
is_error: bool,
|
||||
},
|
||||
CanonicalContext {
|
||||
content: String,
|
||||
},
|
||||
MemoryRecall {
|
||||
content: String,
|
||||
},
|
||||
DelegationResult {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
role: Option<String>,
|
||||
status: String,
|
||||
content: String,
|
||||
},
|
||||
CompactionSummary {
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provider-neutral request assembled for history compaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionRequest<'a> {
|
||||
pub model: Cow<'a, str>,
|
||||
pub instructions: Cow<'a, str>,
|
||||
pub input: Cow<'a, [CompactionInputItem]>,
|
||||
pub metadata: Cow<'a, BTreeMap<String, String>>,
|
||||
pub provider_request_options: ProviderRequestOptions,
|
||||
}
|
||||
|
||||
impl CompactionRequest<'_> {
|
||||
/// Converts a compaction request into an ordinary model request.
|
||||
pub fn into_model_request(self) -> Result<Request<'static>, ProviderError> {
|
||||
let input_json =
|
||||
serde_json::to_string(self.input.as_ref()).map_err(ProviderError::Serialize)?;
|
||||
|
||||
Ok(Request {
|
||||
model: Cow::Owned(self.model.into_owned()),
|
||||
system: Some(Cow::Owned(self.instructions.into_owned())),
|
||||
messages: Cow::Owned(vec![Message::user(ContentBlock::text(format!(
|
||||
"Compaction input JSON:\n{input_json}"
|
||||
)))]),
|
||||
tools: Cow::Owned(Vec::new()),
|
||||
tool_choice: None,
|
||||
temperature: None,
|
||||
max_output_tokens: None,
|
||||
metadata: Cow::Owned(self.metadata.into_owned()),
|
||||
provider_request_options: self.provider_request_options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical raw memory payload used by memory summarization requests.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RawMemory {
|
||||
pub id: String,
|
||||
pub metadata: RawMemoryMetadata,
|
||||
pub items: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RawMemoryMetadata {
|
||||
pub source_path: String,
|
||||
}
|
||||
|
||||
/// Provider-neutral request assembled for trace memory summarization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemorySummarizeRequest<'a> {
|
||||
pub model: Cow<'a, str>,
|
||||
pub raw_memories: Cow<'a, [RawMemory]>,
|
||||
pub reasoning: Option<ReasoningOptions>,
|
||||
pub metadata: Cow<'a, BTreeMap<String, String>>,
|
||||
pub provider_request_options: ProviderRequestOptions,
|
||||
}
|
||||
|
||||
impl MemorySummarizeRequest<'_> {
|
||||
/// Converts a memory summarize request into an ordinary model request.
|
||||
pub fn into_model_request(self) -> Result<Request<'static>, ProviderError> {
|
||||
let raw_memories_json =
|
||||
serde_json::to_string(self.raw_memories.as_ref()).map_err(ProviderError::Serialize)?;
|
||||
|
||||
Ok(Request {
|
||||
model: Cow::Owned(self.model.into_owned()),
|
||||
system: Some(Cow::Borrowed(MEMORY_SUMMARIZE_SYSTEM_PROMPT)),
|
||||
messages: Cow::Owned(vec![Message::user(ContentBlock::text(format!(
|
||||
"Memory summarize input JSON:\n{raw_memories_json}"
|
||||
)))]),
|
||||
tools: Cow::Owned(Vec::new()),
|
||||
tool_choice: None,
|
||||
temperature: None,
|
||||
max_output_tokens: None,
|
||||
metadata: Cow::Owned(self.metadata.into_owned()),
|
||||
provider_request_options: ProviderRequestOptions {
|
||||
reasoning: self.reasoning,
|
||||
..self.provider_request_options
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const MEMORY_SUMMARIZE_SYSTEM_PROMPT: &str = concat!(
|
||||
"You summarize trace memories for Codex.\n",
|
||||
"Return valid JSON only.\n",
|
||||
"The output must be a JSON array with one object per input trace, in the same order.\n",
|
||||
"Each object must have exactly these string fields: `raw_memory` and `memory_summary`.\n",
|
||||
"`raw_memory` should be a concrete, detailed summary of the trace contents.\n",
|
||||
"`memory_summary` should be a shorter durable takeaway focused on reusable context.\n",
|
||||
"Use empty strings when information is unavailable.\n",
|
||||
"Do not include markdown fences or extra commentary.\n",
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_uses_the_five_public_spellings() {
|
||||
for (effort, expected) in [
|
||||
(ReasoningEffort::Low, "low"),
|
||||
(ReasoningEffort::Medium, "medium"),
|
||||
(ReasoningEffort::High, "high"),
|
||||
(ReasoningEffort::XHigh, "xhigh"),
|
||||
(ReasoningEffort::Max, "max"),
|
||||
] {
|
||||
let serialized = serde_json::to_string(&effort).expect("effort should serialize");
|
||||
assert_eq!(serialized, format!("\"{expected}\""));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ReasoningEffort>(&serialized)
|
||||
.expect("effort should deserialize"),
|
||||
effort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_request_into_model_request_serializes_input_as_prompt_text() {
|
||||
let request = CompactionRequest {
|
||||
model: Cow::Borrowed("gpt-5"),
|
||||
instructions: Cow::Borrowed("Summarize the transcript."),
|
||||
input: Cow::Owned(vec![
|
||||
CompactionInputItem::UserTurn {
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
CompactionInputItem::AssistantTurn {
|
||||
content: "world".to_string(),
|
||||
},
|
||||
]),
|
||||
metadata: Cow::Owned(BTreeMap::from([("scope".to_string(), "test".to_string())])),
|
||||
provider_request_options: ProviderRequestOptions {
|
||||
session: SessionRequestOptions {
|
||||
sticky_turn_state: Some("sticky".to_string()),
|
||||
turn_metadata: None,
|
||||
subagent: Some("compact".to_string()),
|
||||
prefer_connection_reuse: Some(true),
|
||||
session_affinity: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
},
|
||||
..ProviderRequestOptions::default()
|
||||
},
|
||||
};
|
||||
|
||||
let model_request = request
|
||||
.into_model_request()
|
||||
.expect("compaction request should convert");
|
||||
|
||||
assert_eq!(model_request.model.as_ref(), "gpt-5");
|
||||
assert_eq!(
|
||||
model_request.system.as_deref(),
|
||||
Some("Summarize the transcript.")
|
||||
);
|
||||
assert_eq!(model_request.metadata["scope"], "test");
|
||||
assert_eq!(
|
||||
model_request
|
||||
.provider_request_options
|
||||
.session
|
||||
.sticky_turn_state
|
||||
.as_deref(),
|
||||
Some("sticky")
|
||||
);
|
||||
assert_eq!(
|
||||
model_request
|
||||
.provider_request_options
|
||||
.session
|
||||
.subagent
|
||||
.as_deref(),
|
||||
Some("compact")
|
||||
);
|
||||
assert_eq!(model_request.messages.len(), 1);
|
||||
|
||||
let prompt = model_request.messages[0].text();
|
||||
assert!(prompt.starts_with("Compaction input JSON:\n"));
|
||||
let payload = prompt
|
||||
.strip_prefix("Compaction input JSON:\n")
|
||||
.expect("prompt should contain the compaction prefix");
|
||||
let input: Vec<Value> = serde_json::from_str(payload).expect("prompt should be json");
|
||||
assert_eq!(input[0]["type"], "user_turn");
|
||||
assert_eq!(input[0]["content"], "hello");
|
||||
assert_eq!(input[1]["type"], "assistant_turn");
|
||||
assert_eq!(input[1]["content"], "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_summarize_request_into_model_request_serializes_input_as_prompt_text() {
|
||||
let request = MemorySummarizeRequest {
|
||||
model: Cow::Borrowed("gpt-5"),
|
||||
raw_memories: Cow::Owned(vec![RawMemory {
|
||||
id: "memory-1".to_string(),
|
||||
metadata: RawMemoryMetadata {
|
||||
source_path: "/tmp/trace.jsonl".to_string(),
|
||||
},
|
||||
items: vec![serde_json::json!({"type":"message","role":"user"})],
|
||||
}]),
|
||||
reasoning: Some(ReasoningOptions {
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: None,
|
||||
}),
|
||||
metadata: Cow::Owned(BTreeMap::from([("scope".to_string(), "test".to_string())])),
|
||||
provider_request_options: ProviderRequestOptions {
|
||||
session: SessionRequestOptions {
|
||||
sticky_turn_state: None,
|
||||
turn_metadata: Some("{\"turn_id\":\"t1\"}".to_string()),
|
||||
subagent: None,
|
||||
prefer_connection_reuse: Some(true),
|
||||
session_affinity: Some("thread-1".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
},
|
||||
..ProviderRequestOptions::default()
|
||||
},
|
||||
};
|
||||
|
||||
let model_request = request
|
||||
.into_model_request()
|
||||
.expect("memory summarize request should convert");
|
||||
|
||||
assert_eq!(model_request.model.as_ref(), "gpt-5");
|
||||
assert_eq!(
|
||||
model_request.system.as_deref(),
|
||||
Some(MEMORY_SUMMARIZE_SYSTEM_PROMPT)
|
||||
);
|
||||
assert_eq!(model_request.metadata["scope"], "test");
|
||||
assert_eq!(
|
||||
model_request
|
||||
.provider_request_options
|
||||
.session
|
||||
.turn_metadata
|
||||
.as_deref(),
|
||||
Some("{\"turn_id\":\"t1\"}")
|
||||
);
|
||||
assert_eq!(
|
||||
model_request
|
||||
.provider_request_options
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.expect("reasoning options")
|
||||
.effort,
|
||||
Some(ReasoningEffort::Medium)
|
||||
);
|
||||
assert_eq!(model_request.messages.len(), 1);
|
||||
|
||||
let prompt = model_request.messages[0].text();
|
||||
assert!(prompt.starts_with("Memory summarize input JSON:\n"));
|
||||
let payload = prompt
|
||||
.strip_prefix("Memory summarize input JSON:\n")
|
||||
.expect("prompt should contain the memory summarize prefix");
|
||||
let input: Vec<RawMemory> = serde_json::from_str(payload).expect("prompt should be json");
|
||||
assert_eq!(input[0].id, "memory-1");
|
||||
assert_eq!(input[0].metadata.source_path, "/tmp/trace.jsonl");
|
||||
assert_eq!(input[0].items[0]["role"], "user");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user