Skip to content

Guides

Migrating from llm-guard

`flowx_border.adapters.llm_guard_compat` keeps llm-guard's `scan_prompt` and `scan_output` signatures and its tuple return shape, so a migration starts as an import change. The tab

flowx_border.adapters.llm_guard_compat keeps llm-guard's scan_prompt and scan_output signatures and its tuple return shape, so a migration starts as an import change. The table below is the adapter's own support list.

The one thing to read before migrating

A scanner with no equivalent raises UnsupportedScannerError. It does not pass and it does not warn. A shim that accepted BanCode and did nothing would leave you believing code was blocked, and the only way to find out otherwise would be an incident. Every scanner llm-guard shipped is in one of the two tables below.

Supported

llm-guard scannerdetectortierside
AnonymizepiiT1input, output
BanCompetitorsbanned_termsT1input, output
BanSubstringsbanned_termsT1input, output
BanTopicstopic_scopeT3input
BiasbiasT2output
Deanonymizeoutput_leakageT1output
FactualConsistencygroundednessT3output
GibberishgibberishT1input
JSONoutput_formatT1output
NSFWnsfwT2input, output
NoRefusalpolitenessT2output
PromptInjectioninjectionT2input
ReadingTimeoutput_formatT1output
Regexoutput_formatT1output
Relevancetopic_scopeT3input
SecretssecretsT0input
Sensitiveoutput_leakageT1output
ToxicitytoxicityT2input, output
URLReachabilityurl_reachabilityT3output

Two of these are worth a note.

Anonymize and Deanonymize were a pair in llm-guard: the first replaced entities with placeholders and the second put them back. This library does not put them back. Redaction is one way, because a vault mapping placeholders to real values is a second copy of the data you were trying not to expose. Deanonymize therefore maps to output_leakage, which answers the question the pair was usually being used for: did personal data appear in the output that the user never supplied.

NoRefusal maps to politeness, and the fit is loose. llm-guard looked for a model refusing to answer; politeness scores whether the tone is acceptable. If you relied on NoRefusal to detect capability failures rather than rudeness, this is not the same check and you should say so in your policy rather than assume it carried over.

Unsupported

llm-guard scannerwhy not
BanCodeno code detector. sql_injection parses generated SQL and says nothing about code in any other language, or about whether prose contains a code block.
Codeno code detector, as above.
InvisibleTextno detector reports these characters yet. Half the work is done: detectors/multilingual.py drops zero-width and format characters before matching, so they cannot be used to evade a term. What is missing is a detector that reports their presence as a finding in its own right.
Languageno language identification detector. The library supports 26 languages in every detector rather than gating on which one a text is in.
LanguageSameno language identification, as above.
MaliciousURLsno URL reputation detector. url_reachability asks whether a link answers, which is a different question from whether it is hostile, and answering the second needs a reputation feed this library does not ship.
Sentimentno sentiment detector. politeness is the nearest, and it is not the same.
TokenLimitoutput_format counts graphemes and words, and a token limit is neither. Tokens depend on the tokenizer of the model you are calling, which this library does not know, so mapping this onto max_length would report a different number than the one you asked about.

InvisibleText is the one on that list worth flagging as a genuine gap rather than a rejected idea. Zero-width and bidi-control characters are a real prompt-injection vector, the check is pure rules, and it would be a T0 detector costing nothing. It is absent because the detector set is fixed for v1 and adding a fourteenth needs an explicit instruction, not because it is a bad idea.

What changes in your code

# beforefrom llm_guard import scan_promptfrom llm_guard.input_scanners import Anonymize, PromptInjection sanitised, valid, scores = scan_prompt(prompt, [Anonymize(vault), PromptInjection()]) # afterfrom flowx_border.adapters.llm_guard_compat import scan_prompt sanitised, valid, scores = scan_prompt(prompt, ["Anonymize", "PromptInjection"])

Behaviour differences that the tuple cannot express:

  • Configuration is a policy file, not constructor arguments. llm-guard configured a scan by how you built the scanners. Here it is YAML validated by a schema, so a compliance officer who does not write Python can read what runs. Pass policy= to use one; without it you get the named scanners at their defaults.
  • There is an evidence record. The tuple has nowhere to put it. decision_for() returns the real Decision, and the record is the reason to be here at all.
  • T0 always runs. secrets and disclosure are in every scan whether you asked or not, because T0 cannot be disabled. They are set to flag in the compat policy, so a migration does not start blocking traffic that llm-guard was allowing.

