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()
130 .to_owned()
131}
132
133fn strip_fence_prefix(text: &str) -> &str {
135 match text.strip_prefix("```") {
136 Some(rest) => rest.strip_prefix("json").unwrap_or(rest).trim_start(),
137 None => text,
138 }
139}
140
141fn strip_fence_suffix(text: &str) -> &str {
144 let trimmed = text.trim_end();
145 match trimmed.strip_suffix("```") {
146 Some(rest) => rest.strip_suffix('\n').unwrap_or(rest),
147 None => text,
148 }
149}
150
151fn strip_markdown_code_fence_suffix(text: &str) -> String {
152 strip_fence_suffix(text).trim_end().to_owned()
153}
154
155fn fence_prefix_len(text: &str) -> Option<usize> {
158 let rest = text.strip_prefix("```")?;
159 let rest = rest.strip_prefix("json").unwrap_or(rest);
160 let consumed = text.len() - rest.len();
161 let whitespace_len = rest
162 .char_indices()
163 .find(|(_, c)| !c.is_whitespace())
164 .map_or(rest.len(), |(index, _)| index);
165 let whitespace = &rest[..whitespace_len];
166 let last_newline = whitespace.rfind('\n')?;
167 Some(consumed + last_newline + 1)
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Phase {
172 Prefix,
173 Streaming,
174 Buffering,
175}
176
177struct Block {
178 start: StreamPart,
179 phase: Phase,
180 buffer: String,
181 prefix_stripped: bool,
182}
183
184struct StreamState {
185 transform: Option<JsonTransformFn>,
186 blocks: HashMap<PartId, Block>,
187}
188
189impl StreamState {
190 fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
191 let mut out = Vec::new();
192 match part {
193 StreamPart::TextStart { ref id, .. } => {
194 let phase = if self.transform.is_some() {
195 Phase::Buffering
196 } else {
197 Phase::Prefix
198 };
199 self.blocks.insert(
200 id.clone(),
201 Block {
202 start: part,
203 phase,
204 buffer: String::new(),
205 prefix_stripped: false,
206 },
207 );
208 }
209 StreamPart::TextDelta { id, delta, .. } => {
210 let Some(block) = self.blocks.get_mut(&id) else {
211 out.push(StreamPart::TextDelta {
212 id,
213 delta,
214 provider_metadata: None,
215 });
216 return out;
217 };
218 block.buffer.push_str(&delta);
219 if block.phase == Phase::Buffering {
220 return out;
221 }
222 if block.phase == Phase::Prefix {
223 if !block.buffer.is_empty() && !block.buffer.starts_with('`') {
224 block.phase = Phase::Streaming;
225 out.push(block.start.clone());
226 } else if block.buffer.starts_with("```") {
227 if block.buffer.contains('\n') {
229 if let Some(len) = fence_prefix_len(&block.buffer) {
230 block.buffer = block.buffer[len..].to_owned();
231 block.prefix_stripped = true;
232 }
233 block.phase = Phase::Streaming;
234 out.push(block.start.clone());
235 }
236 } else if block.buffer.chars().count() >= 3 {
237 block.phase = Phase::Streaming;
238 out.push(block.start.clone());
239 }
240 }
241 if block.phase == Phase::Streaming {
242 let count = block.buffer.chars().count();
243 if count > SUFFIX_BUFFER_CHARS {
244 let split = block
245 .buffer
246 .char_indices()
247 .nth(count - SUFFIX_BUFFER_CHARS)
248 .map_or(block.buffer.len(), |(index, _)| index);
249 let to_stream = block.buffer[..split].to_owned();
250 block.buffer = block.buffer[split..].to_owned();
251 out.push(StreamPart::TextDelta {
252 id,
253 delta: to_stream,
254 provider_metadata: None,
255 });
256 }
257 }
258 }
259 StreamPart::TextEnd { ref id, .. } => {
260 if let Some(block) = self.blocks.remove(id) {
261 if matches!(block.phase, Phase::Prefix | Phase::Buffering) {
262 out.push(block.start);
263 }
264 let remaining = match block.phase {
265 Phase::Buffering => match &self.transform {
266 Some(transform) => transform(&block.buffer),
267 None => strip_json_fences(&block.buffer),
268 },
269 Phase::Prefix => strip_json_fences(&block.buffer),
271 Phase::Streaming => strip_markdown_code_fence_suffix(&block.buffer),
274 };
275 if !remaining.is_empty() {
276 out.push(StreamPart::TextDelta {
277 id: id.clone(),
278 delta: remaining,
279 provider_metadata: None,
280 });
281 }
282 }
283 out.push(part);
284 }
285 other => out.push(other),
286 }
287 out
288 }
289}