@@ -35,6 +35,12 @@ export interface ParseResult {
3535 totalLines : number ;
3636}
3737
38+ export interface ParseJsonlFileOptions {
39+ retainFullRecords ?: boolean ;
40+ timeoutMs ?: number ;
41+ readChunkSizeBytes ?: number ;
42+ }
43+
3844export interface BatchOverrides {
3945 maxTokens ?: number ;
4046 temperature ?: number ;
@@ -54,6 +60,13 @@ const MAX_ERRORS = 20;
5460const MAX_DIFF_SAMPLES = 5 ;
5561// OpenAI Batch API hard limit; MDS enforces the same cap (python/aibrix/.../batch.py).
5662const MAX_REQUESTS_PER_BATCH = 50000 ;
63+ const BYTES_PER_MEBIBYTE = 1024 * 1024 ;
64+ const FILE_READ_CHUNK_SIZE_BYTES = BYTES_PER_MEBIBYTE ;
65+ const MIN_FILE_READ_CHUNK_SIZE_BYTES = 1 ;
66+ const FILE_VALIDATION_TIMEOUT_MS = 60_000 ;
67+ const MILLISECONDS_PER_SECOND = 1000 ;
68+ const VALIDATION_YIELD_INTERVAL_LINES = 500 ;
69+ const VALIDATION_YIELD_DELAY_MS = 0 ;
5770export const JSONL_EXTENSION_ERROR = 'File must use the .jsonl extension.' ;
5871
5972const OVERRIDE_FIELD_MAP : Record < keyof BatchOverrides , string > = {
@@ -66,48 +79,181 @@ const OVERRIDE_FIELD_MAP: Record<keyof BatchOverrides, string> = {
6679// Endpoints where completion-style sampling params apply.
6780const COMPLETION_ENDPOINTS = new Set ( [ '/v1/chat/completions' , '/v1/completions' ] ) ;
6881
82+ class BatchFileValidationTimeoutError extends Error {
83+ constructor ( timeoutMs : number ) {
84+ super ( getFileValidationTimeoutError ( timeoutMs ) ) ;
85+ this . name = 'BatchFileValidationTimeoutError' ;
86+ }
87+ }
88+
6989export function validateBatchFileName ( fileName : string ) : string | null {
7090 return fileName . trim ( ) . toLowerCase ( ) . endsWith ( '.jsonl' ) ? null : JSONL_EXTENSION_ERROR ;
7191}
7292
73- export function parseJsonl ( text : string ) : ParseResult {
74- const rawLines = text . trimEnd ( ) . split ( '\n' ) ;
75- const records : ParsedLine [ ] = [ ] ;
76- const parseErrors : ParseResult [ 'parseErrors' ] = [ ] ;
77- const warnings : ParseResult [ 'warnings' ] = [ ] ;
93+ export function getFileValidationTimeoutError ( timeoutMs = FILE_VALIDATION_TIMEOUT_MS ) : string {
94+ const timeoutSeconds = Math . ceil ( timeoutMs / MILLISECONDS_PER_SECOND ) ;
95+ return `Client-side validation timed out after ${ timeoutSeconds } s. ` +
96+ 'The file is too large to validate in the browser; split it into smaller batches or select an already uploaded dataset.' ;
97+ }
98+
99+ export function formatBatchFileValidationError ( err : unknown ) : string {
100+ if ( err instanceof BatchFileValidationTimeoutError ) {
101+ return err . message ;
102+ }
103+ if ( err instanceof Error && err . message ) {
104+ return `Failed to read file: ${ err . message } ` ;
105+ }
106+ return 'Failed to read file: unknown error' ;
107+ }
108+
109+ interface ParseState extends ParseResult {
110+ pendingEmptyLineNumbers : number [ ] ;
111+ }
112+
113+ function createParseState ( ) : ParseState {
114+ return {
115+ records : [ ] ,
116+ parseErrors : [ ] ,
117+ warnings : [ ] ,
118+ totalLines : 0 ,
119+ pendingEmptyLineNumbers : [ ] ,
120+ } ;
121+ }
122+
123+ function flushPendingEmptyLineWarnings ( state : ParseState ) {
124+ for ( const lineNumber of state . pendingEmptyLineNumbers ) {
125+ state . warnings . push ( { lineNumber, message : 'empty line (will be skipped)' } ) ;
126+ }
127+ state . pendingEmptyLineNumbers = [ ] ;
128+ }
129+
130+ function summarizeRecord ( record : BatchLineRecord ) : BatchLineRecord {
131+ const rawRecord = record as Record < string , unknown > ;
132+ const rawBody = rawRecord . body ;
133+ return {
134+ custom_id : rawRecord . custom_id as string | undefined ,
135+ method : rawRecord . method as string | undefined ,
136+ url : rawRecord . url as string | undefined ,
137+ body : rawBody && typeof rawBody === 'object' && ! Array . isArray ( rawBody )
138+ ? { model : ( rawBody as { model ?: string } ) . model }
139+ : rawBody as BatchLineRecord [ 'body' ] ,
140+ } ;
141+ }
142+
143+ function processJsonlLine (
144+ state : ParseState ,
145+ rawLine : string ,
146+ lineNumber : number ,
147+ retainFullRecords : boolean ,
148+ ) {
149+ const line = rawLine . trim ( ) ;
150+ if ( line === '' ) {
151+ state . pendingEmptyLineNumbers . push ( lineNumber ) ;
152+ return ;
153+ }
154+
155+ flushPendingEmptyLineWarnings ( state ) ;
156+ state . totalLines ++ ;
157+
158+ let parsed : unknown ;
159+ try {
160+ parsed = JSON . parse ( line ) ;
161+ } catch {
162+ state . parseErrors . push ( { lineNumber, message : 'invalid JSON' } ) ;
163+ return ;
164+ }
165+ if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) {
166+ state . parseErrors . push ( { lineNumber, message : 'must be a JSON object' } ) ;
167+ return ;
168+ }
169+
170+ const record = parsed as BatchLineRecord ;
171+ state . records . push ( {
172+ lineNumber,
173+ record : retainFullRecords ? record : summarizeRecord ( record ) ,
174+ } ) ;
175+ }
78176
79- if ( rawLines . length === 0 || ( rawLines . length === 1 && rawLines [ 0 ] . trim ( ) === '' ) ) {
80- return { records, parseErrors : [ { lineNumber : 1 , message : 'File is empty' } ] , warnings, totalLines : 0 } ;
177+ function finishParseState ( state : ParseState ) : ParseResult {
178+ if ( state . totalLines === 0 && state . parseErrors . length === 0 ) {
179+ state . parseErrors . push ( { lineNumber : 1 , message : 'File is empty' } ) ;
81180 }
82181
83- let nonEmpty = 0 ;
182+ return {
183+ records : state . records ,
184+ parseErrors : state . parseErrors ,
185+ warnings : state . warnings ,
186+ totalLines : state . totalLines ,
187+ } ;
188+ }
189+
190+ function assertValidationNotTimedOut ( startedAtMs : number , timeoutMs : number ) {
191+ if ( Date . now ( ) - startedAtMs > timeoutMs ) {
192+ throw new BatchFileValidationTimeoutError ( timeoutMs ) ;
193+ }
194+ }
195+
196+ function yieldToMainThread ( ) : Promise < void > {
197+ return new Promise ( ( resolve ) => {
198+ setTimeout ( resolve , VALIDATION_YIELD_DELAY_MS ) ;
199+ } ) ;
200+ }
201+
202+ export function parseJsonl ( text : string ) : ParseResult {
203+ const rawLines = text . trimEnd ( ) . split ( '\n' ) ;
204+ const state = createParseState ( ) ;
84205 for ( let i = 0 ; i < rawLines . length ; i ++ ) {
85- const lineNumber = i + 1 ;
86- const line = rawLines [ i ] . trim ( ) ;
87- if ( line === '' ) {
88- if ( i < rawLines . length - 1 ) {
89- warnings . push ( { lineNumber, message : 'empty line (will be skipped)' } ) ;
206+ processJsonlLine ( state , rawLines [ i ] ?? '' , i + 1 , true ) ;
207+ }
208+
209+ return finishParseState ( state ) ;
210+ }
211+
212+ export async function parseJsonlFile (
213+ file : File ,
214+ options : ParseJsonlFileOptions = { } ,
215+ ) : Promise < ParseResult > {
216+ const state = createParseState ( ) ;
217+ const decoder = new TextDecoder ( ) ;
218+ const retainFullRecords = options . retainFullRecords ?? false ;
219+ const timeoutMs = options . timeoutMs ?? FILE_VALIDATION_TIMEOUT_MS ;
220+ const readChunkSizeBytes = Math . max (
221+ options . readChunkSizeBytes ?? FILE_READ_CHUNK_SIZE_BYTES ,
222+ MIN_FILE_READ_CHUNK_SIZE_BYTES ,
223+ ) ;
224+ const startedAtMs = Date . now ( ) ;
225+ let bufferedText = '' ;
226+ let lineNumber = 1 ;
227+ let linesSinceYield = 0 ;
228+
229+ for ( let offset = 0 ; offset < file . size ; offset += readChunkSizeBytes ) {
230+ assertValidationNotTimedOut ( startedAtMs , timeoutMs ) ;
231+ const chunkEnd = Math . min ( offset + readChunkSizeBytes , file . size ) ;
232+ const chunk = new Uint8Array ( await file . slice ( offset , chunkEnd ) . arrayBuffer ( ) ) ;
233+ bufferedText += decoder . decode ( chunk , { stream : chunkEnd < file . size } ) ;
234+ const lines = bufferedText . split ( '\n' ) ;
235+ bufferedText = lines . pop ( ) ?? '' ;
236+
237+ for ( const rawLine of lines ) {
238+ processJsonlLine ( state , rawLine , lineNumber , retainFullRecords ) ;
239+ lineNumber ++ ;
240+ linesSinceYield ++ ;
241+
242+ if ( linesSinceYield >= VALIDATION_YIELD_INTERVAL_LINES ) {
243+ linesSinceYield = 0 ;
244+ await yieldToMainThread ( ) ;
245+ assertValidationNotTimedOut ( startedAtMs , timeoutMs ) ;
90246 }
91- continue ;
92- }
93- nonEmpty ++ ;
94-
95- let parsed : unknown ;
96- try {
97- parsed = JSON . parse ( line ) ;
98- } catch {
99- parseErrors . push ( { lineNumber, message : 'invalid JSON' } ) ;
100- continue ;
101- }
102- if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) {
103- parseErrors . push ( { lineNumber, message : 'must be a JSON object' } ) ;
104- continue ;
105247 }
248+ }
106249
107- records . push ( { lineNumber, record : parsed as BatchLineRecord } ) ;
250+ bufferedText += decoder . decode ( ) ;
251+ if ( bufferedText !== '' ) {
252+ processJsonlLine ( state , bufferedText , lineNumber , retainFullRecords ) ;
108253 }
254+ assertValidationNotTimedOut ( startedAtMs , timeoutMs ) ;
109255
110- return { records , parseErrors , warnings , totalLines : nonEmpty } ;
256+ return finishParseState ( state ) ;
111257}
112258
113259export function validateBatchLines ( parsed : ParseResult , ctx : ValidationContext ) : ValidationResult {
@@ -211,23 +357,34 @@ export function validateBatchLines(parsed: ParseResult, ctx: ValidationContext):
211357 } ;
212358}
213359
360+ function validationFailure ( errors : string [ ] ) : ValidationResult {
361+ return {
362+ valid : false ,
363+ totalLines : 0 ,
364+ errors,
365+ warnings : [ ] ,
366+ detectedModel : null ,
367+ endpoints : [ ] ,
368+ } ;
369+ }
370+
214371// Convenience wrapper: read file, parse, validate.
215- export async function validateBatchFile ( file : File , ctx : ValidationContext ) : Promise < ValidationResult > {
372+ export async function validateBatchFile (
373+ file : File ,
374+ ctx : ValidationContext ,
375+ options : ParseJsonlFileOptions = { } ,
376+ ) : Promise < ValidationResult > {
216377 const fileNameError = validateBatchFileName ( file . name ) ;
217378 if ( fileNameError ) {
218- return {
219- valid : false ,
220- totalLines : 0 ,
221- errors : [ fileNameError ] ,
222- warnings : [ ] ,
223- detectedModel : null ,
224- endpoints : [ ] ,
225- } ;
379+ return validationFailure ( [ fileNameError ] ) ;
226380 }
227381
228- const text = await file . text ( ) ;
229- const parsed = parseJsonl ( text ) ;
230- return validateBatchLines ( parsed , ctx ) ;
382+ try {
383+ const parsed = await parseJsonlFile ( file , options ) ;
384+ return validateBatchLines ( parsed , ctx ) ;
385+ } catch ( err ) {
386+ return validationFailure ( [ formatBatchFileValidationError ( err ) ] ) ;
387+ }
231388}
232389
233390export function hasAnyOverride ( overrides : BatchOverrides ) : boolean {
0 commit comments