@miller-tech/uap 1.179.4 → 1.179.5

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.
@@ -179,6 +179,8 @@ set is below.
179
179
  | Env (default) | Behavior |
180
180
  |---|---|
181
181
  | `PROXY_MAX_CONNECTIONS` (20) | httpx pool size; a large pool + 529 backoff absorbs connection churn gracefully |
182
+ | `PROXY_UPSTREAM_RETRY_MAX` (3) / `PROXY_UPSTREAM_RETRY_DELAY_SECS` (5) | Transient-failure retry budget for upstream calls, on both the buffered and the streaming path. Values below 1 are clamped to 1 |
183
+ | **503 "Loading model"** (no toggle) | llama-server answers 503 while a GGUF is still being mapped. Both paths treat it as transient: wait for `/health` (up to 60s), then retry. A wait that times out surfaces the 503 rather than stacking another — so a cold start costs at most ~60s of extra latency per attempt, not `RETRY_MAX × 60s` of dead air |
182
184
  | **529 backpressure** (no toggle) | A pool timeout returns HTTP **529 `overloaded_error`** with `retry-after` — pure graceful degradation, not a hard failure |
183
185
  | `PROXY_CLOSEWAIT_REAP_INTERVAL` (0 = **off**) | Opt-in CLOSE-WAIT reaper (pool self-heal); off by default because pool-swap churn can harm a saturated upstream |
184
186
  | `PROXY_TOOL_NARROWING` (**off**) | Opt-in: drop cycling/banned tools from the set on loops — always keeps the Bash/WebFetch/Agent escape hatch + write tools (a floor invariant that never strands the agent) |
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.179.4",
4
- "version": "1.179.4",
3
+ "version": "1.179.5",
5
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
6
5
  "type": "module",
7
6
  "main": "dist/index.js",
@@ -3560,6 +3560,92 @@ class DisconnectAwareClient(httpx.AsyncClient):
3560
3560
  task.cancel()
3561
3561
 
3562
3562
 
