@miller-tech/uap 1.220.10 → 1.220.12

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.
@@ -41,6 +41,15 @@
41
41
  # =====================================================================
42
42
  LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/latest-master/build/bin/llama-server
43
43
  LLAMA_MODEL=/home/cogtek/Downloads/gemma-4-26B-A4B-it-UD-IQ4_XS.gguf
44
+ # Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
45
+ # here: the script's fallback derives a name from the GGUF filename, which is a
46
+ # name rather than a path but is not the name client configs are written
47
+ # against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
48
+ # rewrite the model on every request.
49
+ # ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
50
+ # *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
51
+ # Keep the id clients actually use sorting first if you add more.
52
+ LLAMA_ALIAS=Gemma4-26B-A4B
44
53
  LLAMA_DRAFT_MODEL=/home/cogtek/Downloads/mtp-gemma-4-26B-A4B-it-Q8_0.gguf
45
54
 
46
55
  # Vision projector MUST be pinned. The launch script's auto-discovery falls
@@ -23,6 +23,15 @@
23
23
  # =====================================================================
24
24
  LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/latest-master/build/bin/llama-server
25
25
  LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.6-35B-A3B-UD-IQ4_XS.gguf
26
+ # Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
27
+ # here: the script's fallback derives a name from the GGUF filename, which is a
28
+ # name rather than a path but is not the name client configs are written
29
+ # against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
30
+ # rewrite the model on every request.
31
+ # ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
32
+ # *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
33
+ # Keep the id clients actually use sorting first if you add more.
34
+ LLAMA_ALIAS=Qwen3.6-35B-A3B
26
35
  LLAMA_MMPROJ=/home/cogtek/Downloads/mmproj-F16.gguf
27
36
 
28
37
  LLAMA_HOST=0.0.0.0
@@ -68,6 +68,15 @@
68
68
  # =====================================================================
69
69
  LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/dflash2-trial/build-cuda/bin/llama-server
70
70
  LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf
71
+ # Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
72
+ # here: the script's fallback derives a name from the GGUF filename, which is a
73
+ # name rather than a path but is not the name client configs are written
74
+ # against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
75
+ # rewrite the model on every request.
76
+ # ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
77
+ # *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
78
+ # Keep the id clients actually use sorting first if you add more.
79
+ LLAMA_ALIAS=Qwen3.8-27B
71
80
  # CONFIRMED INCOMPATIBLE 2026-08-20: mmproj-BF16.gguf crashed the server on
72
81
  # load -- "mismatch between text model (n_embd = 5120) and mmproj
73
82
  # (n_embd = 2048)". It was built for a different, smaller model's vision
@@ -36,6 +36,15 @@
36
36
  # =====================================================================
37
37
  LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/dflash-mtp-upgrade/build-cuda/bin/llama-server
38
38
  LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf
39
+ # Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
40
+ # here: the script's fallback derives a name from the GGUF filename, which is a
41
+ # name rather than a path but is not the name client configs are written
42
+ # against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
43
+ # rewrite the model on every request.
44
+ # ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
45
+ # *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
46
+ # Keep the id clients actually use sorting first if you add more.
47
+ LLAMA_ALIAS=Qwen3.8-27B
39
48
  # CONFIRMED INCOMPATIBLE 2026-08-20: mmproj-BF16.gguf crashed the server on
40
49
  # load -- "mismatch between text model (n_embd = 5120) and mmproj
41
50
  # (n_embd = 2048)". It was built for a different, smaller model's vision
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.220.10",
3
+ "version": "1.220.12",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_stuck_break_hard tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll",
24
+ "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_stuck_break_hard tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll tools.agents.tests.test_upstream_model_aliases",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -1342,7 +1342,20 @@ async def _upstream_model_ids_cached() -> list[str] | None:
1342
1342
  if r.status_code != 200:
1343
1343
  return None
1344
1344
  data = (r.json() or {}).get("data") or []
1345
- ids = [str(m["id"]) for m in data if isinstance(m, dict) and m.get("id")]
1345
+ # Aliases count as served ids. llama-server reports ONE primary `id`
1346
+ # (the ASCII-first entry of a std::set) plus an `aliases` array, so a
1347
+ # config naming any other registered alias looked unserved and got
1348
+ # rewritten on every request -- which is most of what --alias was added
1349
+ # to stop. Reading only `id` made the flag half-useless.
1350
+ ids: list[str] = []
1351
+ for m in data:
1352
+ if not isinstance(m, dict):
1353
+ continue
1354
+ if m.get("id"):
1355
+ ids.append(str(m["id"]))
1356
+ for alias in m.get("aliases") or []:
1357
+ if alias and str(alias) not in ids:
1358
+ ids.append(str(alias))
1346
1359
  if ids:
1347
1360
  _upstream_model_ids = ids
1348
1361
  return ids or None
@@ -4983,6 +4996,33 @@ def _has_tool_definitions(anthropic_body: dict) -> bool:
4983
4996
  return isinstance(tools, list) and len(tools) > 0
4984
4997
 
4985
4998
 
