use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use miette::{Result, miette};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use tracing::warn;
use super::io::{
default_rate_limit_backoff, format_request_error, looks_like_context_window_error,
non_empty_string, normalize_sse_buffer, parse_retry_after_seconds, take_next_sse_event,
truncate_for_error, truncate_for_json_error,
};
use super::payload::{flatten_tool_result_as_assistant_text, image_part_data_url};
use super::{extract_json_value_from_content, shared_request_rate_limiter};
use crate::context::Context;
use crate::context_budget::{
ContextBudgetExceededError, RequestBudgetLimits, estimate_agent_turn_request,
estimate_prompt_request,
};
use crate::core::{Llm, TokenUsage, TokenUsageInfo};
use crate::model_catalog::catalog_model_capacity;
use crate::reasoning::runtime::{
AgentContent, AgentContentPart, AgentMessage, AgentToolCall, AgentToolInputSpec, AgentToolSpec,
AgentTurnItem, AgentTurnRequest, AgentTurnStreamResult, HistoryMessage, PromptRequest,
};
pub(crate) struct ResponsesCompatibleClient {
client: reqwest::Client,
api_key: String,
base_url: String,
model: String,
thinking_budget: Option<String>,
rpm: Option<usize>,
stream_idle_timeout: Duration,
context_window_tokens: usize,
effective_context_window_tokens: usize,
auto_compact_threshold_tokens: usize,
reserved_output_tokens: usize,
request_rate_limiter: Option<Arc<Mutex<VecDeque<Instant>>>>,
token_usage: std::sync::Mutex<TokenUsageInfo>,
supports_vision: std::sync::atomic::AtomicBool,
}
impl ResponsesCompatibleClient {
pub(crate) fn new(
api_key: &str,
base_url: &str,
model_config: &crate::config::ModelConfig,
) -> Self {
let base_url = crate::config::normalize_provider_base_url(base_url);
let request_timeout = Duration::from_secs(model_config.request_timeout_secs());
let stream_idle_timeout = Duration::from_secs(model_config.stream_idle_timeout_secs());
let client = reqwest::Client::builder()
.timeout(request_timeout)
.build()
.expect("failed to build responses-compatible http client");
let context_window_tokens = model_config.context_window_tokens();
let effective_context_window_tokens = model_config.effective_context_window_tokens();
let auto_compact_threshold_tokens = model_config.auto_compact_token_limit();
let reserved_output_tokens = model_config.reserved_output_tokens();
let supports_vision = match model_config.supports_vision {
Some(v) => v,
None => catalog_model_capacity(&model_config.model_id)
.map(|c| c.supports_vision)
.unwrap_or(false),
};
Self {
client,
api_key: api_key.to_string(),
base_url: base_url.clone(),
model: model_config.model_id.clone(),
thinking_budget: model_config
.thinking_budget()
.map(|budget| budget.as_str().to_string()),
rpm: model_config.rpm(),
stream_idle_timeout,
context_window_tokens,
effective_context_window_tokens,
auto_compact_threshold_tokens,
reserved_output_tokens,
request_rate_limiter: shared_request_rate_limiter(
&base_url,
&model_config.model_id,
model_config.rpm(),
),
token_usage: std::sync::Mutex::new(TokenUsageInfo {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
model_context_window: Some(context_window_tokens as i64),
daily_token_usage: Vec::new(),
}),
supports_vision: std::sync::atomic::AtomicBool::new(supports_vision),
}
}
fn url(&self) -> String {
format!("{}/responses", self.base_url.trim_end_matches('/'))
}
fn request_budget_limits(&self) -> RequestBudgetLimits {
RequestBudgetLimits {
context_window_tokens: self.effective_context_window_tokens,
auto_compact_threshold_tokens: self.auto_compact_threshold_tokens,
reserved_output_tokens: self.reserved_output_tokens,
}
}
async fn wait_for_request_slot(&self, _request_context: &[String]) {
let Some(limiter) = &self.request_rate_limiter else {
return;
};
let Some(rpm) = self.rpm else {
return;
};
let window = Duration::from_secs(60);
loop {
let mut queue = limiter.lock().await;
let now = Instant::now();
queue.retain(|t| now.duration_since(*t) < window);
if queue.len() < rpm {
queue.push_back(now);
return;
}
let oldest = *queue.front().unwrap();
let wait = window - now.duration_since(oldest);
drop(queue);
tokio::time::sleep(wait).await;
}
}
async fn post_responses_with_retry(
&self,
payload: &Value,
request_context: &[String],
) -> Result<reqwest::Response> {
const MAX_429_RETRIES: usize = 4;
const MAX_5XX_RETRIES: usize = 3;
let url = self.url();
let mut rate_limit_attempt = 0usize;
let mut transient_attempt = 0usize;
loop {
self.wait_for_request_slot(request_context).await;
let response = self
.client
.post(&url)
.bearer_auth(&self.api_key)
.json(payload)
.send()
.await
.map_err(|err| {
format_request_error(
"responses-compatible request failed",
&url,
request_context,
&err,
)
})?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(parse_retry_after_seconds);
let body = response
.text()
.await
.map_err(|err| miette!("responses-compatible body read failed: {err}"))?;
if rate_limit_attempt >= MAX_429_RETRIES {
return Err(miette!(
"responses-compatible returned HTTP 429 after {} retries: {}",
MAX_429_RETRIES,
truncate_for_error(&body)
));
}
let delay = retry_after
.map(Duration::from_secs)
.unwrap_or_else(|| default_rate_limit_backoff(rate_limit_attempt));
warn!(
"responses-compatible returned HTTP 429; retrying in {} ms (attempt {}/{})\n{}",
delay.as_millis(),
rate_limit_attempt + 1,
MAX_429_RETRIES,
request_context.join("\n")
);
tokio::time::sleep(delay).await;
rate_limit_attempt += 1;
continue;
}
if response.status().is_server_error() {
let status = response.status();
let body = response
.text()
.await
.map_err(|err| miette!("responses-compatible body read failed: {err}"))?;
if transient_attempt >= MAX_5XX_RETRIES {
return Err(miette!(
"responses-compatible returned HTTP {} after {} retries: {}",
status,
MAX_5XX_RETRIES,
truncate_for_error(&body)
));
}
let delay = Duration::from_millis(400 * (1u64 << transient_attempt));
warn!(
"responses-compatible returned HTTP {}; retrying in {} ms (attempt {}/{})\n{}",
status,
delay.as_millis(),
transient_attempt + 1,
MAX_5XX_RETRIES,
request_context.join("\n")
);
tokio::time::sleep(delay).await;
transient_attempt += 1;
continue;
}
return Ok(response);
}
}
fn record_last_usage(&self, usage: TokenUsage) {
if let Ok(mut info) = self.token_usage.lock() {
info.model_context_window = Some(self.context_window_tokens as i64);
info.append_last_usage(usage);
}
}
async fn parse_responses_stream(
&self,
context: Option<&Context>,
response: reqwest::Response,
emit_progress: bool,
) -> Result<AgentTurnStreamResult> {
let url = self.url();
let mut buffer = String::new();
let mut delta_content = String::new();
let mut output_messages = Vec::new();
let mut reasoning_content = String::new();
let mut tool_calls = Vec::new();
let mut completed = false;
let mut last_assistant_progress_emit_at = Instant::now();
let mut last_assistant_progress_char_len = 0usize;
let mut last_reasoning_progress_emit_at = Instant::now();
let mut last_reasoning_progress_char_len = 0usize;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while !completed {
let next_chunk = tokio::time::timeout(self.stream_idle_timeout, stream.next())
.await
.map_err(|_| {
miette!(
"responses-compatible stream stalled for over {}s (model={}, url={})",
self.stream_idle_timeout.as_secs(),
self.model,
url
)
})?;
let Some(chunk) = next_chunk else {
break;
};
let chunk =
chunk.map_err(|err| miette!("responses-compatible stream read failed: {err}"))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
normalize_sse_buffer(&mut buffer);
while let Some(event) = take_next_sse_event(&mut buffer) {
let data = event
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() {
continue;
}
if data == "[DONE]" {
completed = true;
break;
}
let value: Value = serde_json::from_str(&data).map_err(|err| {
miette!(
"responses-compatible stream event is not valid JSON: {err}; data={}",
truncate_for_error(&data)
)
})?;
match responses_event_kind(&value) {
Some("response.output_text.delta") => {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
delta_content.push_str(delta);
if emit_progress {
let should_emit = delta_content
.chars()
.count()
.saturating_sub(last_assistant_progress_char_len)
>= 64
|| last_assistant_progress_emit_at.elapsed()
>= Duration::from_millis(800);
if should_emit && !delta_content.trim().is_empty() {
if let Some(context) = context {
context.emit_live_assistant_progress(&delta_content);
}
last_assistant_progress_emit_at = Instant::now();
last_assistant_progress_char_len =
delta_content.chars().count();
}
}
}
}
Some("response.reasoning_summary_text.delta")
| Some("response.reasoning_text.delta") => {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
reasoning_content.push_str(delta);
if emit_progress {
let should_emit = reasoning_content
.chars()
.count()
.saturating_sub(last_reasoning_progress_char_len)
>= 64
|| last_reasoning_progress_emit_at.elapsed()
>= Duration::from_millis(800);
if should_emit && !reasoning_content.trim().is_empty() {
if let Some(context) = context {
context.emit_live_reasoning_progress(&reasoning_content);
}
last_reasoning_progress_emit_at = Instant::now();
last_reasoning_progress_char_len =
reasoning_content.chars().count();
}
}
}
}
Some("response.output_item.done") => {
if let Some(item) = value.get("item") {
if let Some(message) = response_item_message_text(item) {
output_messages.push(message);
}
if let Some(call) = response_item_tool_call(item)? {
tool_calls.push(call);
}
}
}
Some("response.completed") => {
completed = true;
if let Some(usage) = value
.get("response")
.and_then(|response| response.get("usage"))
.and_then(parse_responses_usage)
{
self.record_last_usage(usage);
}
}
Some("response.failed") | Some("response.incomplete") => {
return Err(miette!(
"responses-compatible stream failed: {}",
truncate_for_json_error(&value)
));
}
_ => {}
}
}
}
if emit_progress
&& !reasoning_content.trim().is_empty()
&& reasoning_content.chars().count() != last_reasoning_progress_char_len
&& let Some(context) = context
{
context.emit_live_reasoning_progress(&reasoning_content);
}
if emit_progress
&& !delta_content.trim().is_empty()
&& delta_content.chars().count() != last_assistant_progress_char_len
&& let Some(context) = context
{
context.emit_live_assistant_progress(&delta_content);
}
let content = if output_messages.is_empty() {
delta_content
} else {
output_messages.join("\n\n")
};
let assistant_message = non_empty_string(content);
let mut items =
Vec::with_capacity(tool_calls.len() + usize::from(assistant_message.is_some()));
if let Some(content) = assistant_message.clone() {
items.push(AgentTurnItem::AssistantMessage { content });
}
items.extend(
tool_calls
.iter()
.cloned()
.map(|call| AgentTurnItem::ToolCall { call }),
);
Ok(AgentTurnStreamResult {
items,
raw_stream_follow_up: !tool_calls.is_empty(),
last_assistant_message: assistant_message,
last_reasoning_content: non_empty_string(reasoning_content),
})
}
}
#[async_trait]
impl Llm for ResponsesCompatibleClient {
async fn run_json(&self, context: &Context, request: PromptRequest) -> Result<Value> {
let budget = estimate_prompt_request(&request, self.request_budget_limits());
if !budget.within_context_window() {
return Err(ContextBudgetExceededError::for_request(
"prompt request",
&self.model,
&budget,
None,
)
.into());
}
let request_context = super::io::summarize_prompt_request(&request, Some(&budget));
let (instructions, input) =
history_messages_to_responses_parts(request.all_messages(), false);
let mut payload = base_payload(self, instructions, input, Vec::new());
payload["text"] = json!({
"format": {
"type": "json_schema",
"name": sanitize_text_format_name(&request.tool_name),
"strict": true,
"schema": request.output_schema.clone(),
}
});
let response = self
.post_responses_with_retry(&payload, &request_context)
.await?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|err| miette!("responses-compatible body read failed: {err}"))?;
return Err(miette!(
"responses-compatible returned HTTP {}: {}",
status,
truncate_for_error(&body)
));
}
let result = self
.parse_responses_stream(Some(context), response, false)
.await?;
let content = result.last_assistant_message.as_deref().unwrap_or_default();
if let Some(value) = extract_json_value_from_content(content) {
return Ok(value);
}
Err(miette!(
"responses-compatible JSON request did not return a JSON object; content={}",
truncate_for_error(content)
))
}
async fn run_agent_turn(
&self,
context: &Context,
request: AgentTurnRequest,
) -> Result<AgentTurnStreamResult> {
let budget = estimate_agent_turn_request(
&request.messages,
&request.tools,
self.request_budget_limits(),
)
.with_calibrated_input_tokens(&context.token_estimate_baseline);
if !budget.within_context_window() {
return Err(ContextBudgetExceededError::for_request(
"agent turn",
&self.model,
&budget,
None,
)
.into());
}
let request_context = super::io::summarize_agent_turn_request(&request, Some(&budget));
use std::sync::atomic::Ordering;
let strip_images = !self.supports_vision.load(Ordering::Relaxed);
let payload = build_agent_payload(self, request.clone(), strip_images);
let response = self
.post_responses_with_retry(&payload, &request_context)
.await?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|err| miette!("responses-compatible body read failed: {err}"))?;
if looks_like_context_window_error(&body) {
return Err(ContextBudgetExceededError::for_request(
"agent turn",
&self.model,
&budget,
Some(&format!(
"provider_status={status}; provider_body={}",
truncate_for_error(&body)
)),
)
.into());
}
if super::io::looks_like_vision_unsupported_error(&body)
&& self.supports_vision.load(Ordering::Relaxed)
{
self.supports_vision.store(false, Ordering::Relaxed);
warn!(
"responses-compatible rejected image input; retrying without images\n{}",
request_context.join("\n")
);
let payload = build_agent_payload(self, request, true);
let response = self
.post_responses_with_retry(&payload, &request_context)
.await?;
let status = response.status();
if status.is_success() {
return self
.parse_responses_stream(Some(context), response, true)
.await;
}
let body = response
.text()
.await
.map_err(|err| miette!("responses-compatible body read failed: {err}"))?;
return Err(miette!(
"responses-compatible returned HTTP {}: {}",
status,
truncate_for_error(&body)
));
}
return Err(miette!(
"responses-compatible returned HTTP {}: {}",
status,
truncate_for_error(&body)
));
}
self.parse_responses_stream(Some(context), response, true)
.await
}
fn token_usage_info(&self) -> Option<TokenUsageInfo> {
self.token_usage.lock().ok().map(|info| info.clone())
}
fn model_name(&self) -> Option<String> {
Some(self.model.clone())
}
}
fn base_payload(
client: &ResponsesCompatibleClient,
instructions: String,
input: Vec<Value>,
tools: Vec<Value>,
) -> Value {
let mut payload = json!({
"model": client.model,
"instructions": instructions,
"input": input,
"tools": tools,
"tool_choice": "auto",
"parallel_tool_calls": true,
"store": false,
"stream": true,
});
if let Some(budget) = client.thinking_budget.as_deref()
&& !budget.eq_ignore_ascii_case("none")
{
payload["reasoning"] = json!({ "effort": budget });
}
payload
}
fn build_agent_payload(
client: &ResponsesCompatibleClient,
request: AgentTurnRequest,
strip_images: bool,
) -> Value {
let (instructions, input) = agent_messages_to_responses_parts(request.messages, strip_images);
let tools = request
.tools
.into_iter()
.map(agent_tool_to_responses_tool)
.collect::<Vec<_>>();
base_payload(client, instructions, input, tools)
}
fn history_messages_to_responses_parts(
messages: Vec<HistoryMessage>,
strip_images: bool,
) -> (String, Vec<Value>) {
let messages = messages
.into_iter()
.map(|message| message.message)
.collect::<Vec<_>>();
agent_messages_to_responses_parts(messages, strip_images)
}
fn agent_messages_to_responses_parts(
messages: Vec<AgentMessage>,
strip_images: bool,
) -> (String, Vec<Value>) {
let mut instructions = Vec::new();
let mut input = Vec::new();
let mut valid_tool_call_ids = HashSet::new();
for message in messages {
match message {
AgentMessage::System { content } => instructions.push(content),
AgentMessage::User { content } => {
input.push(responses_user_message(content, strip_images))
}
AgentMessage::Assistant { content } => {
input.push(responses_message("assistant", "output_text", content));
}
AgentMessage::AssistantToolCallProtocol { content, calls, .. } => {
if let Some(content) = content.filter(|content| !content.trim().is_empty()) {
input.push(responses_message("assistant", "output_text", content));
}
for call in calls {
valid_tool_call_ids.insert(call.id.clone());
input.push(json!({
"type": "function_call",
"call_id": call.id,
"name": call.name,
"arguments": call.arguments.to_string(),
}));
}
}
AgentMessage::Tool {
tool_call_id,
name,
content,
} => {
if valid_tool_call_ids.contains(&tool_call_id) {
input.push(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": content,
}));
} else {
input.push(responses_message(
"assistant",
"output_text",
flatten_tool_result_as_assistant_text(&name, &content),
));
}
}
}
}
(instructions.join("\n\n"), input)
}
fn responses_message(role: &str, content_type: &str, text: String) -> Value {
json!({
"type": "message",
"role": role,
"content": [{
"type": content_type,
"text": text,
}],
})
}
fn responses_user_message(content: AgentContent, strip_images: bool) -> Value {
if content.is_plain_text() {
return responses_message("user", "input_text", content.as_text().to_string());
}
let mut parts = Vec::new();
if !content.as_text().trim().is_empty() {
parts.push(json!({
"type": "input_text",
"text": content.as_text(),
}));
}
for part in content.parts() {
match part {
AgentContentPart::Text { text } => {
parts.push(json!({
"type": "input_text",
"text": text,
}));
}
AgentContentPart::Image {
path, description, ..
} => {
if strip_images {
parts.push(json!({
"type": "input_text",
"text": format!(
"[image: {}]",
description.as_deref().unwrap_or(path)
),
}));
continue;
}
let Some(url) = image_part_data_url(part) else {
parts.push(json!({
"type": "input_text",
"text": format!(
"[image attachment unavailable: {}]",
description.as_deref().unwrap_or(path)
),
}));
continue;
};
parts.push(json!({
"type": "input_image",
"image_url": url,
}));
}
}
}
json!({
"type": "message",
"role": "user",
"content": parts,
})
}
fn agent_tool_to_responses_tool(tool: AgentToolSpec) -> Value {
match tool.input_spec {
AgentToolInputSpec::JsonSchema { schema } => json!({
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": schema,
}),
AgentToolInputSpec::FreeformGrammar {
syntax,
definition,
fallback_schema,
} => {
json!({
"type": "function",
"name": tool.name,
"description": format!(
"{}\n\nThis is a FREEFORM grammar tool. Put the complete tool input in the `input` field.\nsyntax={syntax}\ndefinition=\n{definition}",
tool.description
),
"parameters": fallback_schema,
})
}
}
}
fn responses_event_kind(value: &Value) -> Option<&str> {
value.get("type").and_then(Value::as_str)
}
fn response_item_message_text(item: &Value) -> Option<String> {
if item.get("type").and_then(Value::as_str) != Some("message") {
return None;
}
let text = item
.get("content")
.and_then(Value::as_array)
.into_iter()
.flat_map(|content| content.iter())
.filter_map(|part| match part.get("type").and_then(Value::as_str) {
Some("output_text") | Some("input_text") => part.get("text").and_then(Value::as_str),
_ => None,
})
.collect::<Vec<_>>()
.join("");
(!text.trim().is_empty()).then_some(text)
}
fn response_item_tool_call(item: &Value) -> Result<Option<AgentToolCall>> {
match item.get("type").and_then(Value::as_str) {
Some("function_call") => {
let id = item
.get("call_id")
.and_then(Value::as_str)
.or_else(|| item.get("id").and_then(Value::as_str))
.ok_or_else(|| {
miette!(
"responses-compatible function_call missing call_id; item={}",
truncate_for_json_error(item)
)
})?;
let name = item.get("name").and_then(Value::as_str).ok_or_else(|| {
miette!(
"responses-compatible function_call missing name; item={}",
truncate_for_json_error(item)
)
})?;
let arguments_str = item
.get("arguments")
.and_then(Value::as_str)
.unwrap_or("{}");
let arguments = serde_json::from_str(arguments_str).map_err(|err| {
miette!(
"failed to decode responses-compatible function_call arguments as JSON: {err}; arguments={}",
truncate_for_error(arguments_str)
)
})?;
Ok(Some(AgentToolCall {
id: id.to_string(),
name: name.to_string(),
arguments,
}))
}
Some("custom_tool_call") => {
let id = item
.get("call_id")
.and_then(Value::as_str)
.or_else(|| item.get("id").and_then(Value::as_str))
.ok_or_else(|| {
miette!(
"responses-compatible custom_tool_call missing call_id; item={}",
truncate_for_json_error(item)
)
})?;
let name = item.get("name").and_then(Value::as_str).ok_or_else(|| {
miette!(
"responses-compatible custom_tool_call missing name; item={}",
truncate_for_json_error(item)
)
})?;
let input = item
.get("input")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Ok(Some(AgentToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: Value::String(input),
}))
}
_ => Ok(None),
}
}
fn parse_responses_usage(usage: &Value) -> Option<TokenUsage> {
let input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_i64)
.unwrap_or_default();
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_i64)
.unwrap_or_default();
let total_tokens = usage
.get("total_tokens")
.and_then(Value::as_i64)
.unwrap_or_else(|| input_tokens + output_tokens);
let cached_input_tokens = usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(|value| value.get("cached_tokens"))
.and_then(Value::as_i64)
.unwrap_or_default();
let reasoning_output_tokens = usage
.get("output_tokens_details")
.or_else(|| usage.get("completion_tokens_details"))
.and_then(|value| value.get("reasoning_tokens"))
.and_then(Value::as_i64)
.unwrap_or_default();
let usage = TokenUsage {
input_tokens,
cached_input_tokens,
output_tokens,
reasoning_output_tokens,
total_tokens,
};
if usage.is_zero() { None } else { Some(usage) }
}
fn sanitize_text_format_name(name: &str) -> String {
let mut out = String::new();
for ch in name.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"response".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ModelConfig;
#[test]
fn responses_compat_payload_omits_max_output_tokens_parameter() {
let client = ResponsesCompatibleClient::new(
"test-key",
"https://example.test/v1",
&ModelConfig {
model_id: "gpt-5.5".to_string(),
provider: "responses-compatible".to_string(),
max_completion_tokens: 128_000,
..ModelConfig::default()
},
);
let payload = base_payload(&client, "instructions".to_string(), vec![], vec![]);
assert!(payload.get("max_output_tokens").is_none(), "{payload:#}");
}
}