@miller-tech/uap 2.16.7 → 2.20.0

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.
Files changed (106) hide show
  1. package/config/capacity-policy.json +33 -0
  2. package/config/model-profiles/qwen38.json +32 -33
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/bin/cli.js +57 -0
  5. package/dist/bin/cli.js.map +1 -1
  6. package/dist/capacity/policy.d.ts +32 -1
  7. package/dist/capacity/policy.d.ts.map +1 -1
  8. package/dist/capacity/policy.js +123 -4
  9. package/dist/capacity/policy.js.map +1 -1
  10. package/dist/capacity/probe.d.ts +26 -0
  11. package/dist/capacity/probe.d.ts.map +1 -1
  12. package/dist/capacity/probe.js +84 -3
  13. package/dist/capacity/probe.js.map +1 -1
  14. package/dist/cli/inference.d.ts +4 -0
  15. package/dist/cli/inference.d.ts.map +1 -1
  16. package/dist/cli/inference.js +74 -8
  17. package/dist/cli/inference.js.map +1 -1
  18. package/dist/cli/merge-gate.d.ts +59 -0
  19. package/dist/cli/merge-gate.d.ts.map +1 -0
  20. package/dist/cli/merge-gate.js +238 -0
  21. package/dist/cli/merge-gate.js.map +1 -0
  22. package/dist/cli/merge-queue.d.ts +13 -0
  23. package/dist/cli/merge-queue.d.ts.map +1 -1
  24. package/dist/cli/merge-queue.js +48 -2
  25. package/dist/cli/merge-queue.js.map +1 -1
  26. package/dist/cli/mission.d.ts +25 -0
  27. package/dist/cli/mission.d.ts.map +1 -0
  28. package/dist/cli/mission.js +225 -0
  29. package/dist/cli/mission.js.map +1 -0
  30. package/dist/cli/orch.d.ts +27 -0
  31. package/dist/cli/orch.d.ts.map +1 -0
  32. package/dist/cli/orch.js +70 -0
  33. package/dist/cli/orch.js.map +1 -0
  34. package/dist/cli/systemd-services.d.ts.map +1 -1
  35. package/dist/cli/systemd-services.js +44 -0
  36. package/dist/cli/systemd-services.js.map +1 -1
  37. package/dist/delivery/merge-gate.d.ts +168 -0
  38. package/dist/delivery/merge-gate.d.ts.map +1 -0
  39. package/dist/delivery/merge-gate.js +309 -0
  40. package/dist/delivery/merge-gate.js.map +1 -0
  41. package/dist/inference/analysis.d.ts +69 -3
  42. package/dist/inference/analysis.d.ts.map +1 -1
  43. package/dist/inference/analysis.js +123 -7
  44. package/dist/inference/analysis.js.map +1 -1
  45. package/dist/inference/probe.d.ts +38 -1
  46. package/dist/inference/probe.d.ts.map +1 -1
  47. package/dist/inference/probe.js +303 -30
  48. package/dist/inference/probe.js.map +1 -1
  49. package/dist/inference/strata.d.ts +95 -0
  50. package/dist/inference/strata.d.ts.map +1 -0
  51. package/dist/inference/strata.js +167 -0
  52. package/dist/inference/strata.js.map +1 -0
  53. package/dist/mission/database.d.ts +29 -0
  54. package/dist/mission/database.d.ts.map +1 -0
  55. package/dist/mission/database.js +131 -0
  56. package/dist/mission/database.js.map +1 -0
  57. package/dist/mission/ledger.d.ts +109 -0
  58. package/dist/mission/ledger.d.ts.map +1 -0
  59. package/dist/mission/ledger.js +294 -0
  60. package/dist/mission/ledger.js.map +1 -0
  61. package/dist/mission/poll-cycle.d.ts +73 -0
  62. package/dist/mission/poll-cycle.d.ts.map +1 -0
  63. package/dist/mission/poll-cycle.js +100 -0
  64. package/dist/mission/poll-cycle.js.map +1 -0
  65. package/dist/mission/poll-executor.d.ts +91 -0
  66. package/dist/mission/poll-executor.d.ts.map +1 -0
  67. package/dist/mission/poll-executor.js +328 -0
  68. package/dist/mission/poll-executor.js.map +1 -0
  69. package/dist/models/types.d.ts.map +1 -1
  70. package/dist/models/types.js +33 -27
  71. package/dist/models/types.js.map +1 -1
  72. package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
  73. package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
  74. package/dist/self-tuning/profiles/qwen38.js +17 -18
  75. package/dist/self-tuning/profiles/qwen38.js.map +1 -1
  76. package/dist/types/mission.d.ts +80 -0
  77. package/dist/types/mission.d.ts.map +1 -0
  78. package/dist/types/mission.js +12 -0
  79. package/dist/types/mission.js.map +1 -0
  80. package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
  81. package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
  82. package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
  83. package/docs/guides/CAPACITY_POLICY.md +49 -0
  84. package/docs/guides/INFERENCE_HEALTH.md +65 -8
  85. package/docs/guides/MISSIONS.md +170 -0
  86. package/docs/guides/PROXY.md +50 -0
  87. package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
  88. package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
  89. package/docs/visual/merge-gate.html +404 -0
  90. package/docs/visual/mission-orchestrator.html +472 -0
  91. package/package.json +2 -2
  92. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  93. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  94. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  95. package/src/policies/enforcers/plane_admin_protect.py +133 -0
  96. package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
  97. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  98. package/templates/hooks/uap-policy-gate.sh +33 -0
  99. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  100. package/tools/agents/scripts/anthropic_proxy.py +1271 -89
  101. package/tools/agents/scripts/requirements-proxy.txt +1 -1
  102. package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
  103. package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
  104. package/tools/agents/tests/test_ollama_compat.py +320 -0
  105. package/tools/agents/tests/test_plane_admin_protect.py +124 -0
  106. package/tools/agents/tests/test_stream_heartbeat.py +77 -0
@@ -64,6 +64,37 @@ Configuration (Environment Variables)
64
64
  conversation pruning activates (0.0-1.0)
65
65
  Default: 0.85
66
66
 
67
+ PROXY_PRUNE_SPIRAL_STREAK Consecutive above-threshold prunes with a
68
+ RISING post-prune floor required before the
69
+ prune circuit breaker fires. A tool-heavy
70
+ client's fixed overhead (system + tool
71
+ schemas) can sit above the threshold
72
+ permanently; a flat floor is NOT a death
73
+ spiral. Default: 3
74
+
75
+ PROXY_PRUNE_SPIRAL_EPSILON Minimum post-prune utilization rise
76
+ (fraction of window, e.g. 0.01 = 1 point)
77
+ for a prune to count toward the spiral
78
+ streak. Default: 0.01
79
+
80
+ PROXY_FINAL_ANSWER_CHARS Content reaching this many characters
81
+ is a legitimate final answer: the
82
+ unexpected-end-turn retry is skipped
83
+ and the client's loop ends naturally,
84
+ a `required` tool turn returning such
85
+ content is exempt from the
86
+ required_tool_miss streak, retries, and
87
+ forced-tool dampener, and the
88
+ recon-convergence no-write streak does
89
+ not advance on it (a finished model's
90
+ summary is a completion, not a miss).
91
+ Content matching the deferral
92
+ phrases stays retried even when long —
93
+ a capitulation is not a final answer.
94
+ Env alias: PROXY_END_TURN_FINAL_CHARS
95
+ (deprecated spelling, same knob).
96
+ 0 = always retry. Default: 800
97
+
67
98
  Usage
68
99
  -----
69
100
  # Basic usage (connects to llama.cpp on default port):
@@ -75,6 +106,10 @@ Usage
75
106
  # Custom proxy port:
76
107
  PROXY_PORT=5000 python anthropic_proxy.py
77
108
 
109
+ # Ollama-compatible surface on the well-known port (default; 0 disables —
110
+ # set that on a box running a real Ollama, which owns 11434):
111
+ PROXY_OLLAMA_PORT=11434 python anthropic_proxy.py
112
+
78
113
  # Via npx (after npm install):
79
114
  npx uap-anthropic-proxy
80
115
 
@@ -95,6 +130,8 @@ import json
95
130
  import logging
96
131
  import os
97
132
  import re
133
+ import socket
134
+ import stat
98
135
  import sys
99
136
  import time
100
137
  import uuid
@@ -108,7 +145,7 @@ from dataclasses import dataclass, field
108
145
  from pathlib import Path
109
146
 
110
147
  import httpx
111
- from contextlib import asynccontextmanager
148
+ from contextlib import asynccontextmanager, contextmanager
112
149
  from fastapi import FastAPI, Request, Response
113
150
  from fastapi.responses import StreamingResponse
114
151
  import uvicorn
@@ -256,6 +293,12 @@ ANTHROPIC_API_BASE = os.environ.get(
256
293
  ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
257
294
  ANTHROPIC_PASSTHROUGH_MODELS = os.environ.get("ANTHROPIC_PASSTHROUGH_MODELS", "")
258
295
  PROXY_PORT = int(os.environ.get("PROXY_PORT", "4000"))
296
+ # The Ollama-compatible surface: the SAME app served on a SECOND port so
297
+ # Ollama-native tooling (which auto-discovers localhost:11434 and cannot be
298
+ # pointed at a path-prefixed alternative) works against this proxy. 0 disables
299
+ # it — set that on a box running a real Ollama, which owns 11434 by convention.
300
+ # See the "Ollama-compatible surface" section near the end of this file.
301
+ PROXY_OLLAMA_PORT = int(os.environ.get("PROXY_OLLAMA_PORT", "11434"))
259
302
  # Bind loopback by DEFAULT (security audit: an unauthenticated 0.0.0.0 listener
260
303
  # let any LAN host drive the local model and reach cloud passthrough). To run
261
304
  # the proxy as a shared LAN service, set PROXY_HOST=0.0.0.0 AND set
@@ -299,6 +342,54 @@ PROXY_CONTEXT_WINDOW = int(os.environ.get("PROXY_CONTEXT_WINDOW", "0"))
299
342
  PROXY_CONTEXT_PRUNE_THRESHOLD = float(
300
343
  os.environ.get("PROXY_CONTEXT_PRUNE_THRESHOLD", "0.85")
301
344
  )
345
+ # Prune circuit-breaker floor-trend discrimination (2026-09-30): a tool-heavy
346
+ # client's FIXED overhead (system prompt + tool schemas — observed live:
347
+ # opencode's 99 tools ≈ 66k est tokens in a 114,688-token window) can sit
348
+ # permanently ABOVE PROXY_CONTEXT_PRUNE_THRESHOLD (0.42 in production), so
349
+ # post-prune utilization can never get below the threshold no matter how many
350
+ # messages are dropped. The breaker's old condition — "3+ consecutive prunes
351
+ # and still above threshold" — read that permanent flat floor as a death
352
+ # spiral and force-finalized healthy ~44% sessions every few turns (observed
353
+ # live: 8 breaker fires in 10 minutes, each one ending the client's turn; the
354
+ # operator symptom was "the client keeps stopping"). A REAL death spiral shows
355
+ # a RISING post-prune floor turn over turn; a fixed-overhead floor is flat.
356
+ # The breaker now fires only after PROXY_PRUNE_SPIRAL_STREAK consecutive
357
+ # above-threshold prunes whose post-prune utilization rose by more than
358
+ # PROXY_PRUNE_SPIRAL_EPSILON (fraction of the window). Slow sub-epsilon
359
+ # spirals do not trip this breaker; they escalate to the >=90% critical
360
+ # prune and the catastrophic raw-ctx finalize backstops instead.
361
+ PROXY_PRUNE_SPIRAL_STREAK = int(
362
+ os.environ.get("PROXY_PRUNE_SPIRAL_STREAK", "3")
363
+ )
364
+ PROXY_PRUNE_SPIRAL_EPSILON = float(
365
+ os.environ.get("PROXY_PRUNE_SPIRAL_EPSILON", "0.01")
366
+ )
367
+ # Substantive-final-answer exemption for the unexpected-end-turn retry
368
+ # (2026-10-03): the retry exists for thinking-runaway stalls — an end_turn
369
+ # with EMPTY or near-empty content mid-loop (reproduced 2026-09-17: 5917
370
+ # reasoning chars, finish=length, 0 tool_calls). It had NO completion signal,
371
+ # so a model that GENUINELY finished and wrote its final answer was also
372
+ # "unexpected": the retry coerced it back into a tool call, and a finished
373
+ # model under coercion emits a degenerate no-op call (observed live
374
+ # 2026-10-03 01:00-03:00: "Task complete — recap delivered and one concrete
375
+ # defect fixed", 2211 chars, followed by 96 retry cycles each producing the
376
+ # identical `git status` no-op — two hours of churn the operator saw as
377
+ # "keeps stopping or looping"). Content reaching this many characters is a
378
+ # legitimate final answer: let the client's loop end. The runaway case has
379
+ # empty content, so the threshold separates the two classes cleanly. The
380
+ # threshold now gates a family of exemptions — the end-turn retry, the
381
+ # stream-path malformed-streak counter, the required-tool-miss classification
382
+ # (2026-10-03 13:24-13:40 incident), and the recon-convergence no-write streak
383
+ # — hence the rename from PROXY_END_TURN_FINAL_CHARS (still accepted as a
384
+ # deprecated env alias). 0 restores the old always-retry behavior.
385
+ PROXY_FINAL_ANSWER_CHARS = int(
386
+ os.environ.get("PROXY_FINAL_ANSWER_CHARS")
387
+ or os.environ.get("PROXY_END_TURN_FINAL_CHARS")
388
+ or "800"
389
+ )
390
+ # Deprecated alias (2026-10-03): existing env files and older tooling still
391
+ # use the end-turn spelling; both knobs control the same threshold.
392
+ PROXY_END_TURN_FINAL_CHARS = PROXY_FINAL_ANSWER_CHARS
302
393
  # Verbatim decision compaction (uplift 1.2): when context pressure triggers a
303
394
  # prune, first try per-tool-call keep/truncate/drop decisions that keep all
304
395
  # retained content VERBATIM; fall back to the lossy breadcrumb pruner only
@@ -1748,6 +1839,21 @@ try:
1748
1839
  )
