specado_core/transformer/
normalize.rs1use 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}