ferrin_core/middleware/builtin/
extract_json.rs1use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use ferrin_spec::BoxFuture;
8use ferrin_spec::CallOptions;
9use ferrin_spec::Content;
10use ferrin_spec::PartId;
11use ferrin_spec::StreamPart;
12use ferrin_spec::StreamResult;
13use ferrin_spec::error::ProviderError;
14use ferrin_spec::language_model::GenerateResult;
15use futures_util::StreamExt;
16use futures_util::stream;
17
18use crate::middleware::GenerateNext;
19use crate::middleware::LanguageModelMiddleware;
20use crate::middleware::MiddlewareContext;
21use crate::middleware::StreamNext;
22
23pub type JsonTransformFn = Arc<dyn Fn(&str) -> String + Send + Sync>;
25
26#[derive(Clone)]
28pub struct ExtractJson {
29 transform: Option<JsonTransformFn>,
30}
31
32impl fmt::Debug for ExtractJson {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.debug_struct("ExtractJson")
35 .field("custom_transform", &self.transform.is_some())
36 .finish()
37 }
38}
39
40#[must_use]
49pub fn extract_json() -> ExtractJson {
50 ExtractJson { transform: None }
51}
52
53const SUFFIX_BUFFER_CHARS: usize = 12;
55
56impl ExtractJson {
57 #[must_use]
59 pub fn transform(mut self, transform: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
60 self.transform = Some(Arc::new(transform));
61 self
62 }
63
64 fn run_transform(&self, text: &str) -> String {
65 match &self.transform {
66 Some(transform) => transform(text),
67 None => strip_json_fences(text),
68 }
69 }
70
71 #[must_use]
73 pub fn apply(&self, mut result: GenerateResult) -> GenerateResult {
74 for part in &mut result.content {
75 if let Content::Text { text, .. } = part {
76 *text = self.run_transform(text);
77 }
78 }
79 result
80 }
81
82 #[must_use]
84 pub fn apply_stream(&self, result: StreamResult) -> StreamResult {
85 let StreamResult {
86 stream,
87 request,
88 response,
89 } = result;
90 let mut state = StreamState {
91 transform: self.transform.clone(),
92 blocks: HashMap::new(),
93 };
94 let stream = stream
95 .map(move |part| stream::iter(state.process(part)))
96 .flatten();
97 StreamResult {
98 stream: Box::pin(stream),
99 request,
100 response,
101 }
102 }
103}
104
105impl LanguageModelMiddleware for ExtractJson {
106 fn wrap_generate<'a>(
107 &'a self,
108 options: CallOptions,
109 next: GenerateNext<'a>,
110 _ctx: MiddlewareContext<'a>,
111 ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
112 Box::pin(async move { Ok(self.apply(next(options).await?)) })
113 }
114
115 fn wrap_stream<'a>(
116 &'a self,
117 options: CallOptions,
118 next: StreamNext<'a>,
119 _ctx: MiddlewareContext<'a>,
120 ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
121 Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
122 }
123}
124
125#[must_use]
127pub fn strip_json_fences(text: &str) -> String {
128 strip_fence_suffix(strip_fence_prefix(text))
129 .trim_matches(is_json_fence_whitespace)
130 .to_owned()
131}
132
133fn strip_fence_prefix(text: &str) -> &str {
135 match text.strip_prefix("```") {
136 Some(rest) => rest
137 .strip_prefix("json")
138 .unwrap_or(rest)
139 .trim_start_matches(is_json_fence_whitespace),
140 None => text,
141 }
142}
143
144fn strip_fence_suffix(text: &str) -> &str {
147 let trimmed = text.trim_end_matches(is_json_fence_whitespace);
148 match trimmed.strip_suffix("```") {
149 Some(rest) => rest.strip_suffix('\n').unwrap_or(rest),
150 None => text,
151 }
152}
153
154fn strip_markdown_code_fence_suffix(text: &str) -> String {
155 strip_fence_suffix(text)
156 .trim_end_matches(is_json_fence_whitespace)
157 .to_owned()
158}
159
160fn is_json_fence_whitespace(ch: char) -> bool {
161 matches!(ch, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}'
162 | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
163 | '\u{205f}' | '\u{3000}' | '\u{feff}')
164}
165
166fn fence_prefix_len(text: &str) -> Option<usize> {
169 let rest = text.strip_prefix("```")?;
170 let rest = rest.strip_prefix("json").unwrap_or(rest);
171 let consumed = text.len() - rest.len();
172 let whitespace_len = rest
173 .char_indices()
174 .find(|(_, c)| !is_json_fence_whitespace(*c))
175 .map_or(rest.len(), |(index, _)| index);
176 let whitespace = &rest[..whitespace_len];
177 let last_newline = whitespace.rfind('\n')?;
178 Some(consumed + last_newline + 1)
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182enum Phase {
183 Prefix,
184 Streaming,
185 Buffering,
186}
187
188struct Block {
189 start: StreamPart,
190 phase: Phase,
191 buffer: String,
192 prefix_stripped: bool,
193}
194
195struct StreamState {
196 transform: Option<JsonTransformFn>,
197 blocks: HashMap<PartId, Block>,
198}
199
200impl StreamState {
201 fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
202 let mut out = Vec::new();
203 match part {
204 StreamPart::TextStart { ref id, .. } => {
205 let phase = if self.transform.is_some() {
206 Phase::Buffering
207 } else {
208 Phase::Prefix
209 };
210 self.blocks.insert(
211 id.clone(),
212 Block {
213 start: part,
214 phase,
215 buffer: String::new(),
216 prefix_stripped: false,
217 },
218 );
219 }
220 StreamPart::TextDelta {
221 id,
222 delta,
223 provider_metadata,
224 } => {
225 let Some(block) = self.blocks.get_mut(&id) else {
226 out.push(StreamPart::TextDelta {
227 id,
228 delta,
229 provider_metadata,
230 });
231 return out;
232 };
233 block.buffer.push_str(&delta);
234 if block.phase == Phase::Buffering {
235 return out;
236 }
237 if block.phase == Phase::Prefix {
238 if !block.buffer.is_empty() && !block.buffer.starts_with('`') {
239 block.phase = Phase::Streaming;
240 out.push(block.start.clone());
241 } else if block.buffer.starts_with("```") {
242 if block.buffer.contains('\n') {
244 if let Some(len) = fence_prefix_len(&block.buffer) {
245 block.buffer = block.buffer[len..].to_owned();
246 block.prefix_stripped = true;
247 }
248 block.phase = Phase::Streaming;
249 out.push(block.start.clone());
250 }
251 } else if block.buffer.chars().count() >= 3 {
252 block.phase = Phase::Streaming;
253 out.push(block.start.clone());
254 }
255 }
256 if block.phase == Phase::Streaming {
257 let count = block.buffer.chars().count();
258 if count > SUFFIX_BUFFER_CHARS {
259 let split = block
260 .buffer
261 .char_indices()
262 .nth(count - SUFFIX_BUFFER_CHARS)
263 .map_or(block.buffer.len(), |(index, _)| index);
264 let to_stream = block.buffer[..split].to_owned();
265 block.buffer = block.buffer[split..].to_owned();
266 out.push(StreamPart::TextDelta {
267 id,
268 delta: to_stream,
269 provider_metadata: None,
270 });
271 }
272 }
273 }
274 StreamPart::TextEnd { ref id, .. } => {
275 if let Some(block) = self.blocks.remove(id) {
276 if matches!(block.phase, Phase::Prefix | Phase::Buffering) {
277 out.push(block.start);
278 }
279 let remaining = match block.phase {
280 Phase::Buffering => match &self.transform {
281 Some(transform) => transform(&block.buffer),
282 None => strip_json_fences(&block.buffer),
283 },
284 Phase::Prefix => strip_json_fences(&block.buffer),
286 Phase::Streaming => strip_markdown_code_fence_suffix(&block.buffer),
289 };
290 if !remaining.is_empty() {
291 out.push(StreamPart::TextDelta {
292 id: id.clone(),
293 delta: remaining,
294 provider_metadata: None,
295 });
296 }
297 }
298 out.push(part);
299 }
300 other => out.push(other),
301 }
302 out
303 }
304}