1749
1840
  except ValueError:
1750
1841
  PROXY_STREAM_HEARTBEAT_SECS = 0.0
1842
+ # Journal progress cadence for the same buffered wait. The heartbeat keeps the
1843
+ # CLIENT alive; this keeps the OPERATOR alive: a no-tool streaming turn served
1844
+ # via the guarded path (confidence-escalation buffering, tools-stripped breaker
1845
+ # turns, forced non-stream) can generate for minutes with zero journal output —
1846
+ # live 2026-10-04, opencode's client-side compaction summary (msgs=1, tools=0,
1847
+ # stream=True) buffered 11,099 output tokens over 281 s: the proxy looked idle,
1848
+ # the client looked hung, and nothing in the log distinguished a bug from a
1849
+ # long generation until the RESP line finally landed. One INFO line per this
1850
+ # many seconds of waiting. 0 disables.
1851
+ try:
1852
+ PROXY_GUARDED_WAIT_LOG_SECS = float(
1853
+ os.environ.get("PROXY_GUARDED_WAIT_LOG_SECS", "60")
1854
+ )
1855
+ except ValueError:
1856
+ PROXY_GUARDED_WAIT_LOG_SECS = 60.0
1751
1857
  PROXY_FORCED_TOOL_DAMPENER = os.environ.get(
1752
1858
  "PROXY_FORCED_TOOL_DAMPENER", "on"
1753
1859
  ).lower() not in {
@@ -2410,6 +2516,14 @@ class SessionMonitor:
2410
2516
  pre_prune_input_tokens: int = 0
2411
2517
  prune_count: int = 0 # How many times pruning was triggered
2412
2518
  overflow_count: int = 0 # How many context overflow errors caught
2519
+ # Prune circuit-breaker floor trend (2026-09-30): consecutive
2520
+ # above-threshold prunes whose post-prune utilization ROSE (a real death
2521
+ # spiral regrows faster than the pruner can cut), and the last post-prune
2522
+ # utilization seen, to compute the delta. A flat floor is fixed overhead,
2523
+ # not a spiral — see _prune_breaker_should_fire.
2524
+ prune_spiral_streak: int = 0
2525
+ last_post_prune_util: float = -1.0
2526
+ prune_floor_logged: bool = False # one-shot "floor is flat" operator notice
2413
2527
  # Monotonic: # of oldest middle msgs pruned (B3). Only meaningful for
2414
2528
  # legacy lossy-path turns — a successful verbatim compaction (uplift 1.2)
2415
2529
  # re-indexes the middle non-contiguously and RESETS this to 0, since a
@@ -2457,7 +2571,7 @@ class SessionMonitor:
2457
2571
  session_id: str = "" # owning session id (set by get_session_monitor)
2458
2572
  mandate_deliver_fires: int = 0 # monotonic count of forced deliver-routings (mandate)
2459
2573
  mandate_deliver_active: bool = False # THIS turn is pinned to deliver (beats recon convergence)
2460
- tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
2574
+ tool_starvation_streak: int = 0 # breaker FIRES since the last compaction reset (not per turn)
2461
2575
  last_request_msg_count: int = 0 # Message count of the previous request (compaction-boundary detection)
2462
2576
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
2463
2577
  invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
@@ -3038,6 +3152,61 @@ class SessionMonitor:
3038
3152
  """Highest current streak among malformed/invalid tool outputs."""
3039
3153
  return max(self.malformed_tool_streak, self.invalid_tool_call_streak)
3040
3154
 
3155
+ def reset_forcing_loop(self) -> None:
3156
+ """Reset the forcing-loop family to a clean per-turn baseline.
3157
+
3158
+ 2026-10-04 follow-up to the starvation-breaker fix (PR #830): the
3159
+ membership of this family lives HERE, not in four hand-maintained
3160
+ reset blocks (fresh user text, no-tool-result conversations, the
3161
+ compaction boundary, the contamination hard reset). Members and why:
3162
+
3163
+ consecutive_forced_count, no_progress_streak -- the forcing loop
3164
+ is PER-TURN state; a stale count crossing a fresh user text
3165
+ false-fired the TOOL STARVATION BREAKER ~once per user turn for
3166
+ hours (fixed 2026-10-04).
3167
+
3168
+ Deliberately NOT members (each with its own lifecycle):
3169
+ consecutive_no_write_turns -- PER-TASK state (recon exploration
3170
+ streak), reset at the same four boundary sites INLINE, not here:
3171
+ it is not forcing-loop state, and an ack-shaped fresh text must
3172
+ not clear it (see the fresh_user_text branch). Its four reset
3173
+ sites and rationale are documented at those sites.
3174
+ malformed/invalid/required_tool_miss streaks -- output QUALITY is
3175
+ cross-turn evidence; a new user message says nothing about the
3176
+ model's tool-call formatting.
3177
+ forced_auto_cooldown_turns -- the dampener is a REMEDY for the
3178
+ quality streaks, and quality is cross-turn, so the cooldown
3179
+ carries across mid-episode user acks ("keep going" must not cut
3180
+ the recovery window short). It also cannot re-arm right after a
3181
+ reset: activation needs consecutive_forced_count >= min_forced,
3182
+ which the reset just zeroed. It clears only at a brand-new
3183
+ conversation (the n_msgs <= 1 block) and the emergency
3184
+ contamination reset.
3185
+ deferral_streak -- the capitulation loop it breaks SPANS user
3186
+ acks (model asks for more cycles, user says yes, model asks
3187
+ again), so a per-turn reset would make that breaker unreachable
3188
+ in exactly its true-positive case.
3189
+ tool_starvation_streak -- per-EPOCH session telemetry: counts
3190
+ breaker fires since the last compaction reset (the compaction
3191
+ boundary clears it), not per-turn severity.
3192
+ recon_hard_fires -- monotonic session counter, never reset.
3193
+ catastrophic_ctx_streak -- consecutive-turn utilization streak;
3194
+ resets the moment utilization drops below the finalize ratio,
3195
+ so it is per-turn-consecutive, not monotonic.
3196
+
3197
+ Two more semantic classes reset the member pair INLINE by design,
3198
+ and a grep for "consecutive_forced_count = 0" will find them:
3199
+ consequence/consume-on-fire clears (breaker fire, dampener
3200
+ release, finalize paths, analysis routing) zero the pair after an
3201
+ ACTION was taken, which is a different event from a boundary reset.
3202
+ And the four boundary call sites here are gated behind
3203
+ PROXY_TOOL_STATE_MACHINE (pre-existing posture, unchanged): with
3204
+ the state machine off, the consumers still run and the stale-state
3205
+ false-fire class returns.
3206
+ """
3207
+ self.consecutive_forced_count = 0
3208
+ self.no_progress_streak = 0
3209
+
3041
3210
  def consume_forced_auto_turn(self) -> bool:
3042
3211
  """Consume one dampener turn that temporarily sets tool_choice=auto."""
3043
3212
  if self.forced_auto_cooldown_turns <= 0:
@@ -3529,6 +3698,39 @@ def _prune_overhead_tokens(anthropic_body: dict) -> int:
3529
3698
  return int(overhead_tokens * 1.5) # Safety factor for template overhead
3530
3699
 
3531
3700
 
3701
+ def _prune_breaker_should_fire(post_util: float, monitor) -> bool:
3702
+ """Death-spiral discriminator for the prune circuit breaker (2026-09-30).
3703
+
3704
+ Called once per request AFTER all prune passes, with the post-prune
3705
+ utilization. Updates the monitor's floor-trend streak and returns whether
3706
+ the breaker should fire.
3707
+
3708
+ Why trend, not just "still above threshold": a tool-heavy client's fixed
3709
+ overhead (system + tool schemas; observed: ~66k est tokens of a 114,688
3710
+ window against a 42% threshold) puts the post-prune floor PERMANENTLY
3711
+ above the threshold. The old breaker condition (3+ consecutive prunes and
3712
+ still above threshold) fired every few turns on that flat floor, forcing
3713
+ finalize on healthy ~44% sessions — the operator-visible "the client
3714
+ keeps stopping". A real death spiral (auto-compact transcript regrowth,
3715
+ exploding tool results) shows a post-prune floor that RISES turn over turn
3716
+ even as the pruner cuts; only that counts. Flat or falling floors reset
3717
+ the streak.
3718
+ """
3719
+ prev = monitor.last_post_prune_util
3720
+ monitor.last_post_prune_util = post_util
3721
+ if post_util < PROXY_CONTEXT_PRUNE_THRESHOLD:
3722
+ # Back under the threshold (real headroom recovered): reset the trend.
3723
+ monitor.prune_spiral_streak = 0
3724
+ return False
3725
+ if prev >= 0 and post_util > prev + PROXY_PRUNE_SPIRAL_EPSILON:
3726
+ monitor.prune_spiral_streak += 1
3727
+ else:
3728
+ # Flat (within epsilon), falling, or the first observation above the
3729
+ # threshold: not spiral evidence.
3730
+ monitor.prune_spiral_streak = 0
3731
+ return monitor.prune_spiral_streak >= PROXY_PRUNE_SPIRAL_STREAK
3732
+
3733
+
3532
3734
  def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> bool:
3533
3735
  """Truncate the largest message content in-place until the total fits
3534
3736
  budget_tokens. Used when message-DROPPING cannot reduce below the window —
@@ -5559,7 +5761,14 @@ async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
5559
5761
 
5560
5762
  # Open paths that never require the shared secret (liveness / discovery), so a
5561
5763
  # LAN health check or an SDK model-list probe works without the token.
5562
- _PROXY_AUTH_OPEN_PATHS = frozenset({"/health", "/", "/v1/models"})
5764
+ # /api/version, /api/tags and /api/ps stay open for the same reason /v1/models
5765
+ # does: Ollama clients probe them BEFORE their first real request, and a 401 on
5766
+ # discovery breaks them before any credential could even be presented. The
5767
+ # model-serving /api routes (/api/chat, /api/generate, /api/show) are NOT open
5768
+ # — they carry the same content as /v1/messages and gate identically.
5769
+ _PROXY_AUTH_OPEN_PATHS = frozenset(
5770
+ {"/health", "/", "/v1/models", "/api/version", "/api/tags", "/api/ps"}
5771
+ )
5563
5772
 
5564
5773
 
5565
5774
  # OUTERMOST — and it must be registered AFTER the auth middleware below to BE
@@ -6837,10 +7046,35 @@ def _resolve_state_machine_tool_choice(
6837
7046
  if latest_user_text and not last_user_has_tool_result:
6838
7047
  monitor.tool_call_history = []
6839
7048
  monitor.reset_doubling()
7049
+ # 2026-10-04 fix (recurring false TOOL STARVATION BREAKER, fired ~once
7050
+ # per user turn for hours): the forcing loop is PER-TURN state. The
7051
+ # family membership and each member's lifecycle live in
7052
+ # SessionMonitor.reset_forcing_loop -- reset there, never here.
7053
+ monitor.reset_forcing_loop()
7054
+ # The recon exploration streak is PER-TASK, not per-turn (added
7055
+ # 2026-10-04 with the reset_forcing_loop extraction): the previous
7056
+ # task's read-forever streak is not evidence about the NEW
7057
+ # deliverable, and a stale streak fired the recon convergence
7058
+ # directive on the new task's first request -- the same
7059
+ # stale-state-crossing-a-boundary class as the breaker fix above.
7060
+ # Agent-driven resends carry tool_results and cannot reach this
7061
+ # branch, so genuine same-task recon loops keep counting. One
7062
+ # accepted trade-off: a MID-TASK human ack ("ok", "standing by")
7063
+ # also lands here and would clear a same-task streak — so the
7064
+ # existing _is_no_task_user_text classifier gates the reset: only
7065
+ # fresh text that plausibly starts NEW work clears the streak.
7066
+ # Reviewer P3 (security, 2026-10-04): without the gate, a model
7067
+ # could end each threshold-1 turn soliciting an ack and keep the
7068
+ # convergence directive unreachable forever.
7069
+ if not _is_no_task_user_text(latest_user_text):
7070
+ monitor.consecutive_no_write_turns = 0
6840
7071
  if n_msgs <= 1:
7072
+ # Brand-new conversation only. forced_auto_cooldown_turns is
7073
+ # sticky across mid-episode acks on purpose (see
7074
+ # reset_forcing_loop's docstring) but a 1-message request is a
7075
+ # fresh conversation surface, where the old cooldown describes
7076
+ # a model state that no longer applies.
6841
7077
  monitor.forced_auto_cooldown_turns = 0
6842
- monitor.consecutive_forced_count = 0
6843
- monitor.no_progress_streak = 0
6844
7078
  monitor.malformed_tool_streak = 0
6845
7079
  monitor.invalid_tool_call_streak = 0
6846
7080
  monitor.required_tool_miss_streak = 0
@@ -6870,10 +7104,24 @@ def _resolve_state_machine_tool_choice(
6870
7104
  if not has_tool_results:
6871
7105
  monitor.tool_call_history = []
6872
7106
  monitor.reset_doubling()
7107
+ # Same per-turn reset as fresh_user_text (see the 2026-10-04 fix
7108
+ # there, and SessionMonitor.reset_forcing_loop for the family
7109
+ # membership): a conversation with no tool results cannot host a
7110
+ # MID-LOOP starvation episode -- genuine ones resend with
7111
+ # tool_results and never reach this branch -- so a stale forcing
7112
+ # count from a prior turn carries no signal here, only the
7113
+ # false-fire. The recon streak resets under the same ack gate as
7114
+ # fresh_user_text (latest_user_text is empty in most
7115
+ # no-tool-result conversations, and an empty text is not an ack,
7116
+ # so the gate usually passes here). Output-quality streaks stay
7117
+ # sticky under the n_msgs guard.
7118
+ monitor.reset_forcing_loop()
7119
+ if not _is_no_task_user_text(latest_user_text):
7120
+ monitor.consecutive_no_write_turns = 0
6873
7121
  if n_msgs <= 1:
7122
+ # Brand-new conversation only (see the fresh_user_text
7123
+ # branch for why the cooldown is otherwise sticky).
6874
7124
  monitor.forced_auto_cooldown_turns = 0
6875
- monitor.consecutive_forced_count = 0
6876
- monitor.no_progress_streak = 0
6877
7125
  monitor.malformed_tool_streak = 0
6878
7126
  monitor.invalid_tool_call_streak = 0
6879
7127
  monitor.required_tool_miss_streak = 0
@@ -8657,7 +8905,7 @@ def build_openai_request(
8657
8905
  monitor.no_progress_streak = 0
8658
8906
  monitor.reset_tool_turn_state(reason="tool_starvation_breaker")
8659
8907
  logger.warning(
8660
- "TOOL STARVATION BREAKER: stripped tools after %d forced turns with no tool output (starvation_streak=%d)",
8908
+ "TOOL STARVATION BREAKER: stripped tools after %d forced turns with no tool output (starvation_streak=%d; the streak counts fires since the last compaction reset, not per-turn severity)",
8661
8909
  PROXY_TOOL_STARVATION_THRESHOLD,
8662
8910
  monitor.tool_starvation_streak,
8663
8911
  )
@@ -9121,12 +9369,14 @@ def _record_last_assistant_tool_calls(
9121
9369
  tool_fingerprints = []
9122
9370
  tool_targets: dict[str, str] = {}
9123
9371
  assistant_had_text = False # Fix B: did the last assistant turn emit prose?
9372
+ assistant_prose = "" # raw text of that prose, for the completion gate
9124
9373
  for msg in reversed(messages):
9125
9374
  if msg.get("role") != "assistant":
9126
9375
  continue
9127
9376
  content = msg.get("content")
9128
9377
  if isinstance(content, str) and content.strip():
9129
9378
  assistant_had_text = True
9379
+ assistant_prose = content
9130
9380
  if isinstance(content, list):
9131
9381
  for block in content:
9132
9382
  if (
@@ -9135,6 +9385,11 @@ def _record_last_assistant_tool_calls(
9135
9385
  and str(block.get("text", "")).strip()
9136
9386
  ):
9137
9387
  assistant_had_text = True
9388
+ # Accumulate, never overwrite: a completion split across
9389
+ # several sub-threshold text blocks is still one
9390
+ # completion (parity with _final_answer_content_exempt's
9391
+ # own list handling, which joins text parts).
9392
+ assistant_prose += str(block.get("text", ""))
9138
9393
  if isinstance(block, dict) and block.get("type") == "tool_use":
9139
9394
  tool_fingerprints.append(_tool_call_fingerprint(block))
9140
9395
  # Extract target key for read-only dedup (Option 3)
@@ -9165,8 +9420,12 @@ def _record_last_assistant_tool_calls(
9165
9420
  # Fix B: no tool call in the last assistant turn. A plain-text turn is still
9166
9421
  # a non-write turn, so advance the recon-convergence streak (previously it
9167
9422
  # only moved on tool turns, freezing the counter through prose-only stalls).
9168
- # Guard on real prose so an empty/absent assistant turn never inflates it.
9169
- if assistant_had_text:
9423
+ # Guard on real prose so an empty/absent assistant turn never inflates it —
9424
+ # EXCEPT a substantive final answer (architect follow-up 2026-10-03): a
9425
+ # finished model's completion is not a stall signal, and counting it was
9426
+ # the last site in the false-positive family that treats a completion as a
9427
+ # miss. Same gate as the end-turn and required-miss exemptions.
9428
+ if assistant_had_text and not _final_answer_content_exempt(assistant_prose):
9170
9429
  monitor.note_no_tool_turn()
9171
9430
  monitor.note_doubling_signal(
9172
9431
  "", _latest_tr, msg_count=len(messages), result_error=_latest_err
@@ -9174,6 +9433,77 @@ def _record_last_assistant_tool_calls(
9174
9433
  return ""
9175
9434
 
9176
9435
 
9436
+ def _final_answer_content_exempt(content) -> bool:
9437
+ """True when an end_turn's content is a substantive final answer.
9438
+
9439
+ Shared by the unexpected-end-turn classifier (skip the retry so the
9440
+ client's loop ends naturally), the stream-path malformed_tool_streak
9441
+ counter (a legitimate completion must not feed the forced-tool dampener
9442
+ via the `end_turn + long text` branch), and the required-tool-miss
9443
+ classifier (a finished model's summary is a completion, not a miss).
9444
+ 2026-10-03.
9445
+ """
9446
+ if PROXY_FINAL_ANSWER_CHARS <= 0:
9447
+ return False
9448
+ if not isinstance(content, str):
9449
+ # OpenAI content-parts list: count only the text parts, never the
9450
+ # repr of the list (which would always exceed the threshold). A
9451
+ # part whose "text" is not a str is skipped, not joined — a
9452
+ # malformed backend payload must not abort the turn with a
9453
+ # TypeError mid-classification.
9454
+ if not isinstance(content, list):
9455
+ return False
9456
+ content = " ".join(
9457
+ part.get("text", "")
9458
+ for part in content
9459
+ if isinstance(part, dict)
9460
+ and isinstance(part.get("text", ""), str)
9461
+ )
9462
+ if len(content.strip()) < PROXY_FINAL_ANSWER_CHARS:
9463
+ return False
9464
+ # BUT a LONG DEFERRAL is not a final answer: a model that wrote 800+ chars
9465
+ # of "I need more cycles before acting" is stalling, not finishing, and
9466
+ # must still be herded back to work. The deferral regex already exists
9467
+ # (and already accepts its false-positive risk on no-tool turns), so the
9468
+ # exemption is gated on it rather than on new vocabulary.
9469
+ return not _DEFERRAL_PHRASE_RE.search(content)
9470
+
9471
+
9472
+ def _required_tool_miss_exempt(openai_resp: dict) -> bool:
9473
+ """True when a required tool turn returned no tool calls but the response
9474
+ is a substantive final answer, not a miss.
9475
+
9476
+ Same false-positive family as the unexpected-end-turn retry (mechanism #3)
9477
+ and the garbled-args classifier (mechanism #4), observed live
9478
+ 2026-10-03 13:24-13:40: the tool state machine had forced
9479
+ tool_choice=required for 32 consecutive turns; the model had FINISHED the
9480
+ task (session summaries, "234 tests passing") and kept emitting exactly
9481
+ that. Each summary was counted as required_tool_miss, retries demanded a
9482
+ tool call, the streak fed the contamination breaker (3 resets), and the
9483
+ forced-finalize branch — which clears malformed/invalid streaks but did
9484
+ not clear required_tool_miss_streak — re-fired finalize on EVERY
9485
+ subsequent request (contamination_resets 3→6, ~4 min of GPU burn) until
9486
+ the client gave up. A finished model must be allowed to hand its summary
9487
+ to the client: the loop ends naturally, exactly as with the end-turn
9488
+ exemption.
9489
+ """
9490
+ if _openai_has_tool_calls(openai_resp):
9491
+ return False
9492
+ # A response truncated by max_tokens did not FINISH — its length says
9493
+ # nothing about intent, and the truncation paths (truncated_tool_args
9494
+ # downgrade, empty-max-tokens recovery) own that class. Mirrors
9495
+ # _is_unexpected_end_turn, which only accepts stop/end_turn finishes.
9496
+ choice, _ = _extract_openai_choice(openai_resp)
9497
+ if (choice.get("finish_reason") or "").lower() == "length":
9498
+ return False
9499
+ # Pass the RAW content (not _openai_message_text, whose str() fallback
9500
+ # would stringify a list-parts content to its repr and defeat
9501
+ # _final_answer_content_exempt's count-only-text-parts guard — a 400-char
9502
+ # list payload measured as a 700-char repr). Mirrors _is_unexpected_end_turn.
9503
+ _, message = _extract_openai_choice(openai_resp)
9504
+ return _final_answer_content_exempt(message.get("content", ""))
9505
+
9506
+
9177
9507
  def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
9178
9508
  choices = openai_resp.get("choices") or []
9179
9509
  if not choices:
@@ -9191,6 +9521,16 @@ def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
9191
9521
  if "tools" not in anthropic_body:
9192
9522
  return False
9193
9523
 
9524
+ # Substantive-final-answer exemption (2026-10-03): an end_turn that CARRIES
9525
+ # a real final answer is the agentic loop's natural end, not a stall. The
9526
+ # thinking-runaway this retry exists for produces empty content, so content
9527
+ # length separates the classes. Without this, a finished model gets coerced
9528
+ # back into a degenerate no-op tool call every turn (observed live:
9529
+ # "Task complete ... Summary:" 2211 chars -> 96 retry cycles of the
9530
+ # identical `git status` no-op over two hours).
9531
+ if _final_answer_content_exempt(msg.get("content", "")):
9532
+ return False
9533
+
9194
9534
  has_tool_results = _conversation_has_tool_results(anthropic_body)
9195
9535
 
9196
9536
  return has_tool_results or _last_assistant_was_text_only(anthropic_body)
@@ -10080,16 +10420,14 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
10080
10420
  """Detect garbled/degenerate tool call arguments.
10081
10421
 
10082
10422
  Returns True if the arguments string shows signs of degenerate generation:
10083
- - Runaway closing braces (}}}}})
10084
- - Repetitive digit patterns (000000, 398859738398859738)
10085
- - Extremely long digit strings
10086
- - Unbalanced braces suggesting truncated/corrupt JSON
10423
+ - Repetitive digit patterns (000000, 398859738398859738) and long digit
10424
+ runs (degenerate CONTENT, meaningful even inside valid JSON values)
10425
+ - Structural corruption — runaway closing braces (}}}}}) and unbalanced
10426
+ braces — only when the payload does not parse as JSON
10087
10427
  """
10088
10428
  if not arguments_str or arguments_str == "{}":
10089
10429
  return False
10090
10430
 
10091
- if _GARBLED_RUNAWAY_BRACES_RE.search(arguments_str):
10092
- return True
10093
10431
  if _GARBLED_REPETITIVE_DIGITS_RE.search(arguments_str):
10094
10432
  return True
10095
10433
  if _GARBLED_ZEROS_RE.search(arguments_str):
@@ -10097,11 +10435,31 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
10097
10435
  if _GARBLED_LONG_DIGITS_RE.search(arguments_str):
10098
10436
  return True
10099
10437
 
10100
- # Check brace balance — more than 2 unmatched braces suggests corruption
10101
- open_count = arguments_str.count("{")
10102
- close_count = arguments_str.count("}")
10103
- if abs(open_count - close_count) > 2:
10104
- return True
10438
+ # Structural checks — ONLY meaningful for a payload that does not parse
10439
+ # as JSON: a clean json.loads proves the arguments are not truncated, and
10440
+ # braces inside JSON string VALUES are payload content, not structure.
10441
+ # edit/write tool args legitimately carry partial code excerpts with
10442
+ # unbalanced braces (observed live 2026-10-03: a Rust tail snippet with
10443
+ # 3 closing braces and no opening braces in oldString tripped the raw
10444
+ # balance check on a perfectly valid payload; the reject-retry loop that
10445
+ # followed burned three session-contamination resets and a forced
10446
+ # finalize mid-task). Runaway `}}}}}` gets the same gate: four or more
10447
+ # consecutive closing braces are common inside minified JS/JSON string
10448
+ # values, while the classic degenerate form (runaway closers appended
10449
+ # after the JSON) does not parse and is still caught here.
10450
+ try:
10451
+ json.loads(arguments_str)
10452
+ except (ValueError, TypeError, RecursionError):
10453
+ # Unparseable: brace imbalance and runaway closers are genuine
10454
+ # truncation/corruption signals here. RecursionError covers deeply
10455
+ # nested degenerate input (the exact artifact family this classifier
10456
+ # absorbs) that the C scanner rejects without raising ValueError.
10457
+ if _GARBLED_RUNAWAY_BRACES_RE.search(arguments_str):
10458
+ return True
10459
+ open_count = arguments_str.count("{")
10460
+ close_count = arguments_str.count("}")
10461
+ if abs(open_count - close_count) > 2:
10462
+ return True
10105
10463
 
10106
10464
  return False
10107
10465
 
@@ -10373,6 +10731,11 @@ class ToolResponseIssue:
10373
10731
  kind: str = ""
10374
10732
  reason: str = ""
10375
10733
  retry_hint: str = ""
10734
+ # True when the response was exempted from required_tool_miss by the
10735
+ # substantive-completion gate — set by _classify_tool_response_issue so
10736
+ # the streak/dampener/retry sites read ONE evaluation instead of re-running
10737
+ # the gate and risking site drift (2026-10-03, code-review follow-up).
10738
+ exempt: bool = False
10376
10739
 
10377
10740
  def has_issue(self) -> bool:
10378
10741
  return bool(self.kind)
@@ -11195,6 +11558,14 @@ def _classify_tool_response_issue(
11195
11558
  has_tool_calls = _openai_has_tool_calls(openai_resp)
11196
11559
  if not has_tool_calls:
11197
11560
  if required_tool_choice:
11561
+ # Substantive-final-answer exemption (2026-10-03): a required turn
11562
+ # that carries a real completion is the loop's natural end, not a
11563
+ # miss — see _required_tool_miss_exempt. Passing it through lets
11564
+ # the client finish instead of retrying the model into the
11565
+ # contamination breaker. exempt=True carries the single evaluation
11566
+ # to the streak/dampener/retry sites.
11567
+ if _required_tool_miss_exempt(openai_resp):
11568
+ return ToolResponseIssue(exempt=True)
11198
11569
  return ToolResponseIssue(
11199
11570
  kind="required_tool_miss",
11200
11571
  reason="required tool turn returned no tool calls",
@@ -11948,16 +12319,24 @@ async def _apply_malformed_tool_guardrail(
11948
12319
 
11949
12320
  required_tool_choice = openai_body.get("tool_choice") == "required"
11950
12321
  has_tool_calls = _openai_has_tool_calls(working_resp)
11951
- if required_tool_choice and not has_tool_calls:
11952
- monitor.required_tool_miss_streak += 1
11953
12322
 
11954
12323
  issue = _classify_tool_response_issue(
11955
12324
  working_resp,
11956
12325
  anthropic_body,
11957
12326
  required_tool_choice=required_tool_choice,
11958
12327
  )
12328
+ # The streak and dampener gates read issue.exempt — the ONE evaluation of
12329
+ # the substantive-completion gate, done inside classification. Re-running
12330
+ # the gate at each site risks evaluation drift, which is how this
12331
+ # false-positive family propagates (one site fixed, another missed).
12332
+ if required_tool_choice and not has_tool_calls and not issue.exempt:
12333
+ monitor.required_tool_miss_streak += 1
12334
+
11959
12335
  if not issue.has_issue():
11960
- if required_tool_choice and not has_tool_calls:
12336
+ # A substantive completion is exempt from the miss classification, so
12337
+ # it must not activate the forced-tool dampener either: relaxing
12338
+ # tool_choice for a finished model is at best noise.
12339
+ if required_tool_choice and not has_tool_calls and not issue.exempt:
11961
12340
  monitor.maybe_activate_forced_tool_dampener("required_tool_miss")
11962
12341
  if has_tool_calls:
11963
12342
  monitor.malformed_tool_streak = 0
@@ -12099,14 +12478,20 @@ async def _apply_malformed_tool_guardrail(
12099
12478
 
12100
12479
  retry_has_tool_calls = _openai_has_tool_calls(retry_working)
12101
12480
  retry_required = retry_body.get("tool_choice") == "required"
12102
- if retry_required and not retry_has_tool_calls:
12103
- monitor.required_tool_miss_streak += 1
12104
-
12105
12481
  retry_issue = _classify_tool_response_issue(
12106
12482
  retry_working,
12107
12483
  anthropic_body,
12108
12484
  required_tool_choice=retry_required,
12109
12485
  )
12486
+ # Same substantive-completion exemption as the primary path, read from
12487
+ # the single classification evaluation (retry_issue.exempt) — a retry
12488
+ # that produced a real final answer is not a miss.
12489
+ if (
12490
+ retry_required
12491
+ and not retry_has_tool_calls
12492
+ and not retry_issue.exempt
12493
+ ):
12494
+ monitor.required_tool_miss_streak += 1
12110
12495
 
12111
12496
  if not retry_issue.has_issue():
12112
12497
  # 2026-05-12: Fix #2 — do NOT reset malformed/invalid/miss streaks
@@ -12365,6 +12750,13 @@ def _maybe_apply_session_contamination_breaker(
12365
12750
  monitor.contamination_resets += 1
12366
12751
  monitor.malformed_tool_streak = 0
12367
12752
  monitor.invalid_tool_call_streak = 0
12753
+ # 2026-10-03: the finalize branch cleared the malformed/invalid streaks
12754
+ # but NOT required_tool_miss_streak — and that very streak is a
12755
+ # should_reset trigger, so once condemned, EVERY subsequent request
12756
+ # re-forced finalize (observed: contamination_resets 3→6, four
12757
+ # finalize turns in a row, each burning a full generation). Clear it
12758
+ # so finalize fires ONCE per contamination episode.
12759
+ monitor.required_tool_miss_streak = 0
12368
12760
  # Remove tools to force text-only response
12369
12761
  updated = dict(anthropic_body)
12370
12762
  updated.pop("tools", None)
@@ -12478,12 +12870,21 @@ def _maybe_apply_session_contamination_breaker(
12478
12870
  forced_before = monitor.consecutive_forced_count
12479
12871
  required_miss_before = monitor.required_tool_miss_streak
12480
12872
  monitor.contamination_resets += 1
12873
+ # Emergency tier: the conversation itself is rewritten, so BOTH the
12874
+ # forcing-loop family (via its one policy point) and the output-quality
12875
+ # streaks clear -- the n_msgs stickiness that protects quality streaks
12876
+ # at ordinary turn boundaries does not apply to a hard reset. The
12877
+ # dampener cooldown clears with them: the model state it was remedying
12878
+ # belonged to the collapsed transcript.
12879
+ monitor.reset_forcing_loop()
12880
+ monitor.forced_auto_cooldown_turns = 0
12481
12881
  monitor.malformed_tool_streak = 0
12482
12882
  monitor.invalid_tool_call_streak = 0
12483
12883
  monitor.required_tool_miss_streak = 0
12484
- monitor.no_progress_streak = 0
12485
- monitor.consecutive_forced_count = 0
12486
- monitor.forced_auto_cooldown_turns = 0
12884
+ # The recon streak belongs to the collapsed transcript (per-TASK state,
12885
+ # reset at turn boundaries since 2026-10-04); the rewritten conversation
12886
+ # is a fresh task surface.
12887
+ monitor.consecutive_no_write_turns = 0
12487
12888
  monitor.reset_tool_turn_state(reason=f"contamination_guardrail_reset_{log_reason}")
12488
12889
  if attractor_detected:
12489
12890
  logger.warning(
@@ -13232,6 +13633,12 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
13232
13633
  interval = PROXY_STREAM_HEARTBEAT_SECS if PROXY_STREAM_HEARTBEAT_SECS > 0 else 15.0
13233
13634
  thinking_heartbeat = PROXY_STREAM_THINKING_DELTAS and PROXY_PREFILL_HEARTBEAT_SECS > 0
13234
13635
  hb_thinking_open = False
13636
+ # Operator-visible progress for the same wait the client heartbeats mask
13637
+ # (2026-10-04 incident: a 281 s buffered compaction summary left the
13638
+ # journal silent from REQ to RESP, reading as a hung proxy). One INFO line
13639
+ # per PROXY_GUARDED_WAIT_LOG_SECS boundary crossed, never more.
13640
+ waited_secs = 0.0
13641
+ last_wait_log_boundary = 0
13235
13642
  task = asyncio.ensure_future(produce_coro)
13236
13643
  try:
13237
13644
  while True:
@@ -13241,6 +13648,17 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
13241
13648
  produced = await asyncio.wait_for(asyncio.shield(task), timeout=interval)
13242
13649
  break
13243
13650
  except asyncio.TimeoutError:
13651
+ waited_secs += interval
13652
+ if PROXY_GUARDED_WAIT_LOG_SECS > 0:
13653
+ boundary = int(waited_secs // PROXY_GUARDED_WAIT_LOG_SECS)
13654
+ if boundary > last_wait_log_boundary:
13655
+ last_wait_log_boundary = boundary
13656
+ logger.info(
13657
+ "GUARDED-BUFFER WAIT: buffered non-stream turn still "
13658
+ "generating after %ds (model=%s input_tokens=%d) — "
13659
+ "client is receiving heartbeats, not hung",
13660
+ int(waited_secs), model, input_tokens,
13661
+ )
13244
13662
  if thinking_heartbeat:
13245
13663
  if not hb_thinking_open:
13246
13664
  hb_thinking_open = True
@@ -13942,7 +14360,14 @@ async def stream_anthropic_response(
13942
14360
  and not tool_calls_by_index
13943
14361
  and (
13944
14362
  finish_reason == "max_tokens"
13945
- or (finish_reason == "end_turn" and len(accumulated_text) > 512)
14363
+ or (
14364
+ finish_reason == "end_turn"
14365
+ and len(accumulated_text) > 512
14366
+ # A substantive final answer is a completion, not a malformed
14367
+ # payload: don't feed it to the forced-tool dampener (the
14368
+ # same exemption the unexpected-end-turn retry uses).
14369
+ and not _final_answer_content_exempt(accumulated_text)
14370
+ )
13946
14371
  )
13947
14372
  ):
13948
14373
  monitor.malformed_tool_streak += 1
@@ -14425,10 +14850,23 @@ async def messages(request: Request):
14425
14850
  prev_msg_count = getattr(monitor, "last_request_msg_count", 0)
14426
14851
  if prev_msg_count >= 8 and n_messages <= prev_msg_count // 2:
14427
14852
  monitor.reset_tool_turn_state(reason="compaction_boundary")
14428
- monitor.consecutive_forced_count = 0
14429
- monitor.no_progress_streak = 0
14853
+ # The forcing-loop family via its one policy point (2026-10-04
14854
+ # extraction). forced_auto_cooldown_turns deliberately does NOT
14855
+ # clear here -- the dampener remedies cross-turn output quality,
14856
+ # which survives a compaction (see reset_forcing_loop's
14857
+ # docstring). Test coverage note: this site is inline in the
14858
+ # request handler and unreachable from the build_openai_request
14859
+ # test seam, so its wiring is inspection-verified -- the method
14860
+ # it calls IS pinned by test_reset_forcing_loop_membership_is_exact.
14861
+ monitor.reset_forcing_loop()
14430
14862
  monitor.tool_starvation_streak = 0
14431
14863
  monitor.consecutive_no_write_turns = 0
14864
+ # Prune-breaker floor trend too (2026-09-30, reviewer P2-1): a stale
14865
+ # pre-compaction floor baseline must not contribute a phantom "rise"
14866
+ # to the fresh epoch — the exact breaker false-positive class
14867
+ # _prune_breaker_should_fire exists to prevent.
14868
+ monitor.prune_spiral_streak = 0
14869
+ monitor.last_post_prune_util = -1.0
14432
14870
  logger.info(
14433
14871
  "COMPACTION BOUNDARY: message count collapsed %d -> %d; reset "
14434
14872
  "tool-turn/anti-spin state for the fresh epoch",
@@ -14527,18 +14965,42 @@ async def messages(request: Request):
14527
14965
  n_messages,
14528
14966
  post_util * 100,
14529
14967
  )
14530
- # Option 2: Circuit breaker — if 3+ consecutive prunes and still above,
14531
- # force finalize (drop tools, let model wrap up)
14532
- if monitor.prune_count >= 3 and post_util >= PROXY_CONTEXT_PRUNE_THRESHOLD:
14968
+ # Option 2: Circuit breaker — fires only on a RISING post-prune
14969
+ # floor (_prune_breaker_should_fire, 2026-09-30). The old "3+
14970
+ # consecutive prunes and still above threshold" condition fired
14971
+ # every few turns on tool-heavy clients whose fixed system+tools
14972
+ # overhead alone exceeds the threshold (observed live: 66k est
14973
+ # tokens of a 114,688 window at a 42% threshold), force-finalizing
14974
+ # healthy ~44% sessions.
14975
+ if _prune_breaker_should_fire(post_util, monitor):
14533
14976
  logger.error(
14534
- "PRUNE CIRCUIT BREAKER: %d consecutive prunes, still at %.1f%%. "
14535
- "Forcing finalize to prevent death spiral.",
14536
- monitor.prune_count,
14977
+ "PRUNE CIRCUIT BREAKER: post-prune floor rising for %d consecutive prunes "
14978
+ "(now %.1f%%, threshold %.1f%%). Forcing finalize to prevent death spiral.",
14979
+ monitor.prune_spiral_streak,
14537
14980
  post_util * 100,
14981
+ PROXY_CONTEXT_PRUNE_THRESHOLD * 100,
14538
14982
  )
14539
14983
  monitor.set_tool_turn_phase("finalize", reason="prune_circuit_breaker")
14540
14984
  monitor.tool_state_auto_budget_remaining = 1
14541
14985
  monitor.reset_completion_recovery()
14986
+ elif (
14987
+ post_util >= PROXY_CONTEXT_PRUNE_THRESHOLD
14988
+ and monitor.prune_spiral_streak == 0
14989
+ and not monitor.prune_floor_logged
14990
+ ):
14991
+ # Above threshold with a FLAT floor (no rise this turn): fixed
14992
+ # overhead, not a spiral. Tell the operator ONCE per session —
14993
+ # the per-turn prune above still logs its own warning lines.
14994
+ monitor.prune_floor_logged = True
14995
+ logger.warning(
14996
+ "Post-prune utilization %.1f%% stays above the %.1f%% threshold "
14997
+ "with a FLAT floor — fixed system+tools overhead, not a death "
14998
+ "spiral; circuit breaker held. Consider raising "
14999
+ "PROXY_CONTEXT_PRUNE_THRESHOLD above the overhead floor for "
15000
+ "this client.",
15001
+ post_util * 100,
15002
+ PROXY_CONTEXT_PRUNE_THRESHOLD * 100,
15003
+ )
14542
15004
 
14543
15005
  # Whether this upstream accepts chat_template_kwargs decides what
14544
15006
  # _set_thinking is allowed to write, so it must be answered BEFORE the body
@@ -14587,6 +15049,13 @@ async def messages(request: Request):
14587
15049
  use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
14588
15050
  if use_guarded_non_stream:
14589
15051
  async def _produce_guarded():
15052
+ # Covers the full buffered lifetime including any concurrency-queue
15053
+ # wait: from the first upstream attempt to the served response.
15054
+ # Reported in the "served ... (buffered %ds)" log line below so a
15055
+ # long buffered turn is legible after the fact (2026-10-04: a
15056
+ # client-compaction summary buffered 281 s and the only end-line
15057
+ # evidence was the RESP timestamp).
15058
+ t_guarded = time.monotonic()
14590
15059
  strict_body = dict(openai_body)
14591
15060
  strict_body["stream"] = False
14592
15061
 
@@ -14813,15 +15282,18 @@ async def messages(request: Request):
14813
15282
  )
14814
15283
  if PROXY_FORCE_NON_STREAM:
14815
15284
  logger.info(
14816
- "FORCED NON-STREAM: served stream response via guarded non-stream path"
15285
+ "FORCED NON-STREAM: served stream response via guarded non-stream path (buffered %ds)",
15286
+ int(time.monotonic() - t_guarded),
14817
15287
  )
14818
15288
  elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
14819
15289
  logger.info(
14820
- "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
15290
+ "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path (buffered %ds)",
15291
+ int(time.monotonic() - t_guarded),
14821
15292
  )
14822
15293
  else:
14823
15294
  logger.info(
14824
- "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
15295
+ "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path (buffered %ds)",
15296
+ int(time.monotonic() - t_guarded),
14825
15297
  )
14826
15298
 
14827
15299
  anthropic_resp = await _maybe_apply_recipe(anthropic_resp, body, openai_body, client)
@@ -15274,47 +15746,29 @@ async def messages_anthropic(request: Request):
15274
15746
  return await messages(request)
15275
15747
 
15276
15748
 
15277
- @app.post("/v1/chat/completions")
15278
- async def chat_completions(request: Request):
15279
- """OpenAI-compatible chat/completions endpoint for clients like Forge
15280
- that require the OpenAI API shape.
15749
+ async def _guarded_openai_completion(
15750
+ request: Request, openai_body: dict
15751
+ ) -> tuple[dict | None, int, str | None]:
15752
+ """Run an OpenAI Chat Completions body through the guarded Anthropic
15753
+ pipeline and return the OpenAI-shaped response.
15281
15754
 
15282
- FULL GUARDRAIL PATH: Converts the OpenAI request to Anthropic format,
15283
- runs the full /v1/messages pipeline (loop detection, tool narrowing,
15284
- cycle breaking, malformed tool retry, context pruning, etc.), then
15285
- converts the Anthropic response back to OpenAI format.
15755
+ Shared core of BOTH dual-protocol inbound surfaces: the OpenAI route
15756
+ (/v1/chat/completions) and the Ollama surface (/api/chat, /api/generate).
15757
+ Everything except the protocol framing lives here, so the surfaces cannot
15758
+ drift apart in guardrail coverage — an Ollama client gets the same
15759
+ admission control, rail budgeting, loop detection, tool narrowing,
15760
+ malformed-tool retry and context pruning an Anthropic client gets.
15286
15761
 
15287
- Streaming is down-converted to a single final OpenAI SSE chunk sequence
15288
- built from the completed Anthropic response (not token-by-token from
15289
- upstream). This preserves guardrails at the cost of stream granularity.
15762
+ Returns (openai_response, http_status, error_message). On failure the
15763
+ response is None and exactly one of status/error carries the cause.
15290
15764
  """
