@miller-tech/uap 1.212.6 → 1.214.1
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/model-profiles/qwen38.json +70 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +4 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +107 -4
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/self-harness.js +1 -1
- package/dist/cli/self-harness.js.map +1 -1
- package/dist/cli/self-tuning.js +1 -1
- package/dist/cli/verify.d.ts +5 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +50 -1
- package/dist/cli/verify.js.map +1 -1
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/cli/wizard-config.js +26 -1
- package/dist/cli/wizard-config.js.map +1 -1
- package/dist/config/settings-registry.js +1 -1
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +5 -1
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/await-run.d.ts +47 -0
- package/dist/delivery/await-run.d.ts.map +1 -1
- package/dist/delivery/await-run.js +392 -2
- package/dist/delivery/await-run.js.map +1 -1
- package/dist/delivery/capability-profile.d.ts +124 -0
- package/dist/delivery/capability-profile.d.ts.map +1 -0
- package/dist/delivery/capability-profile.js +392 -0
- package/dist/delivery/capability-profile.js.map +1 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +21 -1
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/execution-gate.d.ts +29 -0
- package/dist/delivery/execution-gate.d.ts.map +1 -1
- package/dist/delivery/execution-gate.js +237 -9
- package/dist/delivery/execution-gate.js.map +1 -1
- package/dist/delivery/mission-acceptance.d.ts +4 -0
- package/dist/delivery/mission-acceptance.d.ts.map +1 -1
- package/dist/delivery/mission-acceptance.js +35 -1
- package/dist/delivery/mission-acceptance.js.map +1 -1
- package/dist/delivery/run-state.d.ts +31 -0
- package/dist/delivery/run-state.d.ts.map +1 -1
- package/dist/delivery/run-state.js +49 -0
- package/dist/delivery/run-state.js.map +1 -1
- package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
- package/dist/mcp-router/tools/deliver.js +5 -2
- package/dist/mcp-router/tools/deliver.js.map +1 -1
- package/dist/models/local-model.d.ts +59 -0
- package/dist/models/local-model.d.ts.map +1 -0
- package/dist/models/local-model.js +123 -0
- package/dist/models/local-model.js.map +1 -0
- package/dist/models/openai-compat-client.d.ts.map +1 -1
- package/dist/models/openai-compat-client.js +6 -1
- package/dist/models/openai-compat-client.js.map +1 -1
- package/dist/models/profile-map.d.ts.map +1 -1
- package/dist/models/profile-map.js +9 -2
- package/dist/models/profile-map.js.map +1 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +69 -21
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/index.d.ts +1 -0
- package/dist/self-tuning/profiles/index.d.ts.map +1 -1
- package/dist/self-tuning/profiles/index.js +3 -0
- package/dist/self-tuning/profiles/index.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +23 -0
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -0
- package/dist/self-tuning/profiles/qwen38.js +46 -0
- package/dist/self-tuning/profiles/qwen38.js.map +1 -0
- package/dist/utils/model-slots.d.ts +17 -1
- package/dist/utils/model-slots.d.ts.map +1 -1
- package/dist/utils/model-slots.js +25 -3
- package/dist/utils/model-slots.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lib/llama-upstream.sh +64 -16
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- 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 +306 -7
- package/tools/agents/scripts/confidence_escalation.py +57 -0
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +12 -6
- package/tools/agents/tests/test_confidence_escalation.py +140 -2
- package/tools/agents/tests/test_thinking_template_kwargs.py +366 -1
|
@@ -1162,6 +1162,247 @@ PROXY_DISABLE_THINKING_ON_TOOL_TURNS = os.environ.get(
|
|
|
1162
1162
|
PROXY_DISABLE_THINKING_ALWAYS = os.environ.get(
|
|
1163
1163
|
"PROXY_DISABLE_THINKING_ALWAYS", "off"
|
|
1164
1164
|
).lower() not in {"0", "false", "off", "no"}
|
|
1165
|
+
# Does the upstream accept `chat_template_kwargs`?
|
|
1166
|
+
#
|
|
1167
|
+
# It is a llama.cpp `--jinja` concept: the Qwen chat template reads its
|
|
1168
|
+
# `enable_thinking` key, and _set_thinking writes it because the top-level flag
|
|
1169
|
+
# alone is silently overridden there (see that function). It is NOT part of the
|
|
1170
|
+
# OpenAI API, and a backend that never had a jinja template has no reason to
|
|
1171
|
+
# accept it. ninfer-serve (the qwen3.8-27b backend, 2026-08-19) REJECTS it:
|
|
1172
|
+
#
|
|
1173
|
+
# HTTP 400 {"error":{"code":"chat_template_option_not_supported",
|
|
1174
|
+
# "message":"chat_template_kwargs.enable_thinking is not supported",
|
|
1175
|
+
# "param":"chat_template_kwargs","type":"invalid_request_error"}}
|
|
1176
|
+
#
|
|
1177
|
+
# which killed every turn that expressed a thinking intent — the operator
|
|
1178
|
+
# switches, the tool-turn breakers, the JSON-verdict grammar, the empty-response
|
|
1179
|
+
# retry and every assistant prefill/continuation.
|
|
1180
|
+
#
|
|
1181
|
+
# 'auto' (default) probes the upstream once and remembers; 'on'/'off' pin it for
|
|
1182
|
+
# an operator who knows their backend and does not want the probe.
|
|
1183
|
+
PROXY_CHAT_TEMPLATE_KWARGS = os.environ.get(
|
|
1184
|
+
"PROXY_CHAT_TEMPLATE_KWARGS", "auto"
|
|
1185
|
+
).strip().lower()
|
|
1186
|
+
|
|
1187
|
+
# Tri-state: True = accepted, False = rejected, None = not yet known.
|
|
1188
|
+
_ctk_supported: bool | None = (
|
|
1189
|
+
True
|
|
1190
|
+
if PROXY_CHAT_TEMPLATE_KWARGS in {"1", "true", "on", "yes"}
|
|
1191
|
+
else False
|
|
1192
|
+
if PROXY_CHAT_TEMPLATE_KWARGS in {"0", "false", "off", "no"}
|
|
1193
|
+
else None
|
|
1194
|
+
)
|
|
1195
|
+
# 'auto' resolves on first use; a pin is already resolved and must not re-probe.
|
|
1196
|
+
_ctk_probed = _ctk_supported is not None
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _ctk_allowed() -> bool:
|
|
1200
|
+
"""May this request carry `chat_template_kwargs`?
|
|
1201
|
+
|
|
1202
|
+
UNKNOWN COUNTS AS YES. The field is load-bearing on llama.cpp — dropping it
|
|
1203
|
+
there does not fail loudly, it silently restores the template default and
|
|
1204
|
+
every thinking control becomes a no-op again, which is the exact bug
|
|
1205
|
+
_set_thinking was written to fix. A backend that cannot take the field says
|
|
1206
|
+
so with a 400 the probe and the error latch both read, so guessing 'yes' is
|
|
1207
|
+
self-correcting while guessing 'no' is not.
|
|
1208
|
+
"""
|
|
1209
|
+
return _ctk_supported is not False
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _note_ctk_rejection(error_text: str) -> bool:
|
|
1213
|
+
"""Latch 'unsupported' from an upstream error body. Returns True if it did.
|
|
1214
|
+
|
|
1215
|
+
The probe is the primary mechanism; this is the safety net for the case it
|
|
1216
|
+
cannot cover — an upstream swapped underneath a running proxy. Matches the
|
|
1217
|
+
machine-readable error code AND the human message, because only the code is
|
|
1218
|
+
contractual and only the message is guaranteed to survive a proxy in the
|
|
1219
|
+
middle that reshapes the envelope.
|
|
1220
|
+
"""
|
|
1221
|
+
global _ctk_supported, _ctk_probed
|
|
1222
|
+
if _ctk_supported is False or PROXY_CHAT_TEMPLATE_KWARGS in {"1", "true", "on", "yes"}:
|
|
1223
|
+
return False
|
|
1224
|
+
blob = (error_text or "").lower()
|
|
1225
|
+
# The contractual error code is the trustworthy signal. The bare parameter
|
|
1226
|
+
# NAME is not: the proxy puts that exact string in its own outbound body, so
|
|
1227
|
+
# any upstream or middlebox that echoes the request in an error envelope
|
|
1228
|
+
# (LiteLLM does, and one sits in front of this) would latch the field off on
|
|
1229
|
+
# an error that says nothing about capability. Demonstrated in review: a
|
|
1230
|
+
# context-overflow error quoting the payload flipped the latch. Require the
|
|
1231
|
+
# name to co-occur with an actual rejection phrase.
|
|
1232
|
+
named = "chat_template_kwargs" in blob and any(
|
|
1233
|
+
phrase in blob for phrase in ("not supported", "unsupported", "unknown field", "unrecognized")
|
|
1234
|
+
)
|
|
1235
|
+
if "chat_template_option_not_supported" not in blob and not named:
|
|
1236
|
+
return False
|
|
1237
|
+
_ctk_supported = False
|
|
1238
|
+
_ctk_probed = True
|
|
1239
|
+
logger.warning(
|
|
1240
|
+
"CHAT_TEMPLATE_KWARGS: upstream rejected the field - disabling it for "
|
|
1241
|
+
"this process (set PROXY_CHAT_TEMPLATE_KWARGS=on to force it back)"
|
|
1242
|
+
)
|
|
1243
|
+
return True
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
# A probe that keeps re-arming on inconclusive answers would add an upstream
|
|
1247
|
+
# round-trip to EVERY /v1/messages for the life of the process. Bounded.
|
|
1248
|
+
_CTK_PROBE_MAX_ATTEMPTS = 3
|
|
1249
|
+
_ctk_probe_attempts = 0
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
async def _probe_chat_template_kwargs() -> None:
|
|
1253
|
+
"""Ask the upstream, with the cheapest request that can actually answer.
|
|
1254
|
+
|
|
1255
|
+
A 1-token completion, not a GET: whether the field is accepted is a property
|
|
1256
|
+
of the request parser, and no metadata endpoint reports it. Vendor-sniffing
|
|
1257
|
+
/v1/models `owned_by` is deliberately not used as the ANSWER - it tells you
|
|
1258
|
+
which backend this is, a different question that goes stale every time a new
|
|
1259
|
+
backend appears - but it IS used to learn the model name, because a
|
|
1260
|
+
completion without one never reaches the part of the parser under test:
|
|
1261
|
+
|
|
1262
|
+
{"error":{"code":null,"message":"missing required field: model",
|
|
1263
|
+
"param":"model","type":"invalid_request_error"}}
|
|
1264
|
+
|
|
1265
|
+
which is a 400 that says nothing about chat_template_kwargs. Measured: the
|
|
1266
|
+
first version of this probe omitted `model`, got exactly that, correctly
|
|
1267
|
+
declined to latch on an unrelated error, and so learned nothing on every
|
|
1268
|
+
attempt while re-arming itself each time.
|
|
1269
|
+
|
|
1270
|
+
Any answer that is NOT the specific rejection leaves the state UNKNOWN, so a
|
|
1271
|
+
probe that could not run (upstream down, timeout, auth) never disables a
|
|
1272
|
+
field llama.cpp needs.
|
|
1273
|
+
"""
|
|
1274
|
+
global _ctk_supported, _ctk_probed, _ctk_probe_attempts
|
|
1275
|
+
if _ctk_probed or http_client is None:
|
|
1276
|
+
return
|
|
1277
|
+
_ctk_probe_attempts += 1
|
|
1278
|
+
_ctk_probed = True # cleared below only if the answer was inconclusive
|
|
1279
|
+
try:
|
|
1280
|
+
model = await _upstream_model_name()
|
|
1281
|
+
if not model:
|
|
1282
|
+
_ctk_probed = _ctk_probe_attempts >= _CTK_PROBE_MAX_ATTEMPTS
|
|
1283
|
+
return
|
|
1284
|
+
resp = await http_client.post(
|
|
1285
|
+
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
1286
|
+
json={
|
|
1287
|
+
"model": model,
|
|
1288
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
1289
|
+
"max_tokens": 1,
|
|
1290
|
+
"chat_template_kwargs": {"enable_thinking": False},
|
|
1291
|
+
},
|
|
1292
|
+
timeout=20.0,
|
|
1293
|
+
)
|
|
1294
|
+
if resp.status_code == 200:
|
|
1295
|
+
_ctk_supported = True
|
|
1296
|
+
logger.info("CHAT_TEMPLATE_KWARGS: upstream accepts the field")
|
|
1297
|
+
return
|
|
1298
|
+
if _is_loading_model_503(resp):
|
|
1299
|
+
# The backend is still loading its weights. That is not an attempt
|
|
1300
|
+
# at answering the question -- it is the question not being asked
|
|
1301
|
+
# yet. Counting it is how a proxy started alongside its model server
|
|
1302
|
+
# burns all three tries before the server is even up, then runs the
|
|
1303
|
+
# rest of its life with the answer stuck at "unknown".
|
|
1304
|
+
_ctk_probe_attempts -= 1
|
|
1305
|
+
_ctk_probed = False
|
|
1306
|
+
return
|
|
1307
|
+
if not _note_ctk_rejection(resp.text[:1000]):
|
|
1308
|
+
# Some other error (auth, bad model, overload). It says nothing
|
|
1309
|
+
# about this field. Re-open the question, up to the attempt cap.
|
|
1310
|
+
logger.debug(
|
|
1311
|
+
"CHAT_TEMPLATE_KWARGS probe inconclusive (HTTP %d): %s",
|
|
1312
|
+
resp.status_code,
|
|
1313
|
+
resp.text[:200],
|
|
1314
|
+
)
|
|
1315
|
+
_ctk_probed = _ctk_probe_attempts >= _CTK_PROBE_MAX_ATTEMPTS
|
|
1316
|
+
except Exception as e: # noqa: BLE001 - a probe must never take the proxy down
|
|
1317
|
+
_ctk_probed = _ctk_probe_attempts >= _CTK_PROBE_MAX_ATTEMPTS
|
|
1318
|
+
logger.debug("CHAT_TEMPLATE_KWARGS probe failed: %s", e)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# Model ids the upstream actually answers to, or None when not yet known.
|
|
1322
|
+
_upstream_model_ids: list[str] | None = None
|
|
1323
|
+
# Ids we have already reported as unservable, so the log says it once per id.
|
|
1324
|
+
_rewritten_model_ids: set[str] = set()
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
async def _upstream_model_ids_cached() -> list[str] | None:
|
|
1328
|
+
"""The upstream's advertised model ids, fetched at most once per process."""
|
|
1329
|
+
global _upstream_model_ids
|
|
1330
|
+
if _upstream_model_ids is not None:
|
|
1331
|
+
return _upstream_model_ids
|
|
1332
|
+
if http_client is None:
|
|
1333
|
+
return None
|
|
1334
|
+
try:
|
|
1335
|
+
r = await http_client.get(f"{LLAMA_CPP_BASE}/models", timeout=10.0)
|
|
1336
|
+
if r.status_code != 200:
|
|
1337
|
+
return None
|
|
1338
|
+
data = (r.json() or {}).get("data") or []
|
|
1339
|
+
ids = [str(m["id"]) for m in data if isinstance(m, dict) and m.get("id")]
|
|
1340
|
+
if ids:
|
|
1341
|
+
_upstream_model_ids = ids
|
|
1342
|
+
return ids or None
|
|
1343
|
+
except Exception: # noqa: BLE001 - discovery must never take the proxy down
|
|
1344
|
+
return None
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
async def _reconcile_wire_model(openai_body: dict) -> None:
|
|
1348
|
+
"""Point a locally-served request at a model the backend will answer to.
|
|
1349
|
+
|
|
1350
|
+
THE BUG THIS FIXES, measured 2026-08-19. llama.cpp ignores the OpenAI
|
|
1351
|
+
`model` field entirely, so for years the proxy could forward whatever the
|
|
1352
|
+
client asked for. ninfer-serve VALIDATES it. With the local-only sentinel
|
|
1353
|
+
(ANTHROPIC_PASSTHROUGH_MODELS=__local_only__ -- the shipped systemd default)
|
|
1354
|
+
EVERY advertised id is served locally, including the four `claude-*` ones,
|
|
1355
|
+
and every one of them came back:
|
|
1356
|
+
|
|
1357
|
+
HTTP 404 {"code":"model_not_found","message":"model 'claude-sonnet-4-6' not found"}
|
|
1358
|
+
|
|
1359
|
+
So the whole point of advertising those ids for SDK compatibility was
|
|
1360
|
+
defeated by the backend swap, and so was any `.uap.json` still naming a
|
|
1361
|
+
retired local preset (this repo's own `roles.fallback` is one).
|
|
1362
|
+
|
|
1363
|
+
Only rewrites an id the upstream does NOT list, and only when the upstream
|
|
1364
|
+
told us what it serves. A request naming a model the backend knows is left
|
|
1365
|
+
exactly alone, so a genuine multi-model gateway keeps working; a backend we
|
|
1366
|
+
could not interrogate changes nothing. Fails open in both directions.
|
|
1367
|
+
"""
|
|
1368
|
+
requested = openai_body.get("model")
|
|
1369
|
+
if not requested:
|
|
1370
|
+
return
|
|
1371
|
+
ids = await _upstream_model_ids_cached()
|
|
1372
|
+
if not ids or requested in ids:
|
|
1373
|
+
return
|
|
1374
|
+
served = ids[0]
|
|
1375
|
+
openai_body["model"] = served
|
|
1376
|
+
if requested not in _rewritten_model_ids:
|
|
1377
|
+
_rewritten_model_ids.add(requested)
|
|
1378
|
+
logger.warning(
|
|
1379
|
+
"MODEL REWRITE: upstream does not serve %r (it serves %s) - sending %r instead. "
|
|
1380
|
+
"Update the advertised/configured id to stop relying on this.",
|
|
1381
|
+
requested, ", ".join(ids[:4]), served,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
async def _upstream_model_name() -> str | None:
|
|
1386
|
+
"""First model id the upstream advertises, or None.
|
|
1387
|
+
|
|
1388
|
+
Only used to make the capability probe well-formed. Never cached as
|
|
1389
|
+
configuration: the proxy's own advertised list is a separate, deliberate
|
|
1390
|
+
choice (ADVERTISED_MODEL_IDS) and must not silently follow the backend.
|
|
1391
|
+
"""
|
|
1392
|
+
if http_client is None:
|
|
1393
|
+
return None
|
|
1394
|
+
try:
|
|
1395
|
+
r = await http_client.get(f"{LLAMA_CPP_BASE}/models", timeout=10.0)
|
|
1396
|
+
if r.status_code != 200:
|
|
1397
|
+
return None
|
|
1398
|
+
data = (r.json() or {}).get("data") or []
|
|
1399
|
+
first = data[0] if data else {}
|
|
1400
|
+
mid = first.get("id") if isinstance(first, dict) else None
|
|
1401
|
+
return str(mid) if mid else None
|
|
1402
|
+
except Exception: # noqa: BLE001
|
|
1403
|
+
return None
|
|
1404
|
+
|
|
1405
|
+
|
|
1165
1406
|
# Force tool_choice='required' on the first turn of a fresh session. Originally
|
|
1166
1407
|
# Qwen-tuned to break out of cold-start "tries to chat instead of calling a tool"
|
|
1167
1408
|
# behaviour. Gemma 4 doesn't need this — it routes 'auto' correctly and the
|
|
@@ -1576,6 +1817,12 @@ def _set_thinking(body: dict, enabled: bool) -> None:
|
|
|
1576
1817
|
# with `dict(openai_body)`, a SHALLOW copy that still shares this dict, so
|
|
1577
1818
|
# mutating in place would silently disable thinking on the original request
|
|
1578
1819
|
# too. Copying keeps any other template kwargs the server was launched with.
|
|
1820
|
+
if not _ctk_allowed():
|
|
1821
|
+
# Backend rejects the field (see _ctk_allowed). The top-level flag set
|
|
1822
|
+
# above is all this backend can be told; sending the nested form would
|
|
1823
|
+
# 400 the whole turn rather than merely fail to take effect.
|
|
1824
|
+
body.pop("chat_template_kwargs", None)
|
|
1825
|
+
return
|
|
1579
1826
|
existing = body.get("chat_template_kwargs")
|
|
1580
1827
|
ctk = dict(existing) if isinstance(existing, dict) else {}
|
|
1581
1828
|
ctk["enable_thinking"] = enabled
|
|
@@ -7335,10 +7582,18 @@ def build_openai_request(
|
|
|
7335
7582
|
# continuation, so disable thinking the way the template actually reads.
|
|
7336
7583
|
_final_msgs = openai_body.get("messages") or []
|
|
7337
7584
|
if _final_msgs and isinstance(_final_msgs[-1], dict) and _final_msgs[-1].get("role") == "assistant":
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
ctk
|
|
7341
|
-
|
|
7585
|
+
if _ctk_allowed():
|
|
7586
|
+
ctk = openai_body.setdefault("chat_template_kwargs", {})
|
|
7587
|
+
if isinstance(ctk, dict):
|
|
7588
|
+
ctk["enable_thinking"] = False
|
|
7589
|
+
openai_body.pop("enable_thinking", None)
|
|
7590
|
+
else:
|
|
7591
|
+
# No jinja template to instruct, so there is no prefill
|
|
7592
|
+
# incompatibility to work around either — the whole reason this
|
|
7593
|
+
# block sets the nested key. Say it the only way this backend
|
|
7594
|
+
# understands and move on.
|
|
7595
|
+
openai_body.pop("chat_template_kwargs", None)
|
|
7596
|
+
openai_body["enable_thinking"] = False
|
|
7342
7597
|
|
|
7343
7598
|
return openai_body
|
|
7344
7599
|
|
|
@@ -12100,12 +12355,21 @@ async def messages(request: Request):
|
|
|
12100
12355
|
monitor.tool_state_auto_budget_remaining = 1
|
|
12101
12356
|
monitor.reset_completion_recovery()
|
|
12102
12357
|
|
|
12358
|
+
# Whether this upstream accepts chat_template_kwargs decides what
|
|
12359
|
+
# _set_thinking is allowed to write, so it must be answered BEFORE the body
|
|
12360
|
+
# is built -- but only here, AFTER the passthrough branch has already
|
|
12361
|
+
# returned. A cloud-only session was otherwise blocking on a capability
|
|
12362
|
+
# probe of a local backend it will never send a token to.
|
|
12363
|
+
await _probe_chat_template_kwargs()
|
|
12103
12364
|
openai_body = build_openai_request(
|
|
12104
12365
|
body,
|
|
12105
12366
|
monitor,
|
|
12106
12367
|
profile_prompt_suffix=profile_prompt_suffix,
|
|
12107
12368
|
profile_grammar=profile_grammar,
|
|
12108
12369
|
)
|
|
12370
|
+
# Only the WIRE body is touched. The Anthropic response still echoes the
|
|
12371
|
+
# model the client asked for, which is what clients check.
|
|
12372
|
+
await _reconcile_wire_model(openai_body)
|
|
12109
12373
|
|
|
12110
12374
|
client = http_client
|
|
12111
12375
|
if client is None:
|
|
@@ -12230,6 +12494,7 @@ async def messages(request: Request):
|
|
|
12230
12494
|
strict_resp.status_code,
|
|
12231
12495
|
error_text,
|
|
12232
12496
|
)
|
|
12497
|
+
_note_ctk_rejection(error_text)
|
|
12233
12498
|
return Response(
|
|
12234
12499
|
content=json.dumps(
|
|
12235
12500
|
{
|
|
@@ -12495,6 +12760,13 @@ async def messages(request: Request):
|
|
|
12495
12760
|
error_text = error_body.decode("utf-8", errors="replace")[:1000]
|
|
12496
12761
|
|
|
12497
12762
|
logger.error("Upstream HTTP %d: %s", resp.status_code, error_text)
|
|
12763
|
+
# Streaming is the DEFAULT client path, and it was the one handler
|
|
12764
|
+
# of three that never latched. Without this, a proxy whose probe
|
|
12765
|
+
# came back inconclusive (upstream still loading during the first
|
|
12766
|
+
# three requests) keeps sending a field the backend rejects, on
|
|
12767
|
+
# every thinking-intent turn, for the life of the process -- the
|
|
12768
|
+
# exact permanent breakage this capability check exists to prevent.
|
|
12769
|
+
_note_ctk_rejection(error_text)
|
|
12498
12770
|
|
|
12499
12771
|
# Parse the error for a user-friendly message
|
|
12500
12772
|
error_message = f"Upstream server error (HTTP {resp.status_code})"
|
|
@@ -12656,6 +12928,7 @@ async def messages(request: Request):
|
|
|
12656
12928
|
logger.error(
|
|
12657
12929
|
"Upstream HTTP %d (non-stream): %s", resp.status_code, error_text
|
|
12658
12930
|
)
|
|
12931
|
+
_note_ctk_rejection(error_text)
|
|
12659
12932
|
return Response(
|
|
12660
12933
|
content=json.dumps(
|
|
12661
12934
|
{
|
|
@@ -13080,13 +13353,39 @@ def _parse_anthropic_sse_to_message(raw: bytes) -> dict | None:
|
|
|
13080
13353
|
}
|
|
13081
13354
|
|
|
13082
13355
|
|
|
13083
|
-
|
|
13356
|
+
# The Claude ids are a hand-maintained contract: SDK clients check /v1/models for
|
|
13357
|
+
# the id they are about to request, and a discovery miss is a confusing 404 even
|
|
13358
|
+
# though this proxy would have served it.
|
|
13359
|
+
#
|
|
13360
|
+
# The LOCAL id is NOT hand-maintained any more. It was, through three model
|
|
13361
|
+
# generations, and each one went stale the moment the served model changed --
|
|
13362
|
+
# harmlessly under llama.cpp, which ignores the model field, and then all at once
|
|
13363
|
+
# under a backend that validates it. `_advertised_model_ids()` asks the upstream
|
|
13364
|
+
# what it serves and falls back to this constant only when it cannot answer.
|
|
13365
|
+
ADVERTISED_CLAUDE_MODEL_IDS = (
|
|
13084
13366
|
"claude-haiku-4-5-20251001",
|
|
13085
13367
|
"claude-sonnet-4-6",
|
|
13086
13368
|
"claude-sonnet-5-20250514",
|
|
13087
13369
|
"claude-fable-5",
|
|
13088
|
-
"qwen36-35b-a3b-iq4xs",
|
|
13089
13370
|
)
|
|
13371
|
+
# Last-resort local id, used only when the upstream cannot be interrogated.
|
|
13372
|
+
FALLBACK_LOCAL_MODEL_ID = "qwen3.8-27b"
|
|
13373
|
+
ADVERTISED_MODEL_IDS = ADVERTISED_CLAUDE_MODEL_IDS + (FALLBACK_LOCAL_MODEL_ID,)
|
|
13374
|
+
|
|
13375
|
+
|
|
13376
|
+
async def _advertised_model_ids() -> tuple[str, ...]:
|
|
13377
|
+
"""The Claude contract ids plus whatever the upstream actually serves.
|
|
13378
|
+
|
|
13379
|
+
Advertising a local id the backend does not answer to is worse than
|
|
13380
|
+
advertising nothing: clients trust the list and select the dead id. Asking
|
|
13381
|
+
the backend removes the only place that answer was previously guessed.
|
|
13382
|
+
"""
|
|
13383
|
+
served = await _upstream_model_ids_cached()
|
|
13384
|
+
local = tuple(m for m in (served or ()) if m not in ADVERTISED_CLAUDE_MODEL_IDS)
|
|
13385
|
+
# Served model FIRST. A client that reads the list as "what does this server
|
|
13386
|
+
# run" takes data[0], and with the Claude contract ids in front of it that
|
|
13387
|
+
# answer was a Claude id -- measured against this very proxy.
|
|
13388
|
+
return (local or (FALLBACK_LOCAL_MODEL_ID,)) + ADVERTISED_CLAUDE_MODEL_IDS
|
|
13090
13389
|
|
|
13091
13390
|
# Keys OpenAI-compatible clients probe for a model's context window. There is no
|
|
13092
13391
|
# standard, so emit the common spellings rather than betting on one: hermes reads
|
|
@@ -13154,7 +13453,7 @@ async def models():
|
|
|
13154
13453
|
# that only refreshes on /v1/messages means the number a client keeps for
|
|
13155
13454
|
# the whole session is the one from before any traffic existed.
|
|
13156
13455
|
await _maybe_recheck_context_window()
|
|
13157
|
-
return {"data": [_model_entry(mid) for mid in
|
|
13456
|
+
return {"data": [_model_entry(mid) for mid in await _advertised_model_ids()]}
|
|
13158
13457
|
|
|
13159
13458
|
|
|
13160
13459
|
@app.get("/health")
|
|
@@ -48,6 +48,17 @@ class Settings:
|
|
|
48
48
|
endpoint: str
|
|
49
49
|
api_key: str
|
|
50
50
|
allow_self_judge: bool = False
|
|
51
|
+
#: Backend width: how many generations the upstream serves CONCURRENTLY.
|
|
52
|
+
#: None = UNKNOWN, which must change nothing (not every backend can be
|
|
53
|
+
#: probed -- ninfer serves no /slots endpoint -- so silence is not evidence).
|
|
54
|
+
#: A FIELD rather than a process-env read at decision time: select_recipe is
|
|
55
|
+
#: called per request and an ambient UAP_MODEL_SLOTS=1 (which is exactly what
|
|
56
|
+
#: writeProxyEnv now puts in proxy.env, and what _load_proxy_env_file
|
|
57
|
+
#: setdefaults into every subprocess) silently rewrote 15 unrelated tests'
|
|
58
|
+
#: expectations. Resolving once, at construction, keeps the decision a
|
|
59
|
+
#: function of its inputs.
|
|
60
|
+
model_slots: "int | None" = None
|
|
61
|
+
force_multi_call: bool = False
|
|
51
62
|
|
|
52
63
|
@classmethod
|
|
53
64
|
def from_env(cls) -> "Settings":
|
|
@@ -78,6 +89,8 @@ class Settings:
|
|
|
78
89
|
endpoint=os.environ.get("PROXY_ESCALATE_ENDPOINT", ""),
|
|
79
90
|
api_key=os.environ.get("PROXY_ESCALATE_API_KEY", ""),
|
|
80
91
|
allow_self_judge=flag("PROXY_ALLOW_SELF_JUDGE"),
|
|
92
|
+
model_slots=resolve_model_slots(),
|
|
93
|
+
force_multi_call=flag("PROXY_FORCE_MULTI_CALL"),
|
|
81
94
|
)
|
|
82
95
|
|
|
83
96
|
def backend_configured(self) -> bool:
|
|
@@ -287,9 +300,53 @@ def load_adaptation_signal(session_id=None, signal_dir=None, ttl=180.0, now=None
|
|
|
287
300
|
return None
|
|
288
301
|
|
|
289
302
|
|
|
303
|
+
#: Recipes that issue MORE THAN ONE upstream generation per request.
|
|
304
|
+
MULTI_CALL_RECIPES = frozenset({"fusion", "ratings", "remom"})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _int_env(name: str) -> "int | None":
|
|
308
|
+
try:
|
|
309
|
+
raw = os.environ.get(name, "")
|
|
310
|
+
return int(raw) if raw.strip() else None
|
|
311
|
+
except (ValueError, TypeError):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def resolve_model_slots() -> "int | None":
|
|
316
|
+
"""Backend width from the environment, or None when it is UNKNOWN.
|
|
317
|
+
|
|
318
|
+
Reads ``UAP_MODEL_SLOTS`` -- the same variable the TypeScript model-slot
|
|
319
|
+
budget resolves and exports -- so both halves of the system agree on the
|
|
320
|
+
backend's real width without a second probe. ``PROXY_MODEL_SLOTS`` is
|
|
321
|
+
accepted as a proxy-local alias. Unparseable is UNKNOWN, not 1: a typo must
|
|
322
|
+
not silently disable fan-out everywhere.
|
|
323
|
+
"""
|
|
324
|
+
for name in ("UAP_MODEL_SLOTS", "PROXY_MODEL_SLOTS"):
|
|
325
|
+
slots = _int_env(name)
|
|
326
|
+
if slots is not None:
|
|
327
|
+
return slots
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
290
331
|
def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) -> str:
|
|
291
332
|
if not settings.enabled or has_tools:
|
|
292
333
|
return "single"
|
|
334
|
+
# SLOT RIGHT-SIZING. A fan-out recipe assumes its N generations overlap. On a
|
|
335
|
+
# backend serving one request at a time they do not -- they queue, so fusion
|
|
336
|
+
# N=3 costs 3x the wall clock of a single call for the same answer, and the
|
|
337
|
+
# deliver run paying it has a fixed wall-clock budget to finish in.
|
|
338
|
+
#
|
|
339
|
+
# Measured live (2026-08-20, ninfer --max-concurrency 1, qwen3.8-27b): tool
|
|
340
|
+
# -carrying turns already bypass recipes via `has_tools` above, but mission
|
|
341
|
+
# PLANNING carries no tools -- so it ran under fusion N=3 and took ~20 minutes,
|
|
342
|
+
# 17% of a 120-minute run budget, before turn 1 existed.
|
|
343
|
+
#
|
|
344
|
+
# Downgrade to the judge-free single path rather than to `confidence`:
|
|
345
|
+
# confidence can still escalate into a second call, which is the cost being
|
|
346
|
+
# avoided. `PROXY_FORCE_MULTI_CALL=1` restores the old behaviour.
|
|
347
|
+
if settings.model_slots is not None and settings.model_slots <= 1 and not settings.force_multi_call:
|
|
348
|
+
if settings.recipe == "auto" or settings.recipe in MULTI_CALL_RECIPES:
|
|
349
|
+
return "single"
|
|
293
350
|
if settings.recipe != "auto":
|
|
294
351
|
return settings.recipe
|
|
295
352
|
pm = (anthropic_body or {}).get("model", "")
|
|
@@ -5333,12 +5333,18 @@ class TestModelsEndpoint(unittest.TestCase):
|
|
|
5333
5333
|
self.assertIn("claude-sonnet-4-6", ids)
|
|
5334
5334
|
self.assertIn("claude-sonnet-5-20250514", ids)
|
|
5335
5335
|
|
|
5336
|
-
# Local model — what
|
|
5337
|
-
# active model:
|
|
5338
|
-
#
|
|
5339
|
-
#
|
|
5340
|
-
#
|
|
5341
|
-
|
|
5336
|
+
# Local model — what the inference server actually serves. Tracks the
|
|
5337
|
+
# active model: qwen3.8-27b as of 2026-08-19, served by ninfer-serve
|
|
5338
|
+
# (NOT llama.cpp). Requests for this ID route locally even with the
|
|
5339
|
+
# __local_only__ passthrough sentinel set.
|
|
5340
|
+
#
|
|
5341
|
+
# This assertion is about AGREEMENT with the backend, not about naming.
|
|
5342
|
+
# Measured on the day of the switch: the list still advertised
|
|
5343
|
+
# qwen36-35b-a3b-iq4xs, and a request for it came back
|
|
5344
|
+
# HTTP 404 model_not_found — SDKs check /v1/models before sending, so
|
|
5345
|
+
# every client dutifully selected an id nothing would answer to.
|
|
5346
|
+
self.assertIn("qwen3.8-27b", ids)
|
|
5347
|
+
self.assertNotIn("qwen36-35b-a3b-iq4xs", ids)
|
|
5342
5348
|
self.assertNotIn("qwen36-27b-iq4xs", ids)
|
|
5343
5349
|
self.assertNotIn("qwen35-a3b-iq4xs", ids)
|
|
5344
5350
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for the serving-layer recipe runtime (Confidence #1/#5, Fusion #3, selector #2)."""
|
|
2
2
|
import asyncio
|
|
3
3
|
import importlib.util
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
5
6
|
import unittest
|
|
6
7
|
from pathlib import Path
|
|
@@ -24,10 +25,16 @@ spec.loader.exec_module(ce)
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def S(enabled=True, recipe="auto", signal="heuristic", threshold=0.5, fusion_n=3,
|
|
27
|
-
auto_chars=600, model="opus", endpoint="http://x/", key="k", remom_quorum=2
|
|
28
|
+
auto_chars=600, model="opus", endpoint="http://x/", key="k", remom_quorum=2,
|
|
29
|
+
model_slots=None, force_multi_call=False):
|
|
30
|
+
# model_slots defaults to None = UNKNOWN width, which changes nothing. The
|
|
31
|
+
# selector reads this FIELD, never process env, so an ambient
|
|
32
|
+
# UAP_MODEL_SLOTS=1 (what proxy.env now carries on a one-slot box) cannot
|
|
33
|
+
# rewrite the expectations of tests that are not about slot width.
|
|
28
34
|
return ce.Settings(enabled=enabled, recipe=recipe, signal=signal, threshold=threshold,
|
|
29
35
|
fusion_n=fusion_n, remom_quorum=remom_quorum, auto_fusion_chars=auto_chars,
|
|
30
|
-
model=model, endpoint=endpoint, api_key=key
|
|
36
|
+
model=model, endpoint=endpoint, api_key=key,
|
|
37
|
+
model_slots=model_slots, force_multi_call=force_multi_call)
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
def resp(text):
|
|
@@ -351,5 +358,136 @@ class AdaptationSignalTest(unittest.TestCase): # LLM-Self-Tuning real-time adap
|
|
|
351
358
|
)
|
|
352
359
|
|
|
353
360
|
|
|
361
|
+
class SlotRightSizingTest(unittest.TestCase):
|
|
362
|
+
"""A fan-out recipe assumes its N generations OVERLAP.
|
|
363
|
+
|
|
364
|
+
On a backend serving one request at a time they do not -- they queue, so
|
|
365
|
+
fusion N=3 costs 3x the wall clock of a single call for the same answer,
|
|
366
|
+
and the deliver run paying it has a fixed wall-clock budget to finish in.
|
|
367
|
+
|
|
368
|
+
Measured live (2026-08-20, ninfer --max-concurrency 1, qwen3.8-27b):
|
|
369
|
+
tool-carrying turns already bypass recipes via has_tools, but mission
|
|
370
|
+
PLANNING carries no tools -- so it ran under fusion N=3 and took ~20
|
|
371
|
+
minutes, 17% of a 120-minute run budget, before turn 1 existed.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
def test_unknown_width_changes_nothing(self):
|
|
375
|
+
# Not every backend can be probed (ninfer serves no /slots), so silence
|
|
376
|
+
# is not evidence -- unknown width must leave behaviour identical.
|
|
377
|
+
self.assertEqual(ce.select_recipe(body(), S(recipe="fusion"), False), "fusion")
|
|
378
|
+
|
|
379
|
+
def test_single_slot_downgrades_explicit_fusion(self):
|
|
380
|
+
self.assertEqual(ce.select_recipe(body(), S(recipe="fusion", model_slots=1), False), "single")
|
|
381
|
+
|
|
382
|
+
def test_single_slot_downgrades_auto(self):
|
|
383
|
+
# auto can select fusion on its own; it must not be able to on one slot.
|
|
384
|
+
self.assertEqual(
|
|
385
|
+
ce.select_recipe(body("x" * 900), S(recipe="auto", model_slots=1), False), "single"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_single_slot_downgrades_other_multi_call_recipes(self):
|
|
389
|
+
for recipe in ("ratings", "remom"):
|
|
390
|
+
self.assertEqual(
|
|
391
|
+
ce.select_recipe(body(), S(recipe=recipe, model_slots=1), False), "single", recipe
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def test_multi_slot_backend_keeps_fan_out(self):
|
|
395
|
+
# The cost being avoided is serialization. With real parallelism there
|
|
396
|
+
# is nothing to avoid.
|
|
397
|
+
self.assertEqual(ce.select_recipe(body(), S(recipe="fusion", model_slots=4), False), "fusion")
|
|
398
|
+
|
|
399
|
+
def test_operator_can_force_fan_out_on_one_slot(self):
|
|
400
|
+
self.assertEqual(
|
|
401
|
+
ce.select_recipe(body(), S(recipe="fusion", model_slots=1, force_multi_call=True), False),
|
|
402
|
+
"fusion",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def test_single_call_recipe_is_left_alone(self):
|
|
406
|
+
# `confidence` issues one generation and only escalates conditionally;
|
|
407
|
+
# downgrading it would remove quality for no latency win.
|
|
408
|
+
self.assertEqual(
|
|
409
|
+
ce.select_recipe(body(), S(recipe="confidence", model_slots=1), False), "confidence"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def test_zero_slots_counts_as_single(self):
|
|
413
|
+
self.assertEqual(ce.select_recipe(body(), S(recipe="fusion", model_slots=0), False), "single")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class SlotWidthResolutionTest(unittest.TestCase):
|
|
417
|
+
"""Width comes from the environment ONCE, at Settings construction."""
|
|
418
|
+
|
|
419
|
+
def setUp(self):
|
|
420
|
+
self._saved = {k: os.environ.get(k) for k in ("UAP_MODEL_SLOTS", "PROXY_MODEL_SLOTS")}
|
|
421
|
+
for k in self._saved:
|
|
422
|
+
os.environ.pop(k, None)
|
|
423
|
+
|
|
424
|
+
def tearDown(self):
|
|
425
|
+
for k, v in self._saved.items():
|
|
426
|
+
if v is None:
|
|
427
|
+
os.environ.pop(k, None)
|
|
428
|
+
else:
|
|
429
|
+
os.environ[k] = v
|
|
430
|
+
|
|
431
|
+
def test_absent_is_unknown(self):
|
|
432
|
+
self.assertIsNone(ce.resolve_model_slots())
|
|
433
|
+
|
|
434
|
+
def test_reads_the_shared_variable(self):
|
|
435
|
+
os.environ["UAP_MODEL_SLOTS"] = "1"
|
|
436
|
+
self.assertEqual(ce.resolve_model_slots(), 1)
|
|
437
|
+
|
|
438
|
+
def test_proxy_local_alias_is_honoured(self):
|
|
439
|
+
os.environ["PROXY_MODEL_SLOTS"] = "2"
|
|
440
|
+
self.assertEqual(ce.resolve_model_slots(), 2)
|
|
441
|
+
|
|
442
|
+
def test_unparseable_width_is_unknown_not_single(self):
|
|
443
|
+
# A typo must not silently disable fan-out everywhere.
|
|
444
|
+
os.environ["UAP_MODEL_SLOTS"] = "lots"
|
|
445
|
+
self.assertIsNone(ce.resolve_model_slots())
|
|
446
|
+
|
|
447
|
+
def test_ambient_one_slot_env_does_not_move_an_explicit_setting(self):
|
|
448
|
+
# THE isolation property. proxy.env now carries UAP_MODEL_SLOTS on a
|
|
449
|
+
# one-slot box and the proxy env loader setdefaults it into every
|
|
450
|
+
# subprocess; when the selector read process env directly, that ambient
|
|
451
|
+
# value silently failed 15 unrelated tests in this file.
|
|
452
|
+
os.environ["UAP_MODEL_SLOTS"] = "1"
|
|
453
|
+
self.assertEqual(ce.select_recipe(body(), S(recipe="fusion"), False), "fusion")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class SlotDowngradeSkipsGenerationsTest(unittest.TestCase):
|
|
457
|
+
"""The cost being avoided is upstream GENERATIONS, so assert on the calls."""
|
|
458
|
+
|
|
459
|
+
def test_one_slot_backend_issues_no_extra_generations(self):
|
|
460
|
+
primary = {"content": [{"type": "text", "text": "answer"}]}
|
|
461
|
+
calls = []
|
|
462
|
+
|
|
463
|
+
async def call_primary(**kw):
|
|
464
|
+
calls.append(kw)
|
|
465
|
+
return primary
|
|
466
|
+
|
|
467
|
+
async def call_judge(**kw):
|
|
468
|
+
raise AssertionError("judge must not be called on a downgraded turn")
|
|
469
|
+
|
|
470
|
+
out = run(
|
|
471
|
+
ce.apply_recipe(
|
|
472
|
+
primary, body(), {"model": "qwen"},
|
|
473
|
+
S(recipe="fusion", model_slots=1),
|
|
474
|
+
False, call_primary, call_judge,
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
self.assertEqual(len(calls), 0)
|
|
478
|
+
self.assertIs(out, primary)
|
|
479
|
+
|
|
480
|
+
def test_off_values_do_not_force_fan_out(self):
|
|
481
|
+
# The escape hatch is a negative-set membership test; a refactor to
|
|
482
|
+
# bool(os.environ.get(...)) would invert it and only "1" is otherwise covered.
|
|
483
|
+
for raw in ("0", "false", "off", "no", ""):
|
|
484
|
+
with self.subTest(raw=raw):
|
|
485
|
+
os.environ["PROXY_FORCE_MULTI_CALL"] = raw
|
|
486
|
+
try:
|
|
487
|
+
self.assertFalse(ce.Settings.from_env().force_multi_call)
|
|
488
|
+
finally:
|
|
489
|
+
os.environ.pop("PROXY_FORCE_MULTI_CALL", None)
|
|
490
|
+
|
|
491
|
+
|
|
354
492
|
if __name__ == "__main__":
|
|
355
493
|
unittest.main()
|