3563
+ async def _send_stream_with_retry(
3564
+ client: httpx.AsyncClient,
3565
+ url: str,
3566
+ payload: dict,
3567
+ ) -> httpx.Response:
3568
+ """POST a streaming completion, retrying transient failures and 503 "Loading model".
3569
+
3570
+ The streaming sibling of `_post_with_retry_inner`. Both retry the
3571
+ `_UPSTREAM_RETRY_EXCEPTIONS` set — which is wider than connect errors; a
3572
+ ReadTimeout/ReadError mid-setup is retried too — so a llama-server restart
3573
+ doesn't fail the turn. Both also treat a 503 "Loading model" as transient:
3574
+ llama-server answers that while a GGUF is still being mapped, so the right
3575
+ move is to wait for /health and retry rather than surface a fatal error.
3576
+
3577
+ Raises the last transient exception when every attempt failed. Otherwise
3578
+ returns the response, which may itself be a non-200 the caller must handle —
3579
+ including a loading 503 that outlived the retry budget, or one whose health
3580
+ wait timed out.
3581
+ """
3582
+ # Clamp: a configured 0 would skip the loop entirely and fall through to the
3583
+ # terminal raise with last_exc unset, escaping the caller's narrow except as
3584
+ # an unhandled 500. One attempt is the floor.
3585
+ retry_max = max(1, PROXY_UPSTREAM_RETRY_MAX)
3586
+ last_exc: Exception | None = None
3587
+ for attempt in range(retry_max):
3588
+ try:
3589
+ resp = await client.send(
3590
+ client.build_request(
3591
+ "POST",
3592
+ url,
3593
+ json=payload,
3594
+ headers={"Content-Type": "application/json"},
3595
+ ),
3596
+ stream=True,
3597
+ )
3598
+ setattr(resp, "_uap_client", client)
3599
+ if resp.status_code == 503:
3600
+ # _is_loading_model_503 reads .text, which raises ResponseNotRead
3601
+ # on an unread streaming body — and its bare `except` turns that
3602
+ # into a silent False, so without this read every loading 503
3603
+ # would look permanent and the retry would never fire. httpx
3604
+ # caches the body, so the caller's non-200 handler re-reads free.
3605
+ try:
3606
+ await resp.aread()
3607
+ except BaseException:
3608
+ # Never leave the connection checked out: a ReadError here is
3609
+ # retryable and would otherwise loop with resp unreferenced
3610
+ # and unclosed, accruing CLOSE-WAIT until the pool saturates.
3611
+ _detach_aclose(resp)
3612
+ raise
3613
+ if _is_loading_model_503(resp) and attempt < retry_max - 1:
3614
+ logger.warning(
3615
+ "Upstream 503 Loading model (stream attempt %d/%d) – waiting for health",
3616
+ attempt + 1,
3617
+ retry_max,
3618
+ )
3619
+ healthy = await _wait_for_upstream_health(client, max_wait=60.0)
3620
+ if not healthy:
3621
+ # Matches _post_with_retry_inner: once a health wait has
3622
+ # timed out, stacking another one just buys the client
3623
+ # minutes of dead air before the same failure. Surface it.
3624
+ return resp
3625
+ _detach_aclose(resp)
3626
+ continue
3627
+ return resp
3628
+ except _UPSTREAM_RETRY_EXCEPTIONS as exc:
3629
+ last_exc = exc
3630
+ if attempt < retry_max - 1:
3631
+ logger.warning(
3632
+ "Upstream transient error (stream attempt %d/%d): %s – retrying in %.0fs",
3633
+ attempt + 1,
3634
+ retry_max,
3635
+ type(exc).__name__,
3636
+ PROXY_UPSTREAM_RETRY_DELAY_SECS,
3637
+ )
3638
+ await asyncio.sleep(PROXY_UPSTREAM_RETRY_DELAY_SECS)
3639
+ else:
3640
+ logger.error(
3641
+ "Upstream stream failed after %d attempts: %s: %s",
3642
+ retry_max,
3643
+ type(exc).__name__,
3644
+ exc,
3645
+ )
3646
+ raise last_exc if last_exc else RuntimeError("upstream stream retry failed")
3647
+
3648
+
3563
3649
  async def _post_with_retry_inner(
3564
3650
  client: httpx.AsyncClient,
3565
3651
  url: str,
@@ -11676,43 +11762,17 @@ async def messages(request: Request):
11676
11762
  # Retry upstream connection with backoff to handle
11677
11763
  # llama-server restarts gracefully instead of 500-ing to the client.
11678
11764
  MAX_UPSTREAM_RETRIES = PROXY_UPSTREAM_RETRY_MAX
11679
- RETRY_DELAY_SECS = PROXY_UPSTREAM_RETRY_DELAY_SECS
11680
11765
  last_exc: Exception | None = None
11681
11766
  resp: httpx.Response | None = None
11682
11767
 
11683
- for attempt in range(MAX_UPSTREAM_RETRIES):
11684
- try:
11685
- resp = await client.send(
11686
- client.build_request(
11687
- "POST",
11688
- f"{LLAMA_CPP_BASE}/chat/completions",
11689
- json=openai_body,
11690
- headers={"Content-Type": "application/json"},
11691
- ),
11692
- stream=True,
11693
- )
11694
- setattr(resp, "_uap_client", client)
11695
- # Connection succeeded – break out of retry loop
11696
- last_exc = None
11697
- break
11698
- except _UPSTREAM_RETRY_EXCEPTIONS as exc:
11699
- last_exc = exc
11700
- if attempt < MAX_UPSTREAM_RETRIES - 1:
11701
- logger.warning(
11702
- "Upstream connect failed (attempt %d/%d): %s – retrying in %.0fs",
11703
- attempt + 1,
11704
- MAX_UPSTREAM_RETRIES,
11705
- type(exc).__name__,
11706
- RETRY_DELAY_SECS,
11707
- )
11708
- await asyncio.sleep(RETRY_DELAY_SECS)
11709
- else:
11710
- logger.error(
11711
- "Upstream connect failed after %d attempts: %s: %s",
11712
- MAX_UPSTREAM_RETRIES,
11713
- type(exc).__name__,
11714
- exc,
11715
- )
11768
+ try:
11769
+ resp = await _send_stream_with_retry(
11770
+ client,
11771
+ f"{LLAMA_CPP_BASE}/chat/completions",
11772
+ openai_body,
11773
+ )
11774
+ except _UPSTREAM_RETRY_EXCEPTIONS as exc:
11775
+ last_exc = exc
11716
11776
 
11717
11777
  if last_exc is not None:
11718
11778
  return Response(
@@ -5834,3 +5834,228 @@ class TestPassthroughTimeout(unittest.IsolatedAsyncioTestCase):
5834
5834
 
5835
5835
  self.assertIsNotNone(captured.get("timeout"))
5836
5836
  self.assertEqual(captured["timeout"].read, proxy.PROXY_PASSTHROUGH_TIMEOUT)
5837
+
5838
+
5839
+ class _FakeStreamResponse:
5840
+ """Stand-in for a streamed httpx.Response, faithful to two contracts.
5841
+
5842
+ Both are load-bearing for _send_stream_with_retry, and a fake that models
5843
+ neither lets the fix be deleted with the suite still green:
5844
+
5845
+ 1. `.text` raises ResponseNotRead until the body has been read. That is why
5846
+ the production code calls aread() before _is_loading_model_503 — whose
5847
+ bare `except` would otherwise swallow the raise into a silent False and
5848
+ silently disable the retry.
5849
+ 2. aread() caches. The caller re-reads the same response in its non-200
5850
+ handler, which only works because httpx serves the second read from
5851
+ cache rather than raising StreamConsumed.
5852
+ """
5853
+
5854
+ def __init__(self, status_code, text=""):
5855
+ self.status_code = status_code
5856
+ self._body = text
5857
+ self._read = False
5858
+ self.read_count = 0
5859
+ self.closed = False
5860
+
5861
+ @property
5862
+ def text(self):
5863
+ if not self._read:
5864
+ raise httpx.ResponseNotRead()
5865
+ return self._body
5866
+
5867
+ async def aread(self):
5868
+ if self._read:
5869
+ return self._body.encode() # cached: no second stream consumption
5870
+ self._read = True
5871
+ self.read_count += 1
5872
+ return self._body.encode()
5873
+
5874
+ async def aclose(self):
5875
+ self.closed = True
5876
+
5877
+
5878
+ class _FakeStreamClient:
5879
+ """Replays a queued script of responses/exceptions for client.send()."""
5880
+
5881
+ def __init__(self, scripted):
5882
+ self._scripted = list(scripted)
5883
+ self.sends = 0
5884
+ self.requests = []
5885
+ self.stream_flags = []
5886
+
5887
+ def build_request(self, method, url, **kwargs):
5888
+ return {"method": method, "url": url, **kwargs}
5889
+
5890
+ async def send(self, request, stream=False):
5891
+ self.sends += 1
5892
+ self.requests.append(request)
5893
+ self.stream_flags.append(stream)
5894
+ if not self._scripted:
5895
+ raise AssertionError("no scripted response left")
5896
+ nxt = self._scripted.pop(0)
5897
+ if isinstance(nxt, Exception):
5898
+ raise nxt
5899
+ return nxt
5900
+
5901
+
5902
+ class TestSendStreamWithRetry(unittest.TestCase):
5903
+ """The streaming path must treat 503 'Loading model' as transient.
5904
+
5905
+ Regression guard: this path used to break out of its retry loop on any HTTP
5906
+ response, so a streamed turn that began while llama-server was still mapping
5907
+ a GGUF surfaced 'AI_APICallError: Loading model' to the client as a fatal
5908
+ error. The non-streaming sibling (_post_with_retry_inner) retried it —
5909
+ health-gated and bounded by PROXY_UPSTREAM_RETRY_MAX — while the streaming
5910
+ path, the one every opencode turn actually uses, did not retry it at all.
5911
+ """
5912
+
5913
+ def _run(self, client, health_ok=True, retry_max=3):
5914
+ waited = []
5915
+
5916
+ async def _fake_health(_client, max_wait=60.0, poll_interval=5.0):
5917
+ waited.append(max_wait)
5918
+ return health_ok
5919
+
5920
+ async def _no_sleep(_secs):
5921
+ return None
5922
+
5923
+ def _sync_close(closeable):
5924
+ # Production schedules the close on a detached task; inside a test
5925
+ # the loop closes before it runs. Close eagerly so `.closed` is a
5926
+ # deterministic assertion rather than a race.
5927
+ closeable.closed = True
5928
+
5929
+ with unittest.mock.patch.object(proxy, "_wait_for_upstream_health", _fake_health), \
5930
+ unittest.mock.patch.object(proxy, "_detach_aclose", _sync_close), \
5931
+ unittest.mock.patch.object(proxy, "PROXY_UPSTREAM_RETRY_MAX", retry_max), \
5932
+ unittest.mock.patch.object(asyncio, "sleep", _no_sleep):
5933
+ resp = asyncio.run(
5934
+ proxy._send_stream_with_retry(client, "http://x/v1/chat/completions", {})
5935
+ )
5936
+ return resp, waited
5937
+
5938
+ def test_retries_loading_model_503_then_returns_success(self):
5939
+ loading = _FakeStreamResponse(503, "Loading model")
5940
+ ok = _FakeStreamResponse(200, "")
5941
+ client = _FakeStreamClient([loading, ok])
5942
+
5943
+ resp, waited = self._run(client)
5944
+
5945
+ self.assertIs(resp, ok)
5946
+ self.assertEqual(client.sends, 2, "should have retried after the 503")
5947
+ self.assertEqual(len(waited), 1, "should have waited for upstream health")
5948
+ self.assertTrue(loading.closed, "the discarded 503 must be closed")
5949
+
5950
+ def test_does_not_retry_a_non_loading_503(self):
5951
+ # A 503 that is not a model load is a real upstream failure; passing it
5952
+ # straight through preserves the existing error contract.
5953
+ overloaded = _FakeStreamResponse(503, "server is overloaded")
5954
+ client = _FakeStreamClient([overloaded, _FakeStreamResponse(200, "")])
5955
+
5956
+ resp, waited = self._run(client)
5957
+
5958
+ self.assertIs(resp, overloaded)
5959
+ self.assertEqual(client.sends, 1)
5960
+ self.assertEqual(waited, [])
5961
+
5962
+ def test_returns_503_after_exhausting_retry_budget(self):
5963
+ responses = [_FakeStreamResponse(503, "Loading model") for _ in range(3)]
5964
+ client = _FakeStreamClient(list(responses))
5965
+
5966
+ resp, waited = self._run(client, retry_max=3)
5967
+
5968
+ self.assertEqual(client.sends, 3)
5969
+ self.assertEqual(resp.status_code, 503)
5970
+ self.assertIs(resp, responses[-1], "last 503 is surfaced to the caller")
5971
+ self.assertEqual(len(waited), 2, "waits between attempts, not after the last")
5972
+
5973
+ def test_surfaces_the_503_when_the_health_wait_times_out(self):
5974
+ # Mirrors _post_with_retry_inner: once upstream is still unhealthy after
5975
+ # a full wait, stacking more waits only buys the client dead air. Without
5976
+ # this gate a dead upstream costs retry_max x 60s before the same failure.
5977
+ loading = _FakeStreamResponse(503, "Loading model")
5978
+ client = _FakeStreamClient([loading, _FakeStreamResponse(200, "")])
5979
+
5980
+ resp, waited = self._run(client, health_ok=False)
5981
+
5982
+ self.assertIs(resp, loading, "unhealthy upstream surfaces the 503 now")
5983
+ self.assertEqual(client.sends, 1, "must not retry a still-unhealthy upstream")
5984
+ self.assertEqual(len(waited), 1)
5985
+
5986
+ def test_reads_the_body_before_classifying_a_503(self):
5987
+ # The fix hinges on aread() preceding _is_loading_model_503, whose bare
5988
+ # `except` would turn httpx's ResponseNotRead into a silent False and
5989
+ # disable the retry entirely. Deleting that aread() must fail here.
5990
+ loading = _FakeStreamResponse(503, "Loading model")
5991
+ ok = _FakeStreamResponse(200, "")
5992
+ client = _FakeStreamClient([loading, ok])
5993
+
5994
+ resp, _ = self._run(client)
5995
+
5996
+ self.assertIs(resp, ok)
5997
+ self.assertEqual(loading.read_count, 1, "body read exactly once, before .text")
5998
+
5999
+ def test_closes_the_response_when_the_body_read_fails(self):
6000
+ # A ReadError while reading the 503 body is retryable; if the discarded
6001
+ # response is not closed the connection accrues as CLOSE-WAIT until the
6002
+ # pool saturates, with the client seeing nothing wrong.
6003
+ class _ExplodingResponse(_FakeStreamResponse):
6004
+ async def aread(self):
6005
+ raise httpx.ReadError("reset mid-body")
6006
+
6007
+ boom = _ExplodingResponse(503, "Loading model")
6008
+ ok = _FakeStreamResponse(200, "")
6009
+ client = _FakeStreamClient([boom, ok])
6010
+
6011
+ resp, _ = self._run(client)
6012
+
6013
+ self.assertIs(resp, ok, "the read failure is retried")
6014
+ self.assertTrue(boom.closed, "the abandoned response must be closed")
6015
+
6016
+ def test_still_retries_connect_errors(self):
6017
+ # Regression guard for the pre-existing behaviour the extraction replaced.
6018
+ ok = _FakeStreamResponse(200, "")
6019
+ client = _FakeStreamClient([httpx.ConnectError("boom"), ok])
6020
+
6021
+ resp, _ = self._run(client)
6022
+
6023
+ self.assertIs(resp, ok)
6024
+ self.assertEqual(client.sends, 2)
6025
+
6026
+ def test_raises_when_every_connect_attempt_fails(self):
6027
+ client = _FakeStreamClient([httpx.ConnectError("boom") for _ in range(3)])
6028
+
6029
+ with self.assertRaises(httpx.ConnectError):
6030
+ self._run(client, retry_max=3)
6031
+
6032
+ self.assertEqual(client.sends, 3)
6033
+
6034
+ def test_retry_max_of_zero_still_makes_one_attempt(self):
6035
+ # An unclamped 0 skipped the loop and raised a bare RuntimeError, which
6036
+ # the call site's narrow `except` misses — an unhandled 500 where the
6037
+ # old code returned a clean 529.
6038
+ ok = _FakeStreamResponse(200, "")
6039
+ client = _FakeStreamClient([ok])
6040
+
6041
+ resp, _ = self._run(client, retry_max=0)
6042
+
6043
+ self.assertIs(resp, ok)
6044
+ self.assertEqual(client.sends, 1)
6045
+
6046
+ def test_preserves_the_streaming_contract_the_caller_depends_on(self):
6047
+ # stream=True keeps the turn incremental (buffering it would silently
6048
+ # reintroduce full-generation latency), and _uap_client drives the
6049
+ # inflight accounting that the pool-retire logic waits on.
6050
+ ok = _FakeStreamResponse(200, "")
6051
+ client = _FakeStreamClient([ok])
6052
+
6053
+ resp, _ = self._run(client)
6054
+
6055
+ self.assertEqual(client.stream_flags, [True])
6056
+ self.assertIs(getattr(resp, "_uap_client"), client)
6057
+ self.assertEqual(client.requests[0]["url"], "http://x/v1/chat/completions")
6058
+
6059
+
6060
+ if __name__ == "__main__":
6061
+ unittest.main()