mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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::*;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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..]
|
||||
}
|
||||
Reference in New Issue
Block a user