use serde::{Deserialize, Serialize};
use meerkat_core::{
HookRunOverrides, OutputSchema, PeerMeta, Provider, SurfaceMetadata, SurfaceMetadataError,
skills::{SkillKey, SkillRef},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CoreCreateParams {
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<Provider>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<std::collections::BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_instructions: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_context: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_env: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_binding: Option<super::connection::WireAuthBindingRef>,
}
impl CoreCreateParams {
#[must_use]
pub fn surface_metadata(&self) -> SurfaceMetadata {
SurfaceMetadata::from_optional_parts(self.labels.clone(), self.app_context.clone())
}
pub fn validate_public_surface_metadata(&self) -> Result<(), SurfaceMetadataError> {
self.surface_metadata().validate_public()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredOutputParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<OutputSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_output_retries: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CommsParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_alive: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comms_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer_meta: Option<PeerMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct HookParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks_override: Option<HookRunOverrides>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SkillsParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preload_skills: Option<Vec<SkillKey>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_refs: Option<Vec<SkillRef>>,
}
impl SkillsParams {
pub fn normalize(&mut self) {
if let Some(ref v) = self.preload_skills
&& v.is_empty()
{
self.preload_skills = None;
}
if let Some(ref v) = self.skill_refs
&& v.is_empty()
{
self.skill_refs = None;
}
}
pub fn canonical_skill_keys(&self) -> Option<Vec<SkillKey>> {
let mut keys: Vec<SkillKey> = Vec::new();
if let Some(refs) = &self.skill_refs {
keys.extend(refs.iter().map(|r| r.key().clone()));
}
if keys.is_empty() { None } else { Some(keys) }
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::redundant_clone)]
mod tests {
use super::*;
use meerkat_core::skills::{SkillKey, SkillName, SourceUuid};
fn test_key(skill: &str) -> SkillKey {
SkillKey {
source_uuid: SourceUuid::parse("dc256086-0d2f-4f61-a307-320d4148107f")
.expect("valid uuid"),
skill_name: SkillName::parse(skill).expect("valid skill slug"),
}
}
#[test]
fn test_skills_params_none_serde() -> Result<(), serde_json::Error> {
let params = SkillsParams {
preload_skills: None,
skill_refs: None,
};
let json = serde_json::to_string(¶ms)?;
assert_eq!(json, "{}");
let parsed: SkillsParams = serde_json::from_str("{}")?;
assert!(parsed.preload_skills.is_none());
assert!(parsed.skill_refs.is_none());
Ok(())
}
#[test]
fn test_skills_params_empty_normalizes() {
let mut params = SkillsParams {
preload_skills: Some(vec![]),
skill_refs: Some(vec![]),
};
params.normalize();
assert!(params.preload_skills.is_none());
assert!(params.skill_refs.is_none());
}
#[test]
fn test_skills_params_with_keys_roundtrip() -> Result<(), serde_json::Error> {
let key = test_key("email-extractor");
let params = SkillsParams {
preload_skills: Some(vec![key.clone()]),
skill_refs: Some(vec![SkillRef::Structured(key.clone())]),
};
let json = serde_json::to_string(¶ms)?;
let parsed: SkillsParams = serde_json::from_str(&json)?;
assert_eq!(parsed, params);
Ok(())
}
#[test]
fn test_skill_refs_rejects_legacy_string_form() {
let legacy_json =
r#"{"skill_refs":["dc256086-0d2f-4f61-a307-320d4148107f/email-extractor"]}"#;
let parsed: Result<SkillsParams, _> = serde_json::from_str(legacy_json);
assert!(parsed.is_err(), "legacy string form must be rejected");
}
#[test]
fn test_skill_refs_parses_structured_only() -> Result<(), serde_json::Error> {
let structured_json = r#"{
"skill_refs":[{
"kind":"structured",
"source_uuid":"dc256086-0d2f-4f61-a307-320d4148107f",
"skill_name":"email-extractor"
}]
}"#;
let parsed: SkillsParams = serde_json::from_str(structured_json)?;
let canonical = parsed.canonical_skill_keys().expect("keys");
assert_eq!(canonical, vec![test_key("email-extractor")]);
Ok(())
}
#[test]
fn test_core_create_params_all_fields_roundtrip() -> Result<(), serde_json::Error> {
let mut labels = std::collections::BTreeMap::new();
labels.insert("env".to_string(), "prod".to_string());
labels.insert("team".to_string(), "infra".to_string());
let params = CoreCreateParams {
prompt: "hello".to_string(),
model: Some("claude-opus-4-6".to_string()),
provider: Some(Provider::Anthropic),
max_tokens: Some(1024),
system_prompt: Some("You are helpful.".to_string()),
labels: Some(labels.clone()),
additional_instructions: Some(vec![
"Be concise.".to_string(),
"Use JSON output.".to_string(),
]),
app_context: Some(serde_json::json!({"org_id": "acme", "tier": "premium"})),
shell_env: None,
auth_binding: None,
};
let json = serde_json::to_string(¶ms)?;
let parsed: CoreCreateParams = serde_json::from_str(&json)?;
assert_eq!(parsed.prompt, "hello");
assert_eq!(parsed.labels, Some(labels));
assert_eq!(
parsed.additional_instructions,
Some(vec![
"Be concise.".to_string(),
"Use JSON output.".to_string()
])
);
assert!(parsed.app_context.is_some());
assert_eq!(
parsed
.surface_metadata()
.labels
.get("team")
.map(String::as_str),
Some("infra")
);
assert_eq!(
parsed.surface_metadata().app_context,
Some(serde_json::json!({"org_id": "acme", "tier": "premium"}))
);
Ok(())
}
#[test]
fn test_core_create_params_surface_metadata_rejects_reserved_keys() {
let params = CoreCreateParams {
prompt: "hello".to_string(),
model: None,
provider: None,
max_tokens: None,
system_prompt: None,
labels: Some(std::collections::BTreeMap::from([(
"meerkat.runtime_id".to_string(),
"spoof".to_string(),
)])),
additional_instructions: None,
app_context: None,
shell_env: None,
auth_binding: None,
};
assert!(params.validate_public_surface_metadata().is_err());
}
#[test]
fn test_core_create_params_defaults_backward_compat() -> Result<(), serde_json::Error> {
let json = r#"{"prompt": "hello"}"#;
let parsed: CoreCreateParams = serde_json::from_str(json)?;
assert_eq!(parsed.prompt, "hello");
assert!(parsed.model.is_none());
assert!(parsed.labels.is_none());
assert!(parsed.additional_instructions.is_none());
assert!(parsed.app_context.is_none());
Ok(())
}
#[test]
fn test_core_create_params_none_fields_omitted() -> Result<(), serde_json::Error> {
let params = CoreCreateParams {
prompt: "hello".to_string(),
model: None,
provider: None,
max_tokens: None,
system_prompt: None,
labels: None,
additional_instructions: None,
app_context: None,
shell_env: None,
auth_binding: None,
};
let json = serde_json::to_string(¶ms)?;
assert!(!json.contains("\"labels\""));
assert!(!json.contains("\"additional_instructions\""));
assert!(!json.contains("\"app_context\""));
assert!(!json.contains("\"shell_env\""));
assert!(!json.contains("\"auth_binding\""));
Ok(())
}
#[test]
fn test_core_create_params_with_auth_binding() -> Result<(), Box<dyn std::error::Error>> {
use crate::wire::WireAuthBindingRef;
let params = CoreCreateParams {
prompt: "hello".to_string(),
model: None,
provider: None,
max_tokens: None,
system_prompt: None,
labels: None,
additional_instructions: None,
app_context: None,
shell_env: None,
auth_binding: Some(WireAuthBindingRef {
realm: meerkat_core::connection::RealmId::parse("dev")?,
binding: meerkat_core::connection::BindingId::parse("default_openai")?,
profile: None,
}),
};
let json = serde_json::to_string(¶ms)?;
assert!(json.contains("\"auth_binding\""));
assert!(json.contains("\"realm\":\"dev\""));
let parsed: CoreCreateParams = serde_json::from_str(&json)?;
assert_eq!(
parsed.auth_binding.map(|r| r.binding.as_str().to_owned()),
Some("default_openai".to_string())
);
Ok(())
}
}