@miller-tech/uap 1.76.5 → 1.76.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.76.5",
3
+ "version": "1.76.6",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -498,6 +498,20 @@ PROXY_FORCE_NON_STREAM = os.environ.get(
498
498
  "off",
499
499
  "no",
500
500
  }
501
+ # Streaming keep-alive heartbeat (seconds) for the guarded-non-stream path.
502
+ # That path buffers the ENTIRE upstream generation before emitting any SSE
503
+ # bytes, so a long generation (e.g. a 28k-token runaway taking ~14 min at
504
+ # depth-slowed decode) sends the client nothing for the whole wait and the
505
+ # client's streaming idle-timeout fires -> "API Error". When > 0, the proxy
506
+ # emits an immediate `message_start` then periodic `ping` events to the client
507
+ # while it awaits+guards the buffered upstream response, keeping the connection
508
+ # alive; the buffered content is streamed once ready. 0 disables (old behavior).
509
+ try:
510
+ PROXY_STREAM_HEARTBEAT_SECS = float(
511
+ os.environ.get("PROXY_STREAM_HEARTBEAT_SECS", "0")
512
+ )
513
+ except ValueError:
514
+ PROXY_STREAM_HEARTBEAT_SECS = 0.0
501
515
  PROXY_FORCED_TOOL_DAMPENER = os.environ.get(
502
516
  "PROXY_FORCED_TOOL_DAMPENER", "on"
503
517
  ).lower() not in {
@@ -7838,19 +7852,84 @@ def openai_to_anthropic_response(
7838
7852
  }
7839
7853
 
7840
7854
 
7841
- async def stream_anthropic_message(anthropic_resp: dict):
7842
- """Stream a finalized Anthropic message as SSE events."""
7843
- message = {
7844
- "id": anthropic_resp.get("id", f"msg_{uuid.uuid4().hex[:24]}"),
7845
- "type": "message",
7846
- "role": "assistant",
7847
- "content": [],
7848
- "model": anthropic_resp.get("model", "unknown"),
7849
- "stop_reason": None,
7850
- "stop_sequence": None,
7851
- "usage": {"input_tokens": 0, "output_tokens": 0},
7852
- }
7853
- yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': message})}\n\n"
7855
+ async def _heartbeat_then_buffered(produce_coro, model: str):
7856
+ """SSE generator: keep-alive heartbeat wrapper for the guarded-non-stream path.
7857
+
7858
+ Emits an immediate ``message_start`` so the client registers an active
7859
+ stream, then ``ping`` events every PROXY_STREAM_HEARTBEAT_SECS while
7860
+ ``produce_coro`` (which awaits + guards the buffered upstream response) runs,
7861
+ then streams the buffered content. Keeps the connection alive through long
7862
+ buffered generations so the client's streaming idle-timeout does not fire.
7863
+
7864
+ ``produce_coro`` resolves to EITHER the finalized Anthropic response dict OR
7865
+ a Starlette ``Response`` (the guarded path's error returns). Since the stream
7866
+ has already committed to HTTP 200, an error Response is re-emitted as an SSE
7867
+ ``error`` event rather than an HTTP status.
7868
+ """
7869
+ msg_id = f"msg_{uuid.uuid4().hex[:24]}"
7870
+ yield (
7871
+ f"event: message_start\n"
7872
+ f"data: {json.dumps({'type': 'message_start', 'message': {'id': msg_id, 'type': 'message', 'role': 'assistant', 'content': [], 'model': model, 'stop_reason': None, 'stop_sequence': None, 'usage': {'input_tokens': 0, 'output_tokens': 0}}})}\n\n"
7873
+ )
7874
+ interval = PROXY_STREAM_HEARTBEAT_SECS if PROXY_STREAM_HEARTBEAT_SECS > 0 else 15.0
7875
+ task = asyncio.ensure_future(produce_coro)
7876
+ try:
7877
+ while True:
7878
+ try:
7879
+ # shield keeps the produce task alive across ping timeouts;
7880
+ # only the wait_for wrapper is cancelled on each TimeoutError.
7881
+ produced = await asyncio.wait_for(asyncio.shield(task), timeout=interval)
7882
+ break
7883
+ except asyncio.TimeoutError:
7884
+ yield 'event: ping\ndata: {"type": "ping"}\n\n'
7885
+ except asyncio.CancelledError:
7886
+ # Client disconnected — cancel the in-flight produce and propagate.
7887
+ task.cancel()
7888
+ raise
7889
+ except Exception as exc:
7890
+ logger.error("heartbeat produce failed: %s", exc)
7891
+ yield (
7892
+ "event: error\n"
7893
+ f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': str(exc)[:500]}})}\n\n"
7894
+ )
7895
+ return
7896
+
7897
+ if isinstance(produced, Response):
7898
+ # Guarded path returned an error Response; re-emit as an SSE error event.
7899
+ try:
7900
+ payload = json.loads(bytes(produced.body).decode("utf-8"))
7901
+ except Exception:
7902
+ payload = {
7903
+ "type": "error",
7904
+ "error": {"type": "overloaded_error", "message": "Upstream error"},
7905
+ }
7906
+ yield f"event: error\ndata: {json.dumps(payload)}\n\n"
7907
+ return
7908
+
7909
+ # produced is the finalized Anthropic response dict — stream its content
7910
+ # without a second message_start (already sent above).
7911
+ async for chunk in stream_anthropic_message(produced, emit_message_start=False):
7912
+ yield chunk
7913
+
7914
+
7915
+ async def stream_anthropic_message(anthropic_resp: dict, emit_message_start: bool = True):
7916
+ """Stream a finalized Anthropic message as SSE events.
7917
+
7918
+ emit_message_start=False skips the leading message_start event for callers
7919
+ (the heartbeat wrapper) that have already emitted one to start the stream.
7920
+ """
7921
+ if emit_message_start:
7922
+ message = {
7923
+ "id": anthropic_resp.get("id", f"msg_{uuid.uuid4().hex[:24]}"),
7924
+ "type": "message",
7925
+ "role": "assistant",
7926
+ "content": [],
7927
+ "model": anthropic_resp.get("model", "unknown"),
7928
+ "stop_reason": None,
7929
+ "stop_sequence": None,
7930
+ "usage": {"input_tokens": 0, "output_tokens": 0},
7931
+ }
7932
+ yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': message})}\n\n"
7854
7933
 
7855
7934
  content_blocks = anthropic_resp.get("content", []) or [{"type": "text", "text": ""}]
7856
7935
  block_index = 0
@@ -8528,88 +8607,108 @@ async def messages(request: Request):
8528
8607
  openai_body,
8529
8608
  )