15291
- body_bytes = await request.body()
15292
- try:
15293
- openai_body = json.loads(body_bytes) if body_bytes else {}
15294
- except (ValueError, TypeError):
15295
- return Response(
15296
- content=b'{"error":{"message":"invalid JSON","type":"invalid_request_error"}}',
15297
- status_code=400,
15298
- media_type="application/json",
15299
- )
15300
-
15301
- requested_stream = bool(openai_body.get("stream", False))
15302
15765
  model = openai_body.get("model", "default")
15303
- client_id = resolve_client_id(request)
15304
-
15305
- logger.info(
15306
- "CHAT (guarded): client=%s model=%s stream=%s msgs=%d tools=%d",
15307
- client_id,
15308
- model,
15309
- requested_stream,
15310
- len(openai_body.get("messages", [])),
15311
- len(openai_body.get("tools", []) or []),
15312
- )
15313
15766
 
15314
15767
  # Convert OpenAI request -> Anthropic request
15315
15768
  anthropic_body = openai_to_anthropic_request(openai_body)
15316
- # Force non-streaming through the pipeline; we re-stream at the end if the
15317
- # client wanted streaming. This keeps guardrail logic simpler/consistent.
15769
+ # Force non-streaming through the pipeline; callers re-stream from the
15770
+ # completed response in whatever dialect they serve. This keeps guardrail
15771
+ # logic simpler/consistent.
15318
15772
  anthropic_body["stream"] = False
