@miller-tech/uap 2.11.1 → 2.16.4
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 +15 -3
- package/config/model-profiles/qwen38.json +42 -34
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +39 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +15 -0
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +23 -0
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +6 -1
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +148 -2
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/inference.d.ts +13 -0
- package/dist/cli/inference.d.ts.map +1 -0
- package/dist/cli/inference.js +132 -0
- package/dist/cli/inference.js.map +1 -0
- package/dist/cli/loops.d.ts +39 -0
- package/dist/cli/loops.d.ts.map +1 -0
- package/dist/cli/loops.js +154 -0
- package/dist/cli/loops.js.map +1 -0
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/cli/wizard-config.js +5 -1
- package/dist/cli/wizard-config.js.map +1 -1
- package/dist/cli/worktree.d.ts.map +1 -1
- package/dist/cli/worktree.js +8 -4
- package/dist/cli/worktree.js.map +1 -1
- package/dist/delivery/adversarial-attack.d.ts +99 -0
- package/dist/delivery/adversarial-attack.d.ts.map +1 -0
- package/dist/delivery/adversarial-attack.js +326 -0
- package/dist/delivery/adversarial-attack.js.map +1 -0
- package/dist/delivery/adversarial-gate.d.ts +120 -0
- package/dist/delivery/adversarial-gate.d.ts.map +1 -0
- package/dist/delivery/adversarial-gate.js +259 -0
- package/dist/delivery/adversarial-gate.js.map +1 -0
- package/dist/delivery/adversarial-sanction.d.ts +52 -0
- package/dist/delivery/adversarial-sanction.d.ts.map +1 -0
- package/dist/delivery/adversarial-sanction.js +134 -0
- package/dist/delivery/adversarial-sanction.js.map +1 -0
- package/dist/delivery/agentic-executor.d.ts +69 -22
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +152 -8
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/applier.d.ts +8 -0
- package/dist/delivery/applier.d.ts.map +1 -1
- package/dist/delivery/applier.js +3 -1
- package/dist/delivery/applier.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +15 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/criteria-lint.d.ts +74 -0
- package/dist/delivery/criteria-lint.d.ts.map +1 -0
- package/dist/delivery/criteria-lint.js +134 -0
- package/dist/delivery/criteria-lint.js.map +1 -0
- package/dist/delivery/deliver-toggle.d.ts +21 -0
- package/dist/delivery/deliver-toggle.d.ts.map +1 -0
- package/dist/delivery/deliver-toggle.js +40 -0
- package/dist/delivery/deliver-toggle.js.map +1 -0
- package/dist/delivery/delivery-evidence.d.ts +109 -0
- package/dist/delivery/delivery-evidence.d.ts.map +1 -0
- package/dist/delivery/delivery-evidence.js +139 -0
- package/dist/delivery/delivery-evidence.js.map +1 -0
- package/dist/delivery/epic-mission.d.ts +5 -1
- package/dist/delivery/epic-mission.d.ts.map +1 -1
- package/dist/delivery/epic-mission.js +34 -6
- package/dist/delivery/epic-mission.js.map +1 -1
- package/dist/delivery/execution-gate.d.ts.map +1 -1
- package/dist/delivery/execution-gate.js +90 -7
- package/dist/delivery/execution-gate.js.map +1 -1
- package/dist/delivery/index.d.ts +9 -1
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +9 -1
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/journey-depth.d.ts +61 -0
- package/dist/delivery/journey-depth.d.ts.map +1 -0
- package/dist/delivery/journey-depth.js +91 -0
- package/dist/delivery/journey-depth.js.map +1 -0
- package/dist/delivery/master-pipeline.d.ts +50 -0
- package/dist/delivery/master-pipeline.d.ts.map +1 -0
- package/dist/delivery/master-pipeline.js +52 -0
- package/dist/delivery/master-pipeline.js.map +1 -0
- package/dist/delivery/mission-acceptance.d.ts.map +1 -1
- package/dist/delivery/mission-acceptance.js +54 -2
- package/dist/delivery/mission-acceptance.js.map +1 -1
- package/dist/delivery/orchestrated-mission.d.ts +4 -1
- package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
- package/dist/delivery/orchestrated-mission.js +18 -9
- package/dist/delivery/orchestrated-mission.js.map +1 -1
- package/dist/delivery/plan-check.d.ts +1 -0
- package/dist/delivery/plan-check.d.ts.map +1 -1
- package/dist/delivery/plan-check.js +48 -0
- package/dist/delivery/plan-check.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts +16 -9
- package/dist/delivery/task-orchestrator.d.ts.map +1 -1
- package/dist/delivery/task-orchestrator.js +4 -2
- package/dist/delivery/task-orchestrator.js.map +1 -1
- package/dist/delivery/task-workspace.d.ts +35 -5
- package/dist/delivery/task-workspace.d.ts.map +1 -1
- package/dist/delivery/task-workspace.js +53 -7
- package/dist/delivery/task-workspace.js.map +1 -1
- package/dist/delivery/test-oracle-additive.d.ts +26 -6
- package/dist/delivery/test-oracle-additive.d.ts.map +1 -1
- package/dist/delivery/test-oracle-additive.js +40 -15
- package/dist/delivery/test-oracle-additive.js.map +1 -1
- package/dist/delivery/user-validation.d.ts +14 -0
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +103 -9
- package/dist/delivery/user-validation.js.map +1 -1
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +24 -6
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/dist/inference/analysis.d.ts +138 -0
- package/dist/inference/analysis.d.ts.map +1 -0
- package/dist/inference/analysis.js +243 -0
- package/dist/inference/analysis.js.map +1 -0
- package/dist/inference/probe.d.ts +85 -0
- package/dist/inference/probe.d.ts.map +1 -0
- package/dist/inference/probe.js +403 -0
- package/dist/inference/probe.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +45 -18
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +17 -8
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +21 -11
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/utils/model-slots.d.ts +11 -5
- package/dist/utils/model-slots.d.ts.map +1 -1
- package/dist/utils/model-slots.js +11 -5
- package/dist/utils/model-slots.js.map +1 -1
- package/docs/INDEX.md +1 -0
- package/docs/guides/CAPACITY_POLICY.md +24 -0
- package/docs/guides/INFERENCE_HEALTH.md +184 -0
- package/docs/guides/PROXY.md +1 -0
- package/docs/guides/QWEN38_EXL3.md +268 -0
- package/docs/plans/hlg-master-architecture-uplift-2026-09-24.md +67 -0
- package/docs/reference/CONFIGURATION.md +10 -1
- package/docs/reference/CONFIGURATION_REFERENCE.md +14 -0
- package/package.json +2 -2
- package/scripts/lib/llama-upstream.sh +7 -1
- 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/expert_review_required.py +1 -4
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +374 -16
- package/tools/agents/scripts/confidence_escalation.py +4 -2
- package/tools/agents/scripts/project_telemetry.py +1 -0
- package/tools/agents/scripts/tool_risk.py +1 -0
- package/tools/agents/scripts/tool_risk_calibrate.py +1 -0
- package/tools/agents/scripts/toolcall_path_normalizer.py +1 -0
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +52 -1
- package/tools/agents/tests/test_confidence_escalation.py +3 -2
- package/tools/agents/tests/test_count_tokens_scale_per_session.py +126 -0
- package/tools/agents/tests/test_expert_review_pr_scope.py +10 -11
- package/tools/agents/tests/test_lockstep_escalation.py +324 -0
- package/tools/agents/tests/test_thinking_template_kwargs.py +10 -4
|
@@ -662,6 +662,48 @@ _DOUBLING_FAIL_RE = re.compile(
|
|
|
662
662
|
re.IGNORECASE,
|
|
663
663
|
)
|
|
664
664
|
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
# LOCKSTEP ESCALATION guardrail: the SAME tool call failing with the SAME
|
|
667
|
+
# error, over and over -- the intersection of DOUBLING-DOWN (identical
|
|
668
|
+
# fingerprint) and ERROR-LOOP (identical failure signature). Those two guards
|
|
669
|
+
# used to split this case against itself: DOUBLING-DOWN yielded ("ERROR-LOOP
|
|
670
|
+
# owns the lockstep case") while ERROR-LOOP's remedy assumes a failing FILE to
|
|
671
|
+
# re-read, which is wrong for tool-misuse loops (a remote command that can
|
|
672
|
+
# never succeed). Observed live (2026-09-25): one ssh/docker/python call
|
|
673
|
+
# re-issued 27 times -- ~15 min of full-context prefills at 100% GPU, three
|
|
674
|
+
# ignored re-read nudges -- before the ERROR-LOOP hard stop finally fired.
|
|
675
|
+
#
|
|
676
|
+
# Lockstep owns the case and escalates decisively: a PIVOT directive at
|
|
677
|
+
# PROXY_LOCKSTEP_PIVOT_AT, a final warning one fire later, and a hard stop
|
|
678
|
+
# (400 + loop-incident record) at PROXY_LOCKSTEP_HARD_AT. Both directives are
|
|
679
|
+
# always given before the stop, so with the defaults the stop lands at
|
|
680
|
+
# max(HARD_AT, PIVOT_AT+2) -- never earlier. A fresh human user turn re-arms
|
|
681
|
+
# the guard, mirroring ERROR-LOOP. PROXY_LOCKSTEP_BREAK=off disables the
|
|
682
|
+
# guard; PIVOT_AT=0 disables it too (the ladder has no entry point);
|
|
683
|
+
# HARD_AT=0 disables only the hard stop (advisory tiers still fire).
|
|
684
|
+
#
|
|
685
|
+
# Dependency: the streaks lockstep reads are fed by the ERROR-LOOP and
|
|
686
|
+
# DOUBLING-DOWN trackers, so PROXY_ERROR_LOOP=off or PROXY_DOUBLING_BREAK=off
|
|
687
|
+
# silently disarms lockstep as well. Fail-safe direction, but operator-visible
|
|
688
|
+
# in the docs.
|
|
689
|
+
PROXY_LOCKSTEP_BREAK = os.environ.get("PROXY_LOCKSTEP_BREAK", "on").lower() not in {
|
|
690
|
+
"0", "off", "false", "no",
|
|
691
|
+
}
|
|
692
|
+
PROXY_LOCKSTEP_PIVOT_AT = int(os.environ.get("PROXY_LOCKSTEP_PIVOT_AT", "2"))
|
|
693
|
+
PROXY_LOCKSTEP_HARD_AT = int(os.environ.get("PROXY_LOCKSTEP_HARD_AT", "4"))
|
|
694
|
+
|
|
695
|
+
# Loop-incident ledger: every lockstep escalation tier appends one JSONL
|
|
696
|
+
# record so `uap loops` can show what looped, how hard, and hand the incident
|
|
697
|
+
# to `uap ideate` / `uap deliver` for resolution. Best-effort telemetry -- the
|
|
698
|
+
# proxy never fails a request over it. "" disables; the default honors
|
|
699
|
+
# XDG_CONFIG_HOME like the rest of the UAP config layout.
|
|
700
|
+
_LOOP_INCIDENT_DEFAULT = os.path.join(
|
|
701
|
+
os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
|
|
702
|
+
"uap",
|
|
703
|
+
"loop-incidents.jsonl",
|
|
704
|
+
)
|
|
705
|
+
UAP_LOOP_INCIDENTS = os.environ.get("UAP_LOOP_INCIDENTS", _LOOP_INCIDENT_DEFAULT)
|
|
706
|
+
|
|
665
707
|
PROXY_TOOL_STATE_MACHINE = os.environ.get(
|
|
666
708
|
"PROXY_TOOL_STATE_MACHINE", "on"
|
|
667
709
|
).lower() not in {
|
|
@@ -1360,6 +1402,12 @@ PROXY_DISABLE_THINKING_ALWAYS = os.environ.get(
|
|
|
1360
1402
|
# switches, the tool-turn breakers, the JSON-verdict grammar, the empty-response
|
|
1361
1403
|
# retry and every assistant prefill/continuation.
|
|
1362
1404
|
#
|
|
1405
|
+
# SUPERSEDED 2026-09-21: that backend is not what runs. The local engine is
|
|
1406
|
+
# buun-llama-cpp with --jinja, which ACCEPTS chat_template_kwargs (verified
|
|
1407
|
+
# with a live 200 carrying enable_thinking). The capability probe below is why
|
|
1408
|
+
# the engine change needed no code change at all — it adapts instead of
|
|
1409
|
+
# assuming. The incident above is kept as the record of why the probe exists.
|
|
1410
|
+
#
|
|
1363
1411
|
# 'auto' (default) probes the upstream once and remembers; 'on'/'off' pin it for
|
|
1364
1412
|
# an operator who knows their backend and does not want the probe.
|
|
1365
1413
|
PROXY_CHAT_TEMPLATE_KWARGS = os.environ.get(
|
|
@@ -1544,7 +1592,11 @@ async def _reconcile_wire_model(openai_body: dict) -> None:
|
|
|
1544
1592
|
|
|
1545
1593
|
THE BUG THIS FIXES, measured 2026-08-19. llama.cpp ignores the OpenAI
|
|
1546
1594
|
`model` field entirely, so for years the proxy could forward whatever the
|
|
1547
|
-
client asked for. ninfer-serve VALIDATES it.
|
|
1595
|
+
client asked for. ninfer-serve VALIDATES it. (SUPERSEDED 2026-09-21: the
|
|
1596
|
+
backend is llama.cpp again, which ignores the field — both `qwen3.8-27b`
|
|
1597
|
+
and the real alias return 200. This reconciliation is now belt-and-braces
|
|
1598
|
+
rather than load-bearing, and stays because the next engine may validate
|
|
1599
|
+
again.) With the local-only sentinel
|
|
1548
1600
|
(ANTHROPIC_PASSTHROUGH_MODELS=__local_only__ -- the shipped systemd default)
|
|
1549
1601
|
EVERY advertised id is served locally, including the four `claude-*` ones,
|
|
1550
1602
|
and every one of them came back:
|
|
@@ -2396,6 +2448,13 @@ class SessionMonitor:
|
|
|
2396
2448
|
doubling_streak: int = 0 # consecutive FAILED retries of that same call
|
|
2397
2449
|
doubling_break_fires: int = 0 # monotonic count of injected pivot directives
|
|
2398
2450
|
last_doubling_obs: str = "" # msg-count:fingerprint key of the last counted observation
|
|
2451
|
+
lockstep_fires: int = 0 # lockstep escalation directives injected this streak
|
|
2452
|
+
lockstep_blocks: int = 0 # monotonic count of lockstep hard stops (400s) served
|
|
2453
|
+
# Streak depth at which the last lockstep tier fired. A client retry resends
|
|
2454
|
+
# an IDENTICAL trailing transcript (5xx, stream abort), which without this
|
|
2455
|
+
# would re-inject the directive and re-append a ledger record per retry.
|
|
2456
|
+
lockstep_last_fire_streak: int = 0
|
|
2457
|
+
session_id: str = "" # owning session id (set by get_session_monitor)
|
|
2399
2458
|
mandate_deliver_fires: int = 0 # monotonic count of forced deliver-routings (mandate)
|
|
2400
2459
|
mandate_deliver_active: bool = False # THIS turn is pinned to deliver (beats recon convergence)
|
|
2401
2460
|
tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
|
|
@@ -3260,6 +3319,7 @@ def get_session_monitor(session_id: str) -> SessionMonitor:
|
|
|
3260
3319
|
monitor = SessionMonitor(context_window=default_context_window)
|
|
3261
3320
|
session_monitors[session_id] = monitor
|
|
3262
3321
|
|
|
3322
|
+
monitor.session_id = session_id
|
|
3263
3323
|
monitor.touch()
|
|
3264
3324
|
if monitor.context_window <= 0:
|
|
3265
3325
|
monitor.context_window = default_context_window
|
|
@@ -6234,7 +6294,7 @@ def openai_to_anthropic_request(openai_body: dict) -> dict:
|
|
|
6234
6294
|
elif isinstance(block, str):
|
|
6235
6295
|
blocks.append({"type": "text", "text": block})
|
|
6236
6296
|
|
|
6237
|
-
for tc in msg.get("tool_calls"
|
|
6297
|
+
for tc in msg.get("tool_calls") or []:
|
|
6238
6298
|
fn = tc.get("function", {})
|
|
6239
6299
|
try:
|
|
6240
6300
|
args = json.loads(fn.get("arguments", "{}") or "{}")
|
|
@@ -7261,6 +7321,226 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
|
|
|
7261
7321
|
logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
|
|
7262
7322
|
|
|
7263
7323
|
|
|
7324
|
+
def _lockstep_streak(monitor: "SessionMonitor") -> int:
|
|
7325
|
+
"""Depth of the identical-call-identical-failure lockstep.
|
|
7326
|
+
|
|
7327
|
+
DOUBLING-DOWN tracks consecutive failed retries of one fingerprint;
|
|
7328
|
+
ERROR-LOOP tracks consecutive recurrences of one error signature. When both
|
|
7329
|
+
climb together the loop is provably deterministic -- the same input
|
|
7330
|
+
produces the same failure -- so the lockstep depth is the shallower of the
|
|
7331
|
+
two (either signal resetting breaks the lockstep)."""
|
|
7332
|
+
if not PROXY_LOCKSTEP_BREAK:
|
|
7333
|
+
return 0
|
|
7334
|
+
if not monitor.doubling_fp:
|
|
7335
|
+
return 0
|
|
7336
|
+
return min(monitor.doubling_streak, monitor.error_signature_streak)
|
|
7337
|
+
|
|
7338
|
+
|
|
7339
|
+
def _lockstep_active(monitor: "SessionMonitor") -> bool:
|
|
7340
|
+
"""True when the lockstep ladder owns this turn. ERROR-LOOP and
|
|
7341
|
+
DOUBLING-DOWN consult this to yield rather than stack a second (and, for
|
|
7342
|
+
ERROR-LOOP's file-centric remedy, actively wrong) directive."""
|
|
7343
|
+
return (
|
|
7344
|
+
PROXY_LOCKSTEP_BREAK
|
|
7345
|
+
and PROXY_LOCKSTEP_PIVOT_AT > 0
|
|
7346
|
+
and _lockstep_streak(monitor) >= PROXY_LOCKSTEP_PIVOT_AT
|
|
7347
|
+
)
|
|
7348
|
+
|
|
7349
|
+
|
|
7350
|
+
def _record_loop_incident(monitor: "SessionMonitor", outcome: str, detail: str) -> None:
|
|
7351
|
+
"""Append one JSONL record to the loop-incident ledger. Best-effort: the
|
|
7352
|
+
proxy never fails a request over telemetry.
|
|
7353
|
+
|
|
7354
|
+
Deliberately lossy -- the fingerprint is hashed and tool ARGUMENTS are
|
|
7355
|
+
never written. The observed loops run remote shells whose command lines
|
|
7356
|
+
can carry tokens, URLs, and credentials; `uap loops` needs the SHAPE of
|
|
7357
|
+
the loop (tool, error signature, streak), not its payload.
|
|
7358
|
+
|
|
7359
|
+
The ledger grows ~3 records per loop episode (pivot / final / hard stop);
|
|
7360
|
+
there is deliberately no rotation -- a wedged session hard-stops instead
|
|
7361
|
+
of streaming records. The file is created 0600 (normalized error
|
|
7362
|
+
fragments can still name internal hosts/services) and opened O_NOFOLLOW
|
|
7363
|
+
so a symlink at the path is refused race-free."""
|
|
7364
|
+
path = UAP_LOOP_INCIDENTS
|
|
7365
|
+
if not path:
|
|
7366
|
+
return
|
|
7367
|
+
try:
|
|
7368
|
+
if os.path.islink(path):
|
|
7369
|
+
logger.warning(
|
|
7370
|
+
"loop-incident ledger is a symlink -- refusing to append (%s)", path
|
|
7371
|
+
)
|
|
7372
|
+
return
|
|
7373
|
+
parent = os.path.dirname(path)
|
|
7374
|
+
if parent:
|
|
7375
|
+
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
7376
|
+
fp = monitor.doubling_fp or ""
|
|
7377
|
+
record = {
|
|
7378
|
+
"v": 1,
|
|
7379
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
7380
|
+
"guard": "lockstep",
|
|
7381
|
+
"outcome": outcome,
|
|
7382
|
+
"session": (monitor.session_id or "")[:64],
|
|
7383
|
+
"tool": "+".join(sorted(_fingerprint_tool_names(fp)))[:80],
|
|
7384
|
+
"fingerprint": hashlib.sha1(fp.encode("utf-8", "replace")).hexdigest()[:12]
|
|
7385
|
+
if fp
|
|
7386
|
+
else "",
|
|
7387
|
+
"error_signature": (monitor.last_error_signature or "")[:200],
|
|
7388
|
+
"streak": _lockstep_streak(monitor),
|
|
7389
|
+
"doubling_streak": monitor.doubling_streak,
|
|
7390
|
+
"error_signature_streak": monitor.error_signature_streak,
|
|
7391
|
+
"fires": monitor.lockstep_fires,
|
|
7392
|
+
"blocks": monitor.lockstep_blocks,
|
|
7393
|
+
"detail": (detail or "")[:200],
|
|
7394
|
+
}
|
|
7395
|
+
fd = os.open(
|
|
7396
|
+
path,
|
|
7397
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0),
|
|
7398
|
+
0o600,
|
|
7399
|
+
)
|
|
7400
|
+
with os.fdopen(fd, "a", encoding="utf-8") as fh:
|
|
7401
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
7402
|
+
except Exception as exc:
|
|
7403
|
+
logger.warning("loop-incident ledger append failed (non-fatal): %s", exc)
|
|
7404
|
+
|
|
7405
|
+
|
|
7406
|
+
def _maybe_lockstep_escalate(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
7407
|
+
"""Decisive escalation for the identical-call-identical-failure loop.
|
|
7408
|
+
|
|
7409
|
+
Owns the lockstep case the older guards split against themselves (see the
|
|
7410
|
+
knob block for the 2026-09-25 incident: 27 identical ssh calls, ~15 min of
|
|
7411
|
+
full-context prefills, three ignored re-read nudges). The ladder:
|
|
7412
|
+
PIVOT_AT -> inject a pivot directive (name the deterministic failure,
|
|
7413
|
+
demand genuinely different approaches, take one);
|
|
7414
|
+
one fire later -> a final warning that the next recurrence hard-fails;
|
|
7415
|
+
HARD_AT -> raise ErrorLoopHardBlock (served as a 400) and record the
|
|
7416
|
+
incident for `uap loops`. A fresh human user turn re-arms,
|
|
7417
|
+
mirroring ERROR-LOOP. Advisory tiers do not touch tool_choice:
|
|
7418
|
+
the model should keep acting, just never with THAT call again."""
|
|
7419
|
+
streak = _lockstep_streak(monitor)
|
|
7420
|
+
if streak < PROXY_LOCKSTEP_PIVOT_AT or PROXY_LOCKSTEP_PIVOT_AT <= 0:
|
|
7421
|
+
# The lockstep broke (a different call, a new error, or a clean
|
|
7422
|
+
# result): reset the ladder so the NEXT loop climbs it from the pivot
|
|
7423
|
+
# tier instead of inheriting "directives ignored" from a previous,
|
|
7424
|
+
# resolved loop.
|
|
7425
|
+
monitor.lockstep_fires = 0
|
|
7426
|
+
monitor.lockstep_last_fire_streak = 0
|
|
7427
|
+
return
|
|
7428
|
+
# Recon-convergence may strip tools / force a terminal summary this turn;
|
|
7429
|
+
# a "take a different approach now" directive would contradict it. Mirrors
|
|
7430
|
+
# DOUBLING-DOWN's yield.
|
|
7431
|
+
if monitor.recon_convergence_pending():
|
|
7432
|
+
return
|
|
7433
|
+
# Deliberately NO yield to STUCK-BREAK: its repeat-call signal is
|
|
7434
|
+
# continuously true during exactly the loop this guard owns, so yielding
|
|
7435
|
+
# would suppress the decisive tier in the target incident shape. The
|
|
7436
|
+
# directives are complementary anyway -- both demand abandoning the
|
|
7437
|
+
# identical call -- and lockstep's diagnosis steps are prose-doable on a
|
|
7438
|
+
# tool-stripped turn.
|
|
7439
|
+
tool = (monitor.doubling_fp or "").split("|", 1)[0].split(":", 1)[0] or "tool"
|
|
7440
|
+
if (
|
|
7441
|
+
PROXY_LOCKSTEP_HARD_AT > 0
|
|
7442
|
+
and streak >= PROXY_LOCKSTEP_HARD_AT
|
|
7443
|
+
and monitor.lockstep_fires >= 2
|
|
7444
|
+
):
|
|
7445
|
+
if _has_fresh_user_turn(openai_body):
|
|
7446
|
+
monitor.lockstep_fires = 0
|
|
7447
|
+
monitor.lockstep_last_fire_streak = 0
|
|
7448
|
+
logger.warning(
|
|
7449
|
+
"LOCKSTEP: human turn after %d hard stop(s) -- guard re-armed",
|
|
7450
|
+
monitor.lockstep_blocks,
|
|
7451
|
+
)
|
|
7452
|
+
return
|
|
7453
|
+
monitor.lockstep_blocks += 1
|
|
7454
|
+
# Refuse EVERY retry (that is the hard stop) but record the incident
|
|
7455
|
+
# once per new depth -- a client retrying the refused request resends
|
|
7456
|
+
# the identical transcript and must not spam the ledger.
|
|
7457
|
+
if streak > monitor.lockstep_last_fire_streak:
|
|
7458
|
+
monitor.lockstep_last_fire_streak = streak
|
|
7459
|
+
_record_loop_incident(monitor, "hard_blocked", f"{tool} x{streak}")
|
|
7460
|
+
raise LockstepHardBlock(
|
|
7461
|
+
"LOOP-ESCALATE hard stop: the identical "
|
|
7462
|
+
+ tool
|
|
7463
|
+
+ " call has now failed "
|
|
7464
|
+
+ str(streak)
|
|
7465
|
+
+ " times with the identical error, and both pivot directives were "
|
|
7466
|
+
"ignored. Retrying a deterministic failure cannot work, so the "
|
|
7467
|
+
"proxy refuses to burn another full-context prefill on this loop "
|
|
7468
|
+
"(operator policy PROXY_LOCKSTEP_HARD_AT="
|
|
7469
|
+
+ str(PROXY_LOCKSTEP_HARD_AT)
|
|
7470
|
+
+ "). The incident was recorded to the loop ledger -- run "
|
|
7471
|
+
"`uap loops` to inspect it and hand it to `uap ideate` / "
|
|
7472
|
+
"`uap deliver`, or send a normal user message to re-arm the guard. "
|
|
7473
|
+
"Failing signature: \""
|
|
7474
|
+
+ monitor.last_error_signature[:120]
|
|
7475
|
+
+ "\""
|
|
7476
|
+
)
|
|
7477
|
+
# Duplicate observation of an already-addressed depth: the client resent
|
|
7478
|
+
# the same trailing transcript. The directive for this depth is already in
|
|
7479
|
+
# the conversation; injecting again would stack copies per retry.
|
|
7480
|
+
if monitor.lockstep_fires > 0 and streak <= monitor.lockstep_last_fire_streak:
|
|
7481
|
+
return
|
|
7482
|
+
if monitor.lockstep_fires == 0:
|
|
7483
|
+
outcome = "pivot"
|
|
7484
|
+
directive = (
|
|
7485
|
+
"\n\nLOOP-ESCALATE -- the identical "
|
|
7486
|
+
+ tool
|
|
7487
|
+
+ " call has failed "
|
|
7488
|
+
+ str(streak)
|
|
7489
|
+
+ " times with the IDENTICAL error: \""
|
|
7490
|
+
+ monitor.last_error_signature[:120]
|
|
7491
|
+
+ "\". Retrying this exact call CANNOT work -- the failure is "
|
|
7492
|
+
"deterministic, so do not issue it again. Switch strategy now:\n"
|
|
7493
|
+
"1. State in one sentence WHY the call fails (quote the decisive "
|
|
7494
|
+
"line of the error).\n"
|
|
7495
|
+
"2. List 2-3 genuinely different approaches -- a different tool, a "
|
|
7496
|
+
"different target, or a smaller verifiable step.\n"
|
|
7497
|
+
"3. Take the most promising one on your next tool turn -- or, if "
|
|
7498
|
+
"tools are unavailable this turn, state the choice now.\n"
|
|
7499
|
+
"If no alternative exists, stop and report the blocker in one "
|
|
7500
|
+
"sentence instead of retrying."
|
|
7501
|
+
)
|
|
7502
|
+
else:
|
|
7503
|
+
outcome = "final_warning"
|
|
7504
|
+
# Accurate deadline: the stop lands at max(HARD_AT, the turn both
|
|
7505
|
+
# directives have been given), so phrase from the knob rather than
|
|
7506
|
+
# promising "once more" when HARD_AT sits further out.
|
|
7507
|
+
if streak + 1 >= PROXY_LOCKSTEP_HARD_AT:
|
|
7508
|
+
deadline = "If the same call with the same error recurs once more"
|
|
7509
|
+
else:
|
|
7510
|
+
deadline = (
|
|
7511
|
+
"If the same call keeps failing through streak "
|
|
7512
|
+
+ str(PROXY_LOCKSTEP_HARD_AT)
|
|
7513
|
+
)
|
|
7514
|
+
directive = (
|
|
7515
|
+
"\n\nFINAL WARNING -- the identical failing "
|
|
7516
|
+
+ tool
|
|
7517
|
+
+ " call was retried AGAIN after the pivot directive. "
|
|
7518
|
+
+ deadline
|
|
7519
|
+
+ ", the proxy HARD-FAILS this session (400) and records the loop "
|
|
7520
|
+
"incident for `uap loops`. Choose one of the alternatives you "
|
|
7521
|
+
"listed, or stop and report the blocker."
|
|
7522
|
+
)
|
|
7523
|
+
monitor.lockstep_fires += 1
|
|
7524
|
+
monitor.lockstep_last_fire_streak = streak
|
|
7525
|
+
_record_loop_incident(monitor, outcome, f"{tool} x{streak}")
|
|
7526
|
+
msgs = openai_body.get("messages")
|
|
7527
|
+
if not isinstance(msgs, list):
|
|
7528
|
+
msgs = []
|
|
7529
|
+
if msgs and msgs[0].get("role") == "system":
|
|
7530
|
+
msgs[0]["content"] = (msgs[0].get("content") or "") + directive
|
|
7531
|
+
else:
|
|
7532
|
+
msgs.insert(0, {"role": "system", "content": directive.strip()})
|
|
7533
|
+
openai_body["messages"] = msgs
|
|
7534
|
+
logger.warning(
|
|
7535
|
+
"LOCKSTEP: %s injected (%s x%d, sig=%r, fires=%d)",
|
|
7536
|
+
outcome,
|
|
7537
|
+
tool,
|
|
7538
|
+
streak,
|
|
7539
|
+
monitor.last_error_signature[:80],
|
|
7540
|
+
monitor.lockstep_fires,
|
|
7541
|
+
)
|
|
7542
|
+
|
|
7543
|
+
|
|
7264
7544
|
class ErrorLoopHardBlock(Exception):
|
|
7265
7545
|
"""A session kept re-hitting the SAME tool_result error after
|
|
7266
7546
|
PROXY_ERROR_LOOP_HARD_LIMIT injected re-read nudges. The /v1/messages
|
|
@@ -7268,6 +7548,12 @@ class ErrorLoopHardBlock(Exception):
|
|
|
7268
7548
|
prefills instead of being nudged yet again."""
|
|
7269
7549
|
|
|
7270
7550
|
|
|
7551
|
+
class LockstepHardBlock(ErrorLoopHardBlock):
|
|
7552
|
+
"""The LOCKSTEP ladder's terminal tier. Subclasses ErrorLoopHardBlock so
|
|
7553
|
+
the single existing 400 handler serves it unchanged; the distinct type
|
|
7554
|
+
lets that handler attribute the stop to the right guard."""
|
|
7555
|
+
|
|
7556
|
+
|
|
7271
7557
|
def _has_fresh_user_turn(openai_body: dict) -> bool:
|
|
7272
7558
|
"""True when the request's LAST message is a human user turn (text), not a
|
|
7273
7559
|
tool_result chain. In an unattended tool loop the tail is always a tool
|
|
@@ -7304,6 +7590,11 @@ def _maybe_inject_error_loop_break(openai_body: dict, monitor: "SessionMonitor")
|
|
|
7304
7590
|
the guard."""
|
|
7305
7591
|
if not PROXY_ERROR_LOOP:
|
|
7306
7592
|
return
|
|
7593
|
+
# LOCKSTEP owns the identical-call-identical-error case: its ladder is
|
|
7594
|
+
# faster and its directive correct (pivot to a different approach, not
|
|
7595
|
+
# re-read a file that may not exist). Yield rather than stack directives.
|
|
7596
|
+
if _lockstep_active(monitor):
|
|
7597
|
+
return
|
|
7307
7598
|
if monitor.error_signature_streak < PROXY_ERROR_LOOP_THRESHOLD:
|
|
7308
7599
|
return
|
|
7309
7600
|
if (
|
|
@@ -7435,6 +7726,10 @@ def _maybe_inject_doubling_break(openai_body: dict, monitor: "SessionMonitor") -
|
|
|
7435
7726
|
should, reason = monitor.should_force_doubling_break()
|
|
7436
7727
|
if not should:
|
|
7437
7728
|
return
|
|
7729
|
+
# LOCKSTEP owns the identical-call-identical-error case (faster ladder,
|
|
7730
|
+
# harder stop); only the varied-error doubling shape remains here.
|
|
7731
|
+
if _lockstep_active(monitor):
|
|
7732
|
+
return
|
|
7438
7733
|
# STUCK-BREAK (a self-aware loop wanting a prose exit) is more urgent.
|
|
7439
7734
|
stuck, _ = monitor.should_force_stuck_break()
|
|
7440
7735
|
if stuck:
|
|
@@ -8619,6 +8914,12 @@ def build_openai_request(
|
|
|
8619
8914
|
|
|
8620
8915
|
_maybe_inject_stuck_break(openai_body, monitor)
|
|
8621
8916
|
|
|
8917
|
+
# LOCKSTEP ESCALATION: the identical call failing with the identical error.
|
|
8918
|
+
# Runs before ERROR-LOOP/DOUBLING-DOWN, which yield to it -- its ladder
|
|
8919
|
+
# (pivot -> final warning -> hard stop + loop-incident record) is decisive
|
|
8920
|
+
# where their nudges drifted for 27 turns on 2026-09-25.
|
|
8921
|
+
_maybe_lockstep_escalate(openai_body, monitor)
|
|
8922
|
+
|
|
8622
8923
|
# ERROR-LOOP: same tool_result failure recurring despite varied edits.
|
|
8623
8924
|
_maybe_inject_error_loop_break(openai_body, monitor)
|
|
8624
8925
|
|
|
@@ -11441,7 +11742,7 @@ def _build_safe_text_openai_response(
|
|
|
11441
11742
|
},
|
|
11442
11743
|
}
|
|
11443
11744
|
],
|
|
11444
|
-
"usage": openai_resp.get("usage"
|
|
11745
|
+
"usage": openai_resp.get("usage") or {},
|
|
11445
11746
|
}
|
|
11446
11747
|
|
|
11447
11748
|
|
|
@@ -11463,7 +11764,7 @@ def _build_clean_guardrail_openai_response(
|
|
|
11463
11764
|
},
|
|
11464
11765
|
}
|
|
11465
11766
|
],
|
|
11466
|
-
"usage": openai_resp.get("usage"
|
|
11767
|
+
"usage": openai_resp.get("usage") or {},
|
|
11467
11768
|
}
|
|
11468
11769
|
|
|
11469
11770
|
|
|
@@ -11656,7 +11957,7 @@ async def _apply_malformed_tool_guardrail(
|
|
|
11656
11957
|
# Option 2: Log garbled argument content for diagnostics
|
|
11657
11958
|
arg_excerpt = ""
|
|
11658
11959
|
if issue.kind == "invalid_tool_args":
|
|
11659
|
-
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls"
|
|
11960
|
+
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls") or []):
|
|
11660
11961
|
raw_args = tc.get("function", {}).get("arguments", "")
|
|
11661
11962
|
if raw_args and _is_garbled_tool_arguments(raw_args):
|
|
11662
11963
|
arg_excerpt = raw_args[:200].replace("\n", " ")
|
|
@@ -11683,7 +11984,7 @@ async def _apply_malformed_tool_guardrail(
|
|
|
11683
11984
|
# Track failing tool names for tool narrowing on retry
|
|
11684
11985
|
failing_tools: set[str] = set()
|
|
11685
11986
|
if issue.kind == "invalid_tool_args":
|
|
11686
|
-
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls"
|
|
11987
|
+
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls") or []):
|
|
11687
11988
|
fn_name = tc.get("function", {}).get("name", "")
|
|
11688
11989
|
raw_args = tc.get("function", {}).get("arguments", "")
|
|
11689
11990
|
if fn_name and raw_args and _is_garbled_tool_arguments(raw_args):
|
|
@@ -11794,7 +12095,7 @@ async def _apply_malformed_tool_guardrail(
|
|
|
11794
12095
|
monitor.invalid_tool_call_streak += 1
|
|
11795
12096
|
monitor.arg_preflight_rejections += 1
|
|
11796
12097
|
# Track failing tools from retries for progressive narrowing
|
|
11797
|
-
for tc in (retry_working.get("choices", [{}])[0].get("message", {}).get("tool_calls"
|
|
12098
|
+
for tc in (retry_working.get("choices", [{}])[0].get("message", {}).get("tool_calls") or []):
|
|
11798
12099
|
fn_name = tc.get("function", {}).get("name", "")
|
|
11799
12100
|
raw_args = tc.get("function", {}).get("arguments", "")
|
|
11800
12101
|
if fn_name and raw_args and _is_garbled_tool_arguments(raw_args):
|
|
@@ -12668,7 +12969,7 @@ def openai_to_anthropic_response(
|
|
|
12668
12969
|
content.append({"type": "text", "text": sanitized_text})
|
|
12669
12970
|
|
|
12670
12971
|
# Convert tool calls
|
|
12671
|
-
for tc in message.get("tool_calls"
|
|
12972
|
+
for tc in message.get("tool_calls") or []:
|
|
12672
12973
|
fn = tc.get("function", {})
|
|
12673
12974
|
try:
|
|
12674
12975
|
args = json.loads(fn.get("arguments", "{}"))
|
|
@@ -12735,7 +13036,7 @@ def openai_to_anthropic_response(
|
|
|
12735
13036
|
"function_call": "tool_use",
|
|
12736
13037
|
}
|
|
12737
13038
|
|
|
12738
|
-
usage = openai_resp.get("usage"
|
|
13039
|
+
usage = openai_resp.get("usage") or {}
|
|
12739
13040
|
|
|
12740
13041
|
_log_non_stream_resp(content, finish, usage)
|
|
12741
13042
|
|
|
@@ -13754,7 +14055,24 @@ async def count_tokens(request: Request):
|
|
|
13754
14055
|
media_type="application/json",
|
|
13755
14056
|
)
|
|
13756
14057
|
est = estimate_total_tokens(body)
|
|
13757
|
-
|
|
14058
|
+
# This endpoint has no session contextvar, but the client sends the same
|
|
14059
|
+
# profile header here as on /v1/messages — so the session's real window is
|
|
14060
|
+
# recoverable from the request itself. Without this the scale is computed
|
|
14061
|
+
# from the shared pool and a profile-capped client is told to compact at a
|
|
14062
|
+
# point it can never reach.
|
|
14063
|
+
_profile_window = 0
|
|
14064
|
+
# getattr: this endpoint is also driven directly by tests with a minimal
|
|
14065
|
+
# request stub, and a missing header map must degrade to "no profile",
|
|
14066
|
+
# never raise.
|
|
14067
|
+
_prof = _resolve_profile_name(getattr(request, "headers", {}) or {}, body)
|
|
14068
|
+
if _prof:
|
|
14069
|
+
_cfg = _load_profile_config(_prof)
|
|
14070
|
+
if _cfg and _cfg.get("context_window"):
|
|
14071
|
+
try:
|
|
14072
|
+
_profile_window = int(_cfg["context_window"])
|
|
14073
|
+
except (TypeError, ValueError):
|
|
14074
|
+
_profile_window = 0
|
|
14075
|
+
scale = _count_tokens_scale(_profile_window)
|
|
13758
14076
|
if scale > 1.0:
|
|
13759
14077
|
scaled = int(est * scale)
|
|
13760
14078
|
# Once per scale value, explain the discrepancy an operator would
|
|
@@ -13768,7 +14086,7 @@ async def count_tokens(request: Request):
|
|
|
13768
14086
|
"fires at ~%d real tokens, before the pruner",
|
|
13769
14087
|
scale,
|
|
13770
14088
|
PROXY_CLIENT_ASSUMED_WINDOW,
|
|
13771
|
-
_effective_context_window(),
|
|
14089
|
+
_profile_window or _effective_context_window(),
|
|
13772
14090
|
int(PROXY_CLIENT_ASSUMED_WINDOW * 0.925 / scale),
|
|
13773
14091
|
)
|
|
13774
14092
|
return {"input_tokens": scaled}
|
|
@@ -13778,12 +14096,50 @@ async def count_tokens(request: Request):
|
|
|
13778
14096
|
_count_scale_logged: float = 0.0
|
|
13779
14097
|
|
|
13780
14098
|
|
|
13781
|
-
def
|
|
14099
|
+
def _scale_window() -> int:
|
|
14100
|
+
"""The window compaction forcing should be computed against.
|
|
14101
|
+
|
|
14102
|
+
The per-SESSION window when one is in force, else the process default.
|
|
14103
|
+
|
|
14104
|
+
This used to be `_effective_context_window()` unconditionally, which is the
|
|
14105
|
+
process-wide value detected from /slots. That silently broke the moment a
|
|
14106
|
+
session carried a profile that capped it BELOW the detected rail, because
|
|
14107
|
+
the two numbers then disagree and only one of them bounds the session:
|
|
14108
|
+
|
|
14109
|
+
detected rail 229376 (shared pool, -np 2 + --kv-unified)
|
|
14110
|
+
profile cap 114688 (what the session may actually use)
|
|
14111
|
+
compact fires 123060 computed from the RAIL -> ABOVE the cap
|
|
14112
|
+
prune fires 80282 computed from the CAP
|
|
14113
|
+
|
|
14114
|
+
Compaction became unreachable and the pruner — designed as a backstop —
|
|
14115
|
+
became the only context manager. That is a real loss, not a wash: the
|
|
14116
|
+
client's compaction writes an LLM summary, while a prune drops the messages
|
|
14117
|
+
and leaves a short breadcrumb list. Resolving the session window first
|
|
14118
|
+
restores the intended ordering (compact ~61.5k, then prune at 80.2k).
|
|
14119
|
+
"""
|
|
14120
|
+
try:
|
|
14121
|
+
sid = _current_request_session.get()
|
|
14122
|
+
except LookupError:
|
|
14123
|
+
sid = None
|
|
14124
|
+
if sid:
|
|
14125
|
+
mon = session_monitors.get(sid)
|
|
14126
|
+
win = getattr(mon, "context_window", 0) if mon else 0
|
|
14127
|
+
if win and win > 0:
|
|
14128
|
+
return int(win)
|
|
14129
|
+
return _effective_context_window()
|
|
14130
|
+
|
|
14131
|
+
|
|
14132
|
+
def _count_tokens_scale(window_override: int = 0) -> float:
|
|
13782
14133
|
"""Resolve the count_tokens compaction-forcing scale (>= 1.0; 1.0 = off).
|
|
13783
14134
|
|
|
13784
14135
|
"auto" derives it from the LIVE rail each call so a rail resize (server
|
|
13785
14136
|
restart with a different --ctx-size) re-tunes the client's compact point
|
|
13786
14137
|
without a proxy restart.
|
|
14138
|
+
|
|
14139
|
+
`window_override` lets a caller that knows the session's window — the
|
|
14140
|
+
count_tokens endpoint, which resolves it from the request's own profile
|
|
14141
|
+
header — supply it directly, since that endpoint runs outside the
|
|
14142
|
+
request-session contextvar that `_scale_window` reads.
|
|
13787
14143
|
"""
|
|
13788
14144
|
raw = PROXY_COUNT_TOKENS_SCALE.strip().lower()
|
|
13789
14145
|
if raw in ("", "off", "none", "0", "1", "1.0"):
|
|
@@ -13793,7 +14149,7 @@ def _count_tokens_scale() -> float:
|
|
|
13793
14149
|
return max(1.0, float(raw))
|
|
13794
14150
|
except ValueError:
|
|
13795
14151
|
return 1.0
|
|
13796
|
-
window =
|
|
14152
|
+
window = window_override if window_override > 0 else _scale_window()
|
|
13797
14153
|
if window <= 0:
|
|
13798
14154
|
return 1.0
|
|
13799
14155
|
frac = (
|
|
@@ -14117,9 +14473,11 @@ async def messages(request: Request):
|
|
|
14117
14473
|
profile_grammar=profile_grammar,
|
|
14118
14474
|
)
|
|
14119
14475
|
except ErrorLoopHardBlock as exc:
|
|
14476
|
+
lockstep = isinstance(exc, LockstepHardBlock)
|
|
14120
14477
|
logger.error(
|
|
14121
|
-
"
|
|
14122
|
-
|
|
14478
|
+
"%s hard stop served (blocks=%d): %s",
|
|
14479
|
+
"LOOP-ESCALATE" if lockstep else "ERROR-LOOP",
|
|
14480
|
+
monitor.lockstep_blocks if lockstep else monitor.error_loop_blocks,
|
|
14123
14481
|
str(exc)[:160],
|
|
14124
14482
|
)
|
|
14125
14483
|
return Response(
|
|
@@ -14984,7 +15342,7 @@ async def chat_completions(request: Request):
|
|
|
14984
15342
|
yield f"data: {json.dumps(content_chunk)}\n\n".encode()
|
|
14985
15343
|
|
|
14986
15344
|
# Tool call chunks
|
|
14987
|
-
for idx, tc in enumerate(message.get("tool_calls"
|
|
15345
|
+
for idx, tc in enumerate(message.get("tool_calls") or []):
|
|
14988
15346
|
tc_chunk = {
|
|
14989
15347
|
"id": resp_id,
|
|
14990
15348
|
"object": "chat.completion.chunk",
|
|
@@ -50,7 +50,7 @@ class Settings:
|
|
|
50
50
|
allow_self_judge: bool = False
|
|
51
51
|
#: Backend width: how many generations the upstream serves CONCURRENTLY.
|
|
52
52
|
#: None = UNKNOWN, which must change nothing (not every backend can be
|
|
53
|
-
#: probed --
|
|
53
|
+
#: probed -- some engines serve no /slots endpoint, and a probe can fail -- so silence is not evidence).
|
|
54
54
|
#: A FIELD rather than a process-env read at decision time: select_recipe is
|
|
55
55
|
#: called per request and an ambient UAP_MODEL_SLOTS=1 (which is exactly what
|
|
56
56
|
#: writeProxyEnv now puts in proxy.env, and what _load_proxy_env_file
|
|
@@ -336,7 +336,9 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
|
|
|
336
336
|
# N=3 costs 3x the wall clock of a single call for the same answer, and the
|
|
337
337
|
# deliver run paying it has a fixed wall-clock budget to finish in.
|
|
338
338
|
#
|
|
339
|
-
# Measured live (2026-08-20,
|
|
339
|
+
# Measured live on a ONE-RAIL backend (2026-08-20, --max-concurrency 1;
|
|
340
|
+
# that engine has since been replaced by llama.cpp with -np 2, so the
|
|
341
|
+
# downgrade now fires only when the probed width really is 1): tool
|
|
340
342
|
# -carrying turns already bypass recipes via `has_tools` above, but mission
|
|
341
343
|
# PLANNING carries no tools -- so it ran under fusion N=3 and took ~20 minutes,
|
|
342
344
|
# 17% of a 120-minute run budget, before turn 1 existed.
|
|
@@ -5335,7 +5335,10 @@ class TestModelsEndpoint(unittest.TestCase):
|
|
|
5335
5335
|
|
|
5336
5336
|
# Local model — what the inference server actually serves. Tracks the
|
|
5337
5337
|
# active model: qwen3.8-27b as of 2026-08-19, served by ninfer-serve
|
|
5338
|
-
#
|
|
5338
|
+
# then; as of 2026-09-21 the backend is buun-llama-cpp serving the alias
|
|
5339
|
+
# qwen38-gsq-rco-27b. The id here still routes locally either way —
|
|
5340
|
+
# llama.cpp ignores the OpenAI `model` field — which is the property
|
|
5341
|
+
# under test. Requests for this ID route locally even with the
|
|
5339
5342
|
# __local_only__ passthrough sentinel set.
|
|
5340
5343
|
#
|
|
5341
5344
|
# This assertion is about AGREEMENT with the backend, not about naming.
|
|
@@ -6186,5 +6189,53 @@ class TestSendStreamWithRetry(unittest.TestCase):
|
|
|
6186
6189
|
self.assertEqual(client.requests[0]["url"], "http://x/v1/chat/completions")
|
|
6187
6190
|
|
|
6188
6191
|
|
|
6192
|
+
class TestNullToolCallsAndUsage(unittest.TestCase):
|
|
6193
|
+
"""tabbyAPI/exllamav3 serializes explicit nulls where llama.cpp omits the
|
|
6194
|
+
keys entirely: "tool_calls": null and "usage": null. dict.get(key, default)
|
|
6195
|
+
returns None for present-but-null keys, so every consumer must coerce with
|
|
6196
|
+
`or []` / `or {}`. Regression coverage for the 2026-09-23 EXL3 cutover
|
|
6197
|
+
500s (TypeError: 'NoneType' object is not iterable)."""
|
|
6198
|
+
|
|
6199
|
+
def test_null_tool_calls_treated_as_absent(self):
|
|
6200
|
+
openai_resp = {
|
|
6201
|
+
"choices": [{
|
|
6202
|
+
"finish_reason": "stop",
|
|
6203
|
+
"message": {"role": "assistant", "content": "hello", "tool_calls": None},
|
|
6204
|
+
}],
|
|
6205
|
+
"usage": {"prompt_tokens": 5, "completion_tokens": 3},
|
|
6206
|
+
}
|
|
6207
|
+
out = proxy.openai_to_anthropic_response(openai_resp, "qwen3.8")
|
|
6208
|
+
self.assertEqual(out["stop_reason"], "end_turn")
|
|
6209
|
+
self.assertEqual(out["content"], [{"type": "text", "text": "hello"}])
|
|
6210
|
+
|
|
6211
|
+
def test_null_usage_defaults_to_zero_counts(self):
|
|
6212
|
+
openai_resp = {
|
|
6213
|
+
"choices": [{
|
|
6214
|
+
"finish_reason": "stop",
|
|
6215
|
+
"message": {"role": "assistant", "content": "hi"},
|
|
6216
|
+
}],
|
|
6217
|
+
"usage": None,
|
|
6218
|
+
}
|
|
6219
|
+
out = proxy.openai_to_anthropic_response(openai_resp, "qwen3.8")
|
|
6220
|
+
self.assertEqual(out["usage"]["input_tokens"], 0)
|
|
6221
|
+
self.assertEqual(out["usage"]["output_tokens"], 0)
|
|
6222
|
+
|
|
6223
|
+
def test_null_tool_calls_and_null_usage_together(self):
|
|
6224
|
+
# exact response shape observed from tabbyAPI non-streaming completions
|
|
6225
|
+
openai_resp = {
|
|
6226
|
+
"id": "chatcmpl-x",
|
|
6227
|
+
"object": "chat.completion",
|
|
6228
|
+
"choices": [{
|
|
6229
|
+
"index": 0,
|
|
6230
|
+
"finish_reason": "stop",
|
|
6231
|
+
"message": {"role": "assistant", "content": "PROXYOK", "tool_calls": None},
|
|
6232
|
+
}],
|
|
6233
|
+
"usage": None,
|
|
6234
|
+
}
|
|
6235
|
+
out = proxy.openai_to_anthropic_response(openai_resp, "qwen3.8")
|
|
6236
|
+
self.assertEqual(out["content"][0]["text"], "PROXYOK")
|
|
6237
|
+
self.assertEqual(out["usage"]["output_tokens"], 0)
|
|
6238
|
+
|
|
6239
|
+
|
|
6189
6240
|
if __name__ == "__main__":
|
|
6190
6241
|
unittest.main()
|