8530
8609
  if use_guarded_non_stream:
8531
- strict_body = dict(openai_body)
8532
- strict_body["stream"] = False
8610
+ async def _produce_guarded():
8611
+ strict_body = dict(openai_body)
8612
+ strict_body["stream"] = False
8533
8613
 
8534
- try:
8535
- strict_resp = await _post_with_generation_timeout(
8536
- client,
8537
- f"{LLAMA_CPP_BASE}/chat/completions",
8538
- strict_body,
8539
- {"Content-Type": "application/json"},
8540
- )
8541
- except Exception as exc:
8542
- # Check if upstream is hung before returning error
8543
- await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
8544
- return Response(
8545
- content=json.dumps(
8546
- {
8547
- "type": "error",
8548
- "error": {
8549
- "type": "overloaded_error",
8550
- "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8551
- },
8552
- }
8553
- ),
8554
- status_code=529,
8555
- media_type="application/json",
8556
- )
8614
+ try:
8615
+ strict_resp = await _post_with_generation_timeout(
8616
+ client,
8617
+ f"{LLAMA_CPP_BASE}/chat/completions",
8618
+ strict_body,
8619
+ {"Content-Type": "application/json"},
8620
+ )
8621
+ except Exception as exc:
8622
+ # Check if upstream is hung before returning error
8623
+ await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
8624
+ return Response(
8625
+ content=json.dumps(
8626
+ {
8627
+ "type": "error",
8628
+ "error": {
8629
+ "type": "overloaded_error",
8630
+ "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8631
+ },
8632
+ }
8633
+ ),
8634
+ status_code=529,
8635
+ media_type="application/json",
8636
+ )
8557
8637
 
