feat: initialize server structure and database schema

- Added initial server setup with Cargo.toml defining dependencies and project structure.
- Created build.rs for generating protobuf bindings and validating wire contracts.
- Established database schema with initial migration files for conversations, messages, and runs.
- Introduced tools and prompts for Cursor functionality, enhancing user interaction capabilities.
This commit is contained in:
leookun
2026-08-30 01:33:39 +08:00
parent d200b3791d
commit 44e2d8057a
206 changed files with 37923 additions and 21 deletions
+15
View File
@@ -0,0 +1,15 @@
//! Defines Checkpoint identity and persistence types.
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct CheckpointId(pub i64);
impl fmt::Display for CheckpointId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
+463
View File
@@ -0,0 +1,463 @@
//! Defines model and provider configuration.
use std::{fmt, str::FromStr};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{Error, Result};
pub const OPENAI_RESPONSES_ENDPOINT: &str = "/v1/responses";
pub const OPENAI_CHAT_ENDPOINT: &str = "/v1/chat/completions";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum ProviderType {
#[serde(rename = "openai-chat")]
OpenAiChat,
#[serde(rename = "openai-responses")]
OpenAiResponses,
#[serde(rename = "anthropic")]
Anthropic,
}
impl ProviderType {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAiChat => "openai-chat",
Self::OpenAiResponses => "openai-responses",
Self::Anthropic => "anthropic",
}
}
}
impl fmt::Display for ProviderType {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ProviderType {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"openai-chat" => Ok(Self::OpenAiChat),
"openai-responses" => Ok(Self::OpenAiResponses),
"anthropic" => Ok(Self::Anthropic),
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ModelType {
OpenAi,
Anthropic,
}
impl ModelType {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::Anthropic => "anthropic",
}
}
}
impl FromStr for ModelType {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"openai" => Ok(Self::OpenAi),
"anthropic" => Ok(Self::Anthropic),
_ => Err(Error::Config(format!("unsupported model type: {value}"))),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ModelConfigInput {
#[serde(default)]
pub sort_order: i64,
pub display_name: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
#[serde(default)]
pub use_full_url: bool,
pub api_key: String,
pub tooltip_data: String,
pub model_id: String,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub openai_endpoint: String,
#[serde(default)]
pub openai_extra_params_enabled: bool,
#[serde(default = "empty_object")]
pub openai_extra_params: serde_json::Value,
#[serde(default)]
pub custom_headers_enabled: bool,
#[serde(default = "empty_object")]
pub custom_headers: serde_json::Value,
#[serde(default)]
pub anthropic_extra_params_enabled: bool,
#[serde(default = "empty_object")]
pub anthropic_extra_params: serde_json::Value,
pub context_window_tokens: Option<u64>,
pub max_completion_tokens: Option<u64>,
pub anthropic_max_tokens: Option<u64>,
#[serde(default)]
pub anthropic_thinking_effort: Option<String>,
pub thinking_budget_tokens: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ModelConfig {
pub model_hash: String,
pub sort_order: i64,
pub display_name: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
pub use_full_url: bool,
pub api_key: String,
pub tooltip_data: String,
pub model_id: String,
pub reasoning_effort: Option<String>,
pub openai_endpoint: String,
pub openai_extra_params_enabled: bool,
pub openai_extra_params: serde_json::Value,
pub custom_headers_enabled: bool,
pub custom_headers: serde_json::Value,
pub anthropic_extra_params_enabled: bool,
pub anthropic_extra_params: serde_json::Value,
pub context_window_tokens: Option<u64>,
pub max_completion_tokens: Option<u64>,
pub anthropic_max_tokens: Option<u64>,
pub anthropic_thinking_effort: Option<String>,
pub thinking_budget_tokens: Option<u64>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
impl ModelConfig {
pub fn provider_type(&self) -> ProviderType {
match self.model_type {
ModelType::Anthropic => ProviderType::Anthropic,
ModelType::OpenAi if self.openai_endpoint == OPENAI_RESPONSES_ENDPOINT => {
ProviderType::OpenAiResponses
}
ModelType::OpenAi => ProviderType::OpenAiChat,
}
}
pub fn request_url(&self) -> Result<String> {
resolve_request_url(
self.model_type,
&self.base_url,
&self.openai_endpoint,
self.use_full_url,
)
}
pub fn max_output_tokens(&self) -> Option<u64> {
match self.model_type {
ModelType::OpenAi => self.max_completion_tokens,
ModelType::Anthropic => self.anthropic_max_tokens.or(self.max_completion_tokens),
}
}
pub fn extra_params(&self) -> &serde_json::Value {
match self.model_type {
ModelType::OpenAi if self.openai_extra_params_enabled => &self.openai_extra_params,
ModelType::Anthropic if self.anthropic_extra_params_enabled => {
&self.anthropic_extra_params
}
_ => empty_object_ref(),
}
}
pub fn configure(&self, model: &mut super::ModelSpec) {
model.display_name = Some(self.display_name.clone());
// A request-selected context is authoritative. Use the saved model
// value only when Cursor did not send a context parameter.
if model.context_window_tokens.is_none() {
model.context_window_tokens = self.context_window_tokens;
}
if model.reasoning.effort.is_none() {
model.reasoning.effort = match self.model_type {
ModelType::OpenAi => self.reasoning_effort.clone(),
ModelType::Anthropic => self.anthropic_thinking_effort.clone(),
};
}
model.reasoning.enabled |= model.reasoning.effort.is_some();
}
}
pub fn normalize_model_input(input: &ModelConfigInput) -> Result<ModelConfigInput> {
let display_name = required(&input.display_name, "model display name")?;
let base_url = normalize_request_url(&input.base_url)?;
let api_key = required(&input.api_key, "model API key")?;
let tooltip_data = required(&input.tooltip_data, "model tooltip")?;
let model_id = required(&input.model_id, "model id")?;
let reasoning_effort = normalize_effort(input.reasoning_effort.as_deref(), true)?;
let anthropic_thinking_effort = match input.model_type {
ModelType::Anthropic => Some(
normalize_effort(
input.anthropic_thinking_effort.as_deref().or(Some("xhigh")),
false,
)?
.expect("Anthropic effort has a default"),
),
ModelType::OpenAi => None,
};
let openai_endpoint = match input.model_type {
ModelType::OpenAi => normalize_openai_endpoint(&input.openai_endpoint)?,
ModelType::Anthropic => String::new(),
};
validate_object(&input.openai_extra_params, "OpenAI extra params")?;
validate_object(&input.anthropic_extra_params, "Anthropic extra params")?;
validate_headers(&input.custom_headers)?;
let normalized = ModelConfigInput {
sort_order: input.sort_order.max(0),
display_name,
model_type: input.model_type,
base_url,
use_full_url: input.use_full_url,
api_key,
tooltip_data,
model_id,
reasoning_effort: (input.model_type == ModelType::OpenAi)
.then_some(reasoning_effort)
.flatten(),
openai_endpoint,
openai_extra_params_enabled: input.model_type == ModelType::OpenAi
&& input.openai_extra_params_enabled,
openai_extra_params: if input.model_type == ModelType::OpenAi {
input.openai_extra_params.clone()
} else {
empty_object()
},
custom_headers_enabled: input.custom_headers_enabled,
custom_headers: input.custom_headers.clone(),
anthropic_extra_params_enabled: input.model_type == ModelType::Anthropic
&& input.anthropic_extra_params_enabled,
anthropic_extra_params: if input.model_type == ModelType::Anthropic {
input.anthropic_extra_params.clone()
} else {
empty_object()
},
context_window_tokens: positive(input.context_window_tokens, "context window")?,
max_completion_tokens: positive(input.max_completion_tokens, "max completion tokens")?,
anthropic_max_tokens: positive(input.anthropic_max_tokens, "Anthropic max tokens")?,
anthropic_thinking_effort,
thinking_budget_tokens: positive(input.thinking_budget_tokens, "thinking budget")?,
};
resolve_request_url(
normalized.model_type,
&normalized.base_url,
&normalized.openai_endpoint,
normalized.use_full_url,
)?;
Ok(normalized)
}
pub fn model_hash(input: &ModelConfigInput) -> Result<String> {
let normalized = normalize_model_input(input)?;
let request_url = resolve_request_url(
normalized.model_type,
&normalized.base_url,
&normalized.openai_endpoint,
normalized.use_full_url,
)?;
let mut parts = vec![
request_url,
normalized.model_id,
normalized.api_key,
normalized.display_name,
];
if normalized.model_type == ModelType::OpenAi {
parts.push(normalized.openai_endpoint);
}
let digest = Sha256::digest(parts.join("\n").as_bytes());
Ok(hex::encode(&digest[..8]))
}
pub fn normalize_request_url(value: &str) -> Result<String> {
let value = value.trim();
let url = Url::parse(value)
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(Error::Config(
"model request URL must be an HTTP(S) URL with a host".into(),
));
}
if url.fragment().is_some() {
return Err(Error::Config(
"model request URL cannot contain a fragment".into(),
));
}
Ok(value.into())
}
pub fn resolve_request_url(
model_type: ModelType,
base_url: &str,
openai_endpoint: &str,
use_full_url: bool,
) -> Result<String> {
let base_url = normalize_request_url(base_url)?;
let endpoint = match model_type {
ModelType::OpenAi => normalize_openai_endpoint(openai_endpoint)?,
ModelType::Anthropic => "/v1/messages".into(),
};
if use_full_url {
return Ok(base_url);
}
append_standard_endpoint(&base_url, &endpoint)
}
fn append_standard_endpoint(base_url: &str, endpoint: &str) -> Result<String> {
let mut url = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid model server URL: {error}")))?;
let base_path = url.path().trim_end_matches('/').to_string();
let endpoint = if has_trailing_version(&base_path) {
endpoint.strip_prefix("/v1").unwrap_or(endpoint)
} else {
endpoint
};
url.set_path(&format!("{base_path}{endpoint}"));
normalize_request_url(url.as_str())
}
fn has_trailing_version(path: &str) -> bool {
let Some(segment) = path.rsplit('/').next() else {
return false;
};
segment.strip_prefix('v').is_some_and(|digits| {
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
})
}
pub fn is_sensitive_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"authorization" | "proxy-authorization" | "x-api-key" | "api-key" | "cookie" | "set-cookie"
)
}
fn normalize_openai_endpoint(value: &str) -> Result<String> {
match value.trim() {
"" | OPENAI_RESPONSES_ENDPOINT => Ok(OPENAI_RESPONSES_ENDPOINT.into()),
OPENAI_CHAT_ENDPOINT => Ok(OPENAI_CHAT_ENDPOINT.into()),
value => Err(Error::Config(format!(
"unsupported OpenAI endpoint: {value}"
))),
}
}
fn normalize_effort(value: Option<&str>, allow_empty: bool) -> Result<Option<String>> {
let value = value.unwrap_or_default().trim().to_ascii_lowercase();
if value.is_empty() && allow_empty {
return Ok(None);
}
if matches!(value.as_str(), "low" | "medium" | "high" | "xhigh" | "max") {
Ok(Some(value))
} else {
Err(Error::Config(format!(
"unsupported reasoning effort: {value}"
)))
}
}
fn positive(value: Option<u64>, label: &str) -> Result<Option<u64>> {
match value {
Some(0) => Err(Error::Config(format!("{label} must be greater than zero"))),
value => Ok(value),
}
}
fn required(value: &str, label: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() {
Err(Error::Config(format!("{label} cannot be empty")))
} else {
Ok(value.into())
}
}
fn validate_object(value: &serde_json::Value, label: &str) -> Result<()> {
if value.is_object() {
Ok(())
} else {
Err(Error::Config(format!("{label} must be a JSON object")))
}
}
fn validate_headers(value: &serde_json::Value) -> Result<()> {
validate_object(value, "custom headers")?;
for (name, value) in value.as_object().expect("validated object") {
if name.trim().is_empty() || !value.is_string() {
return Err(Error::Config(
"custom headers must have non-empty names and string values".into(),
));
}
}
Ok(())
}
fn empty_object() -> serde_json::Value {
serde_json::json!({})
}
fn empty_object_ref() -> &'static serde_json::Value {
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
EMPTY.get_or_init(empty_object)
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReasoningSpec {
pub enabled: bool,
pub effort: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModelLatency {
#[default]
Standard,
Fast,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ModelSpec {
pub model_id: String,
pub display_name: Option<String>,
pub reasoning: ReasoningSpec,
pub latency: ModelLatency,
pub max_output_tokens: Option<u64>,
pub context_window_tokens: Option<u64>,
#[serde(default)]
pub supports_image_generation: bool,
#[serde(default)]
pub extra_params: serde_json::Value,
}
impl ModelSpec {
pub fn new(model_id: impl Into<String>) -> Self {
Self {
model_id: model_id.into(),
display_name: None,
reasoning: ReasoningSpec::default(),
latency: ModelLatency::Standard,
max_output_tokens: None,
context_window_tokens: None,
supports_image_generation: false,
extra_params: serde_json::json!({}),
}
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Defines Conversation identity and state types.
use std::fmt;
use serde::{Deserialize, Serialize};
use super::CheckpointId;
macro_rules! string_id {
($name:ident) => {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct $name(pub String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.into())
}
}
};
}
string_id!(ConversationId);
string_id!(RunId);
string_id!(ToolRoundId);
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Conversation {
pub conversation_id: ConversationId,
pub current_checkpoint_id: CheckpointId,
pub active_run_id: Option<RunId>,
}
+50
View File
@@ -0,0 +1,50 @@
//! Defines provider-independent model requests and streaming responses.
use serde::{Deserialize, Serialize};
use super::{ModelSpec, ProjectedContent, ProjectedMessage, ToolDefinition};
const PROVIDER_TOOL_CALL_ID_MAX_CHARS: usize = 64;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PromptSpec {
pub instructions: String,
pub tools: Vec<ToolDefinition>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ModelRequest {
pub prompt: PromptSpec,
pub model: ModelSpec,
pub history: Vec<ProjectedMessage>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ModelInvocation {
pub call_id: String,
pub run_id: String,
pub conversation_id: String,
pub provider_call_index: u64,
pub request: ModelRequest,
}
pub(crate) fn normalize_provider_tool_call_ids(history: &mut [ProjectedMessage]) {
for message in history {
match &mut message.content {
ProjectedContent::Assistant { calls, .. } => {
for call in calls {
truncate_tool_call_id(&mut call.call_id);
}
}
ProjectedContent::ToolResult(result) => {
truncate_tool_call_id(&mut result.call_id);
}
ProjectedContent::Parts(_) => {}
}
}
}
fn truncate_tool_call_id(call_id: &mut String) {
if let Some((end, _)) = call_id.char_indices().nth(PROVIDER_TOOL_CALL_ID_MAX_CHARS) {
call_id.truncate(end);
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Defines canonical append-only Conversation Messages.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{ToolImageReference, ToolRoundId};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
Prompt,
User,
Runtime,
Assistant,
Tool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolCallContent {
pub index: usize,
pub call_id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolResultContent {
pub call_id: String,
pub name: String,
pub content: String,
pub is_error: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<ToolImageReference>,
#[serde(skip)]
pub provider_parts: Vec<ContentPart>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProviderReplayState {
pub provider_kind: String,
pub value: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text {
text: String,
},
Image {
mime_type: String,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MessageContent {
Parts {
parts: Vec<ContentPart>,
},
Assistant {
text: String,
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_round_id: Option<ToolRoundId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
replay_state: Option<ProviderReplayState>,
tool_calls: Vec<ToolCallContent>,
},
ToolResult(ToolResultContent),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CanonicalMessage {
pub message_id: String,
pub role: Role,
pub origin: Origin,
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_event_id: Option<String>,
}
impl CanonicalMessage {
pub fn text(
message_id: impl Into<String>,
role: Role,
origin: Origin,
text: impl Into<String>,
) -> Self {
Self {
message_id: message_id.into(),
role,
origin,
content: MessageContent::Parts {
parts: vec![ContentPart::Text { text: text.into() }],
},
runtime_event_id: None,
}
}
pub fn parts(
message_id: impl Into<String>,
role: Role,
origin: Origin,
parts: Vec<ContentPart>,
) -> Self {
Self {
message_id: message_id.into(),
role,
origin,
content: MessageContent::Parts { parts },
runtime_event_id: None,
}
}
}
mod base64_bytes {
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&STANDARD.encode(data))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;
STANDARD.decode(encoded).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeEvent {
pub event_id: String,
pub text: String,
}
impl RuntimeEvent {
pub fn into_message(self) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("runtime:{}", self.event_id),
role: Role::User,
origin: Origin::Runtime,
content: MessageContent::Parts {
parts: vec![ContentPart::Text { text: self.text }],
},
runtime_event_id: Some(self.event_id),
}
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Exposes provider-independent domain data types.
mod checkpoint;
mod configuration;
mod conversation;
mod inference;
mod message;
mod observability;
mod projection;
mod run;
mod token_count;
mod tool;
mod tool_result_replay;
pub use checkpoint::*;
pub use configuration::*;
pub use conversation::*;
pub use inference::*;
pub use message::*;
pub use observability::*;
pub use projection::*;
pub use run::*;
pub(crate) use token_count::*;
pub use tool::*;
pub(crate) use tool_result_replay::limit_tool_result_text;
+232
View File
@@ -0,0 +1,232 @@
//! Defines provider call and usage observability records.
use super::ProviderType;
mod usage {
use std::ops::AddAssign;
use serde::{Deserialize, Serialize};
use super::ProviderType;
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Usage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cache_read_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
}
impl Usage {
/// Returns the provider-visible input context without counting cached tokens twice.
pub(crate) fn context_input_tokens(self, provider: ProviderType) -> Option<u64> {
let input = self.input_tokens?;
match provider {
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => Some(input),
ProviderType::Anthropic => input
.checked_add(self.cache_read_tokens.unwrap_or_default())?
.checked_add(self.cache_write_tokens.unwrap_or_default()),
}
}
}
impl AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens = sum(self.input_tokens, rhs.input_tokens);
self.output_tokens = sum(self.output_tokens, rhs.output_tokens);
self.total_tokens = sum(self.total_tokens, rhs.total_tokens);
self.cache_read_tokens = sum(self.cache_read_tokens, rhs.cache_read_tokens);
self.cache_write_tokens = sum(self.cache_write_tokens, rhs.cache_write_tokens);
self.reasoning_tokens = sum(self.reasoning_tokens, rhs.reasoning_tokens);
}
}
fn sum(left: Option<u64>, right: Option<u64>) -> Option<u64> {
left?.checked_add(right?)
}
}
pub use usage::*;
mod llm_call {
use serde::Serialize;
use super::{ProviderType, Usage};
#[derive(Clone, Debug)]
pub struct NewLlmCall {
pub call_id: String,
pub run_id: String,
pub conversation_id: String,
pub provider_call_index: i64,
pub model_hash: String,
pub provider_type: ProviderType,
pub provider_url: String,
pub request_type: ProviderType,
pub request_url: String,
pub model_id: String,
pub display_name: String,
pub reasoning_effort: Option<String>,
pub fast: bool,
pub message_count: usize,
pub tool_count: usize,
pub detailed: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct LlmCallUsageAnchor {
pub request_type: ProviderType,
pub usage: Usage,
pub message_count: usize,
pub tool_count: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct LlmCallSummary {
pub call_id: String,
pub run_id: String,
pub conversation_id: String,
pub provider_call_index: i64,
pub model_hash: Option<String>,
pub provider_type: String,
pub provider_url: String,
pub request_type: String,
pub request_url: String,
pub model_id: String,
pub display_name: String,
pub reasoning_effort: Option<String>,
pub fast: Option<bool>,
pub status: String,
pub finish_reason: Option<String>,
pub created_at_ms: i64,
pub request_started_at_ms: Option<i64>,
pub response_headers_at_ms: Option<i64>,
pub first_event_at_ms: Option<i64>,
pub first_text_at_ms: Option<i64>,
pub first_valid_response_at_ms: Option<i64>,
pub finished_at_ms: Option<i64>,
pub queue_ms: Option<i64>,
pub ttfb_ms: Option<i64>,
pub ttft_ms: Option<i64>,
pub ttfr_ms: Option<i64>,
pub duration_ms: Option<i64>,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
pub cache_read_tokens: Option<i64>,
pub cache_write_tokens: Option<i64>,
pub reasoning_tokens: Option<i64>,
pub usage: Option<serde_json::Value>,
pub message_count: i64,
pub tool_count: i64,
pub request_bytes: Option<i64>,
pub response_bytes: i64,
pub stream_event_count: i64,
pub http_status: Option<i64>,
pub error_kind: Option<String>,
pub error_message: Option<String>,
pub detailed: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct LlmCallRequest {
pub headers: serde_json::Value,
pub body: serde_json::Value,
pub byte_count: i64,
}
#[derive(Clone, Debug, Serialize)]
pub struct LlmCallResponseChunk {
pub seq: i64,
pub received_offset_ms: i64,
pub data: String,
pub byte_count: i64,
}
}
pub use llm_call::*;
mod cursor_trace {
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct CursorRunTraceSummary {
pub request_id: String,
pub conversation_id: Option<String>,
pub route: String,
pub model_id: Option<String>,
pub status: String,
pub request_bytes: i64,
pub response_bytes: i64,
pub response_event_count: i64,
pub http_status: Option<i64>,
pub received_at_ms: i64,
pub first_response_at_ms: Option<i64>,
pub finished_at_ms: Option<i64>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub struct CursorRunTraceArtifact {
pub seq: i64,
pub artifact_type: String,
pub source: String,
pub metadata: serde_json::Value,
pub created_at_ms: i64,
pub data: Vec<u8>,
}
}
pub use cursor_trace::*;
mod overview {
//! Read-only usage aggregates rendered by the desktop overview page.
use serde::Serialize;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct OverviewMetrics {
pub llm_calls: i64,
pub successful_calls: i64,
pub failed_calls: i64,
pub token_usage: i64,
pub prompt_tokens: i64,
pub input_tokens: i64,
pub cache_read_tokens: i64,
pub cache_write_tokens: i64,
pub output_tokens: i64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenUsageGranularity {
Minute,
Hour,
#[default]
Day,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct TokenUsageBucket {
pub bucket_start_ms: i64,
pub input_tokens: i64,
pub cache_read_tokens: i64,
pub cache_write_tokens: i64,
pub output_tokens: i64,
}
impl TokenUsageBucket {
pub fn total_tokens(&self) -> i64 {
self.input_tokens
.saturating_add(self.cache_read_tokens)
.saturating_add(self.cache_write_tokens)
.saturating_add(self.output_tokens)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Overview {
pub metrics: OverviewMetrics,
pub token_usage_granularity: TokenUsageGranularity,
pub token_usage_series: Vec<TokenUsageBucket>,
}
}
pub use overview::*;
+170
View File
@@ -0,0 +1,170 @@
//! Projects canonical Messages into provider-visible model input.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
use super::{
CanonicalMessage, ContentPart, MessageContent, ProviderReplayState, Role, ToolCallContent,
ToolResultContent,
};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum ProjectedContent {
Parts(Vec<ContentPart>),
Assistant {
text: String,
thinking: String,
replay_state: Option<ProviderReplayState>,
calls: Vec<ToolCallContent>,
},
ToolResult(ToolResultContent),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProjectedMessage {
pub message_id: String,
pub role: Role,
pub content: ProjectedContent,
}
pub fn project_messages(messages: &[CanonicalMessage]) -> Result<Vec<ProjectedMessage>> {
let mut projected = Vec::new();
let mut index = 0;
while index < messages.len() {
if let Some((group, next)) = project_tool_round(messages, index)? {
projected.extend(group);
index = next;
} else {
projected.push(project_message(&messages[index]));
index += 1;
}
}
Ok(projected)
}
fn project_tool_round(
messages: &[CanonicalMessage],
start: usize,
) -> Result<Option<(Vec<ProjectedMessage>, usize)>> {
let MessageContent::Assistant {
tool_round_id: Some(group_id),
tool_calls,
..
} = &messages[start].content
else {
return Ok(None);
};
if tool_calls.is_empty() {
return Ok(None);
}
let mut cursor = start;
let mut text = String::new();
let mut thinking = String::new();
let mut replay_state = None;
let mut calls = Vec::new();
let mut results = Vec::new();
let mut result_ids = HashSet::new();
while cursor < messages.len() {
let MessageContent::Assistant {
text: part_text,
thinking: part_thinking,
tool_round_id: Some(candidate_group),
replay_state: part_replay,
tool_calls: part_calls,
} = &messages[cursor].content
else {
break;
};
if candidate_group != group_id || part_calls.is_empty() {
break;
}
text.push_str(part_text);
thinking.push_str(part_thinking);
if replay_state.is_none() {
replay_state = part_replay.clone();
} else if part_replay.is_some() {
return Err(Error::Protocol(
"tool round repeats provider replay state".into(),
));
}
calls.extend(part_calls.iter().cloned());
cursor += 1;
while cursor < messages.len() {
let MessageContent::ToolResult(result) = &messages[cursor].content else {
break;
};
if !calls.iter().any(|call| call.call_id == result.call_id) {
break;
}
if !result_ids.insert(result.call_id.clone()) {
return Err(Error::Protocol(format!(
"duplicate tool result call_id: {}",
result.call_id
)));
}
results.push((messages[cursor].message_id.clone(), result.clone()));
cursor += 1;
}
}
calls.sort_by_key(|call| call.index);
for call in &calls {
if !result_ids.contains(&call.call_id) {
return Err(Error::Protocol(format!(
"assistant tool call has no result call_id: {}",
call.call_id
)));
}
}
let mut output = Vec::with_capacity(results.len() + 1);
output.push(ProjectedMessage {
message_id: messages[start].message_id.clone(),
role: Role::Assistant,
content: ProjectedContent::Assistant {
text,
thinking,
replay_state,
calls,
},
});
output.extend(
results
.into_iter()
.map(|(message_id, result)| ProjectedMessage {
message_id,
role: Role::Tool,
content: ProjectedContent::ToolResult(result),
}),
);
Ok(Some((output, cursor)))
}
fn project_message(message: &CanonicalMessage) -> ProjectedMessage {
let content = match &message.content {
MessageContent::Parts { parts } => ProjectedContent::Parts(parts.clone()),
MessageContent::Assistant {
text,
thinking,
replay_state,
tool_calls,
..
} => ProjectedContent::Assistant {
text: text.clone(),
thinking: thinking.clone(),
replay_state: replay_state.clone(),
calls: tool_calls.clone(),
},
MessageContent::ToolResult(result) => ProjectedContent::ToolResult(result.clone()),
};
ProjectedMessage {
message_id: message.message_id.clone(),
role: message.role.clone(),
content,
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Defines Run identity, preparation, and action types.
use serde::{Deserialize, Serialize};
use super::{
CanonicalMessage, CheckpointId, ConversationId, ModelSpec, PromptSpec, RunId, ToolCall,
ToolRoundAssistant,
};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum SubagentKind {
GeneralPurpose,
Named(String),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum RunKind {
Root,
Subagent {
parent_run_id: RunId,
parent_tool_call_id: String,
kind: SubagentKind,
background: bool,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum SubagentModelOverride {
Explicit(ModelSpec),
Inherit,
Disabled,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum RunAction {
Start,
Compact,
Resume {
pending_tool_round: Option<RecoveredToolRound>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RecoveredToolRound {
pub assistant: ToolRoundAssistant,
pub calls: Vec<ToolCall>,
pub started_at_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PreparedRun {
pub run_id: RunId,
pub cursor_request_id: Option<String>,
pub conversation_id: ConversationId,
pub kind: RunKind,
pub model: ModelSpec,
pub prompt: PromptSpec,
pub initial_messages: Vec<CanonicalMessage>,
pub action: RunAction,
pub base_checkpoint_id: CheckpointId,
}
+20
View File
@@ -0,0 +1,20 @@
//! Estimates and records model token usage.
pub(crate) fn parse_token_count(value: &str) -> Option<u64> {
let value = value.trim().to_ascii_lowercase();
let (number, multiplier) = match value.chars().last()? {
'k' => (&value[..value.len() - 1], 1_000),
'm' => (&value[..value.len() - 1], 1_000_000),
_ => (value.as_str(), 1),
};
number.parse::<u64>().ok()?.checked_mul(multiplier)
}
pub(crate) fn format_token_count(tokens: u64) -> String {
if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
format!("{}M", tokens / 1_000_000)
} else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
format!("{}K", tokens / 1_000)
} else {
tokens.to_string()
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Defines Tool calls, results, and Tool round identities.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::ProviderReplayState;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
pub index: usize,
pub call_id: String,
pub model_call_id: String,
pub name: String,
pub arguments_text: String,
pub arguments: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolResult {
pub call_id: String,
pub content: String,
pub is_error: bool,
pub image: Option<ToolImageReference>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolImageReference {
pub blob_id: String,
pub mime_type: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolRoundAssistant {
pub text: String,
pub thinking: String,
pub model_call_id: String,
pub replay_state: Option<ProviderReplayState>,
}
+195
View File
@@ -0,0 +1,195 @@
//! Restores provider-visible Tool results from persisted data.
use serde_json::Value;
const KIB: usize = 1024;
pub(crate) fn limit_tool_result_text(name: &str, content: &str) -> String {
let Some(limit) = replay_limit(name) else {
return content.to_string();
};
let content = match name.trim() {
"GenerateImage" => compact_generate_image(content),
"Shell" => compact_shell(content),
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" | "Edit" | "Write" => {
compact_edit(name, content)
}
_ => None,
}
.unwrap_or_else(|| content.to_string());
truncate_replay_text(name, &content, limit)
}
fn replay_limit(name: &str) -> Option<usize> {
match name.trim() {
"GenerateImage" | "WebSearch" => Some(16 * KIB),
"Read" => Some(64 * KIB),
"Shell" => Some(128 * KIB),
"Grep" | "Glob" => Some(32 * KIB),
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => Some(4 * KIB),
"Edit" | "EditNotebook" | "Write" | "WebFetch" => Some(32 * KIB),
"CallMcpTool" | "FetchMcpResource" | "ListMcpResources" | "GetMcpTools"
| "SembleSearch" | "SembleFindRelated" => Some(32 * KIB),
_ => None,
}
}
fn truncate_replay_text(name: &str, content: &str, limit: usize) -> String {
if content.len() <= limit {
return content.to_string();
}
let original = content.len();
let mut shown = limit;
loop {
let notice = format!(
"\n\n[truncated: {name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
);
let available = limit.saturating_sub(notice.len());
let kept = utf8_prefix(content, available);
if kept.len() == shown {
return format!("{}{notice}", kept.trim_end_matches('\n'));
}
shown = kept.len();
}
}
fn compact_generate_image(content: &str) -> Option<String> {
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
if !replace_image_data(&mut value) {
return None;
}
serde_json::to_string(&value).ok()
}
fn replace_image_data(value: &mut Value) -> bool {
match value {
Value::Object(object) => {
let mut changed = false;
for (key, child) in object.iter_mut() {
if matches!(key.as_str(), "image_data" | "imageData") {
if let Value::String(data) = child {
if data.starts_with("[base64 image data omitted from replay; bytes=") {
continue;
}
*child = Value::String(format!(
"[base64 image data omitted from replay; bytes={}]",
data.trim().len()
));
changed = true;
continue;
}
}
changed |= replace_image_data(child);
}
changed
}
Value::Array(items) => items.iter_mut().any(replace_image_data),
_ => false,
}
}
fn compact_shell(content: &str) -> Option<String> {
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
if !compact_shell_fields(&mut value) {
return None;
}
serde_json::to_string(&value).ok()
}
fn compact_shell_fields(value: &mut Value) -> bool {
match value {
Value::Object(object) => {
let mut changed = false;
for (key, child) in object.iter_mut() {
if let Value::String(text) = child {
let limit = match key.as_str() {
"stdout" | "stderr" => Some(16 * KIB),
"interleaved_output" | "interleavedOutput" => Some(32 * KIB),
_ => None,
};
if let Some(limit) = limit {
let next = truncate_middle(&format!("Shell {key}"), text, limit);
if next != *text {
*text = next;
changed = true;
}
continue;
}
}
changed |= compact_shell_fields(child);
}
changed
}
Value::Array(items) => items.iter_mut().any(compact_shell_fields),
_ => false,
}
}
fn compact_edit(name: &str, content: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(content.trim()).ok()?;
let success = value.get("success")?.as_object()?;
let diff = success
.get("diff_string")
.or_else(|| success.get("diffString"))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
if let Some(diff) = diff {
return Some(serde_json::json!({"success": {"diff_string": diff}}).to_string());
}
let after = success
.get("after_full_file_content")
.or_else(|| success.get("afterFullFileContent"))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
after
.map(|after| serde_json::json!({"success": {"after_full_file_content": after}}).to_string())
}
fn edit_limit(name: &str) -> usize {
match name.trim() {
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => 4 * KIB,
_ => 32 * KIB,
}
}
fn truncate_middle(name: &str, content: &str, limit: usize) -> String {
if content.len() <= limit {
return content.to_string();
}
let original = content.len();
let mut shown = limit;
loop {
let notice = format!(
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {shown} of {original} bytes]\n\n"
);
let available = limit.saturating_sub(notice.len());
let head = utf8_prefix(content, available / 2);
let tail = utf8_suffix(content, available.saturating_sub(head.len()));
let next_shown = head.len() + tail.len();
let next_notice = format!(
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {next_shown} of {original} bytes]\n\n"
);
let output = format!("{head}{next_notice}{tail}");
if output.len() <= limit || next_notice == notice {
return output;
}
shown = next_shown;
}
}
fn utf8_prefix(value: &str, limit: usize) -> &str {
let mut end = limit.min(value.len());
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
&value[..end]
}
fn utf8_suffix(value: &str, limit: usize) -> &str {
let mut start = value.len().saturating_sub(limit);
while start < value.len() && !value.is_char_boundary(start) {
start += 1;
}
&value[start..]
}