@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.
Files changed (83) hide show
  1. package/config/model-profiles/qwen38.json +70 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/bin/cli.js +4 -2
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/cli/deliver.d.ts.map +1 -1
  6. package/dist/cli/deliver.js +107 -4
  7. package/dist/cli/deliver.js.map +1 -1
  8. package/dist/cli/self-harness.js +1 -1
  9. package/dist/cli/self-harness.js.map +1 -1
  10. package/dist/cli/self-tuning.js +1 -1
  11. package/dist/cli/verify.d.ts +5 -0
  12. package/dist/cli/verify.d.ts.map +1 -1
  13. package/dist/cli/verify.js +50 -1
  14. package/dist/cli/verify.js.map +1 -1
  15. package/dist/cli/wizard-config.d.ts.map +1 -1
  16. package/dist/cli/wizard-config.js +26 -1
  17. package/dist/cli/wizard-config.js.map +1 -1
  18. package/dist/config/settings-registry.js +1 -1
  19. package/dist/config/settings-registry.js.map +1 -1
  20. package/dist/delivery/agentic-executor.d.ts.map +1 -1
  21. package/dist/delivery/agentic-executor.js +5 -1
  22. package/dist/delivery/agentic-executor.js.map +1 -1
  23. package/dist/delivery/await-run.d.ts +47 -0
  24. package/dist/delivery/await-run.d.ts.map +1 -1
  25. package/dist/delivery/await-run.js +392 -2
  26. package/dist/delivery/await-run.js.map +1 -1
  27. package/dist/delivery/capability-profile.d.ts +124 -0
  28. package/dist/delivery/capability-profile.d.ts.map +1 -0
  29. package/dist/delivery/capability-profile.js +392 -0
  30. package/dist/delivery/capability-profile.js.map +1 -0
  31. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  32. package/dist/delivery/convergence-loop.js +21 -1
  33. package/dist/delivery/convergence-loop.js.map +1 -1
  34. package/dist/delivery/execution-gate.d.ts +29 -0
  35. package/dist/delivery/execution-gate.d.ts.map +1 -1
  36. package/dist/delivery/execution-gate.js +237 -9
  37. package/dist/delivery/execution-gate.js.map +1 -1
  38. package/dist/delivery/mission-acceptance.d.ts +4 -0
  39. package/dist/delivery/mission-acceptance.d.ts.map +1 -1
  40. package/dist/delivery/mission-acceptance.js +35 -1
  41. package/dist/delivery/mission-acceptance.js.map +1 -1
  42. package/dist/delivery/run-state.d.ts +31 -0
  43. package/dist/delivery/run-state.d.ts.map +1 -1
  44. package/dist/delivery/run-state.js +49 -0
  45. package/dist/delivery/run-state.js.map +1 -1
  46. package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
  47. package/dist/mcp-router/tools/deliver.js +5 -2
  48. package/dist/mcp-router/tools/deliver.js.map +1 -1
  49. package/dist/models/local-model.d.ts +59 -0
  50. package/dist/models/local-model.d.ts.map +1 -0
  51. package/dist/models/local-model.js +123 -0
  52. package/dist/models/local-model.js.map +1 -0
  53. package/dist/models/openai-compat-client.d.ts.map +1 -1
  54. package/dist/models/openai-compat-client.js +6 -1
  55. package/dist/models/openai-compat-client.js.map +1 -1
  56. package/dist/models/profile-map.d.ts.map +1 -1
  57. package/dist/models/profile-map.js +9 -2
  58. package/dist/models/profile-map.js.map +1 -1
  59. package/dist/models/types.d.ts.map +1 -1
  60. package/dist/models/types.js +69 -21
  61. package/dist/models/types.js.map +1 -1
  62. package/dist/self-tuning/profiles/index.d.ts +1 -0
  63. package/dist/self-tuning/profiles/index.d.ts.map +1 -1
  64. package/dist/self-tuning/profiles/index.js +3 -0
  65. package/dist/self-tuning/profiles/index.js.map +1 -1
  66. package/dist/self-tuning/profiles/qwen38.d.ts +23 -0
  67. package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -0
  68. package/dist/self-tuning/profiles/qwen38.js +46 -0
  69. package/dist/self-tuning/profiles/qwen38.js.map +1 -0
  70. package/dist/utils/model-slots.d.ts +17 -1
  71. package/dist/utils/model-slots.d.ts.map +1 -1
  72. package/dist/utils/model-slots.js +25 -3
  73. package/dist/utils/model-slots.js.map +1 -1
  74. package/package.json +2 -2
  75. package/scripts/lib/llama-upstream.sh +64 -16
  76. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  77. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  78. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  79. package/tools/agents/scripts/anthropic_proxy.py +306 -7
  80. package/tools/agents/scripts/confidence_escalation.py +57 -0
  81. package/tools/agents/tests/test_anthropic_proxy_streaming.py +12 -6
  82. package/tools/agents/tests/test_confidence_escalation.py +140 -2
  83. package/tools/agents/tests/test_thinking_template_kwargs.py +366 -1
