nielsr HF Staff commited on
Commit
1dba287
·
verified ·
1 Parent(s): f00ffa3

Recover detection JSON after reasoning token limit

Browse files
Files changed (2) hide show
  1. app.py +190 -35
  2. tests/test_app.py +98 -0
app.py CHANGED
@@ -291,6 +291,33 @@ def _client() -> OpenAI:
291
  return OpenAI(api_key=api_key, base_url=BASE_URL, timeout=180.0, max_retries=2)
292
 
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  def _check_rate_limit(session_hash: str | None) -> None:
295
  session = session_hash or "anonymous"
296
  now = time.monotonic()
@@ -446,6 +473,7 @@ def chat(
446
 
447
  reasoning_parts: list[str] = []
448
  answer_parts: list[str] = []
 
449
 
450
  try:
451
  stream = _client().responses.create(
@@ -457,6 +485,10 @@ def chat(
457
  )
458
  for event in stream:
459
  event_type = getattr(event, "type", "")
 
 
 
 
460
  if event_type == "response.reasoning_summary_text.delta":
461
  reasoning_parts.append(str(getattr(event, "delta", "")))
462
  elif event_type == "response.output_text.delta":
@@ -485,6 +517,25 @@ def chat(
485
  yield cleared_message, visible_history, conversation
486
  return
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  reasoning = "".join(reasoning_parts).strip()
489
  answer = "".join(answer_parts).strip() or "The model returned no text response."
490
  visible_history[-1] = {
@@ -762,29 +813,30 @@ Use XYXY coordinates normalized to integers from 0 to 1000. Do not return the al
762
  For reference, the prompt boxes in normalized XYXY coordinates are: {_normalized_boxes(original_path, annotations)}
763
  """.strip()
764
 
 
 
 
 
 
 
 
 
 
765
  reasoning_parts: list[str] = []
766
  answer_parts: list[str] = []
 
 
767
  waiting_reasoning = "▌" if enable_thinking else "_Thinking mode is disabled._"
768
  yield waiting_reasoning, "**Connecting to Qwen…**\n\n```json\n▌\n```", None
769
 
770
  try:
771
- stream = _client().responses.create(
772
- model=MODEL,
773
- input=[
774
- {
775
- "role": "user",
776
- "content": [
777
- {"type": "input_text", "text": prompt},
778
- {"type": "input_image", "image_url": _pil_data_url(annotated_image)},
779
- ],
780
- }
781
- ],
782
- max_output_tokens=int(max_output_tokens),
783
- stream=True,
784
- extra_body={"enable_thinking": bool(enable_thinking)},
785
- )
786
  for event in stream:
787
  event_type = getattr(event, "type", "")
 
 
 
 
788
  if event_type == "response.reasoning_summary_text.delta":
789
  reasoning_parts.append(str(getattr(event, "delta", "")))
790
  elif event_type == "response.output_text.delta":
@@ -810,8 +862,54 @@ For reference, the prompt boxes in normalized XYXY coordinates are: {_normalized
810
  yield reasoning, error_markdown, None
811
  return
812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
  reasoning = "".join(reasoning_parts).strip()
814
- answer = "".join(answer_parts).strip() or '{"objects": []}'
815
  with Image.open(original_path) as image:
816
  width, height = image.size
817
 
@@ -826,7 +924,12 @@ For reference, the prompt boxes in normalized XYXY coordinates are: {_normalized
826
  detections = []
827
  note = "Qwen's response could not be parsed into boxes; the raw response is shown below."
828
 
829
- details = f"**{note}**\n\n### Model response\n\n```json\n{answer}\n```"
 
 
 
 
 
830
  reasoning_display = reasoning or "_The model returned no reasoning summary._"
831
  yield reasoning_display, details, (original_path, prompt_annotations + detections)
832
 
@@ -862,29 +965,30 @@ Return only valid JSON in this exact shape:
862
  Use tight XYXY bounding boxes with coordinates normalized to integers from 0 to 1000. Return one entry per visible object instance that satisfies the instruction. Use concise labels that distinguish requested categories or attributes. Do not invent objects. If there are no matches, return {{"objects": []}}. Return at most 100 objects.
863
  """.strip()
864
 
 
 
 
 
 
 
 
 
 
865
  reasoning_parts: list[str] = []
866
  answer_parts: list[str] = []
 
 
867
  waiting_reasoning = "▌" if enable_thinking else "_Thinking mode is disabled._"
868
  yield waiting_reasoning, "**Connecting to Qwen…**\n\n```json\n▌\n```", None
869
 
870
  try:
871
- stream = _client().responses.create(
872
- model=MODEL,
873
- input=[
874
- {
875
- "role": "user",
876
- "content": [
877
- {"type": "input_text", "text": prompt},
878
- {"type": "input_image", "image_url": _image_data_url(image_path)},
879
- ],
880
- }
881
- ],
882
- max_output_tokens=int(max_output_tokens),
883
- stream=True,
884
- extra_body={"enable_thinking": bool(enable_thinking)},
885
- )
886
  for event in stream:
887
  event_type = getattr(event, "type", "")
 
 
 
 
888
  if event_type == "response.reasoning_summary_text.delta":
889
  reasoning_parts.append(str(getattr(event, "delta", "")))
890
  elif event_type == "response.output_text.delta":
@@ -911,8 +1015,54 @@ Use tight XYXY bounding boxes with coordinates normalized to integers from 0 to
911
  yield reasoning, details, None
912
  return
913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
  reasoning = "".join(reasoning_parts).strip()
915
- answer = "".join(answer_parts).strip() or '{"objects": []}'
916
  with Image.open(image_path) as image:
917
  width, height = image.size
918
 
@@ -925,7 +1075,12 @@ Use tight XYXY bounding boxes with coordinates normalized to integers from 0 to
925
  detections = []
926
  note = "Qwen's response could not be parsed into boxes; the raw response is shown below."
927
 
928
- details = f"**{note}**\n\n### Model response\n\n```json\n{answer}\n```"
 
 
 
 
 
929
  reasoning_display = reasoning or "_The model returned no reasoning summary._"
930
  yield reasoning_display, details, (image_path, detections)
931
 
@@ -1042,7 +1197,7 @@ with gr.Blocks(title="Free Qwen 3.8 Max") as demo:
1042
  max_tokens = gr.Slider(
1043
  minimum=256,
1044
  maximum=8_192,
1045
- value=4_096,
1046
  step=256,
1047
  label="Maximum output tokens",
1048
  )
 
291
  return OpenAI(api_key=api_key, base_url=BASE_URL, timeout=180.0, max_retries=2)
292
 
293
 
294
+ def _stream_terminal_problem(event: Any) -> tuple[str, str] | None:
295
+ event_type = getattr(event, "type", "")
296
+ response = getattr(event, "response", None)
297
+ if event_type == "response.incomplete":
298
+ details = getattr(response, "incomplete_details", None)
299
+ reason = str(getattr(details, "reason", None) or "unknown_reason")
300
+ return reason, f"Qwen returned an incomplete response ({reason})."
301
+ if event_type == "response.failed":
302
+ error = getattr(response, "error", None) or getattr(event, "error", None)
303
+ return "response_failed", f"Qwen reported a failed response: {error or 'unknown error'}"
304
+ return None
305
+
306
+
307
+ def _detection_stream(
308
+ input_messages: list[dict[str, Any]],
309
+ enable_thinking: bool,
310
+ max_output_tokens: int,
311
+ ) -> Any:
312
+ return _client().responses.create(
313
+ model=MODEL,
314
+ input=input_messages,
315
+ max_output_tokens=int(max_output_tokens),
316
+ stream=True,
317
+ extra_body={"enable_thinking": bool(enable_thinking)},
318
+ )
319
+
320
+
321
  def _check_rate_limit(session_hash: str | None) -> None:
322
  session = session_hash or "anonymous"
323
  now = time.monotonic()
 
473
 
474
  reasoning_parts: list[str] = []
475
  answer_parts: list[str] = []
476
+ terminal_problem: tuple[str, str] | None = None
477
 
478
  try:
479
  stream = _client().responses.create(
 
485
  )
486
  for event in stream:
487
  event_type = getattr(event, "type", "")
488
+ problem = _stream_terminal_problem(event)
489
+ if problem:
490
+ terminal_problem = problem
491
+ continue
492
  if event_type == "response.reasoning_summary_text.delta":
493
  reasoning_parts.append(str(getattr(event, "delta", "")))
494
  elif event_type == "response.output_text.delta":
 
517
  yield cleared_message, visible_history, conversation
518
  return
519
 
520
+ if terminal_problem:
521
+ reason, problem = terminal_problem
522
+ reasoning = "".join(reasoning_parts).strip()
523
+ partial = "".join(answer_parts).strip()
524
+ guidance = (
525
+ " Increase **Maximum output tokens** or shorten the request."
526
+ if reason == "max_output_tokens"
527
+ else ""
528
+ )
529
+ visible_history[-1] = {
530
+ "role": "assistant",
531
+ "content": _streamed_assistant_message(
532
+ reasoning,
533
+ (partial + "\n\n" if partial else "") + f"⚠️ {problem}{guidance}",
534
+ ),
535
+ }
536
+ yield cleared_message, visible_history, conversation
537
+ return
538
+
539
  reasoning = "".join(reasoning_parts).strip()
540
  answer = "".join(answer_parts).strip() or "The model returned no text response."
541
  visible_history[-1] = {
 
813
  For reference, the prompt boxes in normalized XYXY coordinates are: {_normalized_boxes(original_path, annotations)}
814
  """.strip()
815
 
816
+ input_messages = [
817
+ {
818
+ "role": "user",
819
+ "content": [
820
+ {"type": "input_text", "text": prompt},
821
+ {"type": "input_image", "image_url": _pil_data_url(annotated_image)},
822
+ ],
823
+ }
824
+ ]
825
  reasoning_parts: list[str] = []
826
  answer_parts: list[str] = []
827
+ terminal_problem: tuple[str, str] | None = None
828
+ retried_without_thinking = False
829
  waiting_reasoning = "▌" if enable_thinking else "_Thinking mode is disabled._"
830
  yield waiting_reasoning, "**Connecting to Qwen…**\n\n```json\n▌\n```", None
831
 
832
  try:
833
+ stream = _detection_stream(input_messages, enable_thinking, max_output_tokens)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  for event in stream:
835
  event_type = getattr(event, "type", "")
836
+ problem = _stream_terminal_problem(event)
837
+ if problem:
838
+ terminal_problem = problem
839
+ continue
840
  if event_type == "response.reasoning_summary_text.delta":
841
  reasoning_parts.append(str(getattr(event, "delta", "")))
842
  elif event_type == "response.output_text.delta":
 
862
  yield reasoning, error_markdown, None
863
  return
864
 
865
+ should_retry = enable_thinking and (
866
+ (terminal_problem and terminal_problem[0] == "max_output_tokens")
867
+ or not "".join(answer_parts).strip()
868
+ )
869
+ if should_retry:
870
+ retried_without_thinking = True
871
+ terminal_problem = None
872
+ answer_parts.clear()
873
+ reasoning = "".join(reasoning_parts).strip() or waiting_reasoning
874
+ yield (
875
+ reasoning,
876
+ "**Reasoning reached the output limit. Retrying the final JSON without thinking…**\n\n"
877
+ "```json\n▌\n```",
878
+ None,
879
+ )
880
+ try:
881
+ retry_stream = _detection_stream(input_messages, False, max_output_tokens)
882
+ for event in retry_stream:
883
+ event_type = getattr(event, "type", "")
884
+ problem = _stream_terminal_problem(event)
885
+ if problem:
886
+ terminal_problem = problem
887
+ continue
888
+ if event_type != "response.output_text.delta":
889
+ continue
890
+ answer_parts.append(str(getattr(event, "delta", "")))
891
+ answer = "".join(answer_parts)
892
+ yield (
893
+ reasoning,
894
+ "**Recovering final JSON…**\n\n" f"```json\n{answer or '▌'}\n```",
895
+ None,
896
+ )
897
+ except Exception as error:
898
+ terminal_problem = ("retry_failed", f"The automatic JSON retry failed: {error}")
899
+
900
+ if terminal_problem or not "".join(answer_parts).strip():
901
+ problem = terminal_problem[1] if terminal_problem else "Qwen returned no final JSON."
902
+ partial_answer = "".join(answer_parts).strip()
903
+ details = (
904
+ f"**⚠️ {problem}**\n\n"
905
+ + (f"```json\n{partial_answer}\n```\n\n" if partial_answer else "")
906
+ + "No detection result was fabricated. Try a larger token budget or turn off Thinking mode."
907
+ )
908
+ yield "".join(reasoning_parts).strip() or waiting_reasoning, details, None
909
+ return
910
+
911
  reasoning = "".join(reasoning_parts).strip()
912
+ answer = "".join(answer_parts).strip()
913
  with Image.open(original_path) as image:
914
  width, height = image.size
915
 
 
924
  detections = []
925
  note = "Qwen's response could not be parsed into boxes; the raw response is shown below."
926
 
927
+ recovery_note = (
928
+ "\n\n_Automatic recovery: the reasoning pass exhausted its token budget, so the final JSON was retried without thinking._"
929
+ if retried_without_thinking
930
+ else ""
931
+ )
932
+ details = f"**{note}**{recovery_note}\n\n### Model response\n\n```json\n{answer}\n```"
933
  reasoning_display = reasoning or "_The model returned no reasoning summary._"
934
  yield reasoning_display, details, (original_path, prompt_annotations + detections)
935
 
 
965
  Use tight XYXY bounding boxes with coordinates normalized to integers from 0 to 1000. Return one entry per visible object instance that satisfies the instruction. Use concise labels that distinguish requested categories or attributes. Do not invent objects. If there are no matches, return {{"objects": []}}. Return at most 100 objects.
966
  """.strip()
967
 
968
+ input_messages = [
969
+ {
970
+ "role": "user",
971
+ "content": [
972
+ {"type": "input_text", "text": prompt},
973
+ {"type": "input_image", "image_url": _image_data_url(image_path)},
974
+ ],
975
+ }
976
+ ]
977
  reasoning_parts: list[str] = []
978
  answer_parts: list[str] = []
979
+ terminal_problem: tuple[str, str] | None = None
980
+ retried_without_thinking = False
981
  waiting_reasoning = "▌" if enable_thinking else "_Thinking mode is disabled._"
982
  yield waiting_reasoning, "**Connecting to Qwen…**\n\n```json\n▌\n```", None
983
 
984
  try:
985
+ stream = _detection_stream(input_messages, enable_thinking, max_output_tokens)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
986
  for event in stream:
987
  event_type = getattr(event, "type", "")
988
+ problem = _stream_terminal_problem(event)
989
+ if problem:
990
+ terminal_problem = problem
991
+ continue
992
  if event_type == "response.reasoning_summary_text.delta":
993
  reasoning_parts.append(str(getattr(event, "delta", "")))
994
  elif event_type == "response.output_text.delta":
 
1015
  yield reasoning, details, None
1016
  return
1017
 
1018
+ should_retry = enable_thinking and (
1019
+ (terminal_problem and terminal_problem[0] == "max_output_tokens")
1020
+ or not "".join(answer_parts).strip()
1021
+ )
1022
+ if should_retry:
1023
+ retried_without_thinking = True
1024
+ terminal_problem = None
1025
+ answer_parts.clear()
1026
+ reasoning = "".join(reasoning_parts).strip() or waiting_reasoning
1027
+ yield (
1028
+ reasoning,
1029
+ "**Reasoning reached the output limit. Retrying the final JSON without thinking…**\n\n"
1030
+ "```json\n▌\n```",
1031
+ None,
1032
+ )
1033
+ try:
1034
+ retry_stream = _detection_stream(input_messages, False, max_output_tokens)
1035
+ for event in retry_stream:
1036
+ event_type = getattr(event, "type", "")
1037
+ problem = _stream_terminal_problem(event)
1038
+ if problem:
1039
+ terminal_problem = problem
1040
+ continue
1041
+ if event_type != "response.output_text.delta":
1042
+ continue
1043
+ answer_parts.append(str(getattr(event, "delta", "")))
1044
+ answer = "".join(answer_parts)
1045
+ yield (
1046
+ reasoning,
1047
+ "**Recovering final JSON…**\n\n" f"```json\n{answer or '▌'}\n```",
1048
+ None,
1049
+ )
1050
+ except Exception as error:
1051
+ terminal_problem = ("retry_failed", f"The automatic JSON retry failed: {error}")
1052
+
1053
+ if terminal_problem or not "".join(answer_parts).strip():
1054
+ problem = terminal_problem[1] if terminal_problem else "Qwen returned no final JSON."
1055
+ partial_answer = "".join(answer_parts).strip()
1056
+ details = (
1057
+ f"**⚠️ {problem}**\n\n"
1058
+ + (f"```json\n{partial_answer}\n```\n\n" if partial_answer else "")
1059
+ + "No detection result was fabricated. Try a larger token budget or turn off Thinking mode."
1060
+ )
1061
+ yield "".join(reasoning_parts).strip() or waiting_reasoning, details, None
1062
+ return
1063
+
1064
  reasoning = "".join(reasoning_parts).strip()
1065
+ answer = "".join(answer_parts).strip()
1066
  with Image.open(image_path) as image:
1067
  width, height = image.size
1068
 
 
1075
  detections = []
1076
  note = "Qwen's response could not be parsed into boxes; the raw response is shown below."
1077
 
1078
+ recovery_note = (
1079
+ "\n\n_Automatic recovery: the reasoning pass exhausted its token budget, so the final JSON was retried without thinking._"
1080
+ if retried_without_thinking
1081
+ else ""
1082
+ )
1083
+ details = f"**{note}**{recovery_note}\n\n### Model response\n\n```json\n{answer}\n```"
1084
  reasoning_display = reasoning or "_The model returned no reasoning summary._"
1085
  yield reasoning_display, details, (image_path, detections)
1086
 
 
1197
  max_tokens = gr.Slider(
1198
  minimum=256,
1199
  maximum=8_192,
1200
+ value=8_192,
1201
  step=256,
1202
  label="Maximum output tokens",
1203
  )
tests/test_app.py CHANGED
@@ -241,3 +241,101 @@ def test_object_detection_streams_and_renders_labeled_boxes(
241
  assert reasoning == "Scanning scene."
242
  assert "Rendered 1 detected object" in details
243
  assert result[1] == [((20, 20, 120, 80), "blue car")]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  assert reasoning == "Scanning scene."
242
  assert "Rendered 1 detected object" in details
243
  assert result[1] == [((20, 20, 120, 80), "blue car")]
244
+
245
+
246
+ def test_object_detection_retries_without_thinking_after_token_limit(
247
+ tmp_path, monkeypatch
248
+ ) -> None:
249
+ image_path = tmp_path / "scene.png"
250
+ Image.new("RGB", (200, 100), "white").save(image_path)
251
+ calls = []
252
+ streams = [
253
+ [
254
+ SimpleNamespace(
255
+ type="response.reasoning_summary_text.delta",
256
+ delta="I found the requested car.",
257
+ ),
258
+ SimpleNamespace(
259
+ type="response.incomplete",
260
+ response=SimpleNamespace(
261
+ incomplete_details=SimpleNamespace(reason="max_output_tokens")
262
+ ),
263
+ ),
264
+ ],
265
+ [
266
+ SimpleNamespace(
267
+ type="response.output_text.delta",
268
+ delta='{"objects":[{"label":"car","box_2d":[100,200,600,800]}]}',
269
+ ),
270
+ SimpleNamespace(type="response.completed", response=SimpleNamespace()),
271
+ ],
272
+ ]
273
+
274
+ def create(**kwargs):
275
+ calls.append(kwargs)
276
+ return iter(streams[len(calls) - 1])
277
+
278
+ monkeypatch.setattr(
279
+ app,
280
+ "_client",
281
+ lambda: SimpleNamespace(responses=SimpleNamespace(create=create)),
282
+ )
283
+
284
+ updates = list(
285
+ app.detect_objects(
286
+ str(image_path),
287
+ "Detect the car.",
288
+ False,
289
+ True,
290
+ 256,
291
+ SimpleNamespace(session_hash="object-retry-test"),
292
+ )
293
+ )
294
+
295
+ assert len(calls) == 2
296
+ assert calls[0]["extra_body"] == {"enable_thinking": True}
297
+ assert calls[1]["extra_body"] == {"enable_thinking": False}
298
+ assert any("Retrying the final JSON" in details for _, details, _ in updates)
299
+ reasoning, details, result = updates[-1]
300
+ assert reasoning == "I found the requested car."
301
+ assert "Automatic recovery" in details
302
+ assert result[1] == [((20, 20, 120, 80), "car")]
303
+
304
+
305
+ def test_detection_does_not_fabricate_empty_json_after_incomplete_response(
306
+ tmp_path, monkeypatch
307
+ ) -> None:
308
+ image_path = tmp_path / "scene.png"
309
+ Image.new("RGB", (200, 100), "white").save(image_path)
310
+ events = [
311
+ SimpleNamespace(
312
+ type="response.incomplete",
313
+ response=SimpleNamespace(
314
+ incomplete_details=SimpleNamespace(reason="max_output_tokens")
315
+ ),
316
+ )
317
+ ]
318
+ monkeypatch.setattr(
319
+ app,
320
+ "_client",
321
+ lambda: SimpleNamespace(
322
+ responses=SimpleNamespace(create=lambda **kwargs: iter(events))
323
+ ),
324
+ )
325
+
326
+ updates = list(
327
+ app.detect_objects(
328
+ str(image_path),
329
+ "Detect the car.",
330
+ False,
331
+ False,
332
+ 256,
333
+ SimpleNamespace(session_hash="object-incomplete-test"),
334
+ )
335
+ )
336
+
337
+ _, details, result = updates[-1]
338
+ assert "max_output_tokens" in details
339
+ assert "No detection result was fabricated" in details
340
+ assert '{"objects": []}' not in details
341
+ assert result is None