Skip to main content

specado_core/transformer/
normalize.rs

1use crate::error::{Error, Result};
2use crate::types::{
3    Extensions, FinishReason, LossinessReport, ProviderSpec, StrictMode, UniformResponse,
4};
5use serde_json::Value;
6use serde_json_path::JsonPath;
7
8pub fn normalize(raw: Value, provider: &ProviderSpec) -> Result<UniformResponse> {
9    let mut content = String::new();
10    let mut finish_reason = FinishReason::Stop;
11
12    for mapping in &provider.mappings.response {
13        let path = JsonPath::parse(&mapping.from)
14            .map_err(|e| Error::Transform(format!("Invalid JSONPath '{}': {}", mapping.from, e)))?;
15        let matches = path.query(&raw).all();
16        if matches.is_empty() {
17            continue;
18        }
19
20        let value = if matches.len() == 1 {
21            matches[0].clone()
22        } else {
23            Value::Array(matches.iter().map(|v| (*v).clone()).collect())
24        };
25
26        match mapping.to.as_str() {
27            "content" => {
28                if let Some(text) = value.as_str() {
29                    if !text.is_empty() {
30                        content = text.to_string();
31                    }
32                } else if let Some(array) = value.as_array() {
33                    let joined = array
34                        .iter()
35                        .filter_map(Value::as_str)
36                        .filter(|s| !s.is_empty())
37                        .collect::<Vec<_>>()
38                        .join(" ");
39                    if !joined.is_empty() {
40                        content = joined;
41                    }
42                }
43            }
44            "finish_reason" => {
45                if let Some(reason) = value.as_str() {
46                    finish_reason = map_finish_reason(reason);
47                }
48            }
49            _ => {}
50        }
51    }
52
53    let provider_capabilities = provider.capabilities_json();
54
55    Ok(UniformResponse {
56        content,
57        tool_calls: Vec::new(),
58        finish_reason,
59        model: provider
60            .models
61            .first()
62            .map(|m| m.id.clone())
63            .unwrap_or_default(),
64        provider_used: provider.provider.clone(),
65        usage: None,
66        extensions: Extensions {
67            lossiness: LossinessReport::new(StrictMode::Warn),
68            provider_capabilities,
69        },
70    })
71}
72
73fn map_finish_reason(raw: &str) -> FinishReason {
74    match raw {
75        "stop" | "end_turn" | "completed" => FinishReason::Stop,
76        "length" | "max_tokens" => FinishReason::Length,
77        "tool_calls" | "tool_use" => FinishReason::ToolCall,
78        "content_filter" => FinishReason::ContentFilter,
79        _ => FinishReason::Error,
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::types::{
87        Capabilities, Constraints, EndpointConfig, Endpoints, HttpMethod, Mappings, ModelConfig,
88        ProviderSpec, ResponseMapping, SupportFlags,
89    };
90    use serde_json::json;
91    use std::collections::HashMap;
92
93    fn provider() -> ProviderSpec {
94        ProviderSpec {
95            provider: "openai".into(),
96            models: vec![ModelConfig {
97                id: "gpt-4o".into(),
98            }],
99            interface: Some("conversational.generate".into()),
100            contract_version: Some("1.0.0".into()),
101            inherits: None,
102            endpoints: Endpoints {
103                chat: EndpointConfig {
104                    method: HttpMethod::Post,
105                    url: "https://example.com".into(),
106                    headers: Default::default(),
107                },
108            },
109            mappings: Mappings {
110                request: Vec::new(),
111                response: vec![
112                    ResponseMapping {
113                        from: "$.choices[0].message.content".into(),
114                        to: "content".into(),
115                    },
116                    ResponseMapping {
117                        from: "$.choices[0].finish_reason".into(),
118                        to: "finish_reason".into(),
119                    },
120                ],
121            },
122            constraints: Constraints {
123                supports: SupportFlags {
124                    json_mode: true,
125                    tools: true,
126                },
127            },
128            auth: crate::auth::AuthScheme::Bearer {
129                token_env: "KEY".into(),
130            },
131            capabilities: Capabilities::default(),
132            capabilities_extra: HashMap::new(),
133            extensions: HashMap::new(),
134            unsupported_parameters: Vec::new(),
135        }
136    }
137
138    #[test]
139    fn extracts_content_and_finish_reason() {
140        let raw = json!({
141            "choices": [
142                {
143                    "message": {"content": "Hello"},
144                    "finish_reason": "stop"
145                }
146            ]
147        });
148
149        let response = normalize(raw, &provider()).expect("normalize");
150        assert_eq!(response.content, "Hello");
151        assert_eq!(response.finish_reason, FinishReason::Stop);
152        assert_eq!(response.model, "gpt-4o");
153    }
154
155    #[test]
156    fn joins_array_content() {
157        let raw = json!({
158            "choices": [
159                {
160                    "message": {
161                        "content": [
162                            {"text": "Hello"},
163                            {"text": "world"}
164                        ]
165                    },
166                    "finish_reason": "stop"
167                }
168            ]
169        });
170
171        let mut provider = provider();
172        provider.mappings.response[0].from = "$.choices[0].message.content[*].text".into();
173
174        let response = normalize(raw, &provider).expect("normalize");
175        assert_eq!(response.content, "Hello world");
176    }
177
178    #[test]
179    fn maps_unknown_reason_to_error() {
180        let raw = json!({
181            "choices": [
182                {
183                    "message": {"content": "Hi"},
184                    "finish_reason": "rate_limited"
185                }
186            ]
187        });
188
189        let response = normalize(raw, &provider()).expect("normalize");
190        assert_eq!(response.finish_reason, FinishReason::Error);
191    }
192}