4646 --provider openai_compatible --base-url https://proxy.corp/v1 \\
4747 --model claude-sonnet-5
4848
49+ # local model via LM Studio on a shared box (free inference, so audit +
50+ # classification can stay on); --model must match the id LM Studio shows
51+ python benchmarks/benchmark_claude.py --smoke \\
52+ --provider lmstudio --base-url http://bionic:1234/v1 \\
53+ --model qwen3-27b
54+
4955 # offline plumbing check: zero API calls
5056 python benchmarks/benchmark_claude.py --smoke --mock
5157
7581
7682sys .path .insert (0 , str (Path (__file__ ).resolve ().parent ))
7783
84+ # Parse --request-timeout BEFORE importing the client stack: engine/client.py
85+ # binds DEFAULT_REQUEST_TIMEOUT_SECONDS at import time, so the env var must be
86+ # set first. Slow local models (27B reasoning on CPU/offload) need 900-1800s;
87+ # cloud APIs are fine with the 120s default.
88+ _request_parser = argparse .ArgumentParser (add_help = False )
89+ _request_parser .add_argument ("--request-timeout" , type = float , default = None )
90+ _request_known , _ = _request_parser .parse_known_args ()
91+ if _request_known .request_timeout :
92+ import os as _os
93+
94+ _os .environ ["BUFFDATA_REQUEST_TIMEOUT" ] = str (_request_known .request_timeout )
95+
7896from benchmark_buffdata import ( # noqa: E402
7997 build_vocab ,
8098 make_dirty ,
90108DEFAULT_DATASETS = ["ag_news" , "imdb" ]
91109
92110
111+ # provider -> (base-url env var, api-key env var) read by create_llm_client
112+ # when --base-url is not passed explicitly. Local servers (ollama/lmstudio/...)
113+ # also have loopback presets, so --base-url is only needed for shared boxes
114+ # like --base-url http://bionic:1234/v1.
115+ _GATEWAY_ENVS = {
116+ "openai_compatible" : ("OPENAI_COMPATIBLE_BASE_URL" , "OPENAI_COMPATIBLE_API_KEY" ),
117+ "ollama" : ("OLLAMA_BASE_URL" , "OLLAMA_API_KEY" ),
118+ "lmstudio" : ("LMSTUDIO_BASE_URL" , "LMSTUDIO_API_KEY" ),
119+ "vllm" : ("VLLM_BASE_URL" , "VLLM_API_KEY" ),
120+ "llamacpp" : ("LLAMACPP_BASE_URL" , "LLAMACPP_API_KEY" ),
121+ }
122+
123+
93124def _configure_gateway (provider : str , base_url : str | None ) -> None :
94125 """Forward gateway settings into the env vars the client layer reads.
95126
96127 The optimize() helper below builds its own PipelineConfig without a
97- base_url, but create_llm_client(provider="openai_compatible") falls back
98- to OPENAI_COMPATIBLE_BASE_URL / OPENAI_COMPATIBLE_API_KEY env vars -- so
99- exporting here makes both the direct classifier calls and the full
100- pipeline audit path reach the proxy. Reuses . env ANTHROPIC_API_KEY as the
101- gateway key when no gateway-specific key is set.
128+ base_url, but create_llm_client falls back to the provider's *_BASE_URL /
129+ *_API_KEY env vars -- so exporting here makes both the direct classifier
130+ calls and the full pipeline audit path reach the proxy or local server.
131+ For openai_compatible, reuses . env ANTHROPIC_API_KEY as the gateway key
132+ when no gateway-specific key is set. Local servers need no key .
102133 """
103134 import os
104135
136+ envs = _GATEWAY_ENVS .get (provider )
137+ if not envs :
138+ return
139+ base_env , key_env = envs
105140 if base_url :
106- os .environ ["OPENAI_COMPATIBLE_BASE_URL" ] = base_url
107- if provider == "openai_compatible" and not os .getenv ("OPENAI_COMPATIBLE_API_KEY" ):
141+ os .environ [base_env ] = base_url
142+ if provider == "openai_compatible" and not os .getenv (key_env ):
108143 fallback = os .getenv ("ANTHROPIC_API_KEY" )
109144 if fallback :
110- os .environ ["OPENAI_COMPATIBLE_API_KEY" ] = fallback
145+ os .environ [key_env ] = fallback
111146
112147
113148def _synthetic_rows (count : int , classes : int , seed : int ) -> list [dict [str , Any ]]:
@@ -225,6 +260,25 @@ async def measure_classification(
225260 }
226261
227262
263+ async def _safe_optimize (
264+ rows : list [dict [str , Any ]], label : str , ** kwargs : Any ,
265+ ) -> tuple [list [dict [str , Any ]], dict [str , Any ]]:
266+ """optimize() that never kills the benchmark: on provider failure (wedged
267+ local server, dead quota, 503 spike) fall back to the unoptimized input so
268+ recovery still reports -- with the error recorded in quality instead of
269+ silently presenting fallback numbers as a real result."""
270+ try :
271+ return await optimize (rows , ** kwargs )
272+ except Exception as exc : # noqa: BLE001
273+ print (f" [{ label } ] optimize failed, using unoptimized input: { exc } " , flush = True )
274+ return list (rows ), {
275+ "error" : f"{ type (exc ).__name__ } : { exc } " ,
276+ "input_rows" : len (rows ), "output_rows" : len (rows ),
277+ "provider" : kwargs .get ("provider" ), "model" : kwargs .get ("anthropic_model" ),
278+ "usage" : {"input_tokens" : 0 , "output_tokens" : 0 , "total_tokens" : 0 },
279+ }
280+
281+
228282async def run_smoke (args : argparse .Namespace ) -> dict [str , Any ]:
229283 """Synthetic end-to-end check without Hugging Face downloads."""
230284 print ("=== smoke: synthetic binary classification (no HF download) ===" , flush = True )
@@ -239,9 +293,11 @@ async def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
239293 # force the audit off (quality_mode="off") instead of attempting a live
240294 # sampled audit with an absent key.
241295 audit_rows = 0 if args .mock else args .audit_rows
242- clean_opt , clean_q = await optimize (clean , provider = args .provider , anthropic_model = args .model )
243- dirty_opt , dirty_q = await optimize (
244- optimizer_input ,
296+ clean_opt , clean_q = await _safe_optimize (
297+ clean , "smoke-clean" , provider = args .provider , anthropic_model = args .model ,
298+ )
299+ dirty_opt , dirty_q = await _safe_optimize (
300+ optimizer_input , "smoke-dirty" ,
245301 gemini_audit_rows = audit_rows ,
246302 gemini_model = args .model ,
247303 anthropic_model = args .model ,
@@ -304,9 +360,11 @@ async def run_dataset(
304360 clean_test = stratified_rows (test_split , args .test_rows , classes , text_fields , 200 + dataset_offset , label_field = label_col )
305361 _ , optimizer_input , defects = make_dirty (clean_train , classes , 300 + dataset_offset )
306362
307- clean_opt , clean_q = await optimize (clean_train , provider = args .provider , anthropic_model = args .model )
308- dirty_opt , dirty_q = await optimize (
309- optimizer_input ,
363+ clean_opt , clean_q = await _safe_optimize (
364+ clean_train , f"{ name } -clean" , provider = args .provider , anthropic_model = args .model ,
365+ )
366+ dirty_opt , dirty_q = await _safe_optimize (
367+ optimizer_input , f"{ name } -dirty" ,
310368 gemini_audit_rows = args .audit_rows ,
311369 gemini_model = args .model ,
312370 anthropic_model = args .model ,
@@ -367,7 +425,7 @@ def markdown_report(payload: dict[str, Any]) -> str:
367425 f"# { title } " ,
368426 "" ,
369427 f"Provider: `{ m ['provider' ]} ` | Model: `{ m ['model' ]} ` | Base URL: `{ m .get ('base_url' ) or '-' } ` | Seeds: { m ['seeds' ]} | Epochs: { m ['epochs' ]} |" ,
370- f"Audit rows: { m ['audit_rows' ]} | Classify sample: { m ['classify_sample_size' ]} " ,
428+ f"Audit rows: { m ['audit_rows' ]} | Classify sample: { m ['classify_sample_size' ]} | Request timeout: { m . get ( 'request_timeout_s' , '-' ) } s " ,
371429 "" ,
372430 ]
373431 if payload .get ("smoke" ):
@@ -396,9 +454,12 @@ def markdown_report(payload: dict[str, Any]) -> str:
396454 q = r .get ("dirty_audit_quality" , {})
397455 u = q .get ("usage" , {})
398456 cl = (r .get ("classification" ) or {}).get ("usage" , {})
457+ audit_cell : Any = q .get ("audit_avg_score" , "n/a" )
458+ if q .get ("error" ):
459+ audit_cell = f"error ({ str (q ['error' ])[:80 ]} )"
399460 lines .append (
400461 f"| { r ['dataset' ]} | { u .get ('input_tokens' , 0 )} /{ u .get ('output_tokens' , 0 )} /{ u .get ('total_tokens' , 0 )} | "
401- f"{ q . get ( 'audit_avg_score' , 'n/a' ) } | { cl .get ('total_tokens' , cl .get ('total' , 0 ))} |"
462+ f"{ audit_cell } | { cl .get ('total_tokens' , cl .get ('total' , 0 ))} |"
402463 )
403464 lines .append ("" )
404465 return "\n " .join (lines )
@@ -423,9 +484,13 @@ async def main(args: argparse.Namespace) -> None:
423484
424485 from benchmark_buffdata import DATASETS
425486
487+ from buffdata .engine .client import DEFAULT_REQUEST_TIMEOUT_SECONDS
488+
489+ print (f"request timeout: { DEFAULT_REQUEST_TIMEOUT_SECONDS :g} s" , flush = True )
426490 payload : dict [str , Any ] = {
427491 "method" : {
428492 "provider" : args .provider , "model" : args .model , "base_url" : args .base_url ,
493+ "request_timeout_s" : DEFAULT_REQUEST_TIMEOUT_SECONDS ,
429494 "seeds" : args .seeds ,
430495 "epochs" : args .epochs , "audit_rows" : args .audit_rows ,
431496 "classify_sample_size" : args .classify_sample_size ,
@@ -465,4 +530,9 @@ async def main(args: argparse.Namespace) -> None:
465530 parser .add_argument ("--skip-classification" , action = "store_true" )
466531 parser .add_argument ("--smoke" , action = "store_true" , help = "Synthetic data, no HF download." )
467532 parser .add_argument ("--mock" , action = "store_true" , help = "Zero API calls (offline plumbing check)." )
533+ parser .add_argument (
534+ "--request-timeout" , type = float , default = None ,
535+ help = "Per-request seconds (sets BUFFDATA_REQUEST_TIMEOUT before client import). "
536+ "Use 900-1800 for slow local models; default 120 suits cloud APIs." ,
537+ )
468538 asyncio .run (main (parser .parse_args ()))
0 commit comments