15319
15773
 
15320
15774
  # Build a synthetic Request that the existing messages() handler can consume
@@ -15366,19 +15820,63 @@ async def chat_completions(request: Request):
15366
15820
  anthropic_resp_dict = inner_resp
15367
15821
 
15368
15822
  if anthropic_resp_dict is None or "content" not in anthropic_resp_dict:
15369
- # Upstream error: forward as-is in OpenAI error shape
15370
15823
  err_msg = "upstream returned no message"
15371
15824
  if isinstance(anthropic_resp_dict, dict) and "error" in anthropic_resp_dict:
15372
15825
  err_msg = anthropic_resp_dict["error"].get("message", err_msg)
15826
+ return None, status_code, err_msg
15827
+
15828
+ anthropic_resp_dict.setdefault("model", model)
15829
+ return anthropic_to_openai_response(anthropic_resp_dict), 200, None
15830
+
15831
+
15832
+ @app.post("/v1/chat/completions")
15833
+ async def chat_completions(request: Request):
15834
+ """OpenAI-compatible chat/completions endpoint for clients like Forge
15835
+ that require the OpenAI API shape.
15836
+
15837
+ FULL GUARDRAIL PATH: Converts the OpenAI request to Anthropic format,
15838
+ runs the full /v1/messages pipeline (loop detection, tool narrowing,
15839
+ cycle breaking, malformed tool retry, context pruning, etc.), then
15840
+ converts the Anthropic response back to OpenAI format.
15841
+
15842
+ Streaming is down-converted to a single final OpenAI SSE chunk sequence
15843
+ built from the completed Anthropic response (not token-by-token from
15844
+ upstream). This preserves guardrails at the cost of stream granularity.
15845
+ """
15846
+ body_bytes = await request.body()
15847
+ try:
15848
+ openai_body = json.loads(body_bytes) if body_bytes else {}
15849
+ except (ValueError, TypeError):
15373
15850
  return Response(
15374
- content=json.dumps({"error": {"message": err_msg, "type": "upstream_error"}}).encode(),
15375
- status_code=status_code if status_code >= 400 else 502,
15851
+ content=b'{"error":{"message":"invalid JSON","type":"invalid_request_error"}}',
15852
+ status_code=400,
15376
15853
  media_type="application/json",
15377
15854
  )
