@@ -16,6 +16,8 @@ export interface LlmClientConfig {
1616export interface LlmClient {
1717 /** Send a prompt and parse the JSON response. Returns null on failure. */
1818 completeJson < T > ( prompt : string , label ?: string ) : Promise < T | null > ;
19+ /** Best-effort diagnostics for the most recent failure, if any. */
20+ getLastError ( ) : string | null ;
1921}
2022
2123/**
@@ -56,16 +58,108 @@ function previewText(value: string, maxLen = 200): string {
5658 return `${ normalized . slice ( 0 , maxLen - 3 ) } ...` ;
5759}
5860
61+ function nextNonWhitespaceChar ( text : string , start : number ) : string | undefined {
62+ for ( let i = start ; i < text . length ; i ++ ) {
63+ const ch = text [ i ] ;
64+ if ( ! / \s / . test ( ch ) ) return ch ;
65+ }
66+ return undefined ;
67+ }
68+
69+ /**
70+ * Best-effort repair for common LLM JSON issues:
71+ * - unescaped quotes inside string values
72+ * - raw newlines / tabs inside strings
73+ * - trailing commas before } or ]
74+ */
75+ function repairCommonJson ( text : string ) : string {
76+ let result = "" ;
77+ let inString = false ;
78+ let escaped = false ;
79+
80+ for ( let i = 0 ; i < text . length ; i ++ ) {
81+ const ch = text [ i ] ;
82+
83+ if ( escaped ) {
84+ result += ch ;
85+ escaped = false ;
86+ continue ;
87+ }
88+
89+ if ( inString ) {
90+ if ( ch === "\\" ) {
91+ result += ch ;
92+ escaped = true ;
93+ continue ;
94+ }
95+
96+ if ( ch === "\"" ) {
97+ const nextCh = nextNonWhitespaceChar ( text , i + 1 ) ;
98+ // A string may legally end before object/array delimiters or a key colon.
99+ if (
100+ nextCh === undefined ||
101+ nextCh === "," ||
102+ nextCh === "}" ||
103+ nextCh === "]" ||
104+ nextCh === ":"
105+ ) {
106+ result += ch ;
107+ inString = false ;
108+ } else {
109+ // Treat stray quotes inside a string as literal content.
110+ result += "\\\"" ;
111+ }
112+ continue ;
113+ }
114+
115+ if ( ch === "\n" ) {
116+ result += "\\n" ;
117+ continue ;
118+ }
119+ if ( ch === "\r" ) {
120+ result += "\\r" ;
121+ continue ;
122+ }
123+ if ( ch === "\t" ) {
124+ result += "\\t" ;
125+ continue ;
126+ }
127+
128+ result += ch ;
129+ continue ;
130+ }
131+
132+ if ( ch === "\"" ) {
133+ result += ch ;
134+ inString = true ;
135+ continue ;
136+ }
137+
138+ if ( ch === "," ) {
139+ const nextCh = nextNonWhitespaceChar ( text , i + 1 ) ;
140+ if ( nextCh === "}" || nextCh === "]" ) {
141+ continue ;
142+ }
143+ }
144+
145+ result += ch ;
146+ }
147+
148+ return result ;
149+ }
150+
59151export function createLlmClient ( config : LlmClientConfig ) : LlmClient {
60152 const client = new OpenAI ( {
61153 apiKey : config . apiKey ,
62154 baseURL : config . baseURL ,
63155 timeout : config . timeoutMs ?? 30000 ,
64156 } ) ;
65157 const log = config . log ?? ( ( ) => { } ) ;
158+ let lastError : string | null = null ;
66159
67160 return {
68161 async completeJson < T > ( prompt : string , label = "generic" ) : Promise < T | null > {
162+ lastError = null ;
69163 try {
70164 const response = await client . chat . completions . create ( {
71165 model : config . model ,
@@ -82,43 +176,61 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
82176
83177 const raw = response . choices ?. [ 0 ] ?. message ?. content ;
84178 if ( ! raw ) {
85- log (
86- `memory-lancedb-pro: llm-client [${ label } ] empty response content from model ${ config . model } ` ,
87- ) ;
179+ lastError =
180+ `memory-lancedb-pro: llm-client [${ label } ] empty response content from model ${ config . model } ` ;
181+ log ( lastError ) ;
88182 return null ;
89183 }
90184 if ( typeof raw !== "string" ) {
91- log (
92- `memory-lancedb-pro: llm-client [${ label } ] non-string response content type=${ Array . isArray ( raw ) ? "array" : typeof raw } from model ${ config . model } ` ,
93- ) ;
185+ lastError =
186+ `memory-lancedb-pro: llm-client [${ label } ] non-string response content type=${ Array . isArray ( raw ) ? "array" : typeof raw } from model ${ config . model } ` ;
187+ log ( lastError ) ;
94188 return null ;
95189 }
96190
97191 const jsonStr = extractJsonFromResponse ( raw ) ;
98192 if ( ! jsonStr ) {
99- log (
100- `memory-lancedb-pro: llm-client [${ label } ] no JSON object found (chars=${ raw . length } , preview=${ JSON . stringify ( previewText ( raw ) ) } )` ,
101- ) ;
193+ lastError =
194+ `memory-lancedb-pro: llm-client [${ label } ] no JSON object found (chars=${ raw . length } , preview=${ JSON . stringify ( previewText ( raw ) ) } )` ;
195+ log ( lastError ) ;
102196 return null ;
103197 }
104198
105199 try {
106200 return JSON . parse ( jsonStr ) as T ;
107201 } catch ( err ) {
108- log (
109- `memory-lancedb-pro: llm-client [${ label } ] JSON.parse failed: ${ err instanceof Error ? err . message : String ( err ) } (jsonChars=${ jsonStr . length } , jsonPreview=${ JSON . stringify ( previewText ( jsonStr ) ) } )` ,
110- ) ;
202+ const repairedJsonStr = repairCommonJson ( jsonStr ) ;
203+ if ( repairedJsonStr !== jsonStr ) {
204+ try {
205+ const repaired = JSON . parse ( repairedJsonStr ) as T ;
206+ log (
207+ `memory-lancedb-pro: llm-client [${ label } ] recovered malformed JSON via heuristic repair (jsonChars=${ jsonStr . length } )` ,
208+ ) ;
209+ return repaired ;
210+ } catch ( repairErr ) {
211+ lastError =
212+ `memory-lancedb-pro: llm-client [${ label } ] JSON.parse failed: ${ err instanceof Error ? err . message : String ( err ) } ; repair failed: ${ repairErr instanceof Error ? repairErr . message : String ( repairErr ) } (jsonChars=${ jsonStr . length } , jsonPreview=${ JSON . stringify ( previewText ( jsonStr ) ) } )` ;
213+ log ( lastError ) ;
214+ return null ;
215+ }
216+ }
217+ lastError =
218+ `memory-lancedb-pro: llm-client [${ label } ] JSON.parse failed: ${ err instanceof Error ? err . message : String ( err ) } (jsonChars=${ jsonStr . length } , jsonPreview=${ JSON . stringify ( previewText ( jsonStr ) ) } )` ;
219+ log ( lastError ) ;
111220 return null ;
112221 }
113222 } catch ( err ) {
114223 // Graceful degradation — return null so caller can fall back
115- log (
116- `memory-lancedb-pro: llm-client [${ label } ] request failed for model ${ config . model } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
117- ) ;
224+ lastError =
225+ `memory-lancedb-pro: llm-client [${ label } ] request failed for model ${ config . model } : ${ err instanceof Error ? err . message : String ( err ) } ` ;
226+ log ( lastError ) ;
118227 return null ;
119228 }
120229 } ,
230+ getLastError ( ) : string | null {
231+ return lastError ;
232+ } ,
121233 } ;
122234}
123235
124- export { extractJsonFromResponse } ;
236+ export { extractJsonFromResponse , repairCommonJson } ;
0 commit comments