@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.
- package/config/capacity-policy.json +33 -0
- package/config/model-profiles/qwen38.json +32 -33
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +57 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +32 -1
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +123 -4
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts +26 -0
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +84 -3
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/inference.d.ts +4 -0
- package/dist/cli/inference.d.ts.map +1 -1
- package/dist/cli/inference.js +74 -8
- package/dist/cli/inference.js.map +1 -1
- package/dist/cli/merge-gate.d.ts +59 -0
- package/dist/cli/merge-gate.d.ts.map +1 -0
- package/dist/cli/merge-gate.js +238 -0
- package/dist/cli/merge-gate.js.map +1 -0
- package/dist/cli/merge-queue.d.ts +13 -0
- package/dist/cli/merge-queue.d.ts.map +1 -1
- package/dist/cli/merge-queue.js +48 -2
- package/dist/cli/merge-queue.js.map +1 -1
- package/dist/cli/mission.d.ts +25 -0
- package/dist/cli/mission.d.ts.map +1 -0
- package/dist/cli/mission.js +225 -0
- package/dist/cli/mission.js.map +1 -0
- package/dist/cli/orch.d.ts +27 -0
- package/dist/cli/orch.d.ts.map +1 -0
- package/dist/cli/orch.js +70 -0
- package/dist/cli/orch.js.map +1 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +44 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/delivery/merge-gate.d.ts +168 -0
- package/dist/delivery/merge-gate.d.ts.map +1 -0
- package/dist/delivery/merge-gate.js +309 -0
- package/dist/delivery/merge-gate.js.map +1 -0
- package/dist/inference/analysis.d.ts +69 -3
- package/dist/inference/analysis.d.ts.map +1 -1
- package/dist/inference/analysis.js +123 -7
- package/dist/inference/analysis.js.map +1 -1
- package/dist/inference/probe.d.ts +38 -1
- package/dist/inference/probe.d.ts.map +1 -1
- package/dist/inference/probe.js +303 -30
- package/dist/inference/probe.js.map +1 -1
- package/dist/inference/strata.d.ts +95 -0
- package/dist/inference/strata.d.ts.map +1 -0
- package/dist/inference/strata.js +167 -0
- package/dist/inference/strata.js.map +1 -0
- package/dist/mission/database.d.ts +29 -0
- package/dist/mission/database.d.ts.map +1 -0
- package/dist/mission/database.js +131 -0
- package/dist/mission/database.js.map +1 -0
- package/dist/mission/ledger.d.ts +109 -0
- package/dist/mission/ledger.d.ts.map +1 -0
- package/dist/mission/ledger.js +294 -0
- package/dist/mission/ledger.js.map +1 -0
- package/dist/mission/poll-cycle.d.ts +73 -0
- package/dist/mission/poll-cycle.d.ts.map +1 -0
- package/dist/mission/poll-cycle.js +100 -0
- package/dist/mission/poll-cycle.js.map +1 -0
- package/dist/mission/poll-executor.d.ts +91 -0
- package/dist/mission/poll-executor.d.ts.map +1 -0
- package/dist/mission/poll-executor.js +328 -0
- package/dist/mission/poll-executor.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +33 -27
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +17 -18
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/types/mission.d.ts +80 -0
- package/dist/types/mission.d.ts.map +1 -0
- package/dist/types/mission.js +12 -0
- package/dist/types/mission.js.map +1 -0
- package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
- package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
- package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
- package/docs/guides/CAPACITY_POLICY.md +49 -0
- package/docs/guides/INFERENCE_HEALTH.md +65 -8
- package/docs/guides/MISSIONS.md +170 -0
- package/docs/guides/PROXY.md +50 -0
- package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
- package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
- package/docs/visual/merge-gate.html +404 -0
- package/docs/visual/mission-orchestrator.html +472 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/plane_admin_protect.py +133 -0
- package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +33 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1271 -89
- package/tools/agents/scripts/requirements-proxy.txt +1 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
- package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
- package/tools/agents/tests/test_ollama_compat.py +320 -0
- package/tools/agents/tests/test_plane_admin_protect.py +124 -0
- 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 #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
10084
|
-
|
|
10085
|
-
-
|
|
10086
|
-
|
|
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
|
-
#
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
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 (
|
|
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
|
-
|
|
14429
|
-
|
|
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 —
|
|
14531
|
-
#
|
|
14532
|
-
|
|
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
|
|
14535
|
-
"Forcing finalize to prevent death spiral.",
|
|
14536
|
-
monitor.
|
|
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
|
-
|
|
15278
|
-
|
|
15279
|
-
|
|
15280
|
-
|
|
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
|
-
|
|
15283
|
-
|
|
15284
|
-
|
|
15285
|
-
|
|
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
|
-
|
|
15288
|
-
|
|
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;
|
|
15317
|
-
#
|
|
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=
|
|
15375
|
-
status_code=
|
|
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
|
-
|
|
15380
|
-
|
|
15381
|
-
|
|
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
|
-
|
|
15828
|
-
|
|
15829
|
-
|
|
15830
|
-
port
|
|
15831
|
-
|
|
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
|
+
)
|