15378
15855
 
15379
- # Ensure model field is set for response
15380
- anthropic_resp_dict.setdefault("model", model)
15381
- openai_resp = anthropic_to_openai_response(anthropic_resp_dict)
15856
+ requested_stream = bool(openai_body.get("stream", False))
15857
+ model = openai_body.get("model", "default")
15858
+ client_id = resolve_client_id(request)
15859
+
15860
+ logger.info(
15861
+ "CHAT (guarded): client=%s model=%s stream=%s msgs=%d tools=%d",
15862
+ client_id,
15863
+ model,
15864
+ requested_stream,
15865
+ len(openai_body.get("messages", [])),
15866
+ len(openai_body.get("tools", []) or []),
15867
+ )
15868
+
15869
+ openai_resp, status_code, err_msg = await _guarded_openai_completion(
15870
+ request, openai_body
15871
+ )
15872
+ if openai_resp is None:
15873
+ return Response(
15874
+ content=json.dumps(
15875
+ {"error": {"message": err_msg, "type": "upstream_error"}}
15876
+ ).encode(),
15877
+ status_code=status_code if status_code >= 400 else 502,
15878
+ media_type="application/json",
15879
+ )
15382
15880
 
15383
15881
  if not requested_stream:
15384
15882
  return Response(
@@ -15638,6 +16136,557 @@ def _model_entry(model_id: str) -> dict:
15638
16136
  return entry
15639
16137
 
15640
16138
 
16139
+ # ===========================================================================
16140
+ # Ollama-compatible surface (/api/*) — PROXY_OLLAMA_PORT, default 11434
16141
+ # ===========================================================================
16142
+ # The proxy already speaks two inbound dialects (Anthropic Messages on
16143
+ # /v1/messages, OpenAI Chat Completions on /v1/chat/completions), both
16144
+ # normalised through the same guarded pipeline. Ollama clients are a third
16145
+ # dialect: their own discovery handshake (/api/version, /api/tags, /api/show),
16146
+ # their own NDJSON streaming, and their own field vocabulary — but the
16147
+ # semantics underneath are identical. So this surface is a pure translation
16148
+ # layer: Ollama request -> OpenAI request -> _guarded_openai_completion ->
16149
+ # OpenAI response -> Ollama response. Every guardrail (admission, rail
16150
+ # budgeting, loop detection, tool narrowing, malformed-tool retry, context
16151
+ # pruning) therefore applies to Ollama clients too.
16152
+ #
16153
+ # The known tradeoff, identical to the OpenAI inbound surface: streaming is
16154
+ # buffered through the guardrails and re-emitted as Ollama NDJSON afterwards
16155
+ # (one content line + one final line), not token-by-token from upstream. A
16156
+ # client sees its reply appear all at once; correctness of the guardrails is
16157
+ # preserved. Token-granular streaming would require bypassing the pipeline.
16158
+ #
16159
+ # Served from the SAME process on a SECOND port (Ollama's well-known 11434)
16160
+ # because Ollama tooling auto-discovers localhost:11434 and offers no way to
16161
+ # point it at a path-prefixed alternative. Same process, not a second one, is
16162
+ # the whole point: a second process would carry its own admission table and
16163
+ # rail semaphore, silently doubling the concurrency budget the backend was
16164
+ # sized for.
16165
+
16166
+ _OLLAMA_API_VERSION = "0.12.6" # the Ollama release whose wire shape this mirrors
16167
+ _OLLAMA_START_ISO = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
16168
+
16169
+
16170
+ def _ollama_error(status: int, message: str) -> Response:
16171
+ return Response(
16172
+ content=json.dumps({"error": message}).encode(),
16173
+ status_code=status,
16174
+ media_type="application/json",
16175
+ )
16176
+
16177
+
16178
+ def _ollama_content_to_text(content) -> str:
16179
+ """Ollama message content: a plain string, or (newer clients) a list of
16180
+ typed parts. Images have no rail in this pipeline — extract text only."""
16181
+ if isinstance(content, str):
16182
+ return content
16183
+ if isinstance(content, list):
16184
+ return "".join(
16185
+ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
16186
+ )
16187
+ return "" if content is None else str(content)
16188
+
16189
+
16190
+ def _ollama_options_to_openai(options) -> dict:
16191
+ """Translate Ollama's `options` object to OpenAI Chat Completions fields.
16192
+
16193
+ Deliberately minimal: only what survives BOTH translation hops. The second
16194
+ hop (openai_to_anthropic_request) forwards temperature/top_p/top_k/stop/
16195
+ max_tokens, so those are mapped and NOTHING else — an Ollama client's
16196
+ min_p/repeat_penalty/seed are dropped here rather than accepted and
16197
+ silently ignored mid-pipeline (the honest drop also keeps this function
16198
+ truthful about what it claims to do). num_ctx is dropped for a different
16199
+ reason: the rail budget owns context sizing, and forwarding a client's
16200
+ num_ctx would silently fight the admission controller."""
16201
+ if not isinstance(options, dict) or not options:
16202
+ return {}
16203
+ out: dict = {}
16204
+ for src, dst in (("temperature", "temperature"), ("top_p", "top_p"), ("top_k", "top_k")):
16205
+ if src in options:
16206
+ out[dst] = options[src]
16207
+ if "num_predict" in options:
16208
+ try:
16209
+ out["max_tokens"] = max(1, int(options["num_predict"]))
16210
+ except (TypeError, ValueError):
16211
+ pass
16212
+ stop = options.get("stop")
16213
+ if isinstance(stop, str) and stop:
16214
+ out["stop"] = [stop]
16215
+ elif isinstance(stop, list) and stop:
16216
+ out["stop"] = [str(s) for s in stop]
16217
+ return out
16218
+
16219
+
16220
+ def _finish_common_ollama_fields(body: dict) -> dict:
16221
+ """Fields shared by the /api/chat and /api/generate request builders:
16222
+ option mapping, tools passthrough, and the buffered stream decision (the
16223
+ pipeline always runs non-stream; callers re-emit)."""
16224
+ fields: dict = {}
16225
+ fields.update(_ollama_options_to_openai(body.get("options")))
16226
+ if isinstance(body.get("tools"), list) and body["tools"]:
16227
+ fields["tools"] = body["tools"]
16228
+ fields["tool_choice"] = body.get("tool_choice") or "auto"
16229
+ # Ollama's `format` field (structured output) is accepted and DROPPED:
16230
+ # the second translation hop (openai_to_anthropic_request) does not carry
16231
+ # response_format, so wiring it through is a pipeline contract change that
16232
+ # deserves its own schema-diff pass — not something to fake acceptance of.
16233
+ # keep_alive / raw / template are Ollama lifecycle features with no local
16234
+ # equivalent: the backend stays loaded (capacity policy owns that) and the
16235
+ # chat template is the backend's own.
16236
+ fields["stream"] = False
16237
+ return fields
16238
+
16239
+
16240
+ def ollama_chat_to_openai_request(body: dict) -> dict:
16241
+ """Ollama /api/chat body -> OpenAI Chat Completions body."""
16242
+ messages = []
16243
+ # Deterministic tool-call ids for replayed assistant turns. Ollama
16244
+ # associates a tool result with its call by ORDER within the message list;
16245
+ # OpenAI (and the Anthropic hop after it) pairs by id. Without explicit
16246
+ # ids, the second hop mints random ids while the tool results carry "" —
16247
+ # pairs that can never match.
16248
+ pending_call_ids: list[str] = []
16249
+ for m in body.get("messages") or []:
16250
+ if not isinstance(m, dict):
16251
+ continue
16252
+ role = m.get("role") or "user"
16253
+ content = _ollama_content_to_text(m.get("content"))
16254
+ # Ollama roles map onto the OpenAI four. An unknown role is demoted
16255
+ # to user rather than rejected — clients invent roles, and dropping
16256
+ # the text would silently lose conversation history.
16257
+ if role not in ("system", "user", "assistant", "tool"):
16258
+ role = "user"
16259
+ entry: dict = {"role": role, "content": content}
16260
+ if role == "assistant" and isinstance(m.get("tool_calls"), list) and m["tool_calls"]:
16261
+ # Conversation replay of a prior tool call. Ollama carries the
16262
+ # arguments as an object; OpenAI as a JSON string.
16263
+ rebuilt = []
16264
+ pending_call_ids = []
16265
+ for i, tc in enumerate(m["tool_calls"]):
16266
+ fn = (tc or {}).get("function", {}) or {}
16267
+ args = fn.get("arguments", "")
16268
+ if not isinstance(args, str):
16269
+ try:
16270
+ args = json.dumps(args)
16271
+ except (TypeError, ValueError):
16272
+ args = "{}"
16273
+ call_id = f"call_{len(messages)}_{i}"
16274
+ pending_call_ids.append(call_id)
16275
+ rebuilt.append(
16276
+ {
16277
+ "id": call_id,
16278
+ "function": {"name": fn.get("name", ""), "arguments": args},
16279
+ }
16280
+ )
16281
+ entry["tool_calls"] = rebuilt
16282
+ elif role == "tool" and pending_call_ids:
16283
+ # Consume the ids in order: this tool result answers the oldest
16284
+ # still-unpaired call, which is how Ollama ordered them.
16285
+ entry["tool_call_id"] = pending_call_ids.pop(0)
16286
+ messages.append(entry)
16287
+ openai_body: dict = {
16288
+ "model": body.get("model") or FALLBACK_LOCAL_MODEL_ID,
16289
+ "messages": messages,
16290
+ }
16291
+ openai_body.update(_finish_common_ollama_fields(body))
16292
+ return openai_body
16293
+
16294
+
16295
+ def ollama_generate_to_openai_request(body: dict) -> dict:
16296
+ """Ollama /api/generate body -> OpenAI Chat Completions body.
16297
+
16298
+ generate() has no messages array — just prompt (+ optional system). The
16299
+ pipeline has no notion of raw completion mode, so the prompt rides as a
16300
+ user turn. Tools are accepted because some clients pass them to generate;
16301
+ a tool-call finish then surfaces in the Ollama response text as the
16302
+ serialized call rather than being dropped."""
16303
+ messages = []
16304
+ system = body.get("system")
16305
+ if isinstance(system, str) and system.strip():
16306
+ messages.append({"role": "system", "content": system})
16307
+ prompt = body.get("prompt")
16308
+ messages.append({"role": "user", "content": "" if prompt is None else str(prompt)})
16309
+ openai_body: dict = {
16310
+ "model": body.get("model") or FALLBACK_LOCAL_MODEL_ID,
16311
+ "messages": messages,
16312
+ }
16313
+ openai_body.update(_finish_common_ollama_fields(body))
16314
+ return openai_body
16315
+
16316
+
16317
+ def _ollama_done_reason(finish_reason) -> str:
16318
+ return "length" if finish_reason == "length" else "stop"
16319
+
16320
+
16321
+ def _ollama_now_iso() -> str:
16322
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
16323
+
16324
+
16325
+ def _ollama_usage_lines(usage: dict, duration_ns: int) -> dict:
16326
+ """The trailing count block shared by every Ollama response shape.
16327
+ Real Ollama reports nanosecond durations and token counts; our numbers
16328
+ come from the guarded pipeline's usage accounting."""
16329
+ usage = usage or {}
16330
+ return {
16331
+ "total_duration": duration_ns,
16332
+ "load_duration": 0,
16333
+ "prompt_eval_count": usage.get("prompt_tokens", 0),
16334
+ "prompt_eval_duration": 0,
16335
+ "eval_count": usage.get("completion_tokens", 0),
16336
+ "eval_duration": duration_ns,
16337
+ }
16338
+
16339
+
16340
+ def openai_to_ollama_chat_response(
16341
+ openai_resp: dict, requested_model: str, duration_ns: int
16342
+ ) -> dict:
16343
+ """OpenAI Chat Completions response -> Ollama /api/chat response."""
16344
+ choice = (openai_resp.get("choices") or [{}])[0] or {}
16345
+ message = choice.get("message", {}) or {}
16346
+ ollama_message: dict = {
16347
+ "role": "assistant",
16348
+ "content": message.get("content") or "",
16349
+ }
16350
+ if isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
16351
+ tool_calls = []
16352
+ for tc in message["tool_calls"]:
16353
+ fn = (tc or {}).get("function", {}) or {}
16354
+ args = fn.get("arguments", "")
16355
+ if isinstance(args, str):
16356
+ # Ollama carries arguments as an object; OpenAI as a string.
16357
+ # An unparseable string stays a string — a mangled call beats
16358
+ # a silently dropped one.
16359
+ try:
16360
+ args = json.loads(args) if args.strip() else {}
16361
+ except (ValueError, TypeError):
16362
+ pass
16363
+ tool_calls.append(
16364
+ {"function": {"name": fn.get("name", ""), "arguments": args}}
16365
+ )
16366
+ ollama_message["tool_calls"] = tool_calls
16367
+ resp = {
16368
+ "model": requested_model,
16369
+ "created_at": _ollama_now_iso(),
16370
+ "message": ollama_message,
16371
+ "done": True,
16372
+ "done_reason": _ollama_done_reason(choice.get("finish_reason")),
16373
+ }
16374
+ resp.update(_ollama_usage_lines(openai_resp.get("usage"), duration_ns))
16375
+ return resp
16376
+
16377
+
16378
+ def openai_to_ollama_generate_response(
16379
+ openai_resp: dict, requested_model: str, duration_ns: int
16380
+ ) -> dict:
16381
+ """OpenAI Chat Completions response -> Ollama /api/generate response."""
16382
+ choice = (openai_resp.get("choices") or [{}])[0] or {}
16383
+ message = choice.get("message", {}) or {}
16384
+ text = message.get("content") or ""
16385
+ if isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
16386
+ # generate() has no tool contract; surface the call as text rather
16387
+ # than lose it.
16388
+ text += "\n" + json.dumps({"tool_calls": message["tool_calls"]})
16389
+ resp = {
16390
+ "model": requested_model,
16391
+ "created_at": _ollama_now_iso(),
16392
+ "response": text,
16393
+ "done": True,
16394
+ "done_reason": _ollama_done_reason(choice.get("finish_reason")),
16395
+ }
16396
+ resp.update(_ollama_usage_lines(openai_resp.get("usage"), duration_ns))
16397
+ return resp
16398
+
16399
+
16400
+ async def _emit_ollama_ndjson(resp: dict, chunk_fields: dict):
16401
+ """Buffered Ollama streaming: one content line, then the final line.
16402
+
16403
+ The pipeline completed before this generator starts, so both lines are
16404
+ emitted back-to-back — a client sees the reply arrive at once. The line
16405
+ split exists so stream-mode clients (which parse NDJSON line-by-line and
16406
+ wait for done:true) terminate correctly.
16407
+
16408
+ chunk_fields names the payload key(s) of this dialect ("message" for
16409
+ /api/chat, "response" for /api/generate). The final line carries the same
16410
+ key EMPTY, exactly like real Ollama — a client concatenating payload
16411
+ fields across lines sees the content exactly once."""
16412
+ chunk = {"model": resp["model"], "created_at": resp["created_at"], "done": False}
16413
+ chunk.update(chunk_fields)
16414
+ yield (json.dumps(chunk) + "\n").encode()
16415
+ final = dict(resp)
16416
+ for k in chunk_fields:
16417
+ if k == "message":
16418
+ final["message"] = {"role": "assistant", "content": ""}
16419
+ else:
16420
+ final[k] = ""
16421
+ final["done"] = True
16422
+ yield (json.dumps(final) + "\n").encode()
16423
+
16424
+
16425
+ _OLLAMA_META_CACHE: "OrderedDict[str, tuple[float, dict]]" = OrderedDict()
16426
+ _OLLAMA_META_TTL_SECS = 60.0
16427
+
16428
+
16429
+ async def _ollama_model_meta(model_id: str) -> dict:
16430
+ """Best-effort card data for /api/tags, /api/show, /api/ps.
16431
+
16432
+ Real Ollama reads these from its blob store. We mirror what the backend
16433
+ actually exposes (llama.cpp /props: model path -> file size + mtime) and
16434
+ leave unknowables as stable placeholders instead of inventing
16435
+ plausible-looking numbers — a fabricated parameter_size would show up in
16436
+ clients' model pickers as fact.
16437
+
16438
+ The result is cached per model id (60s): these are discovery endpoints,
16439
+ some of them unauthenticated, and a 1:1 request -> upstream probe would
16440
+ make them an amplifier against the backend. The upstream-provided path is
16441
+ validated as an existing regular FILE before stat results are reflected
16442
+ to clients — the backend is inside the trust boundary, but an open
16443
+ endpoint should not become a generic filesystem oracle for it."""
16444
+ now = time.monotonic()
16445
+ hit = _OLLAMA_META_CACHE.get(model_id)
16446
+ if hit is not None and now - hit[0] < _OLLAMA_META_TTL_SECS:
16447
+ return hit[1]
16448
+ meta = {"modified_at": _OLLAMA_START_ISO, "size": 0}
16449
+ try:
16450
+ if http_client:
16451
+ r = await http_client.get(
16452
+ LLAMA_CPP_BASE.replace("/v1", "/props"), timeout=5.0
16453
+ )
16454
+ if r.status_code == 200:
16455
+ props = r.json() or {}
16456
+ path = props.get("model_path") or ""
16457
+ if isinstance(path, str) and path:
16458
+ st = os.stat(path)
16459
+ if stat.S_ISREG(st.st_mode):
16460
+ meta["size"] = st.st_size
16461
+ meta["modified_at"] = time.strftime(
16462
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)
16463
+ )
16464
+ except Exception as exc:
16465
+ # Best-effort by design (the card degrades to placeholders), but a
16466
+ # silently misconfigured upstream is invisible without this line —
16467
+ # e.g. an LLAMA_CPP_BASE without the /v1 suffix breaks the .replace.
16468
+ logger.debug("OLLAMA: /props metadata miss for %s: %s", model_id, exc)
16469
+ _OLLAMA_META_CACHE[model_id] = (now, meta)
16470
+ while len(_OLLAMA_META_CACHE) > 16:
16471
+ _OLLAMA_META_CACHE.popitem(last=False)
16472
+ return meta
16473
+
16474
+
16475
+ def _ollama_digest(model_id: str) -> str:
16476
+ """Stable pseudo-digest. Real Ollama's digest addresses a content blob;
16477
+ the equivalent truth here is 'this backend serving this model id'. The
16478
+ sha256: prefix matches real Ollama — some clients parse on the colon."""
16479
+ return "sha256:" + hashlib.sha256(f"uap-strata::{model_id}".encode()).hexdigest()
16480
+
16481
+
16482
+ def _ollama_family(model_id: str) -> str:
16483
+ lower = (model_id or "").lower()
16484
+ for fam in (
16485
+ "qwen3",
16486
+ "qwen2",
16487
+ "qwen",
16488
+ "llama3",
16489
+ "llama",
16490
+ "gemma",
16491
+ "mistral",
16492
+ "phi",
16493
+ "deepseek",
16494
+ "codestral",
16495
+ ):
16496
+ if lower.startswith(fam):
16497
+ return fam
16498
+ return "llama"
16499
+
16500
+
16501
+ def _ollama_quantization_level(model_id: str) -> str:
16502
+ m = re.search(
16503
+ r"(iq\d+[a-z0-9_]*|q\d+_[a-z0-9]+|f16|bf16|fp16|f32)", model_id or "", re.IGNORECASE
16504
+ )
16505
+ return m.group(1).upper() if m else ""
16506
+
16507
+
16508
+ def _ollama_details(model_id: str) -> dict:
16509
+ return {
16510
+ "parent_model": "",
16511
+ "format": "gguf",
16512
+ "family": _ollama_family(model_id),
16513
+ "families": [_ollama_family(model_id)],
16514
+ "parameter_size": "",
16515
+ "quantization_level": _ollama_quantization_level(model_id),
16516
+ }
16517
+
16518
+
16519
+ async def _ollama_served_model_ids() -> tuple[str, ...]:
16520
+ """Backend-served ids only. The Claude contract ids that /v1/models
16521
+ advertises for Anthropic-protocol SDK compatibility are protocol fictions
16522
+ — they must not leak into an Ollama client's model picker, where a client
16523
+ would select them and send them to /api/chat as if they were real."""
16524
+ served = await _upstream_model_ids_cached() or ()
16525
+ local = tuple(m for m in served if m not in ADVERTISED_CLAUDE_MODEL_IDS)
16526
+ return local or (FALLBACK_LOCAL_MODEL_ID,)
16527
+
16528
+
16529
+ @app.get("/api/version")
16530
+ async def ollama_version():
16531
+ return {"version": _OLLAMA_API_VERSION}
16532
+
16533
+
16534
+ @app.get("/api/tags")
16535
+ async def ollama_tags():
16536
+ """Ollama model discovery. Served ids only — see _ollama_served_model_ids."""
16537
+ # Same rationale as /v1/models: refresh the rail before answering, because
16538
+ # this is the first thing Ollama clients call and they cache the list.
16539
+ await _maybe_recheck_context_window()
16540
+ models_out = []
16541
+ for mid in await _ollama_served_model_ids():
16542
+ meta = await _ollama_model_meta(mid)
16543
+ models_out.append(
16544
+ {
16545
+ "name": mid,
16546
+ "model": mid,
16547
+ "modified_at": meta["modified_at"],
16548
+ "size": meta["size"],
16549
+ "digest": _ollama_digest(mid),
16550
+ "details": _ollama_details(mid),
16551
+ }
16552
+ )
16553
+ return {"models": models_out}
16554
+
16555
+
16556
+ @app.post("/api/show")
16557
+ async def ollama_show(request: Request):
16558
+ """Ollama model card. An unknown model 404s like real Ollama, because
16559
+ clients use this to validate a model id they remembered."""
16560
+ try:
16561
+ body = json.loads(await request.body() or b"{}")
16562
+ except (ValueError, TypeError):
16563
+ return _ollama_error(400, "invalid JSON body")
16564
+ if not isinstance(body, dict):
16565
+ return _ollama_error(400, "body must be a JSON object")
16566
+ requested = body.get("model") or ""
16567
+ served = await _ollama_served_model_ids()
16568
+ if requested and requested not in served:
16569
+ return _ollama_error(404, f"model '{requested}' not found")
16570
+ model_id = requested or served[0]
16571
+ meta = await _ollama_model_meta(model_id)
16572
+ details = _ollama_details(model_id)
16573
+ parameters: dict = {}
16574
+ window = _effective_context_window() if _context_window_measured else 0
16575
+ if window > 0:
16576
+ # The honest number: the measured rail, not the backend's raw n_ctx.
16577
+ parameters["num_ctx"] = window
16578
+ family = details["family"]
16579
+ return {
16580
+ "modelfile": (
16581
+ "# Modelfile synthesised by the UAP proxy for the live backend.\n"
16582
+ f"FROM {model_id}\n"
16583
+ ),
16584
+ "parameters": parameters,
16585
+ "template": "{{ .System }} {{ .Prompt }}",
16586
+ "details": details,
16587
+ "model_info": {
16588
+ "general.architecture": family,
16589
+ f"{family}.context_length": window or 0,
16590
+ "general.file_size": meta["size"],
16591
+ },
16592
+ }
16593
+
16594
+
16595
+ @app.get("/api/ps")
16596
+ async def ollama_ps():
16597
+ """Running-model list. The backend stays loaded (capacity policy owns
16598
+ that), so a far-future expiry is the honest answer — models do not
16599
+ unload after Ollama's default keep_alive here."""
16600
+ models_out = []
16601
+ for mid in await _ollama_served_model_ids():
16602
+ meta = await _ollama_model_meta(mid)
16603
+ entry = {
16604
+ "name": mid,
16605
+ "model": mid,
16606
+ "size": meta["size"],
16607
+ "digest": _ollama_digest(mid),
16608
+ "details": _ollama_details(mid),
16609
+ "expires_at": time.strftime(
16610
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 86400)
16611
+ ),
16612
+ "size_vram": meta["size"],
16613
+ }
16614
+ models_out.append(entry)
16615
+ return {"models": models_out}
16616
+
16617
+
16618
+ @app.post("/api/chat")
16619
+ async def ollama_chat(request: Request):
16620
+ """Ollama /api/chat: full guardrail path, Ollama wire shape."""
16621
+ try:
16622
+ body = json.loads(await request.body() or b"{}")
16623
+ except (ValueError, TypeError):
16624
+ return _ollama_error(400, "invalid JSON body")
16625
+ if not isinstance(body, dict):
16626
+ return _ollama_error(400, "body must be a JSON object")
16627
+ # Ollama defaults to streaming.
16628
+ requested_stream = bool(body.get("stream", True))
16629
+ requested_model = body.get("model") or FALLBACK_LOCAL_MODEL_ID
16630
+ client_id = resolve_client_id(request)
16631
+ openai_body = ollama_chat_to_openai_request(body)
16632
+ logger.info(
16633
+ "OLLAMA chat: client=%s model=%s stream=%s msgs=%d tools=%d",
16634
+ client_id,
16635
+ requested_model,
16636
+ requested_stream,
16637
+ len(openai_body.get("messages", [])),
16638
+ len(openai_body.get("tools", []) or []),
16639
+ )
16640
+ t0 = time.monotonic()
16641
+ openai_resp, status, err = await _guarded_openai_completion(request, openai_body)
16642
+ duration_ns = int((time.monotonic() - t0) * 1e9)
16643
+ if openai_resp is None:
16644
+ return _ollama_error(status if status >= 400 else 502, err or "upstream returned no message")
16645
+ resp = openai_to_ollama_chat_response(openai_resp, requested_model, duration_ns)
16646
+ if not requested_stream:
16647
+ return Response(content=json.dumps(resp).encode(), media_type="application/json")
16648
+ return StreamingResponse(
16649
+ _emit_ollama_ndjson(resp, {"message": resp["message"]}),
16650
+ media_type="application/x-ndjson",
16651
+ headers={"Cache-Control": "no-cache"},
16652
+ )
16653
+
16654
+
16655
+ @app.post("/api/generate")
16656
+ async def ollama_generate(request: Request):
16657
+ """Ollama /api/generate: full guardrail path, Ollama wire shape."""
16658
+ try:
16659
+ body = json.loads(await request.body() or b"{}")
16660
+ except (ValueError, TypeError):
16661
+ return _ollama_error(400, "invalid JSON body")
16662
+ if not isinstance(body, dict):
16663
+ return _ollama_error(400, "body must be a JSON object")
16664
+ requested_stream = bool(body.get("stream", True))
16665
+ requested_model = body.get("model") or FALLBACK_LOCAL_MODEL_ID
16666
+ client_id = resolve_client_id(request)
16667
+ openai_body = ollama_generate_to_openai_request(body)
16668
+ logger.info(
16669
+ "OLLAMA generate: client=%s model=%s stream=%s prompt_len=%d",
16670
+ client_id,
16671
+ requested_model,
16672
+ requested_stream,
16673
+ len(str(body.get("prompt") or "")),
16674
+ )
16675
+ t0 = time.monotonic()
16676
+ openai_resp, status, err = await _guarded_openai_completion(request, openai_body)
16677
+ duration_ns = int((time.monotonic() - t0) * 1e9)
16678
+ if openai_resp is None:
16679
+ return _ollama_error(status if status >= 400 else 502, err or "upstream returned no message")
16680
+ resp = openai_to_ollama_generate_response(openai_resp, requested_model, duration_ns)
16681
+ if not requested_stream:
16682
+ return Response(content=json.dumps(resp).encode(), media_type="application/json")
16683
+ return StreamingResponse(
16684
+ _emit_ollama_ndjson(resp, {"response": resp["response"]}),
16685
+ media_type="application/x-ndjson",
16686
+ headers={"Cache-Control": "no-cache"},
16687
+ )
16688
+
16689
+
15641
16690
  @app.get("/v1/models")
