@@ -130,55 +130,53 @@ pub async fn generate_blocking(
130130
131131 accumulate_usage ( & mut total_usage, & resp. usage ) ;
132132
133- let raw_text = extract_raw_output ( & resp. message , mode) ;
134- let parsed = extract_json_value ( & raw_text) ;
133+ // Mine the object from every place a model might have parked it (tool call,
134+ // text content, AND the reasoning channel), trying each balanced JSON
135+ // candidate against the schema. Reasoning models routinely leave `content`
136+ // empty and emit the object inside `reasoning`, so without the reasoning
137+ // fallback generate_object failed with "no structured output" across models.
138+ let candidates = extract_raw_candidates ( & resp. message , mode) ;
139+ let resolution = resolve_structured ( & candidates, & req. schema ) ;
140+
141+ if let Some ( ( value, raw) ) = resolution. valid {
142+ return Ok ( StructuredResult {
143+ object : value,
144+ raw_text : Some ( raw) ,
145+ usage : total_usage,
146+ repair_rounds,
147+ mode_used : mode,
148+ } ) ;
149+ }
135150
136- match parsed {
137- Ok ( value) => match validate_against_schema ( & value, & req. schema ) {
138- Ok ( ( ) ) => {
139- return Ok ( StructuredResult {
140- object : value,
141- raw_text : Some ( raw_text) ,
142- usage : total_usage,
143- repair_rounds,
144- mode_used : mode,
145- } ) ;
146- }
147- Err ( errors) if repair_rounds < req. max_repair_attempts => {
148- repair_rounds += 1 ;
149- let repair_msg = build_repair_message ( & raw_text, & errors) ;
150- append_repair_context (
151- & mut messages,
152- & resp. message ,
153- & repair_msg,
154- mode,
155- & raw_text,
156- ) ;
157- }
158- Err ( errors) => {
159- bail ! (
160- "Structured output failed schema validation after {} repair attempts. Errors: {}" ,
161- repair_rounds,
162- errors. join( "; " )
163- ) ;
164- }
165- } ,
166- Err ( parse_err) if repair_rounds < req. max_repair_attempts => {
167- repair_rounds += 1 ;
168- let repair_msg = format ! (
169- "Your previous output could not be parsed as JSON:\n \n {}\n \n Error: {}\n \n Please return ONLY a valid JSON object matching the schema." ,
170- raw_text, parse_err
171- ) ;
172- append_repair_context ( & mut messages, & resp. message , & repair_msg, mode, & raw_text) ;
173- }
174- Err ( parse_err) => {
175- bail ! (
176- "Structured output failed JSON parsing after {} repair attempts: {}" ,
151+ if repair_rounds >= req. max_repair_attempts {
152+ return Err ( match resolution. invalid {
153+ Some ( ( _, errors) ) => anyhow:: anyhow!(
154+ "Structured output failed schema validation after {} repair attempts. Errors: {}" ,
177155 repair_rounds,
178- parse_err
179- ) ;
180- }
156+ errors. join( "; " )
157+ ) ,
158+ None => anyhow:: anyhow!(
159+ "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel" ,
160+ repair_rounds
161+ ) ,
162+ } ) ;
181163 }
164+
165+ repair_rounds += 1 ;
166+ let ( repair_msg, raw_for_ctx) = match resolution. invalid {
167+ Some ( ( raw, errors) ) => ( build_repair_message ( & raw , & errors) , raw) ,
168+ None => {
169+ let raw = resolution. raw_seen . unwrap_or_default ( ) ;
170+ ( build_parse_failure_repair ( & raw ) , raw)
171+ }
172+ } ;
173+ append_repair_context (
174+ & mut messages,
175+ & resp. message ,
176+ & repair_msg,
177+ mode,
178+ & raw_for_ctx,
179+ ) ;
182180 }
183181}
184182
@@ -260,16 +258,24 @@ pub async fn generate_streaming(
260258 }
261259
262260 let resp = final_response. context ( "Stream ended without Done event" ) ?;
263- let raw_text = extract_raw_output ( & resp. message , mode) ;
264- let value =
265- extract_json_value ( & raw_text) . context ( "Failed to parse final streamed output as JSON" ) ?;
266-
267- validate_against_schema ( & value, & req. schema ) . map_err ( |errors| {
268- anyhow:: anyhow!(
269- "Streamed structured output failed schema validation: {}" ,
270- errors. join( "; " )
271- )
272- } ) ?;
261+ // Same multi-source resolution as the blocking path: the final message may carry
262+ // the object in the tool call, the text content, or the reasoning channel.
263+ let candidates = extract_raw_candidates ( & resp. message , mode) ;
264+ let resolution = resolve_structured ( & candidates, & req. schema ) ;
265+ let ( value, raw_text) = match resolution. valid {
266+ Some ( vr) => vr,
267+ None => {
268+ return Err ( match resolution. invalid {
269+ Some ( ( _, errors) ) => anyhow:: anyhow!(
270+ "Streamed structured output failed schema validation: {}" ,
271+ errors. join( "; " )
272+ ) ,
273+ None => anyhow:: anyhow!(
274+ "Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
275+ ) ,
276+ } ) ;
277+ }
278+ } ;
273279
274280 // Emit final complete object
275281 on_partial ( & value) ;
@@ -362,6 +368,11 @@ fn find_balanced_json_array(text: &str) -> Option<&str> {
362368}
363369
364370fn find_balanced ( text : & str , open : char , close : char ) -> Option < & str > {
371+ find_balanced_range ( text, open, close) . map ( |( start, end) | & text[ start..end] )
372+ }
373+
374+ /// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
375+ fn find_balanced_range ( text : & str , open : char , close : char ) -> Option < ( usize , usize ) > {
365376 let bytes = text. as_bytes ( ) ;
366377 let open_byte = open as u8 ;
367378 let close_byte = close as u8 ;
@@ -406,7 +417,7 @@ fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
406417 _ if b == close_byte => {
407418 depth -= 1 ;
408419 if depth == 0 {
409- return Some ( & text [ start.. start + i + 1 ] ) ;
420+ return Some ( ( start, start + i + 1 ) ) ;
410421 }
411422 }
412423 _ => { }
@@ -415,6 +426,26 @@ fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
415426 None
416427}
417428
429+ /// Every top-level balanced `open..close` substring, in document order.
430+ ///
431+ /// Reasoning traces often contain several objects (worked examples, partial drafts)
432+ /// before the final answer, so callers validate each against the schema and keep the
433+ /// one that fits rather than blindly trusting the first `{...}`.
434+ fn find_all_balanced ( text : & str , open : char , close : char ) -> Vec < String > {
435+ let mut out = Vec :: new ( ) ;
436+ let mut base = 0usize ;
437+ while base < text. len ( ) {
438+ match find_balanced_range ( & text[ base..] , open, close) {
439+ Some ( ( start, end) ) => {
440+ out. push ( text[ base + start..base + end] . to_string ( ) ) ;
441+ base += end;
442+ }
443+ None => break ,
444+ }
445+ }
446+ out
447+ }
448+
418449/// Find the byte offset where JSON content starts in a text stream.
419450/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
420451fn find_json_start ( text : & str ) -> Option < usize > {
@@ -957,29 +988,138 @@ fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinit
957988 }
958989}
959990
960- /// Extract the raw JSON string from the LLM response based on mode.
961- fn extract_raw_output ( message : & super :: Message , mode : StructuredMode ) -> String {
962- match mode {
963- StructuredMode :: Tool => {
964- // Look for tool call input
965- let calls = message. tool_calls ( ) ;
966- if let Some ( call) = calls. first ( ) {
967- serde_json:: to_string ( & call. args ) . unwrap_or_default ( )
968- } else {
969- // Fallback: maybe the model responded with text anyway
970- message. text ( )
991+ /// Outcome of mining a response for the structured object across all candidate sources.
992+ struct StructuredResolution {
993+ /// A schema-valid object plus the raw source string it came from.
994+ valid : Option < ( Value , String ) > ,
995+ /// First parseable-but-schema-invalid object source + its validation errors,
996+ /// used to build a targeted repair prompt.
997+ invalid : Option < ( String , Vec < String > ) > ,
998+ /// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
999+ raw_seen : Option < String > ,
1000+ }
1001+
1002+ /// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
1003+ fn push_candidate ( out : & mut Vec < String > , s : String ) {
1004+ let trimmed = s. trim ( ) ;
1005+ if !trimmed. is_empty ( ) && !out. iter ( ) . any ( |c| c == trimmed) {
1006+ out. push ( trimmed. to_string ( ) ) ;
1007+ }
1008+ }
1009+
1010+ /// Ordered raw strings to mine for the structured object, most authoritative first:
1011+ /// tool-call arguments, then text content, then the reasoning channel.
1012+ ///
1013+ /// The reasoning fallback is the crux of the cross-model fix: reasoning models
1014+ /// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
1015+ /// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
1016+ /// at the tool call / text, so those models yielded an empty string and the whole
1017+ /// generate_object failed even though a perfectly good object was produced.
1018+ fn extract_raw_candidates ( message : & super :: Message , mode : StructuredMode ) -> Vec < String > {
1019+ let mut out: Vec < String > = Vec :: new ( ) ;
1020+ if mode == StructuredMode :: Tool {
1021+ if let Some ( call) = message. tool_calls ( ) . first ( ) {
1022+ push_candidate (
1023+ & mut out,
1024+ serde_json:: to_string ( & call. args ) . unwrap_or_default ( ) ,
1025+ ) ;
1026+ }
1027+ }
1028+ push_candidate ( & mut out, message. text ( ) ) ;
1029+ if let Some ( reasoning) = message. reasoning_content . as_deref ( ) {
1030+ push_candidate ( & mut out, reasoning. to_string ( ) ) ;
1031+ }
1032+ out
1033+ }
1034+
1035+ /// Every JSON object/array value mineable from possibly-dirty text, in document order
1036+ /// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
1037+ fn extract_all_json_values ( text : & str ) -> Vec < Value > {
1038+ let trimmed = text. trim ( ) ;
1039+ let mut values: Vec < Value > = Vec :: new ( ) ;
1040+ let consider = |candidate : & str , values : & mut Vec < Value > | {
1041+ if let Ok ( v) = serde_json:: from_str :: < Value > ( candidate. trim ( ) ) {
1042+ if ( v. is_object ( ) || v. is_array ( ) ) && !values. contains ( & v) {
1043+ values. push ( v) ;
1044+ }
1045+ }
1046+ } ;
1047+ consider ( trimmed, & mut values) ;
1048+ if let Some ( inner) = strip_code_fence ( trimmed) {
1049+ consider ( inner, & mut values) ;
1050+ }
1051+ for candidate in find_all_balanced ( trimmed, '{' , '}' ) {
1052+ consider ( & candidate, & mut values) ;
1053+ }
1054+ for candidate in find_all_balanced ( trimmed, '[' , ']' ) {
1055+ consider ( & candidate, & mut values) ;
1056+ }
1057+ values
1058+ }
1059+
1060+ /// Try every raw candidate × every JSON value it yields against the schema; return the
1061+ /// first schema-valid object, else the best parseable-but-invalid object (for repair).
1062+ fn resolve_structured ( candidates : & [ String ] , schema : & Value ) -> StructuredResolution {
1063+ let mut invalid: Option < ( String , Vec < String > ) > = None ;
1064+ let mut raw_seen: Option < String > = None ;
1065+ for raw in candidates {
1066+ if raw_seen. is_none ( ) && !raw. trim ( ) . is_empty ( ) {
1067+ raw_seen = Some ( raw. clone ( ) ) ;
1068+ }
1069+ for value in extract_all_json_values ( raw) {
1070+ match validate_against_schema ( & value, schema) {
1071+ Ok ( ( ) ) => {
1072+ return StructuredResolution {
1073+ valid : Some ( ( value, raw. clone ( ) ) ) ,
1074+ invalid,
1075+ raw_seen,
1076+ } ;
1077+ }
1078+ Err ( errors) => {
1079+ if invalid. is_none ( ) {
1080+ invalid = Some ( ( raw. clone ( ) , errors) ) ;
1081+ }
1082+ }
9711083 }
9721084 }
973- _ => message. text ( ) ,
9741085 }
1086+ StructuredResolution {
1087+ valid : None ,
1088+ invalid,
1089+ raw_seen,
1090+ }
1091+ }
1092+
1093+ /// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
1094+ /// repair prompts echo arbitrary model output, including CJK).
1095+ fn truncate_utf8 ( s : & str , max : usize ) -> & str {
1096+ if s. len ( ) <= max {
1097+ return s;
1098+ }
1099+ let mut end = max;
1100+ while end > 0 && !s. is_char_boundary ( end) {
1101+ end -= 1 ;
1102+ }
1103+ & s[ ..end]
1104+ }
1105+
1106+ /// Repair prompt for when nothing parseable was produced at all.
1107+ fn build_parse_failure_repair ( raw_text : & str ) -> String {
1108+ if raw_text. trim ( ) . is_empty ( ) {
1109+ return "Your previous response contained no JSON. Respond with ONLY a single valid JSON object that matches the schema — no prose, no markdown, no analysis, and put the object in your reply content (not in a thinking/reasoning aside)." . to_string ( ) ;
1110+ }
1111+ format ! (
1112+ "Your previous output could not be parsed as a JSON object:\n \n {}\n \n Return ONLY a single valid JSON object matching the schema — no prose, no markdown." ,
1113+ truncate_utf8( raw_text, 2000 )
1114+ )
9751115}
9761116
9771117fn build_repair_message ( raw_text : & str , errors : & [ String ] ) -> String {
9781118 // Truncate raw output in repair message to avoid blowing context
9791119 let truncated_raw = if raw_text. len ( ) > 2000 {
9801120 format ! (
9811121 "{}...[truncated, {} bytes total]" ,
982- & raw_text[ .. 2000 ] ,
1122+ truncate_utf8 ( raw_text, 2000 ) ,
9831123 raw_text. len( )
9841124 )
9851125 } else {
0 commit comments