@miller-tech/uap 1.212.5 → 1.213.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/model-profiles/qwen38.json +70 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +2 -2
- package/dist/bin/cli.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/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/execution-gate.d.ts.map +1 -1
- package/dist/delivery/execution-gate.js +77 -4
- package/dist/delivery/execution-gate.js.map +1 -1
- package/dist/delivery/playwright-driver.d.ts +0 -6
- package/dist/delivery/playwright-driver.d.ts.map +1 -1
- package/dist/delivery/playwright-driver.js +27 -1
- package/dist/delivery/playwright-driver.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/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/tests/test_anthropic_proxy_streaming.py +12 -6
- 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 (
|
|
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()
|