@@ -14,7 +14,9 @@ PROXY_DISABLE_THINKING_ALWAYS, PROXY_DISABLE_THINKING_ON_TOOL_TURNS and the
14
14
  Anthropic `thinking: {type: disabled}` parameter were all no-ops against a jinja
15
15
  server: the controls existed, logged themselves as active, and changed nothing.
16
16
  """
17
+ import asyncio
17
18
  import importlib.util
19
+ import json
18
20
  import os
19
21
  import unittest
20
22
  from pathlib import Path
@@ -37,7 +39,13 @@ def load_proxy(**env):
37
39
  # Explicit, so a project .uap/proxy.env cannot leak in when this module is
38
40
  # run directly rather than through the npm script.
39
41
  os.environ["UAP_PROXY_ENV_AUTOLOAD"] = "0"
40
- for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
42
+ for k in (
43
+ "PROXY_DISABLE_THINKING_ALWAYS",
44
+ "PROXY_DISABLE_THINKING_ON_TOOL_TURNS",
45
+ # This branch exists precisely so an operator will set this one, so an
46
+ # operator's shell must not be able to change what the suite means.
47
+ "PROXY_CHAT_TEMPLATE_KWARGS",
48
+ ):
41
49
  os.environ.pop(k, None)
42
50
  os.environ.update({k: v for k, v in env.items()})
43
51
  spec = importlib.util.spec_from_file_location("anthropic_proxy_thinking", proxy_path)
@@ -151,5 +159,362 @@ class SwitchesReachTheTemplateTest(unittest.TestCase):
151
159
  self.assertNotIn("enable_thinking", built.get("chat_template_kwargs", {}))
152
160
 
153
161
 
162
+
163
+
164
+ class ChatTemplateKwargsCapabilityTest(unittest.TestCase):
165
+ """`chat_template_kwargs` is a llama.cpp `--jinja` concept, not OpenAI API.
166
+
167
+ Measured 2026-08-19 against ninfer-serve (the qwen3.8-27b backend):
168
+
169
+ HTTP 400 {"error":{"code":"chat_template_option_not_supported",
170
+ "message":"chat_template_kwargs.enable_thinking is not supported",
171
+ "param":"chat_template_kwargs","type":"invalid_request_error"}}
172
+
173
+ Sending it there does not merely fail to take effect — it 400s the whole
174
+ turn, so every path that expresses a thinking intent (the operator
175
+ switches, the tool-turn breakers, the JSON-verdict grammar, the
176
+ empty-response retry, every assistant prefill) died on that backend.
177
+ """
178
+
179
+ def tearDown(self):
180
+ os.environ.pop("PROXY_CHAT_TEMPLATE_KWARGS", None)
181
+
182
+ def test_unknown_support_still_writes_the_field(self):
183
+ # UNKNOWN MUST MEAN YES. Dropping the field on llama.cpp does not fail
184
+ # loudly, it silently restores the template default and every thinking
185
+ # control becomes a no-op again — the original bug this file covers.
186
+ ap = load_proxy()
187
+ self.assertIsNone(ap._ctk_supported)
188
+ body = {}
189
+ ap._set_thinking(body, False)
190
+ self.assertIs(body["chat_template_kwargs"]["enable_thinking"], False)
191
+
192
+ def test_rejecting_backend_gets_only_the_top_level_flag(self):
193
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS="off")
194
+ body = {}
195
+ ap._set_thinking(body, False)
196
+ self.assertNotIn("chat_template_kwargs", body)
197
+ self.assertIs(body["enable_thinking"], False)
198
+
199
+ def test_pinned_on_writes_the_field(self):
200
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS="on")
201
+ body = {}
202
+ ap._set_thinking(body, True)
203
+ self.assertIs(body["chat_template_kwargs"]["enable_thinking"], True)
204
+
205
+ def test_a_stale_field_is_removed_not_left_behind(self):
206
+ # A retry body is built with dict(openai_body): the caller may hand us
207
+ # one that already carries the field from before the latch flipped.
208
+ # Leaving it would 400 the retry the latch exists to rescue.
209
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS="off")
210
+ body = {"chat_template_kwargs": {"enable_thinking": True}}
211
+ ap._set_thinking(body, False)
212
+ self.assertNotIn("chat_template_kwargs", body)
213
+
214
+ def test_error_latch_recognises_the_rejection(self):
215
+ ap = load_proxy()
216
+ self.assertTrue(
217
+ ap._note_ctk_rejection(
218
+ '{"error":{"code":"chat_template_option_not_supported",'
219
+ '"message":"chat_template_kwargs.enable_thinking is not supported"}}'
220
+ )
221
+ )
222
+ self.assertIs(ap._ctk_supported, False)
223
+ body = {}
224
+ ap._set_thinking(body, False)
225
+ self.assertNotIn("chat_template_kwargs", body)
226
+
227
+ def test_error_latch_ignores_unrelated_upstream_errors(self):
228
+ # An overload / bad-model / auth error says nothing about this field.
229
+ # Latching on it would disable a field llama.cpp needs, permanently,
230
+ # for the life of the process.
231
+ ap = load_proxy()
232
+ self.assertFalse(ap._note_ctk_rejection('{"error":{"code":"model_not_found"}}'))
233
+ self.assertFalse(ap._note_ctk_rejection("upstream overloaded"))
234
+ self.assertFalse(ap._note_ctk_rejection(""))
235
+ self.assertIsNone(ap._ctk_supported)
236
+ body = {}
237
+ ap._set_thinking(body, False)
238
+ self.assertIn("chat_template_kwargs", body)
239
+
240
+ def test_an_operator_pin_of_on_is_not_overridden_by_the_latch(self):
241
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS="on")
242
+ self.assertFalse(
243
+ ap._note_ctk_rejection("chat_template_option_not_supported")
244
+ )
245
+ self.assertIs(ap._ctk_supported, True)
246
+
247
+ def test_prefill_turn_does_not_send_the_field_to_a_rejecting_backend(self):
248
+ # The prefill block sets the nested key to dodge the jinja template's
249
+ # "prefill is incompatible with enable_thinking" 400. A backend with no
250
+ # jinja template has no such incompatibility — and would 400 on the
251
+ # workaround itself.
252
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS="off")
253
+ out = ap.build_openai_request(
254
+ {
255
+ "model": "qwen3.8-27b",
256
+ "max_tokens": 64,
257
+ "messages": [
258
+ {"role": "user", "content": "hi"},
259
+ {"role": "assistant", "content": "partial"},
260
+ ],
261
+ },
262
+ ap.SessionMonitor(context_window=131072),
263
+ )
264
+ self.assertNotIn("chat_template_kwargs", out)
265
+ self.assertIs(out.get("enable_thinking"), False)
266
+
267
+
268
+ class _FakeResponse:
269
+ def __init__(self, status_code, payload=None, text=None):
270
+ self.status_code = status_code
271
+ self._payload = payload if payload is not None else {}
272
+ self.text = text if text is not None else json.dumps(self._payload)
273
+
274
+ def json(self):
275
+ return self._payload
276
+
277
+
278
+ class _FakeClient:
279
+ """Records what the probe actually put on the wire."""
280
+
281
+ def __init__(self, models_status=200, models_payload=None, post_response=None):
282
+ self.posts = []
283
+ self.gets = []
284
+ self._models_status = models_status
285
+ self._models_payload = (
286
+ models_payload
287
+ if models_payload is not None
288
+ else {"data": [{"id": "qwen3.8-27b", "object": "model"}]}
289
+ )
290
+ self._post_response = post_response
291
+
292
+ async def get(self, url, **kw):
293
+ self.gets.append(url)
294
+ return _FakeResponse(self._models_status, self._models_payload)
295
+
296
+ async def post(self, url, json=None, **kw):
297
+ self.posts.append(json or {})
298
+ return self._post_response
299
+
300
+
301
+ REJECTION = _FakeResponse(
302
+ 400,
303
+ text=(
304
+ '{"error":{"code":"chat_template_option_not_supported","message":'
305
+ '"chat_template_kwargs.enable_thinking is not supported",'
306
+ '"param":"chat_template_kwargs","type":"invalid_request_error"}}'
307
+ ),
308
+ )
309
+
310
+
311
+ class ChatTemplateKwargsProbeTest(unittest.TestCase):
312
+ def tearDown(self):
313
+ os.environ.pop("PROXY_CHAT_TEMPLATE_KWARGS", None)
314
+
315
+ def test_probe_sends_a_model_or_it_cannot_learn_anything(self):
316
+ # REGRESSION. The first version of the probe omitted `model`. The server
317
+ # answered `missing required field: model` — a 400 that says nothing
318
+ # about chat_template_kwargs — so the probe correctly declined to latch
319
+ # and learned nothing on every attempt. Verified live against
320
+ # ninfer-serve before this test was written.
321
+ ap = load_proxy()
322
+ client = _FakeClient(post_response=REJECTION)
323
+ ap.http_client = client
324
+ asyncio.run(ap._probe_chat_template_kwargs())
325
+ self.assertEqual(len(client.posts), 1)
326
+ self.assertEqual(client.posts[0].get("model"), "qwen3.8-27b")
327
+ self.assertIn("chat_template_kwargs", client.posts[0])
328
+ self.assertIs(ap._ctk_supported, False)
329
+
330
+ def test_a_200_marks_the_field_supported(self):
331
+ ap = load_proxy()
332
+ ap.http_client = _FakeClient(post_response=_FakeResponse(200, {"choices": []}))
333
+ asyncio.run(ap._probe_chat_template_kwargs())
334
+ self.assertIs(ap._ctk_supported, True)
335
+
336
+ def test_an_unrelated_error_leaves_the_answer_unknown(self):
337
+ # Unknown must stay unknown: latching 'unsupported' off an overload or
338
+ # auth error would permanently disable a field llama.cpp needs.
339
+ ap = load_proxy()
340
+ ap.http_client = _FakeClient(
341
+ post_response=_FakeResponse(503, text='{"error":{"message":"overloaded"}}')
342
+ )
343
+ asyncio.run(ap._probe_chat_template_kwargs())
344
+ self.assertIsNone(ap._ctk_supported)
345
+
346
+ def test_inconclusive_probes_stop_after_the_attempt_cap(self):
347
+ # Re-arming forever would add an upstream round-trip to EVERY request
348
+ # for the life of the process.
349
+ ap = load_proxy()
350
+ client = _FakeClient(
351
+ post_response=_FakeResponse(503, text='{"error":{"message":"overloaded"}}')
352
+ )
353
+ ap.http_client = client
354
+ for _ in range(10):
355
+ asyncio.run(ap._probe_chat_template_kwargs())
356
+ self.assertEqual(ap._ctk_probe_attempts, ap._CTK_PROBE_MAX_ATTEMPTS)
357
+ self.assertIsNone(ap._ctk_supported)
358
+
359
+ def test_an_unreachable_model_list_does_not_disable_the_field(self):
360
+ ap = load_proxy()
361
+ ap.http_client = _FakeClient(models_status=500, post_response=REJECTION)
362
+ asyncio.run(ap._probe_chat_template_kwargs())
363
+ self.assertIsNone(ap._ctk_supported)
364
+
365
+ def test_an_operator_pin_skips_the_probe_entirely(self):
366
+ for pin in ("on", "off"):
367
+ with self.subTest(pin=pin):
368
+ ap = load_proxy(PROXY_CHAT_TEMPLATE_KWARGS=pin)
369
+ client = _FakeClient(post_response=REJECTION)
370
+ ap.http_client = client
371
+ asyncio.run(ap._probe_chat_template_kwargs())
372
+ self.assertEqual(client.posts, [])
373
+
374
+
375
+ class MatcherStrictnessTest(unittest.TestCase):
376
+ """The latch must not fire on an error that merely QUOTES the request.
377
+
378
+ The proxy puts the literal string `chat_template_kwargs` in its own outbound
379
+ body, and a middlebox that echoes the request in an error envelope is the
380
+ most likely component to reshape it. Matching the bare name therefore made
381
+ any unrelated 400/429/503 able to disable a field llama.cpp needs -- the bad
382
+ direction, permanently, for the life of the process.
383
+ """
384
+
385
+ def tearDown(self):
386
+ os.environ.pop("PROXY_CHAT_TEMPLATE_KWARGS", None)
387
+
388
+ def test_an_error_echoing_the_request_body_does_not_latch(self):
389
+ ap = load_proxy()
390
+ echoed = (
391
+ '{"error":{"message":"context length exceeded. received request body: '
392
+ '{\'model\': \'x\', \'chat_template_kwargs\': {\'enable_thinking\': false}}"}}'
393
+ )
394
+ self.assertFalse(ap._note_ctk_rejection(echoed))
395
+ self.assertIsNone(ap._ctk_supported)
396
+
397
+ def test_the_contractual_code_still_latches(self):
398
+ ap = load_proxy()
399
+ self.assertTrue(ap._note_ctk_rejection('{"error":{"code":"chat_template_option_not_supported"}}'))
400
+ self.assertIs(ap._ctk_supported, False)
401
+
402
+ def test_the_human_message_alone_still_latches(self):
403
+ # The documented reshaped-envelope case: no machine-readable code
404
+ # survived, but the sentence did.
405
+ ap = load_proxy()
406
+ self.assertTrue(ap._note_ctk_rejection("unknown field: chat_template_kwargs"))
407
+ self.assertIs(ap._ctk_supported, False)
408
+
409
+ def test_the_name_with_no_rejection_phrase_does_not_latch(self):
410
+ ap = load_proxy()
411
+ self.assertFalse(ap._note_ctk_rejection("debug: chat_template_kwargs seen"))
412
+ self.assertIsNone(ap._ctk_supported)
413
+
414
+
415
+ class ColdBackendProbeBudgetTest(unittest.TestCase):
416
+ """A model server still loading its weights must not spend the probe budget.
417
+
418
+ `uap proxy` starts the proxy alongside the model server, so the first
419
+ requests routinely land while it is loading. Counting those 503s exhausts
420
+ all three attempts before the server is up, and the answer then stays
421
+ "unknown" for the life of the process -- which is "keep sending the field",
422
+ i.e. permanent breakage on a backend that rejects it.
423
+ """
424
+
425
+ def tearDown(self):
426
+ os.environ.pop("PROXY_CHAT_TEMPLATE_KWARGS", None)
427
+
428
+ def test_a_loading_model_503_is_not_an_attempt(self):
429
+ ap = load_proxy()
430
+ loading = _FakeResponse(503, text='{"error":{"message":"Loading model"}}')
431
+ ap.http_client = _FakeClient(post_response=loading)
432
+ for _ in range(5):
433
+ asyncio.run(ap._probe_chat_template_kwargs())
434
+ self.assertEqual(ap._ctk_probe_attempts, 0)
435
+ self.assertFalse(ap._ctk_probed)
436
+ self.assertIsNone(ap._ctk_supported)
437
+
438
+ def test_and_the_probe_still_answers_once_the_backend_is_up(self):
439
+ ap = load_proxy()
440
+ ap.http_client = _FakeClient(post_response=_FakeResponse(503, text='{"error":{"message":"Loading model"}}'))
441
+ asyncio.run(ap._probe_chat_template_kwargs())
442
+ ap.http_client = _FakeClient(post_response=REJECTION)
443
+ asyncio.run(ap._probe_chat_template_kwargs())
444
+ self.assertIs(ap._ctk_supported, False)
445
+
446
+
447
+ class WireModelReconciliationTest(unittest.TestCase):
448
+ """Send a model the backend answers to, or the turn is lost to a 404.
449
+
450
+ llama.cpp ignored the OpenAI `model` field; ninfer validates it. With the
451
+ local-only sentinel every advertised id is served locally, so the four
452
+ `claude-*` ids the proxy advertises for SDK compatibility all came back
453
+ `404 model_not_found` -- measured against the live backend.
454
+ """
455
+
456
+ def _ap(self, ids=("qwen3.8-27b",)):
457
+ ap = load_proxy()
458
+ ap.http_client = _FakeClient(
459
+ models_payload={"data": [{"id": i, "object": "model"} for i in ids]},
460
+ post_response=_FakeResponse(200, {"choices": []}),
461
+ )
462
+ return ap
463
+
464
+ def test_an_unservable_id_is_rewritten_to_what_the_backend_serves(self):
465
+ ap = self._ap()
466
+ body = {"model": "claude-sonnet-4-6", "messages": []}
467
+ asyncio.run(ap._reconcile_wire_model(body))
468
+ self.assertEqual(body["model"], "qwen3.8-27b")
469
+
470
+ def test_a_retired_local_preset_id_is_rewritten_too(self):
471
+ # This repo's own .uap.json still names qwen36-a3b as roles.fallback,
472
+ # whose apiModel is the id the backend 404s on.
473
+ ap = self._ap()
474
+ body = {"model": "qwen36-35b-a3b-iq4xs", "messages": []}
475
+ asyncio.run(ap._reconcile_wire_model(body))
476
+ self.assertEqual(body["model"], "qwen3.8-27b")
477
+
478
+ def test_an_id_the_backend_serves_is_left_ALONE(self):
479
+ # A genuine multi-model gateway must keep working: only ids the upstream
480
+ # does not list are touched.
481
+ ap = self._ap(ids=("qwen3.8-27b", "some-other-model"))
482
+ body = {"model": "some-other-model", "messages": []}
483
+ asyncio.run(ap._reconcile_wire_model(body))
484
+ self.assertEqual(body["model"], "some-other-model")
485
+
486
+ def test_an_uninterrogable_backend_changes_nothing(self):
487
+ ap = load_proxy()
488
+ ap.http_client = _FakeClient(models_status=500, post_response=_FakeResponse(200, {}))
489
+ body = {"model": "claude-sonnet-4-6", "messages": []}
490
+ asyncio.run(ap._reconcile_wire_model(body))
491
+ self.assertEqual(body["model"], "claude-sonnet-4-6")
492
+
493
+ def test_the_model_list_is_fetched_once_not_per_request(self):
494
+ ap = self._ap()
495
+ for _ in range(5):
496
+ asyncio.run(ap._reconcile_wire_model({"model": "claude-sonnet-4-6", "messages": []}))
497
+ self.assertEqual(len(ap.http_client.gets), 1)
498
+
499
+
500
+ class StreamingLatchWiringTest(unittest.TestCase):
501
+ """The latch must be wired into ALL upstream error handlers, not most.
502
+
503
+ Streaming is the default client path and was the one handler of three that
504
+ never latched, so the safety net had a hole exactly where the traffic is.
505
+ Asserted structurally: counting call sites is crude, but it is the only
506
+ check that fails when a fourth handler is added without the call.
507
+ """
508
+
509
+ def test_every_upstream_error_handler_latches(self):
510
+ src = proxy_path.read_text(encoding="utf-8")
511
+ handlers = src.count('logger.error("Upstream HTTP') + src.count('"Upstream HTTP %d (')
512
+ latches = src.count("_note_ctk_rejection(error_text)")
513
+ self.assertGreaterEqual(
514
+ latches, 3, "an upstream error handler is missing _note_ctk_rejection"
515
+ )
516
+ self.assertGreaterEqual(latches, handlers - 1)
517
+
518
+
154
519
  if __name__ == "__main__":
155
520
  unittest.main()