use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use crate::cm_api_contract::api::ApiError;
use crate::cm_api_contract::chat_keys::{
reject_unknown_async_chat_body_keys, reject_unknown_chat_body_keys,
};
fn schema_open_json_object(_gen: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "object",
"additionalProperties": true,
"description": "键为题目的 id,值为字符串(或 JSON 数字/布尔,服务端会规范为字符串)。"
})
}
fn schema_session_mode(_gen: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": ["string", "null"],
"enum": ["ask", "plan", "act"],
"description": "Session capability mode (orthogonal to agent_role). ask/plan → readonly tools; act → full tools ∩ role allowlist. Default from config default_session_mode."
})
}
fn schema_open_object_array(_gen: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
},
"description": "OpenAI 兼容 chat messages 对象数组"
})
}
#[derive(Deserialize, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ClarifyQuestionnaireAnswersBody {
pub questionnaire_id: String,
#[serde(default)]
#[schemars(schema_with = "schema_open_json_object")]
pub answers: serde_json::Value,
}
pub struct ChatRequestBody {
pub message: String,
pub conversation_id: Option<String>,
pub agent_role: Option<String>,
pub session_mode: Option<String>,
pub approval_session_id: Option<String>,
pub temperature: Option<f64>,
pub seed: Option<i64>,
pub seed_policy: Option<String>,
pub client_llm: Option<ClientLlmBody>,
pub executor_llm: Option<ExecutorLlmBody>,
pub readonly_tool_ttl_cache_secs: Option<u64>,
pub stream_resume: Option<StreamResumeBody>,
pub client_sse_protocol: Option<u8>,
pub image_urls: Vec<String>,
pub clarify_questionnaire_answers: Option<ClarifyQuestionnaireAnswersBody>,
}
pub struct ChatAsyncRequestBody {
pub chat: ChatRequestBody,
pub webhook_url: Option<String>,
pub webhook_secret: Option<String>,
}
#[derive(Serialize, JsonSchema)]
pub struct ChatAsyncSubmitResponseBody {
pub job_id: u64,
pub status: &'static str,
pub conversation_id: String,
}
#[derive(Serialize, JsonSchema)]
pub struct ChatJobStatusResponseBody {
pub job_id: u64,
pub status: String,
pub conversation_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_revision: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ApiError>,
}
#[derive(Deserialize, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StreamResumeBody {
pub job_id: u64,
#[serde(default)]
pub after_seq: Option<u64>,
}
#[derive(Deserialize, Default, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ClientLlmBody {
#[serde(default)]
pub api_base: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub llm_context_tokens: Option<u64>,
#[serde(default)]
pub llm_thinking_mode: Option<String>,
}
#[derive(Deserialize, Default, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExecutorLlmBody {
#[serde(default)]
pub api_base: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq, JsonSchema)]
pub struct TiktokenPromptTokensOpenApi {
pub prompt_tokens: u32,
pub tiktoken_model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub used_input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reserved_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_schema_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub counting_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_input_tokens: Option<u64>,
}
pub const CONVERSATION_LAYOUT_SCHEMA_VERSION_V2: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct ConversationLayoutSegment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
pub segment_id: String,
pub segment_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_tool_call_id: Option<String>,
pub sequence: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct ConversationLayoutMeta {
pub layout_schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub segments: Vec<ConversationLayoutSegment>,
}
#[derive(Serialize, JsonSchema)]
pub struct ConversationMessagesResponseBodyOpenApi {
pub conversation_id: String,
pub revision: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_agent_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_session_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tiktoken_prompt_tokens: Option<TiktokenPromptTokensOpenApi>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<ConversationLayoutMeta>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schemars(schema_with = "schema_open_object_array")]
pub context_artifacts: Vec<serde_json::Value>,
#[schemars(schema_with = "schema_open_object_array")]
pub messages: Vec<serde_json::Value>,
#[serde(default)]
pub total_count: u32,
#[serde(default)]
pub window_start_index: u32,
#[serde(default)]
pub has_older: bool,
}
#[derive(JsonSchema)]
#[allow(dead_code)]
pub struct ChatAsyncRequestBodyOpenApi {
#[schemars(flatten)]
chat: ChatRequestBodyWire,
webhook_url: Option<String>,
webhook_secret: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ChatRequestBodyWire {
pub message: String,
#[serde(default)]
pub conversation_id: Option<String>,
#[serde(default, rename = "agent_role")]
pub agent_role: Option<String>,
#[serde(default)]
#[schemars(schema_with = "schema_session_mode")]
pub session_mode: Option<String>,
#[serde(default)]
pub approval_session_id: Option<String>,
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default)]
pub seed: Option<i64>,
#[serde(default)]
pub seed_policy: Option<String>,
#[serde(default)]
pub client_llm: Option<ClientLlmBody>,
#[serde(default)]
pub executor_llm: Option<ExecutorLlmBody>,
#[serde(default)]
pub readonly_tool_ttl_cache_secs: Option<u64>,
#[serde(default)]
pub stream_resume: Option<StreamResumeBody>,
#[serde(default, rename = "client_sse_protocol")]
pub client_sse_protocol: Option<u8>,
#[serde(default)]
pub image_urls: Vec<String>,
#[serde(default)]
pub clarify_questionnaire_answers: Option<ClarifyQuestionnaireAnswersBody>,
}
impl From<ChatRequestBodyWire> for ChatRequestBody {
fn from(s: ChatRequestBodyWire) -> Self {
ChatRequestBody {
message: s.message,
conversation_id: s.conversation_id,
agent_role: s.agent_role,
session_mode: s.session_mode,
approval_session_id: s.approval_session_id,
temperature: s.temperature,
seed: s.seed,
seed_policy: s.seed_policy,
client_llm: s.client_llm,
executor_llm: s.executor_llm,
readonly_tool_ttl_cache_secs: s.readonly_tool_ttl_cache_secs,
stream_resume: s.stream_resume,
client_sse_protocol: s.client_sse_protocol,
image_urls: s.image_urls,
clarify_questionnaire_answers: s.clarify_questionnaire_answers,
}
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ChatApprovalRequestBody {
pub approval_session_id: String,
pub decision: String,
}
#[derive(Serialize, JsonSchema)]
pub struct ChatApprovalResponseBody {
pub ok: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ChatBranchRequestBody {
pub conversation_id: String,
pub before_user_ordinal: u64,
pub expected_revision: u64,
}
#[derive(Serialize, JsonSchema)]
pub struct ChatBranchResponseBody {
pub ok: bool,
pub revision: u64,
}
#[derive(Serialize, JsonSchema)]
pub struct ChatResponseBody {
pub reply: String,
pub conversation_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_revision: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConversationMessagesQuery {
pub conversation_id: String,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub before_index: Option<u32>,
}
#[derive(serde::Serialize)]
pub struct ConversationMessagesResponseBody<M> {
pub conversation_id: String,
pub revision: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_agent_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_session_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tiktoken_prompt_tokens: Option<crate::cm_types::TiktokenPromptTokensSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<ConversationLayoutMeta>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_artifacts: Vec<serde_json::Value>,
pub messages: Vec<M>,
#[serde(default)]
pub total_count: u32,
#[serde(default)]
pub window_start_index: u32,
#[serde(default)]
pub has_older: bool,
}
fn chat_request_body_from_json(v: serde_json::Value) -> Result<ChatRequestBody, String> {
let obj = v
.as_object()
.ok_or_else(|| "expected JSON object".to_string())?;
reject_unknown_chat_body_keys(obj)?;
let inner: ChatRequestBodyWire = serde_json::from_value(v).map_err(|e| e.to_string())?;
Ok(inner.into())
}
fn chat_async_request_body_from_json(v: serde_json::Value) -> Result<ChatAsyncRequestBody, String> {
let mut map = match v.as_object().cloned() {
Some(m) => m,
None => return Err("expected JSON object".to_string()),
};
reject_unknown_async_chat_body_keys(&map)?;
let webhook_url = take_async_webhook_string(&mut map, "webhook_url")?;
let webhook_secret = take_async_webhook_string(&mut map, "webhook_secret")?;
let chat_val = serde_json::Value::Object(map);
let inner: ChatRequestBodyWire = serde_json::from_value(chat_val).map_err(|e| e.to_string())?;
Ok(ChatAsyncRequestBody {
chat: inner.into(),
webhook_url,
webhook_secret,
})
}
fn take_async_webhook_string(
map: &mut serde_json::Map<String, serde_json::Value>,
key: &'static str,
) -> Result<Option<String>, String> {
match map.remove(key) {
None => Ok(None),
Some(v) if v.is_null() => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s)),
Some(_) => Err(format!("{key} 须为 JSON 字符串或省略")),
}
}
impl<'de> Deserialize<'de> for ChatRequestBody {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(deserializer)?;
chat_request_body_from_json(v).map_err(serde::de::Error::custom)
}
}
impl<'de> Deserialize<'de> for ChatAsyncRequestBody {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(deserializer)?;
chat_async_request_body_from_json(v).map_err(serde::de::Error::custom)
}
}