15642
16691
  async def models():
15643
16692
  """Return available model list.
@@ -15812,21 +16861,154 @@ def _assert_bind_is_authenticated(host: str, token: str) -> None:
15812
16861
  )
15813
16862
 
15814
16863
 
16864
+ def _ollama_port_bindable(host: str, port: int) -> bool:
16865
+ """True when the Ollama surface can bind its port.
16866
+
16867
+ A real Ollama is the likeliest collision (it owns 11434 by convention),
16868
+ and uvicorn exits the whole process on a bind failure — which must never
16869
+ take the primary Anthropic surface down with it. This check is advisory;
16870
+ the race after it (a port grabbed between this check and the companion's
16871
+ bind) is contained by _serve_companion_guarded below, which turns a
16872
+ companion SystemExit into a log line and keeps the primary serving."""
16873
+ try:
16874
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
16875
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
16876
+ s.bind((host, port))
16877
+ return True
16878
+ except OSError:
16879
+ return False
16880
+
16881
+
16882
+ class _CompanionListener(uvicorn.Server):
16883
+ """The second uvicorn server for the Ollama port.
16884
+
16885
+ uvicorn installs signal handlers per Server.serve() call: two servers would
16886
+ hand SIGTERM to whichever installed last and leave the other listening,
16887
+ hanging systemd stops. The companion installs none and instead mirrors
16888
+ the primary's should_exit, so one SIGTERM stops both ports. lifespan="off"
16889
+ (set by the caller) matters even more — the app's startup handlers own the
16890
+ http client and the session monitors, and a second lifespan would run
16891
+ them twice against shared state."""
16892
+
16893
+ @contextmanager
16894
+ def capture_signals(self):
16895
+ yield
16896
+
16897
+
16898
+ async def _run_proxy_servers() -> None:
16899
+ """Serve the app on PROXY_PORT, plus the Ollama surface port when enabled.
16900
+
16901
+ One process, two listeners, one asyncio loop: the Ollama surface shares
16902
+ the app object — and with it the admission table, rail semaphore and every
16903
+ guardrail — with the Anthropic surface."""
16904
+ primary = uvicorn.Server(
16905
+ uvicorn.Config(
16906
+ app, host=PROXY_HOST, port=PROXY_PORT, log_level=PROXY_LOG_LEVEL.lower()
16907
+ )
16908
+ )
16909
+ primary_task = asyncio.ensure_future(primary.serve())
16910
+ tasks: list = [primary_task]
16911
+ ollama_enabled = PROXY_OLLAMA_PORT > 0
16912
+ if ollama_enabled and PROXY_OLLAMA_PORT == PROXY_PORT:
16913
+ # The advisory bind check cannot catch this one: it runs before the
16914
+ # primary has bound, so it would pass for the primary's own port and
16915
+ # the companion would later die on EADDRINUSE.
16916
+ logger.error(
16917
+ "OLLAMA: PROXY_OLLAMA_PORT=%d equals PROXY_PORT — Ollama surface "
16918
+ "disabled. Pick a different port or set 0.",
16919
+ PROXY_OLLAMA_PORT,
16920
+ )
16921
+ ollama_enabled = False
16922
+ if ollama_enabled:
16923
+ if not _ollama_port_bindable(PROXY_HOST, PROXY_OLLAMA_PORT):
16924
+ logger.error(
16925
+ "OLLAMA: port %d is busy (a real Ollama?) — Ollama surface "
16926
+ "disabled, Anthropic/OpenAI surface unaffected. Set "
16927
+ "PROXY_OLLAMA_PORT=0 to silence this.",
16928
+ PROXY_OLLAMA_PORT,
16929
+ )
16930
+ else:
16931
+ companion = _CompanionListener(
16932
+ uvicorn.Config(
16933
+ app,
16934
+ host=PROXY_HOST,
16935
+ port=PROXY_OLLAMA_PORT,
16936
+ log_level=PROXY_LOG_LEVEL.lower(),
16937
+ log_config=None, # the primary already owns logging config
16938
+ lifespan="off", # app startup must not run twice
16939
+ )
16940
+ )
16941
+
16942
+ async def _serve_companion_guarded() -> None:
16943
+ # uvicorn handles a bind failure with sys.exit(1) INSIDE
16944
+ # serve(). SystemExit is a BaseException: it is NOT contained
16945
+ # by gather(return_exceptions=True) — it tears the process
16946
+ # down and takes the primary with it. Contain it here so a
16947
+ # companion failure only ever disables the Ollama surface.
16948
+ try:
16949
+ await companion.serve()
16950
+ except BaseException as exc: # containment is the point
16951
+ logger.error(
16952
+ "OLLAMA: companion listener died (%s: %s) — Ollama "
16953
+ "surface disabled, Anthropic/OpenAI surface "
16954
+ "unaffected.",
16955
+ type(exc).__name__,
16956
+ exc,
16957
+ )
16958
+
16959
+ companion_task = asyncio.ensure_future(_serve_companion_guarded())
16960
+
16961
+ async def _tie_companion() -> None:
16962
+ # The companion cannot see signals (it installs no handlers).
16963
+ # Mirror the primary two ways: when SIGTERM sets the primary's
16964
+ # should_exit, and when the primary task ENDS for any other
16965
+ # reason (crash) — one surface must never outlive the other,
16966
+ # or the process would hang half-alive on systemd stops.
16967
+ while not primary_task.done() and not primary.should_exit:
16968
+ await asyncio.sleep(0.25)
16969
+ companion.should_exit = True
16970
+
16971
+ logger.info(
16972
+ "OLLAMA: serving /api/* on %s:%d (guarded, same process)",
16973
+ PROXY_HOST,
16974
+ PROXY_OLLAMA_PORT,
16975
+ )
16976
+ tasks.append(companion_task)
16977
+ tasks.append(_tie_companion())
16978
+ # return_exceptions: a companion bind failure (the advisory check raced)
16979
+ # must not cancel the primary. A primary failure still exits the process,
16980
+ # because _tie_companion flags the companion and every task then ends —
16981
+ # and the raise below restores the pre-change contract that a dead
16982
+ # primary aborts startup non-zero (Restart=on-failure reads that).
16983
+ await asyncio.gather(*tasks, return_exceptions=True)
16984
+ if not primary_task.cancelled() and primary_task.exception() is not None:
16985
+ raise primary_task.exception()
16986
+
16987
+
15815
16988
  if __name__ == "__main__":
15816
16989
  # Config discovery is silent by design (it fails open so a missing file can
15817
16990
  # never stop the proxy). Say what it found, so a miss is visible in the
15818
16991
  # journal instead of showing up later as unexplained default behaviour.
15819
16992
  logger.info(
15820
- "proxy env: %s | auth=%s | bind=%s:%d",
16993
+ "proxy env: %s | auth=%s | bind=%s:%d | ollama=%s",
15821
16994
  f"loaded {_PROXY_ENV_FILE_LOADED}" if _PROXY_ENV_FILE_LOADED else "no proxy.env found",
15822
16995
  "enabled" if PROXY_AUTH_TOKEN else "DISABLED",
15823
16996
  PROXY_HOST,
15824
16997
  PROXY_PORT,
16998
+ f"port {PROXY_OLLAMA_PORT}" if PROXY_OLLAMA_PORT > 0 else "off",
15825
16999
  )
15826
17000
  _assert_bind_is_authenticated(PROXY_HOST, PROXY_AUTH_TOKEN)
15827
- uvicorn.run(
15828
- app,
15829
- host=PROXY_HOST,
15830
- port=PROXY_PORT,
15831
- log_level=PROXY_LOG_LEVEL.lower(),
15832
- )
17001
+ if PROXY_OLLAMA_PORT > 0:
17002
+ asyncio.run(_run_proxy_servers())
17003
+ else:
17004
+ # Legacy single-port path kept byte-identical to the pre-Ollama
17005
+ # launcher ON PURPOSE: PROXY_OLLAMA_PORT=0 deployments must not
17006
+ # start exercising the gather/tie machinery just because it exists
17007
+ # (blast-radius minimization; a lifecycle regression in the dual path
17008
+ # must not reproduce in single-port setups).
17009
+ uvicorn.run(
17010
+ app,
17011
+ host=PROXY_HOST,
17012
+ port=PROXY_PORT,
17013
+ log_level=PROXY_LOG_LEVEL.lower(),
17014
+ )