Scanners we added that llm-guard had no equivalent for

  • disclosure (T0, output)
  • regulated_advice (T2, output)
  • banned_terms (T1, input and output)
  • system_prompt_leakage (T1, output)
  • markup_injection (T1, input and output)
  • internal_domains (T1, output)
  • output_format (T1, output)
  • sql_injection (T1, output, needs the sql extra)
  • url_reachability (T3, output, makes an HTTP request)
  • invisible_text (T0, input and output)
  • postal_code (T1, output)
  • repetition (T1, output)
  • json_schema (T1, output, needs the schema extra)
  • moderation (T2, input and output, no artifact published yet)
  • encoded_payload (T1, input and output)

The last twelve arrived on 2026-08-11 with the Guardrails Hub port, and six of them moved a scanner out of the unsupported table above.

encoded_payload arrived on 2026-08-16 and moved no scanner, because llm-guard has nothing for it. It decodes base64, hex, percent-encoding and rot13 and applies the rules to what comes out, which is the half of prompt injection a classifier cannot reach: the surface text of a base64 blob carries no attack, so PromptInjection and our own injection both score it clean. Decoding alone is never a finding, so a JWT, a git hash and base64 of ordinary prose all decode and none is reported.

Scanners that gained a detector on 2026-08-11

BanSubstrings, BanCompetitors, JSON, Regex, ReadingTime and URLReachability were all declined before that date because the detector they need did not exist. It does now, and they are mapped.

Five of them raise unless you pass a policy. llm-guard configured a scan by how you built the scanner, BanSubstrings(substrings=[...]). Here configuration is policy, and this shim cannot read a constructor argument even when you pass an instance, because those attribute names are private to llm-guard and guessing one wrong yields an empty list rather than an error. An empty list is the case that matters: banned_terms with no terms reports terms_not_configured and finds nothing, so accepting the call would hand you a clean-looking tuple for a check that never ran. UnconfiguredScannerError names the option to set.

scannerset this in your policy
BanSubstringsbanned_terms.options.terms, with whole_words: false
BanCompetitorsbanned_terms.options.terms
Regexoutput_format.options.regex
ReadingTimeoutput_format.options.max_reading_seconds
JSONoutput_format.options.json: true

URLReachability is not on that list because it has usable defaults. It is the one mapping that changes what your deployment needs: url_reachability declares requires={"network"}, so enabling it puts a third party in the latency path of every scan. registry.deployment_notes returns a line saying so. It also refuses to request private addresses, which llm-guard's version does not, so a URL resolving to your intranet is reported rather than fetched.

Three that are still refused, and why the near miss was refused too

TokenLimit looks like it maps to output_format and does not. That detector counts graphemes and words; a token limit counts tokens, and tokens depend on the tokenizer of the model you are calling, which this library does not know. Mapping it would report a different number than the one you asked about.

MaliciousURLs looks like it maps to url_reachability and does not. Whether a link answers and whether a link is hostile are different questions, and the second needs a reputation feed this library does not ship.

InvisibleText is no longer one of them. invisible_text closes it: it reports bidirectional controls, tag characters and zero-width characters, at T0, on both sides. The shim still raises for the scanner name, for the same reason as the entries above, and the mapping is a one-line change to SUPPORTED whenever somebody wants it.

disclosure is the one to look at if you are here for an AI Act evidence trail: it reports whether a required disclosure is present in the output, in any of 26 languages, and records the affirmative rather than only the absence.

Added since the port

summary_support is new, and llm-guard has no equivalent scanner: it comes from the Guardrails Hub's extracted_summary_sentences_match. It asks whether each sentence of a summary appears in the source it summarises, by string overlap rather than by asking a model. Useful when the output is meant to be extractive. It is not a groundedness check, and its docstring is explicit about that: a true paraphrase reads as unsupported and a copied sentence with a negation inserted reads as supported.

This page is generated from docs/migrating-from-llm-guard.md in the library repository. Read it as markdown, or edit it at the source.