8558
- if strict_resp.status_code != 200:
8559
- error_text = strict_resp.text[:1000]
8560
- # Try the Gemma 4 PEG parse-failure recovery first — relax
8561
- # tool_choice='required' so the retry isn't constrained by the
8562
- # strict-grammar that triggered the parse failure.
8563
- relaxed = _is_gemma4_peg_parse_failure(strict_resp.status_code, error_text) and \
8564
- _relax_tool_choice_for_gemma4_peg_retry(strict_body, "strict-stream")
8565
- if relaxed:
8566
- try:
8567
- strict_resp = await _post_with_generation_timeout(
8568
- client,
8569
- f"{LLAMA_CPP_BASE}/chat/completions",
8570
- strict_body,
8571
- {"Content-Type": "application/json"},
8572
- )
8573
- except Exception:
8574
- pass # fall through to next handler
8575
- if strict_resp.status_code != 200:
8576
- error_text = strict_resp.text[:1000]
8577
- if _maybe_disable_grammar_for_tools_error(
8578
- strict_body,
8579
- strict_resp.status_code,
8580
- error_text,
8581
- "strict-stream",
8582
- ):
8583
- try:
8584
- strict_resp = await _post_with_generation_timeout(
8585
- client,
8586
- f"{LLAMA_CPP_BASE}/chat/completions",
8587
- strict_body,
8588
- {"Content-Type": "application/json"},
8638
+ if strict_resp.status_code != 200:
8639
+ error_text = strict_resp.text[:1000]
8640
+ # Try the Gemma 4 PEG parse-failure recovery first — relax
8641
+ # tool_choice='required' so the retry isn't constrained by the
8642
+ # strict-grammar that triggered the parse failure.
8643
+ relaxed = _is_gemma4_peg_parse_failure(strict_resp.status_code, error_text) and \
8644
+ _relax_tool_choice_for_gemma4_peg_retry(strict_body, "strict-stream")
8645
+ if relaxed:
8646
+ try:
8647
+ strict_resp = await _post_with_generation_timeout(
8648
+ client,
8649
+ f"{LLAMA_CPP_BASE}/chat/completions",
8650
+ strict_body,
8651
+ {"Content-Type": "application/json"},
8652
+ )
8653
+ except Exception:
8654
+ pass # fall through to next handler
8655
+ if strict_resp.status_code != 200:
8656
+ error_text = strict_resp.text[:1000]
8657
+ if _maybe_disable_grammar_for_tools_error(
8658
+ strict_body,
8659
+ strict_resp.status_code,
8660
+ error_text,
8661
+ "strict-stream",
8662
+ ):
8663
+ try:
8664
+ strict_resp = await _post_with_generation_timeout(
8665
+ client,
8666
+ f"{LLAMA_CPP_BASE}/chat/completions",
8667
+ strict_body,
8668
+ {"Content-Type": "application/json"},
8669
+ )
8670
+ except Exception as exc:
8671
+ return Response(
8672
+ content=json.dumps(
8673
+ {
8674
+ "type": "error",
8675
+ "error": {
8676
+ "type": "overloaded_error",
8677
+ "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8678
+ },
8679
+ }
8680
+ ),
8681
+ status_code=529,
8682
+ media_type="application/json",
8683
+ )
8684
+
8685
+ if strict_resp.status_code != 200:
8686
+ error_text = strict_resp.text[:1000]
8687
+ # Cycle 19 Option 2: For 503 "Loading model", don't advance state
8688
+ # machine — return retriable 503 with Retry-After header so the
8689
+ # client can retry without wasting state machine budget.
8690
+ if _is_loading_model_503(strict_resp):
8691
+ logger.warning(
8692
+ "Upstream 503 Loading model (strict-stream) — returning retriable 503 without advancing state",
8589
8693
  )
8590
- except Exception as exc:
8591
8694
  return Response(
8592
8695
  content=json.dumps(
8593
8696
  {
8594
8697
  "type": "error",
8595
8698
  "error": {
8596
8699
  "type": "overloaded_error",
8597
- "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8700
+ "message": "Upstream model is loading. Retry in 10 seconds.",
8598
8701
  },
8599
8702
  }
8600
8703
  ),
8601
- status_code=529,
8704
+ status_code=503,
8705
+ headers={"Retry-After": "10"},
8602
8706
  media_type="application/json",
8603
8707
  )
8604
-
8605
- if strict_resp.status_code != 200:
8606
- error_text = strict_resp.text[:1000]
8607
- # Cycle 19 Option 2: For 503 "Loading model", don't advance state
8608
- # machine — return retriable 503 with Retry-After header so the
8609
- # client can retry without wasting state machine budget.
8610
- if _is_loading_model_503(strict_resp):
8611
- logger.warning(
8612
- "Upstream 503 Loading model (strict-stream) — returning retriable 503 without advancing state",
8708
+ logger.error(
8709
+ "Upstream HTTP %d (strict-stream): %s",
8710
+ strict_resp.status_code,
8711
+ error_text,
8613
8712
  )
8614
8713
  return Response(
8615
8714
  content=json.dumps(
@@ -8617,126 +8716,122 @@ async def messages(request: Request):
8617
8716
  "type": "error",
8618
8717
  "error": {
8619
8718
  "type": "overloaded_error",
8620
- "message": "Upstream model is loading. Retry in 10 seconds.",
8719
+ "message": f"Upstream error (HTTP {strict_resp.status_code}): {error_text[:500]}",
8621
8720
  },
8622
8721
  }
8623
8722
  ),
8624
- status_code=503,
8625
- headers={"Retry-After": "10"},
8723
+ status_code=529,
8626
8724
  media_type="application/json",
8627
8725
  )
8628
- logger.error(
8629
- "Upstream HTTP %d (strict-stream): %s",
8630
- strict_resp.status_code,
8631
- error_text,
8726
+
8727
+ openai_resp = strict_resp.json()
8728
+ # Recover tool calls from <tool_call> XML before guardrails run
8729
+ _maybe_extract_text_tool_calls(
8730
+ openai_resp,
8731
+ anthropic_tools=body.get("tools"),
8732
+ suppress=monitor.suppress_text_tool_extraction,
8632
8733
  )
8633
- return Response(
8634
- content=json.dumps(
8635
- {
8636
- "type": "error",
8637
- "error": {
8638
- "type": "overloaded_error",
8639
- "message": f"Upstream error (HTTP {strict_resp.status_code}): {error_text[:500]}",
8640
- },
8641
- }
8642
- ),
8643
- status_code=529,
8644
- media_type="application/json",
8734
+ openai_resp = await _apply_unexpected_end_turn_guardrail(
8735
+ client,
8736
+ openai_resp,
8737
+ strict_body,
8738
+ body,
8739
+ monitor,
8740
+ session_id,
8741
+ )
8742
+ openai_resp = await _apply_malformed_tool_guardrail(
8743
+ client,
8744
+ openai_resp,
8745
+ strict_body,
8746
+ body,
8747
+ monitor,
8748
+ session_id,
8645
8749
  )
8646
8750
 
8647
- openai_resp = strict_resp.json()
8648
- # Recover tool calls from <tool_call> XML before guardrails run
8649
- _maybe_extract_text_tool_calls(
8650
- openai_resp,
8651
- anthropic_tools=body.get("tools"),
8652
- suppress=monitor.suppress_text_tool_extraction,
8653
- )
8654
- openai_resp = await _apply_unexpected_end_turn_guardrail(
8655
- client,
8656
- openai_resp,
8657
- strict_body,
8658
- body,
8659
- monitor,
8660
- session_id,
8661
- )
8662
- openai_resp = await _apply_malformed_tool_guardrail(
8663
- client,
8664
- openai_resp,
8665
- strict_body,
8666
- body,
8667
- monitor,
8668
- session_id,
8669
- )
8670
-
8671
- openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
8672
- if was_degenerate:
8673
- # Retry with constrained parameters to avoid degenerate output.
8674
- # With tools: force tool_choice=required for a useful tool call.
8675
- # Without tools (finalize): retry with capped max_tokens for clean text.
8676
- has_tools = bool(strict_body.get("tools"))
8677
- retry_body = dict(strict_body)
8678
- retry_body["max_tokens"] = 2048
8679
- retry_body["temperature"] = 0.1
8680
- retry_body["stream"] = False
8681
- if has_tools:
8682
- retry_body["tool_choice"] = "required"
8683
- logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=2048")
8751
+ openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
8752
+ if was_degenerate:
8753
+ # Retry with constrained parameters to avoid degenerate output.
8754
+ # With tools: force tool_choice=required for a useful tool call.
8755
+ # Without tools (finalize): retry with capped max_tokens for clean text.
8756
+ has_tools = bool(strict_body.get("tools"))
8757
+ retry_body = dict(strict_body)
8758
+ retry_body["max_tokens"] = 2048
8759
+ retry_body["temperature"] = 0.1
8760
+ retry_body["stream"] = False
8761
+ if has_tools:
8762
+ retry_body["tool_choice"] = "required"
8763
+ logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=2048")
8764
+ else:
8765
+ logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
8766
+ try:
8767
+ retry_resp = await _post_with_generation_timeout(
8768
+ client, f"{LLAMA_CPP_BASE}/chat/completions", retry_body,
8769
+ {"Content-Type": "application/json"},
8770
+ )
8771
+ if retry_resp.status_code == 200:
8772
+ retry_data = retry_resp.json()
8773
+ retry_text = _openai_message_text(retry_data)
8774
+ _, retry_degenerate = _detect_and_truncate_degenerate_repetition(retry_data)
8775
+ if retry_degenerate:
8776
+ logger.info("DEGENERATE RETRY: retry also degenerate, using truncated original")
8777
+ elif has_tools and (retry_data.get("choices", [{}])[0]
8778
+ .get("message", {}).get("tool_calls")):
8779
+ logger.info("DEGENERATE RETRY: success, got tool call")
8780
+ openai_resp = retry_data
8781
+ elif not has_tools and retry_text and len(retry_text) > 50:
8782
+ logger.info("DEGENERATE RETRY: success, got clean text (%d chars)", len(retry_text))
8783
+ openai_resp = retry_data
8784
+ else:
8785
+ logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
8786
+ except Exception as exc:
8787
+ logger.warning("DEGENERATE RETRY: failed: %s", exc)
8788
+ anthropic_resp = openai_to_anthropic_response(
8789
+ openai_resp, model,
8790
+ expose_thinking=isinstance(body.get("thinking"), dict)
8791
+ and (body["thinking"].get("type") or "").lower() == "enabled",
8792
+ suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8793
+ )
8794
+ _maybe_normalize_toolcall_paths(anthropic_resp, body)
8795
+ # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
8796
+ if (
8797
+ monitor.finalize_turn_active
8798
+ and monitor.finalize_continuation_count < PROXY_FINALIZE_CONTINUATION_MAX
8799
+ and anthropic_resp.get("stop_reason") == "end_turn"
8800
+ ):
8801
+ anthropic_resp = _inject_synthetic_continuation(anthropic_resp, monitor, body)
8802
+ monitor.record_response(anthropic_resp.get("usage", {}).get("output_tokens", 0))
8803
+ # Update last_input_tokens from upstream's actual prompt_tokens
8804
+ upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
8805
+ if upstream_input > 0:
8806
+ monitor.last_input_tokens = upstream_input
8807
+ if PROXY_FORCE_NON_STREAM:
8808
+ logger.info(
8809
+ "FORCED NON-STREAM: served stream response via guarded non-stream path"
8810
+ )
8811
+ elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
8812
+ logger.info(
8813
+ "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
8814
+ )
8684
8815
  else:
8685
- logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
8686
- try:
8687
- retry_resp = await _post_with_generation_timeout(
8688
- client, f"{LLAMA_CPP_BASE}/chat/completions", retry_body,
8689
- {"Content-Type": "application/json"},
8816
+ logger.info(
8817
+ "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
8690
8818
  )
8691
- if retry_resp.status_code == 200:
8692
- retry_data = retry_resp.json()
8693
- retry_text = _openai_message_text(retry_data)
8694
- _, retry_degenerate = _detect_and_truncate_degenerate_repetition(retry_data)
8695
- if retry_degenerate:
8696
- logger.info("DEGENERATE RETRY: retry also degenerate, using truncated original")
8697
- elif has_tools and (retry_data.get("choices", [{}])[0]
8698
- .get("message", {}).get("tool_calls")):
8699
- logger.info("DEGENERATE RETRY: success, got tool call")
8700
- openai_resp = retry_data
8701
- elif not has_tools and retry_text and len(retry_text) > 50:
8702
- logger.info("DEGENERATE RETRY: success, got clean text (%d chars)", len(retry_text))
8703
- openai_resp = retry_data
8704
- else:
8705
- logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
8706
- except Exception as exc:
8707
- logger.warning("DEGENERATE RETRY: failed: %s", exc)
8708
- anthropic_resp = openai_to_anthropic_response(
8709
- openai_resp, model,
8710
- expose_thinking=isinstance(body.get("thinking"), dict)
8711
- and (body["thinking"].get("type") or "").lower() == "enabled",
8712
- suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8713
- )
8714
- _maybe_normalize_toolcall_paths(anthropic_resp, body)
8715
- # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
8716
- if (
8717
- monitor.finalize_turn_active
8718
- and monitor.finalize_continuation_count < PROXY_FINALIZE_CONTINUATION_MAX
8719
- and anthropic_resp.get("stop_reason") == "end_turn"
8720
- ):
8721
- anthropic_resp = _inject_synthetic_continuation(anthropic_resp, monitor, body)
8722
- monitor.record_response(anthropic_resp.get("usage", {}).get("output_tokens", 0))
8723
- # Update last_input_tokens from upstream's actual prompt_tokens
8724
- upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
8725
- if upstream_input > 0:
8726
- monitor.last_input_tokens = upstream_input
8727
- if PROXY_FORCE_NON_STREAM:
8728
- logger.info(
8729
- "FORCED NON-STREAM: served stream response via guarded non-stream path"
8730
- )
8731
- elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
8732
- logger.info(
8733
- "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
8734
- )
8735
- else:
8736
- logger.info(
8737
- "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
8738
- )
8739
8819
 
8820
+ return anthropic_resp
8821
+
8822
+ if PROXY_STREAM_HEARTBEAT_SECS > 0:
8823
+ return StreamingResponse(
8824
+ _heartbeat_then_buffered(_produce_guarded(), model),
8825
+ media_type="text/event-stream",
8826
+ headers={
8827
+ "Cache-Control": "no-cache",
8828
+ "Connection": "keep-alive",
8829
+ },
8830
+ )
8831
+ _produced = await _produce_guarded()
8832
+ if isinstance(_produced, Response):
8833
+ return _produced
8834
+ anthropic_resp = _produced
8740
8835
  return StreamingResponse(
8741
8836
  stream_anthropic_message(anthropic_resp),
8742
8837
  media_type="text/event-stream",
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Streaming keep-alive heartbeat for the guarded-non-stream path.
3
+
4
+ The guarded-non-stream path buffers the ENTIRE upstream generation before
5
+ emitting any SSE bytes, so a long generation sends the client nothing for the
6
+ whole wait and the client's streaming idle-timeout fires -> "API Error".
7
+
8
+ `_heartbeat_then_buffered` wraps the buffered produce coroutine: it emits an
9
+ immediate `message_start`, then `ping` events every PROXY_STREAM_HEARTBEAT_SECS
10
+ while the produce runs, then streams the buffered content (without a second
11
+ message_start). On an error it re-emits the guarded path's error Response as an
12
+ SSE `error` event (the stream has already committed to HTTP 200).
13
+ """
14
+
15
+ import asyncio
16
+ import importlib.util
17
+ import unittest
18
+ from pathlib import Path
19
+
20
+
21
+ def _load():
22
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
23
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
24
+ m = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(m)
26
+ return m
27
+
28
+
29
+ proxy = _load()
30
+
31
+
32
+ async def _collect(produce_coro, model="test-model"):
33
+ return [chunk async for chunk in proxy._heartbeat_then_buffered(produce_coro, model)]
34
+
35
+
36
+ class TestStreamHeartbeat(unittest.TestCase):
37
+ def setUp(self):
38
+ # small interval so the slow-produce test emits pings quickly
39
+ self._orig = proxy.PROXY_STREAM_HEARTBEAT_SECS
40
+ proxy.PROXY_STREAM_HEARTBEAT_SECS = 0.05
41
+
42
+ def tearDown(self):
43
+ proxy.PROXY_STREAM_HEARTBEAT_SECS = self._orig
44
+
45
+ def test_fast_produce_no_pings_single_message_start(self):
46
+ async def produce():
47
+ return {
48
+ "id": "msg_x",
49
+ "model": "m",
50
+ "content": [{"type": "text", "text": "hi"}],
51
+ "stop_reason": "end_turn",
52
+ "usage": {"output_tokens": 1},
53
+ }
54
+
55
+ out = "".join(asyncio.run(_collect(produce())))
56
+ # exactly one message_start (heartbeat's own; converter skips its own)
57
+ self.assertEqual(out.count("event: message_start"), 1)
58
+ self.assertNotIn("event: ping", out)
59
+ self.assertIn("hi", out)
60
+ self.assertIn("event: message_stop", out)
61
+ self.assertNotIn("event: error", out)
62
+
63
+ def test_slow_produce_emits_pings_then_content(self):
64
+ async def produce():
65
+ await asyncio.sleep(0.18) # > 3 intervals
66
+ return {
67
+ "id": "msg_y",
68
+ "model": "m",
69
+ "content": [{"type": "text", "text": "done"}],
70
+ "stop_reason": "end_turn",
71
+ "usage": {"output_tokens": 1},
72
+ }
73
+
74
+ chunks = asyncio.run(_collect(produce()))
75
+ out = "".join(chunks)
76
+ self.assertEqual(out.count("event: message_start"), 1)
77
+ self.assertGreaterEqual(out.count("event: ping"), 1)
78
+ self.assertIn("done", out)
79
+ # message_start precedes the first ping, ping precedes content
80
+ self.assertLess(out.index("event: message_start"), out.index("event: ping"))
81
+ self.assertLess(out.index("event: ping"), out.index("done"))
82
+
83
+ def test_error_response_becomes_sse_error_event(self):
84
+ import json
85
+
86
+ async def produce():
87
+ return proxy.Response(
88
+ content=json.dumps(
89
+ {"type": "error", "error": {"type": "overloaded_error", "message": "boom"}}
90
+ ),
91
+ status_code=529,
92
+ media_type="application/json",
93
+ )
94
+
95
+ out = "".join(asyncio.run(_collect(produce())))
96
+ self.assertEqual(out.count("event: message_start"), 1)
97
+ self.assertIn("event: error", out)
98
+ self.assertIn("boom", out)
99
+ self.assertNotIn("event: message_stop", out)
100
+
101
+ def test_produce_raises_becomes_sse_error_event(self):
102
+ async def produce():
103
+ raise RuntimeError("kaboom")
104
+
105
+ out = "".join(asyncio.run(_collect(produce())))
106
+ self.assertIn("event: error", out)
107
+ self.assertIn("kaboom", out)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()