4999
+ def _should_buffer_turn(
5000
+ is_stream: bool,
5001
+ body: dict,
5002
+ openai_body: dict,
5003
+ monitor: "SessionMonitor",
5004
+ ) -> bool:
5005
+ """Whether this turn must be collected in full before anything is sent.
5006
+
5007
+ Wraps _should_use_guarded_non_stream with one extra reason: a turn whose
5008
+ tools were deliberately stripped.
5009
+
5010
+ That turn CANNOT be streamed. The stripping is what makes it terminal, and
5011
+ the sanitiser that scrubs tool-call markup out of the reply only runs on the
5012
+ buffered path -- a streamed turn is already on the wire before anything can
5013
+ inspect it. Worse, the hard tier pops `tool_choice`, which is precisely the
5014
+ key _should_use_guarded_non_stream requires, so stripping the tools was
5015
+ itself enough to route the turn AWAY from the guarded path. Measured
5016
+ 2026-08-25: a hard-tier streaming turn delivered the raw markup verbatim to
5017
+ opencode, which rendered it and ended the session -- the incident this rail
5018
+ exists to end. Buffering costs nothing here: the turn is terminal by
5019
+ construction, so there is no long generation for the client to watch.
5020
+ """
5021
+ if monitor.suppress_text_tool_extraction:
5022
+ return True
5023
+ return _should_use_guarded_non_stream(is_stream, body, openai_body)
5024
+
5025
+
4986
5026
  def _should_use_guarded_non_stream(
4987
5027
  is_stream: bool,
4988
5028
  anthropic_body: dict,
@@ -8723,6 +8763,84 @@ def _strip_residual_tool_call_xml(text: str) -> str:
8723
8763
  return cleaned
8724
8764
 
8725
8765
 
8766
+ # The prose a suppressed turn falls back to when stripping the markup leaves
8767
+ # nothing. An EMPTY assistant message is no better for the client than the raw
8768
+ # XML was: opencode ends the turn either way and the operator sees a blank
8769
+ # reply, so the breaker must say what it did.
8770
+ STUCK_BREAK_PROSE_FALLBACK = (
8771
+ "I repeated the same tool call without making progress, so tools were "
8772
+ "withheld for this turn. I have not run anything further. Tell me which "
8773
+ "different approach to take, or confirm the next step."
8774
+ )
8775
+
8776
+
8777
+ # Hermes blocks, for DELETION rather than parsing.
8778
+ #
8779
+ # Deliberately not _HERMES_FUNCTION_RE. That one ends with `(?:</function>|\Z)`
8780
+ # so it can salvage a premature-EOS tool call, which is right when parsing and
8781
+ # destructive when deleting: a reply that merely MENTIONS a tag loses everything
8782
+ # after it. Measured — "The proxy scans for <function=bash> and then deletes
8783
+ # everything after it. THIS SHOULD SURVIVE." collapsed to "The proxy scans for".
8784
+ # That is not hypothetical here: the hard-tier directive asks the model to say
8785
+ # "what the repeated call returned", i.e. it invites naming the call.
8786
+ #
8787
+ # So a block only counts as markup when it is closed, or when it contains a
8788
+ # parameter tag — both of which a prose mention will not have. The name class is
8789
+ # also wider than the parser's (`[^>\s]+`): the parser can afford to ignore a
8790
+ # dotted or hyphenated name it cannot map to a tool, but leaving that block in
8791
+ # the text is the leak.
8792
+ _SANITIZE_HERMES_BLOCK_RE = re.compile(
8793
+ r"<function=[^>\s]+>"
8794
+ # Either a properly closed block -- whose body may not contain another
8795
+ # opener, or one malformed block swallows the prose up to the NEXT block's
8796
+ # closing tag (measured: "IMPORTANT PROSE" between two blocks vanished) --
8797
+ r"(?:(?:(?!<function=).)*?</function>"
8798
+ # ...or an unclosed one that at least carries a parameter tag, which is what
8799
+ # separates a real premature-EOS emission from a prose mention.
8800
+ r"|(?:(?!<function=).)*?<parameter=(?:(?!<function=).)*?(?:</function>|\Z))",
8801
+ re.DOTALL,
8802
+ )
8803
+ # Gemma's DSL, with the premature-EOS arm the parsing regex lacks.
8804
+ _SANITIZE_GEMMA_DSL_RE = re.compile(
8805
+ r"<\|tool_call>.*?(?:<tool_call\|>|\Z)",
8806
+ re.DOTALL,
8807
+ )
8808
+ # Whatever the block patterns left behind: closing tags with no opener, and a
8809
+ # LONE opener. The lone opener is stripped as a tag rather than as a block on
8810
+ # purpose -- it is inert to every client, and the text around it is far more
8811
+ # likely to be the model explaining which call it kept repeating (which the
8812
+ # hard-tier directive asks for) than a real emission.
8813
+ _SANITIZE_ORPHAN_TAG_RE = re.compile(
8814
+ r"</?parameter(?:=[^>\s]+)?>|</?function(?:=[^>\s]+)?>|</?tool_call>|<tool_call\|>",
8815
+ )
8816
+
8817
+
8818
+ def _strip_all_tool_call_markup(text: str) -> str:
8819
+ """Remove the tool-call markup forms a model emits INSTEAD of calling a tool.
8820
+
8821
+ `_strip_residual_tool_call_xml` only understands the ``<tool_call>``
8822
+ envelope. A model that emits a BARE Hermes ``<function=…>`` block (no
8823
+ envelope) or Gemma's DSL walks straight through it, which is how raw markup
8824
+ reached a client on a turn whose whole purpose was to end in prose. Applied
8825
+ on the suppressed path only — the normal path promotes this markup to real
8826
+ tool calls instead of deleting it.
8827
+
8828
+ Fenced ```json`` calls are deliberately NOT stripped: the normal path
8829
+ schema-matches them, but here they are indistinguishable from a model
8830
+ legitimately quoting JSON, and destroying real output is the worse error.
8831
+ """
8832
+ cleaned = _strip_residual_tool_call_xml(text)
8833
+ if "<function=" in cleaned:
8834
+ cleaned = _SANITIZE_HERMES_BLOCK_RE.sub("", cleaned)
8835
+ if "<|tool_call>" in cleaned:
8836
+ cleaned = _SANITIZE_GEMMA_DSL_RE.sub("", cleaned)
8837
+ # Orphans left by a block whose closing tag never arrived, or by the
8838
+ # envelope strip above.
8839
+ cleaned = _SANITIZE_ORPHAN_TAG_RE.sub("", cleaned)
8840
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
8841
+ return cleaned
8842
+
8843
+
8726
8844
  # Pattern: runaway closing braces like }}}}}
8727
8845
  _GARBLED_RUNAWAY_BRACES_RE = re.compile(r"\}{4,}")
8728
8846
  # Pattern: repetitive digit sequences like 000000 or 398859738398859738
@@ -10952,6 +11070,69 @@ def _maybe_apply_session_contamination_breaker(
10952
11070
  # ===========================================================================
10953
11071
 
10954
11072
 
11073
+ # Anything carrying one of these is worth running the stripper over. Kept wider
11074
+ # than the shapes the PARSER understands: an orphan closing tag or a lone
11075
+ # parameter tag is still markup on the client's screen, and gating the stripper
11076
+ # on the parser's markers let those through untouched.
11077
+ _SUPPRESSED_MARKUP_MARKERS = (
11078
+ "<tool_call>",
11079
+ "</tool_call>",
11080
+ "<tool_call|>",
11081
+ "<function=",
11082
+ "</function>",
11083
+ "<parameter=",
11084
+ "</parameter>",
11085
+ "<|tool_call>",
11086
+ )
11087
+
11088
+
11089
+ def _sanitize_suppressed_tool_markup(openai_resp: dict) -> dict:
11090
+ """Scrub tool-call markup from a turn whose tools were deliberately stripped.
11091
+
11092
+ Mutates *openai_resp* in place and returns it. A no-op unless a message
11093
+ is text-only and actually carries markup, so an ordinary prose reply on a
11094
+ suppressed turn passes through byte-for-byte.
11095
+ """
11096
+ for choice in openai_resp.get("choices") or []:
11097
+ if not isinstance(choice, dict):
11098
+ continue
11099
+ message = choice.get("message")
11100
+ # A malformed upstream payload must not turn a degraded turn into a 500.
11101
+ if not isinstance(message, dict) or message.get("tool_calls"):
11102
+ continue
11103
+ # The reasoning sidecar is scrubbed too: when `content` is empty the
11104
+ # EMPTY-OUTPUT GUARD promotes reasoning into the VISIBLE text, so
11105
+ # leaving it alone just relocates the leak.
11106
+ for field in ("content", "reasoning_content", "reasoning"):
11107
+ text = message.get(field)
11108
+ if not isinstance(text, str) or not text:
11109
+ continue
11110
+ if not any(marker in text for marker in _SUPPRESSED_MARKUP_MARKERS):
11111
+ continue
11112
+ cleaned = _strip_all_tool_call_markup(text)
11113
+ if field == "content":
11114
+ # Emptiness is judged on what SURVIVES thinking extraction. A
11115
+ # reply of "<think></think>" is truthy but renders blank, and
11116
+ # blank is the outcome the fallback exists to prevent.
11117
+ _, visible = _extract_thinking_block(cleaned)
11118
+ if not visible.strip():
11119
+ cleaned = (
11120
+ f"{cleaned}\n\n{STUCK_BREAK_PROSE_FALLBACK}"
11121
+ if cleaned.strip()
11122
+ else STUCK_BREAK_PROSE_FALLBACK
11123
+ )
11124
+ message[field] = cleaned
11125
+ logger.warning(
11126
+ "SUPPRESSED TOOL MARKUP: scrubbed tool-call markup from %s on a "
11127
+ "tools-stripped turn (%d chars in, %d out) -- the turn now ends "
11128
+ "in prose as intended",
11129
+ field,
11130
+ len(text),
11131
+ len(cleaned),
11132
+ )
11133
+ return openai_resp
11134
+
11135
+
10955
11136
  def _maybe_extract_text_tool_calls(
10956
11137
  openai_resp: dict,
10957
11138
  anthropic_tools: list[dict] | None = None,
@@ -10970,8 +11151,15 @@ def _maybe_extract_text_tool_calls(
10970
11151
  # A hard finalize breaker stripped tools this turn to force a terminal
10971
11152
  # text-only end_turn; do not resurrect prose tool-calls (that would defeat
10972
11153
  # the breaker and continue the loop). Carried per-turn on the SessionMonitor.
11154
+ #
11155
+ # Not resurrecting is only half the job. Returning the response untouched
11156
+ # ships the raw markup to the client as the assistant's visible text --
11157
+ # measured live 2026-08-25 on opencode session ses_fc7a27ea…, which rendered
11158
+ # a <tool_call><function=bash>… block as the reply, logged "exiting loop",
11159
+ # and left the operator retyping "go" into the same loop. A turn forced to
11160
+ # end in prose must actually END IN PROSE.
10973
11161
  if suppress:
10974
- return openai_resp
11162
+ return _sanitize_suppressed_tool_markup(openai_resp)
10975
11163
  choice = (openai_resp.get("choices") or [{}])[0]
10976
11164
  message = choice.get("message", {})
10977
11165
 
@@ -12640,11 +12828,7 @@ async def messages(request: Request):
12640
12828
  # Last step before the wire: names llama.cpp's grammar builder can parse.
12641
12829
  _sanitize_tool_names_for_upstream(openai_body)
12642
12830
 
12643
- use_guarded_non_stream = _should_use_guarded_non_stream(
12644
- is_stream,
12645
- body,
12646
- openai_body,
12647
- )
12831
+ use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
12648
12832
  if use_guarded_non_stream:
12649
12833
  async def _produce_guarded():
12650
12834
  strict_body = dict(openai_body)
@@ -99,14 +99,24 @@ class TestFinalizeSuppressionExtractor(unittest.TestCase):
99
99
  self.assertEqual(msg["tool_calls"][0]["function"]["name"], "Bash")
100
100
  self.assertEqual(resp["choices"][0]["finish_reason"], "tool_calls")
101
101
 
102
- def test_suppressed_leaves_prose_as_text(self):
103
- """suppress=True: prose stays text -> the client sees a clean end_turn
104
- with no action, so the agentic loop terminates."""
102
+ def test_suppressed_ends_the_turn_in_clean_prose(self):
103
+ """suppress=True: the client sees a clean end_turn with no action, so
104
+ the agentic loop terminates.
105
+
106
+ This used to assert the markup was still IN the text. That was an
107
+ incidental detail of the first implementation, not the contract this
108
+ docstring describes -- and it froze a real defect in place: the raw
109
+ block was shipped to the client as the assistant's visible reply.
110
+ Measured live 2026-08-25 (opencode ses_fc7a27ea...), which rendered the
111
+ markup, logged "exiting loop", and left the operator retyping "go".
112
+ The contract is: no resurrected call, finish_reason stop, and NO markup.
113
+ """
105
114
  resp = _prose_tool_resp()
106
115
  proxy._maybe_extract_text_tool_calls(resp, suppress=True)
107
116
  msg = resp["choices"][0]["message"]
108
117
  self.assertFalse(msg.get("tool_calls"), "must NOT resurrect tool call on finalize")
109
- self.assertIn("<function=Bash>", msg["content"])
118
+ self.assertNotIn(chr(60) + "function=", msg["content"])
119
+ self.assertTrue(msg["content"].strip(), "an empty reply is no better than markup")
110
120
  self.assertEqual(resp["choices"][0]["finish_reason"], "stop")
111
121
 
112
122
  def test_conversion_respects_suppression(self):
@@ -26,6 +26,10 @@ def _load_proxy_module():
26
26
 
27
27
  proxy = _load_proxy_module()
28
28
 
29
+ # Angle brackets built at runtime; see _markup below for why.
30
+ LT = chr(60)
31
+ GT = chr(62)
32
+
29
33
 
30
34
  def _looping_monitor(fires_so_far: int):
31
35
  mon = proxy.SessionMonitor()
@@ -83,5 +87,329 @@ class TestStuckBreakHard(unittest.TestCase):
83
87
  self.assertLessEqual(proxy.PROXY_STUCK_BREAK_HARD_FIRES, 5)
84
88
 
85
89
 
90
+ class TestSuppressedTurnEndsInProse(unittest.TestCase):
91
+ """The response half of the hard break.
92
+
93
+ Suppressing XML resurrection stops the loop re-arming, but on its own it
94
+ ships the raw markup to the client as the assistant's visible text.
95
+ Measured live 2026-08-25 (opencode ses_fc7a27ea...): the client rendered a
96
+ <tool_call> block as the reply, logged "exiting loop", and the operator
97
+ retyped "go" straight back into the same loop. A turn forced to end in
98
+ prose must actually end in prose.
99
+ """
100
+
101
+ @staticmethod
102
+ def _markup(envelope=True, closed=True, lead=""):
103
+ # Built from chr() so the literal tags never appear in this file --
104
+ # the repo's bash-safety enforcer refuses commands carrying standalone
105
+ # tool-call tag lines, which makes a literal fixture unrunnable.
106
+ def tag(name):
107
+ return LT + name + GT
108
+ parts = [tag("function=bash"), tag("parameter=command"), "grep -rn X ."]
109
+ if closed:
110
+ parts += [tag("/parameter"), tag("/function")]
111
+ if envelope:
112
+ parts = [tag("tool_call")] + parts + [tag("/tool_call")]
113
+ return (lead + "\n" + "\n".join(parts)) if lead else "\n".join(parts)
114
+
115
+ @staticmethod
116
+ def _resp(text):
117
+ return {
118
+ "choices": [
119
+ {"finish_reason": "stop", "message": {"role": "assistant", "content": text}}
120
+ ]
121
+ }
122
+
123
+ def test_enveloped_markup_never_reaches_the_client(self):
124
+ out = proxy._maybe_extract_text_tool_calls(
125
+ self._resp(self._markup()), suppress=True
126
+ )
127
+ content = out["choices"][0]["message"]["content"]
128
+ self.assertNotIn(LT + "tool_call" + GT, content)
129
+ self.assertNotIn(LT + "function=", content)
130
+ self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
131
+
132
+ def test_bare_hermes_block_is_stripped_too(self):
133
+ # _strip_residual_tool_call_xml only knows the envelope; a bare
134
+ # function block walked straight through it.
135
+ out = proxy._maybe_extract_text_tool_calls(
136
+ self._resp(self._markup(envelope=False)), suppress=True
137
+ )
138
+ self.assertNotIn(LT + "function=", out["choices"][0]["message"]["content"])
139
+
140
+ def test_unclosed_block_is_stripped_too(self):
141
+ out = proxy._maybe_extract_text_tool_calls(
142
+ self._resp(self._markup(envelope=False, closed=False)), suppress=True
143
+ )
144
+ content = out["choices"][0]["message"]["content"]
145
+ self.assertNotIn(LT + "function=", content)
146
+ self.assertNotIn(LT + "parameter=", content)
147
+
148
+ def test_markup_only_reply_gets_fallback_prose(self):
149
+ # An empty assistant message is no better than XML: the client ends the
150
+ # turn either way and the operator sees a blank reply.
151
+ out = proxy._maybe_extract_text_tool_calls(
152
+ self._resp(self._markup()), suppress=True
153
+ )
154
+ self.assertEqual(
155
+ out["choices"][0]["message"]["content"], proxy.STUCK_BREAK_PROSE_FALLBACK
156
+ )
157
+
158
+ def test_the_model_own_prose_is_kept_when_it_wrote_any(self):
159
+ out = proxy._maybe_extract_text_tool_calls(
160
+ self._resp(self._markup(lead="I will check the DB.")), suppress=True
161
+ )
162
+ self.assertEqual(out["choices"][0]["message"]["content"], "I will check the DB.")
163
+
164
+ def test_plain_prose_passes_through_untouched(self):
165
+ # Whitespace and blank lines included: the sanitiser collapses those, so
166
+ # a fixture without them is a fixed point and would pass even if the
167
+ # early exit were deleted.
168
+ text = "\n The cleanup at line 725 takes\n\n\n the lock in the wrong order. \n"
169
+ out = proxy._maybe_extract_text_tool_calls(self._resp(text), suppress=True)
170
+ self.assertEqual(out["choices"][0]["message"]["content"], text)
171
+
172
+ def test_suppression_still_refuses_to_resurrect_the_call(self):
173
+ # The original guarantee must survive the sanitiser.
174
+ out = proxy._maybe_extract_text_tool_calls(
175
+ self._resp(self._markup()), suppress=True
176
+ )
177
+ self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
178
+ self.assertEqual(out["choices"][0]["finish_reason"], "stop")
179
+
180
+ def test_unsuppressed_turns_still_promote_the_call(self):
181
+ # The sanitiser must not leak onto the normal path, where recovering a
182
+ # prose tool call is the whole point.
183
+ out = proxy._maybe_extract_text_tool_calls(
184
+ self._resp(self._markup()), suppress=False
185
+ )
186
+ calls = out["choices"][0]["message"].get("tool_calls") or []
187
+ self.assertEqual([c["function"]["name"] for c in calls], ["bash"])
188
+ self.assertEqual(out["choices"][0]["finish_reason"], "tool_calls")
189
+
190
+ def test_end_to_end_the_anthropic_response_is_a_prose_end_turn(self):
191
+ # BARE hermes, not the enveloped form: the pre-existing
192
+ # _strip_residual_tool_call_xml already deleted a whole <tool_call>
193
+ # envelope downstream, so the enveloped fixture passed on the OLD code
194
+ # too -- with an EMPTY text block, the very outcome this rail rejects.
195
+ resp = self._resp(self._markup(envelope=False))
196
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
197
+ out = proxy.openai_to_anthropic_response(
198
+ resp, "qwen", suppress_text_tool_extraction=True
199
+ )
200
+ self.assertEqual(out["stop_reason"], "end_turn")
201
+ blocks = out.get("content") or []
202
+ self.assertTrue(blocks, "a suppressed turn must still carry a text block")
203
+ self.assertTrue(all(b.get("type") == "text" for b in blocks))
204
+ joined = "".join(b.get("text") or "" for b in blocks)
205
+ # An empty reply is as useless to the client as the markup was.
206
+ self.assertTrue(joined.strip(), "the turn must end in ACTUAL prose")
207
+ self.assertNotIn(LT + "function=", joined)
208
+ self.assertNotIn(LT + "tool_call" + GT, joined)
209
+
210
+
86
211
  if __name__ == "__main__":
87
212
  unittest.main()
213
+
214
+ class TestSuppressedMarkupLeakClasses(unittest.TestCase):
215
+ """Every markup shape that reached a client, and every prose shape that must not be eaten.
216
+
217
+ The sanitiser deletes text, so its failure modes run both ways: markup left
218
+ behind is the original bug, and prose destroyed is a new one. Both are
219
+ pinned here.
220
+ """
221
+
222
+ @staticmethod
223
+ def _clean(text):
224
+ return proxy._strip_all_tool_call_markup(text)
225
+
226
+ @staticmethod
227
+ def _tag(name):
228
+ return LT + name + GT
229
+
230
+ def _block(self, name="bash", closed=True, param=True):
231
+ parts = [self._tag(f"function={name}")]
232
+ if param:
233
+ parts += [self._tag("parameter=command"), "grep X ."]
234
+ if closed:
235
+ parts.append(self._tag("/parameter"))
236
+ else:
237
+ parts.append("body")
238
+ if closed:
239
+ parts.append(self._tag("/function"))
240
+ return "".join(parts)
241
+
242
+ # --- markup that must go ---
243
+
244
+ def test_strips_a_dotted_or_hyphenated_tool_name(self):
245
+ # The PARSER's name class is [A-Za-z_][A-Za-z0-9_]*, so it ignored these
246
+ # -- and the old stripper reused it, leaving a half-eaten block behind.
247
+ for name in ("web.search", "read-file", "2fa_check"):
248
+ with self.subTest(name=name):
249
+ self.assertEqual(self._clean(self._block(name)), "")
250
+
251
+ def test_strips_an_unclosed_block_that_carries_a_parameter(self):
252
+ self.assertEqual(self._clean(self._block(closed=False)), "")
253
+
254
+ def test_strips_an_orphan_closing_tag(self):
255
+ self.assertEqual(self._clean("All done." + self._tag("/function")), "All done.")
256
+ self.assertEqual(self._clean("Done." + self._tag("/tool_call")), "Done.")
257
+ self.assertEqual(self._clean("Done." + self._tag("/parameter")), "Done.")
258
+
259
+ def test_strips_an_unclosed_gemma_dsl_call(self):
260
+ # The Gemma parsing regex has no premature-EOS arm, so a truncated call
261
+ # passed through whole.
262
+ dsl = LT + "|tool_call" + GT + "call: bash {c:1}"
263
+ self.assertEqual(self._clean("Done.\n" + dsl), "Done.")
264
+
265
+ # --- prose that must survive ---
266
+
267
+ def test_prose_that_merely_mentions_a_tag_keeps_its_sentence(self):
268
+ # The hard-tier directive asks the model what the repeated call
269
+ # returned, i.e. it invites naming the call. A \Z-anchored delete turned
270
+ # that into "The proxy scans for".
271
+ text = (
272
+ "The proxy scans for "
273
+ + self._tag("function=bash")
274
+ + " and then deletes the rest. THIS SHOULD SURVIVE."
275
+ )
276
+ out = self._clean(text)
277
+ self.assertIn("THIS SHOULD SURVIVE.", out)
278
+ self.assertIn("The proxy scans for", out)
279
+
280
+ def test_prose_between_two_blocks_survives(self):
281
+ text = (
282
+ "Alpha.\n"
283
+ + self._block("b")
284
+ + "\nBETWEEN-PROSE\n"
285
+ + self._block("d")
286
+ + "\nOmega."
287
+ )
288
+ out = self._clean(text)
289
+ self.assertIn("BETWEEN-PROSE", out)
290
+ self.assertIn("Alpha.", out)
291
+ self.assertIn("Omega.", out)
292
+
293
+ def test_a_malformed_block_does_not_swallow_the_next_blocks_prose(self):
294
+ # One unclosed opener used to match through the NEXT block's </function>.
295
+ text = (
296
+ "Alpha.\n"
297
+ + self._tag("function=bash")
298
+ + "X\nIMPORTANT PROSE\n"
299
+ + self._block("read")
300
+ + "\nOmega."
301
+ )
302
+ out = self._clean(text)
303
+ self.assertIn("IMPORTANT PROSE", out)
304
+ self.assertNotIn(LT + "function=", out)
305
+
306
+ def test_a_fenced_json_call_is_deliberately_left_alone(self):
307
+ # The normal path schema-matches these; here they are indistinguishable
308
+ # from a model quoting JSON, and destroying real output is worse.
309
+ text = 'I will run it.\n\n```json\n{"name": "bash"}\n```'
310
+ self.assertEqual(self._clean(text), text)
311
+
312
+
313
+ class TestSuppressedSanitiserReach(unittest.TestCase):
314
+ """Fields and shapes the sanitiser has to reach, beyond choices[0].content."""
315
+
316
+ @staticmethod
317
+ def _markup():
318
+ return (
319
+ LT + "function=bash" + GT + LT + "parameter=command" + GT
320
+ + "grep X ." + LT + "/parameter" + GT + LT + "/function" + GT
321
+ )
322
+
323
+ def test_scrubs_the_reasoning_sidecar_too(self):
324
+ # With content empty, the EMPTY-OUTPUT GUARD promotes reasoning into the
325
+ # VISIBLE text -- leaving it alone just relocates the leak.
326
+ resp = {"choices": [{"message": {"role": "assistant", "content": "", "reasoning_content": self._markup()}}]}
327
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
328
+ self.assertNotIn(LT + "function=", resp["choices"][0]["message"]["reasoning_content"])
329
+
330
+ def test_a_think_wrapped_call_still_yields_visible_prose(self):
331
+ # "<think></think>" is truthy but renders blank. Emptiness has to be
332
+ # judged on what survives thinking extraction.
333
+ text = LT + "think" + GT + LT + "/think" + GT + self._markup()
334
+ resp = {"choices": [{"message": {"role": "assistant", "content": text}}]}
335
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
336
+ _, visible = proxy._extract_thinking_block(resp["choices"][0]["message"]["content"])
337
+ self.assertTrue(visible.strip(), "a think-only reply renders blank to the client")
338
+
339
+ def test_scrubs_every_choice_not_just_the_first(self):
340
+ resp = {"choices": [
341
+ {"message": {"role": "assistant", "content": "fine"}},
342
+ {"message": {"role": "assistant", "content": self._markup()}},
343
+ ]}
344
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
345
+ self.assertNotIn(LT + "function=", resp["choices"][1]["message"]["content"])
346
+
347
+ def test_leaves_a_message_that_has_real_tool_calls_alone(self):
348
+ # Rewriting the text beside a live tool call would attach an apology to
349
+ # a turn that is actually doing work.
350
+ mk = self._markup()
351
+ resp = {"choices": [{"message": {"role": "assistant", "content": mk, "tool_calls": [{"id": "x"}]}}]}
352
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
353
+ self.assertEqual(resp["choices"][0]["message"]["content"], mk)
354
+
355
+ def test_survives_a_malformed_upstream_payload(self):
356
+ # A degraded turn must not become a 500.
357
+ for bad in ({"choices": [{"message": None}]}, {"choices": ["oops"]}, {"choices": []}, {}):
358
+ with self.subTest(bad=bad):
359
+ proxy._maybe_extract_text_tool_calls(dict(bad), suppress=True)
360
+
361
+
362
+ class TestSuppressedTurnIsBuffered(unittest.TestCase):
363
+ """A tools-stripped turn must not be streamed.
364
+
365
+ The sanitiser only runs on the buffered path -- a streamed turn is already
366
+ on the wire before anything can inspect it. And the hard tier pops
367
+ `tool_choice`, which is precisely the key _should_use_guarded_non_stream
368
+ requires, so stripping the tools ALSO routed the turn away from the guarded
369
+ path. That is why the request handler ORs the monitor flag in.
370
+ """
371
+
372
+ @staticmethod
373
+ def _hard_tier_body():
374
+ # What _maybe_inject_stuck_break leaves behind at the hard tier: no
375
+ # tools, no tool_choice, no grammar.
376
+ return {"messages": [{"role": "user", "content": "go"}]}
377
+
378
+ @staticmethod
379
+ def _monitor(suppress):
380
+ mon = proxy.SessionMonitor()
381
+ mon.suppress_text_tool_extraction = suppress
382
+ return mon
383
+
384
+ def test_a_suppressed_turn_is_always_buffered(self):
385
+ # Regardless of stream flag, body shape, or any ambient proxy config:
386
+ # the sanitiser only runs on the buffered path.
387
+ for is_stream in (True, False):
388
+ with self.subTest(is_stream=is_stream):
389
+ self.assertTrue(
390
+ proxy._should_buffer_turn(
391
+ is_stream,
392
+ {"stream": is_stream},
393
+ self._hard_tier_body(),
394
+ self._monitor(True),
395
+ )
396
+ )
397
+
398
+ def test_an_ordinary_turn_defers_to_the_existing_check(self):
399
+ # The new reason must ADD to the old rule, never replace it.
400
+ body = self._hard_tier_body()
401
+ mon = self._monitor(False)
402
+ self.assertEqual(
403
+ proxy._should_buffer_turn(True, {"stream": True}, body, mon),
404
+ proxy._should_use_guarded_non_stream(True, {"stream": True}, body),
405
+ )
406
+
407
+ def test_suppression_overrides_a_declining_guarded_check(self):
408
+ # Pin the actual defect: with the flag off this exact turn is NOT
409
+ # buffered by the old rule alone, and with it on it is.
410
+ body = {"stream": True}
411
+ openai_body = self._hard_tier_body()
412
+ if proxy._should_use_guarded_non_stream(True, body, openai_body):
413
+ self.skipTest("ambient proxy config already forces buffering here")
414
+ self.assertFalse(proxy._should_buffer_turn(True, body, openai_body, self._monitor(False)))
415
+ self.assertTrue(proxy._should_buffer_turn(True, body, openai_body, self._monitor(True)))
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """An advertised alias is a servable model id.
3
+
4
+ llama-server registers aliases in a std::set and reports ONE primary `id` --
5
+ `*begin()`, i.e. the ASCII-first entry -- alongside an `aliases` array carrying
6
+ all of them. The proxy read only `id`, so every client configured with any other
7
+ registered alias looked unservable and had its model rewritten on EVERY request
8
+ (MODEL REWRITE, 6 in a 3h window on 2026-08-25).
9
+
10
+ That made --alias half-useless for the thing it was added to fix: naming legacy
11
+ ids alongside a new one so an older client config keeps resolving untouched.
12
+ """
13
+
14
+ import asyncio
15
+ import importlib.util
16
+ import unittest
17
+ from pathlib import Path
18
+
19
+
20
+ def _load_proxy_module():
21
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
22
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
23
+ assert spec is not None and spec.loader is not None
24
+ module = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ proxy = _load_proxy_module()
30
+
31
+
32
+ class _FakeResponse:
33
+ def __init__(self, payload, status_code=200):
34
+ self._payload = payload
35
+ self.status_code = status_code
36
+ self.text = str(payload)
37
+
38
+ def json(self):
39
+ return self._payload
40
+
41
+
42
+ class _FakeClient:
43
+ """Stands in for the httpx client; records nothing, just answers /models."""
44
+
45
+ def __init__(self, payload, status_code=200):
46
+ self._response = _FakeResponse(payload, status_code)
47
+
48
+ async def get(self, _url, timeout=None): # noqa: ARG002 - signature parity
49
+ return self._response
50
+
51
+
52
+ class UpstreamModelAliasTests(unittest.TestCase):
53
+ def setUp(self):
54
+ # The result is cached in a module global for the process lifetime.
55
+ self._saved_client = proxy.http_client
56
+ proxy._upstream_model_ids = None
57
+
58
+ def tearDown(self):
59
+ proxy.http_client = self._saved_client
60
+ proxy._upstream_model_ids = None
61
+
62
+ def _ids(self, payload, status_code=200):
63
+ proxy.http_client = _FakeClient(payload, status_code)
64
+ return asyncio.run(proxy._upstream_model_ids_cached())
65
+
66
+ def test_aliases_are_reported_as_served_ids(self):
67
+ ids = self._ids(
68
+ {
69
+ "data": [
70
+ {
71
+ "id": "Qwen3.8-27B",
72
+ "aliases": ["Qwen3.8-27B", "qwen36-35b-a3b-iq4xs", "qwen35-a3b-iq4xs"],
73
+ }
74
+ ]
75
+ }
76
+ )
77
+ # The legacy ids are exactly the ones that were being rewritten.
78
+ self.assertIn("qwen36-35b-a3b-iq4xs", ids)
79
+ self.assertIn("qwen35-a3b-iq4xs", ids)
80
+ self.assertIn("Qwen3.8-27B", ids)
81
+
82
+ def test_the_primary_id_stays_first(self):
83
+ # Callers pick ids[0] as the rewrite target, so the primary must lead
84
+ # even though it also appears in the aliases array.
85
+ ids = self._ids({"data": [{"id": "Primary", "aliases": ["Primary", "Alt"]}]})
86
+ self.assertEqual(ids[0], "Primary")
87
+
88
+ def test_the_primary_id_is_not_duplicated_by_its_own_alias_entry(self):
89
+ # llama-server lists the primary inside `aliases` too.
90
+ ids = self._ids({"data": [{"id": "Same", "aliases": ["Same"]}]})
91
+ self.assertEqual(ids, ["Same"])
92
+
93
+ def test_a_payload_with_no_aliases_key_still_works(self):
94
+ # Older llama-server builds, and any other OpenAI-compatible upstream.
95
+ ids = self._ids({"data": [{"id": "OnlyId"}]})
96
+ self.assertEqual(ids, ["OnlyId"])
97
+
98
+ def test_junk_entries_are_skipped_rather_than_raising(self):
99
+ # Discovery must never take the proxy down.
100
+ ids = self._ids(
101
+ {
102
+ "data": [
103
+ "not-a-dict",
104
+ {"no_id": True},
105
+ {"id": "Good", "aliases": [None, "", "AlsoGood"]},
106
+ ]
107
+ }
108
+ )
109
+ self.assertEqual(ids, ["Good", "AlsoGood"])
110
+
111
+ def test_a_non_200_yields_no_ids(self):
112
+ self.assertIsNone(self._ids({"data": [{"id": "x"}]}, status_code=503))
113
+
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()