specado-core-temp 0.2.2

Core engine for Specado, a provider-neutral AI orchestration SDK.
Documentation
use crate::types::{
    LossinessCode, LossinessEntry, LossinessLevel, LossinessReport, PromptSpec, ProviderSpec,
    ResponseFormat,
};
use serde_json::json;

pub fn detect_unsupported(
    prompt: &PromptSpec,
    provider: &ProviderSpec,
    report: &mut LossinessReport,
) {
    if matches!(
        prompt.response.format,
        ResponseFormat::Json | ResponseFormat::JsonSchema
    ) && !provider.constraints.supports.json_mode
    {
        report.add_entry(LossinessEntry {
            code: LossinessCode::Unsupported,
            level: LossinessLevel::Warn,
            path: "response.format".to_string(),
            reason: "Provider does not support native JSON mode".to_string(),
            suggested_fix: Some(
                "Use a provider with native JSON support or accept emulation".to_string(),
            ),
            details: Some(json!({
                "requested": prompt.response.format,
                "supported": false,
            })),
        });
    }

    if !prompt.tools.is_empty() && !provider.constraints.supports.tools {
        report.add_entry(LossinessEntry {
            code: LossinessCode::Unsupported,
            level: LossinessLevel::Error,
            path: "tools".to_string(),
            reason: "Provider does not support tools".to_string(),
            suggested_fix: Some("Remove tools or use a different provider".to_string()),
            details: Some(json!({"tool_count": prompt.tools.len()})),
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{
        Capabilities, Constraints, EndpointConfig, Endpoints, HttpMethod, Mappings, ModelConfig,
        RequestMapping, ResponseConfig, ResponseMapping, StrictMode, SupportFlags, Tool,
    };
    use std::collections::HashMap;

    fn provider_supports(json_mode: bool, tools: bool) -> ProviderSpec {
        ProviderSpec {
            provider: "fake".into(),
            models: vec![ModelConfig { id: "m".into() }],
            interface: Some("conversational.generate".into()),
            contract_version: Some("1.0.0".into()),
            inherits: None,
            endpoints: Endpoints {
                chat: EndpointConfig {
                    method: HttpMethod::Post,
                    url: "https://example.com".into(),
                    headers: Default::default(),
                },
            },
            mappings: Mappings {
                request: vec![RequestMapping {
                    from: "messages".into(),
                    to: "body.messages".into(),
                    code: None,
                    clamp: None,
                }],
                response: vec![ResponseMapping {
                    from: "body".into(),
                    to: "response".into(),
                }],
            },
            constraints: Constraints {
                supports: SupportFlags { json_mode, tools },
            },
            auth: crate::auth::AuthScheme::Bearer {
                token_env: "TOKEN".into(),
            },
            capabilities: Capabilities::default(),
            capabilities_extra: HashMap::new(),
            extensions: HashMap::new(),
            unsupported_parameters: Vec::new(),
        }
    }

    fn prompt_with_response(format: ResponseFormat, tool_count: usize) -> PromptSpec {
        PromptSpec {
            version: "1".into(),
            messages: vec![crate::types::Message {
                role: crate::types::MessageRole::User,
                content: "Hello".into(),
            }],
            sampling: Default::default(),
            response: ResponseConfig {
                format,
                ..Default::default()
            },
            tools: (0..tool_count)
                .map(|i| Tool {
                    name: format!("tool-{}", i),
                    description: None,
                    json_schema: serde_json::json!({"type": "object"}),
                })
                .collect(),
            tool_choice: None,
            strict_mode: StrictMode::Warn,
            metadata: Default::default(),
        }
    }

    #[test]
    fn warns_when_json_mode_not_supported() {
        let prompt = prompt_with_response(ResponseFormat::Json, 0);
        let provider = provider_supports(false, true);
        let mut report = LossinessReport::new(StrictMode::Warn);

        detect_unsupported(&prompt, &provider, &mut report);
        assert!(report.is_lossy);
        assert_eq!(report.entries[0].code, LossinessCode::Unsupported);
    }

    #[test]
    fn warns_for_json_schema_mode() {
        let prompt = prompt_with_response(ResponseFormat::JsonSchema, 0);
        let provider = provider_supports(false, true);
        let mut report = LossinessReport::new(StrictMode::Warn);

        detect_unsupported(&prompt, &provider, &mut report);
        assert!(report.is_lossy);
        assert_eq!(report.entries[0].path, "response.format");
    }

    #[test]
    fn errors_when_tools_unsupported() {
        let prompt = prompt_with_response(ResponseFormat::Text, 2);
        let provider = provider_supports(true, false);
        let mut report = LossinessReport::new(StrictMode::Warn);

        detect_unsupported(&prompt, &provider, &mut report);
        assert_eq!(report.entries.len(), 1);
        assert_eq!(report.entries[0].level, LossinessLevel::Error);
    }

    #[test]
    fn no_entries_when_supported() {
        let prompt = prompt_with_response(ResponseFormat::Json, 1);
        let provider = provider_supports(true, true);
        let mut report = LossinessReport::new(StrictMode::Warn);

        detect_unsupported(&prompt, &provider, &mut report);
        assert!(!report.is_lossy);
    }
}