# OpenMed > Open-source healthcare NLP toolkit with curated models, Apple Silicon MLX acceleration, OpenMedKit for Swift apps, advanced NER, and one-call orchestration. OpenMed is a local-first healthcare NLP toolkit for clinical entity extraction, de-identification, model discovery, and on-device or self-hosted integrations. # Getting started # OpenMed Documentation OpenMed bundles curated biomedical models, advanced de-identification, multimodal intake, structured health-data utilities, and one-call orchestration so you can ship clinical NLP workflows without wrangling infrastructure. This documentation keeps copied snippets and workflows close at hand: each section is Markdown-first, searchable, and optimized for quick scanning or copy/paste into notebooks. OpenMed `1.9.1` completes the `1.9` rollout for one ONNX token-classification model contract across Python, browsers, Node.js, and Android, alongside a corrected Swift package, expanded clinical extraction, 21-language PII coverage, and stronger release evidence: - **Policy-aware de-identification** with signed audit reports, reproducibility hashes, review bundles, redaction previews, and release gates. - **Multimodal and structured inputs** across OCR, images, PDFs, DOCX, EPUB, vCard/iCalendar, DICOM, CSV/TSV, JSONL chat logs, HL7 v2, CDA/C-CDA, FHIR operations, and FHIR Bulk NDJSON. - **Python, Swift, Kotlin/Android, REST, gRPC, React Native, TypeScript, and browser paths** including OpenMedKit, typed REST clients, ONNX/WebGPU, and Transformers.js export bundles. - **22 supported PII language codes: am, ar, de, en, es, fr, he, hi, id, it, ja, ko, nl, pt, ro, sw, te, th, tr, xh, zh, and zu**, with Chinese using the documented multilingual default-model placeholder, locale-aware validation and surrogate generation. A user-configured Indic NER adapter adds nine optional routes and can also serve Hindi and Telugu. Additional validator-backed national-ID coverage remains available for ID-only locales. - **Release evidence** for leakage heatmaps, model scorecards, threshold sweeps, k-anonymity/l-diversity/t-closeness, utility loss, SBOMs, signed images, SLSA provenance, secret scanning, and reproducible dependency locks. ## What you get - **Curated registries** – discoverable Hugging Face models with metadata (domain, size, device guidance). - **One-line orchestration** – `analyze_text` wraps validation, inference, and formatting for scripts, notebooks, or services. - **PII detection & de-identification** – HIPAA-aware smart entity merging, policy profiles, signed audit reports, and production-ready de-identification. - **Apple Silicon and mobile acceleration** – MLX-backed Python inference plus Swift-native and Android/Kotlin app integration through OpenMedKit. - **REST service** – FastAPI endpoints for `/livez`, `/readyz`, `/analyze`, `/pii/extract`, `/pii/deidentify`, warm pools, batching, metrics, and typed Python/TypeScript clients. - **Browser and React Native export** – ONNX/WebGPU bundles for Transformers.js token classification in browser runtimes plus a React Native bridge for mobile apps. - **Advanced NER post-processing** – score-aware grouping, PHI-friendly filtering, and CSV/JSON/HTML export helpers. - **Composable config** – `OpenMedConfig` reads YAML/ENV so deployments stay reproducible across laptops and clusters. Copy-friendly defaults Every page in this site exposes code fences with copy buttons and callouts so teammates (or AI copilots) can lift the exact snippet they need. Use the search shortcut (`/` or `cmd/ctrl + K`) to jump straight to an entity, API call, or API surface. LLM-ready documentation Use the curated [llms.txt](https://openmed.life/docs/llms.txt) index to discover the quickstarts, APIs, and agent tool surfaces, or load [llms-full.txt](https://openmed.life/docs/llms-full.txt) when you need their full rendered content in one file. Both feeds are regenerated by the documentation build. ## First look ```python from openmed import analyze_text result = analyze_text( "Patient started on imatinib for chronic myeloid leukemia.", model_name="disease_detection_superclinical", confidence_threshold=0.55, ) for entity in result.entities: print(entity.label, entity.text, entity.confidence) ``` ```bash uv pip install "openmed[hf]" uv run python examples/pii_model_comparison.py ``` The rest of the docs expand on this snippet—head to **Quick Start** for the end-to-end setup, then explore the guides for configuration, zero-shot GLiNER workflows, and advanced processing helpers. ## Latest release highlights - [OpenMed 1.9.1 Release Notes](https://openmed.life/docs/release/v1.9.1/index.md) – Swift packaging, Android release hardening, current model examples, and dependency-security fixes for the cross-platform 1.9 release. - [OpenMed 1.8.0 Release Notes](https://openmed.life/docs/release/v1.8.0/index.md) – historical cross-platform runtime and service release inventory. - [OpenMed v1.6-v1.7 Feature Coverage](https://openmed.life/docs/release/v1.6-v1.7-feature-coverage/index.md) – historical coverage checklist across examples, docs, website, and source modules. - [Examples & Copy/Paste Recipes](https://openmed.life/docs/examples/index.md) – release-friendly snippets for Python, PII, batch jobs, Apple runtimes, browser export, multimodal inputs, and FHIR/HL7. - [Transformers.js Export](https://openmed.life/docs/export-transformersjs/index.md) – browser/WebGPU packaging for token classification bundles. - [FHIR Interop Helpers](https://openmed.life/docs/fhir-interop/index.md), [HL7 v2 De-identification](https://openmed.life/docs/hl7v2-deidentification/index.md), and [OMOP/lakehouse integrations](https://openmed.life/docs/integrations/lakehouse-redaction/index.md) – structured health-data workflows. - [MLX Backend](https://openmed.life/docs/mlx-backend/index.md), [OpenMedKit](https://openmed.life/docs/swift-openmedkit/index.md), [Android Span Parity](https://openmed.life/docs/android-parity/index.md), and [CoreML Packaging](https://openmed.life/docs/coreml-export/index.md) – local mobile/runtime paths. ## How these docs are structured 1. [Quick Start](https://openmed.life/docs/getting-started/index.md) – fastest path to a working environment plus a copy/paste script. 1. [Feature Map](https://openmed.life/docs/feature-map/index.md) – see how every capability maps back to the code. 1. [OpenMed 1.9.1 Release Notes](https://openmed.life/docs/release/v1.9.1/index.md) – review the current patch fixes, installation coordinates, and validation evidence. 1. Core guides: 1. [Analyze Text Helper](https://openmed.life/docs/analyze-text/index.md) for single-call inference. 1. [REST Service (MVP)](https://openmed.life/docs/rest-service/index.md) for Dockerized HTTP endpoints. 1. [PII Detection & Smart Merging](https://openmed.life/docs/pii-smart-merging/index.md) for HIPAA-compliant de-identification (v0.5.0). 1. [Batch Processing](https://openmed.life/docs/batch-processing/index.md) for multi-text/file processing. 1. [ModelLoader & Pipelines](https://openmed.life/docs/model-loader/index.md) for long-running jobs. 1. [Model Registry](https://openmed.life/docs/model-registry/index.md) to pick the right checkpoint. 1. [Configuration Profiles](https://openmed.life/docs/profiles/index.md) for dev/prod/test switching. 1. [Advanced NER & Output Formatting](https://openmed.life/docs/output-formatting/index.md) to polish spans. 1. [Medical-Aware Tokenizer](https://openmed.life/docs/medical-tokenizer/index.md) for better clinical token boundaries. 1. [Configuration & Validation](https://openmed.life/docs/configuration/index.md) to keep deployments reproducible. 1. [Zero-shot Toolkit](https://openmed.life/docs/zero-shot-ner/index.md) when you need GLiNER workflows. 1. [Performance Profiling](https://openmed.life/docs/profiling/index.md) for timing and optimization. 1. [Examples](https://openmed.life/docs/examples/index.md) and [Testing & QA](https://openmed.life/docs/testing/index.md) for day-to-day operations. 1. Project operations: 1. [Contributing & Releases](https://openmed.life/docs/contributing/index.md) – how we cut releases, publish docs, and keep CI green. 1. [Release Streams & Channels](https://openmed.life/docs/release/semver-and-channels/index.md) – model artifact and library release policy. 1. [Generative Model Policy](https://openmed.life/docs/generative-model-policy/index.md) – approved and prohibited model-assisted workflows. Need something that is not here yet? Drop an issue on GitHub and mention the missing recipe. Every addition is just a Markdown file away. # Quick Start This guide gets you from a blank workstation to copying results from the docs within minutes. It uses [uv](https://github.com/astral-sh/uv) for dependency management, but any Python 3.11+ environment works. ## 1. Bootstrap the environment ```bash curl -LsSf https://astral.sh/uv/install.sh | sh # install uv (skip if already installed) uv venv --python 3.11 # create a dedicated virtualenv source .venv/bin/activate # or use `uv python` directly # install OpenMed with Hugging Face extras and doc tooling uv pip install ".[hf]" ``` ```powershell powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" uv venv --python 3.11 .venv\Scripts\Activate.ps1 # install OpenMed with Hugging Face extras and doc tooling uv pip install ".[hf]" ``` Need the zero-shot GLiNER stack or dev tools? Stack extras as needed: ```bash uv pip install ".[hf,gliner]" # add GLiNER + transformers uv pip install ".[dev]" # pytest + coverage + linting ``` For scanned images and document OCR, install the multimodal extra plus the system Tesseract binary: ```bash uv pip install ".[multimodal]" brew install tesseract # macOS sudo apt-get install tesseract-ocr # Debian/Ubuntu ``` PaddleOCR is available as a heavier opt-in OCR backend: ```bash uv pip install ".[ocr-paddle]" ``` CDA/C-CDA XML de-identification is available in the core install. It redacts structured header PHI, sweeps CDA section narrative text, keeps XML parseable, and only routes `.xml` files that look like CDA documents: ```python from openmed.interop.cda import redact_cda redacted_xml = redact_cda("synthetic_ccda.xml", date_shift_days=30) ``` On an Apple Silicon Mac, you can start directly on the new MLX path: ```bash uv pip install ".[mlx]" # Python MLX runtime + tokenizer/artifact deps uv run python -c "from openmed.core.backends import get_backend; print(type(get_backend()).__name__)" ``` If you want the full launch surface on one machine, combine them: ```bash uv pip install ".[hf,mlx,docs]" ``` ## 2. Run `analyze_text` ```python from openmed import analyze_text text = "Metastatic breast cancer treated with paclitaxel and trastuzumab." resp = analyze_text(text, model_name="disease_detection_superclinical") print(resp.entities[0]) # Want ready-to-embed HTML instead? Ask for the "html" output format: html = analyze_text(text, model_name="disease_detection_superclinical", output_format="html") print(html) # ready for dashboards or docs ``` Prefer a quick script entrypoint? Run a one-file smoke script: ```bash uv run python examples/pii_model_comparison.py ``` ## 3. De-identify PII ```python from openmed import deidentify result = deidentify("Patient John Doe, DOB 01/15/1970", method="mask") print(result.deidentified_text) # Patient [first_name] [last_name], DOB [date] ``` `deidentify()` supports five methods (`mask`, `remove`, `replace`, `hash`, `shift_dates`) — see the [Anonymization quickstart](https://openmed.life/docs/anonymization/#quickstart-choosing-a-method) for a runnable example of each, plus how to reverse one with `reidentify()`. ## 4. Pull a model reliably for offline use Use the model pull command to warm the Hugging Face cache before working offline. Downloads resume after interrupted transfers, retry transient network failures, and verify every file against Hub metadata: ```bash openmed models pull disease_detection_superclinical ``` On a metered or unstable connection, pin the revision, limit transfer speed, and tune the retry count explicitly: ```bash openmed models pull disease_detection_superclinical \ --revision main \ --max-bandwidth 524288 \ --retries 5 ``` Progress contains only repository filenames and byte/file totals. After the pull completes, set `OPENMED_OFFLINE=1`; the same command then performs a cache-only lookup and never attempts a network connection. ## 5. Copy code snippets from the docs All code blocks ship with Material for MkDocs copy buttons. Invoking the command palette (`/` or `cmd/ctrl + K`) lets you search for “GLiNER,” “OpenMedConfig,” or “token classification,” then copy the snippet that appears in the preview pane. If you rely on AI copilots (ChatGPT, Copilot, etc.), point them at the published docs URL so they crawl the same structured Markdown and surface canonical answers. ## 6. Optional: pin configuration ```python from openmed.core import OpenMedConfig, ModelLoader config = OpenMedConfig.from_env_fallback( cache_dir="~/.cache/openmed", device="cuda", default_org="OpenMed", ) loader = ModelLoader(config=config) ner = loader.create_pipeline("disease_detection_superclinical") entities = ner("Hydroxyurea dose reduced after platelet drop.") ``` Continue to the **Configuration** section for the full YAML/ENV schema, PHI-aware validation helpers, and logging setup. # Persona Quickstarts The [Quick Start](https://openmed.life/docs/getting-started/index.md) walks through installation in one linear path. This page splits that path into three short, copy-paste tracks so you land on the right first command for your role, then hands you off to the deeper guide. Pick the track that matches how you'll use OpenMed: | Persona | Goal | Jump to | | ----------------- | ------------------------------------------------ | ------------------------------- | | **Researcher** | De-identify a corpus and measure PHI leakage | [Researcher](#researcher) | | **App developer** | Call de-identification from a service or agent | [App developer](#app-developer) | | **Data engineer** | Redact files, directories, and datasets at scale | [Data engineer](#data-engineer) | Not a medical device OpenMed is a research and engineering toolkit for text de-identification and named-entity recognition. It is **not** a medical device and does not provide clinical decisions, diagnosis, or treatment advice. De-identification is probabilistic: review residual-risk output and validate against your own compliance requirements before releasing data. Every example below uses **synthetic, non-PHI** text. All tracks assume a working install. The minimal setup is: ```bash curl -LsSf https://astral.sh/uv/install.sh | sh # install uv (skip if present) uv venv --python 3.11 source .venv/bin/activate uv pip install "openmed[hf]" # core + Hugging Face models ``` ```powershell powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" uv venv --python 3.11 .venv\Scripts\Activate.ps1 uv pip install "openmed[hf]" # core + Hugging Face models ``` The first model-backed call may download model artifacts. Clinical text is still processed locally; after warming the cache, use [local-only offline mode](https://openmed.life/docs/configuration/#local-only-offline-mode) to block outbound connections during inference. ______________________________________________________________________ ## Researcher **Goal:** run entity extraction and de-identification over clinical notes, then quantify how much PHI leaked through. ### 1. Extract entities from a synthetic note `extract_pii()` returns a `PredictionResult` whose `entities` carry the detected text, canonical label, confidence, and character offsets. ```python from openmed import extract_pii note = "Patient Casey Example (MRN 00123) called from 555-0100 on 01/15/2020." result = extract_pii(note) for entity in result.entities: print(f"{entity.label:>10} {entity.text!r} [{entity.start}:{entity.end}]") ``` Reach for `analyze_text()` when you want the clinical NER models (diseases, medications, findings) instead of PII labels: ```python from openmed import analyze_text clinical = analyze_text( "Metastatic breast cancer treated with paclitaxel and trastuzumab.", model_name="disease_detection_superclinical", ) print(clinical.entities[0]) ``` ### 2. De-identify the note ```python from openmed import deidentify redacted = deidentify(note, method="mask") print(redacted.deidentified_text) # Detected spans are replaced with labels such as [first_name] and [phone_number]. ``` ### 3. Measure PHI leakage For a de-identification study, compare your gold PHI spans against the spans the model predicted. `compute_leakage_rate()` reports the character-weighted fraction of gold PHI not covered by a same-label prediction — the lower the better. ```python from openmed.eval import compute_leakage_rate gold = [ {"start": 8, "end": 21, "label": "NAME"}, # "Casey Example" {"start": 46, "end": 54, "label": "PHONE"}, # "555-0100" ] predicted = [ {"start": 8, "end": 21, "label": "NAME"}, # phone missed on purpose ] metrics = compute_leakage_rate(gold, predicted, source_text=note) print(f"Overall PHI leakage: {metrics.overall:.1%}") print(f"Missed phone leakage: {metrics.by_label['PHONE']:.1%}") ``` **Next steps:** the full method matrix (`mask`, `remove`, `replace`, `hash`, `shift_dates`), reversible mappings, and longitudinal patient-keyed date shifting live in [PII Anonymization](https://openmed.life/docs/anonymization/#quickstart-choosing-a-method). For the metric bundle and release gates, see [Eval Harness & Metrics](https://openmed.life/docs/eval-harness/index.md). ______________________________________________________________________ ## App developer **Goal:** de-identify text from an application or agent without embedding the models in your own request path. ### Option A — call the library directly If your service is already Python, one call is enough: ```python from openmed import deidentify redacted = deidentify("Contact Jordan Sample at jordan.sample@example.org.") print(redacted.deidentified_text) # Contact [first_name] [last_name] at [email]. ``` ### Option B — run the REST service Install the service extra and start the API: ```bash uv pip install "openmed[hf,service]" uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Call `POST /pii/deidentify` with any HTTP client: ```bash curl -s http://127.0.0.1:8080/pii/deidentify \ -H "Content-Type: application/json" \ -d '{"text": "Contact Jordan Sample at jordan.sample@example.org.", "method": "mask"}' ``` Or use the typed Python client bundled with the `service` extra: ```python from openmed.service.client import OpenMedClient with OpenMedClient("http://127.0.0.1:8080", timeout=300.0) as client: redacted = client.deidentify( "Contact Jordan Sample at jordan.sample@example.org.", method="mask", ) print(redacted["deidentified_text"]) ``` ### Option C — expose OpenMed to an AI agent over MCP Install the MCP extra and start the Model Context Protocol server. It exposes an `openmed_deidentify` tool (plus `openmed_extract_pii` and `openmed_analyze_text`) over stdio, so agent frameworks can redact PHI as a tool call: ```bash uv pip install "openmed[hf,mcp]" python -m openmed.mcp.server # stdio transport (default) ``` For a local agent runtime that connects over HTTP, keep the server on loopback: ```bash python -m openmed.mcp.server --transport streamable-http --host 127.0.0.1 --port 8081 ``` These service examples deliberately bind to loopback. Do not expose them to an untrusted network without an authenticated, TLS-terminating boundary. **Next steps:** health checks, model warm-pool controls, request batching, the privacy gateway, and the full endpoint reference are in [REST Service](https://openmed.life/docs/rest-service/index.md). ______________________________________________________________________ ## Data engineer **Goal:** de-identify many files or dataset rows with progress reporting and PHI-free aggregate summaries — no per-record boilerplate. ### 1. Process a directory of notes `BatchProcessor` reuses one warmed model across the batch and keeps going when a single record fails. It returns de-identified text in memory and does not overwrite the source files: ```python from pathlib import Path from tempfile import TemporaryDirectory from openmed import BatchProcessor processor = BatchProcessor( operation="deidentify", model_name="pii_detection", method="mask", batch_size=16, confidence_threshold=0.7, continue_on_error=True, ) with TemporaryDirectory() as temp_dir: notes_dir = Path(temp_dir) (notes_dir / "note-1.txt").write_text( "Patient Casey Example called from 555-0100.", encoding="utf-8" ) (notes_dir / "note-2.txt").write_text( "Email Jordan Sample at jordan.sample@example.org.", encoding="utf-8" ) result = processor.process_directory( notes_dir, pattern="*.txt", recursive=True ) redacted_texts = [ item.result.deidentified_text for item in result.get_successful_results() ] print(f"Processed {len(redacted_texts)}/{result.total_items} files") print(f"Failures: {result.failed_items}") ``` ### 2. Redact specific columns of a dataset For CSV or JSONL input, `redact_dataset()` de-identifies only the free-text columns you name and returns an aggregate, PHI-free audit summary. Parquet uses the same API after installing the `columnar` extra with `uv pip install "openmed[hf,columnar]"`: ```python from openmed import redact_dataset result = redact_dataset( "notes.csv", text_columns=["note", "comment"], output_path="notes.redacted.csv", policy="strict_no_leak", ) # Aggregate counts only — total spans, per-label counts, residual-leakage estimate. print(result.summary.to_dict()) ``` The source dataset is never overwritten: `output_path` receives the redacted rows, while the summary remains in memory unless you explicitly persist it. The same path is available from the console entry point: ```bash openmed redact-dataset notes.csv \ --text-columns note,comment \ --policy strict_no_leak \ --output notes.redacted.csv ``` **Next steps:** streaming iteration, progress callbacks, batch PII extraction, and result export are covered in [Batch Processing](https://openmed.life/docs/batch-processing/index.md). # APIs and de-identification # Analyze Text Helper `openmed.analyze_text` is the top-level orchestrator that most users start with. It validates input, spins up a token-classification pipeline, segments sentences with pySBD, and normalizes the output so you can copy dict/JSON/HTML/CSV payloads straight into downstream systems. ## Quick reference ```python from openmed import analyze_text result = analyze_text( text="Patient started on imatinib for chronic myeloid leukemia.", model_name="disease_detection_superclinical", aggregation_strategy="simple", output_format="dict", include_confidence=True, confidence_threshold=0.55, group_entities=True, metadata={"source": "clinic-note-42"}, ) print(result.model) print(result.entities[:3]) payload = result.to_dict() ``` ### Key arguments - `model_name`: registry alias, full Hugging Face id, or local model directory. Use `openmed.list_models()` if you need auto-discovery. - `model_id`: alias for `model_name`, supported for API-style callers that use model-id terminology. - `aggregation_strategy`: forwarded to the HF pipeline. `simple` (default) yields grouped tokens; `None` keeps raw tokens. - `output_format`: `"dict"` (default, returns `AnalyzeResult`), `"json"`, `"html"`, or `"csv"`. - `include_confidence` & `confidence_threshold`: control the final payload; defaults keep all scores. - `group_entities`: merge adjacent spans of the same label after formatting. - `formatter_kwargs`: forwarded to `openmed.processing.format_predictions`. - Sentence options (`sentence_detection`, `sentence_language`, `sentence_clean`, `sentence_segmenter`) wrap pySBD so each prediction carries the sentence span; disable them if latency matters more than helper metadata. ## Chunking & truncation ```python result = analyze_text( text=long_report, model_name="pharma_detection_superclinical", aggregation_strategy=None, # work with raw tokens max_length=512, # forwarded to HF pipeline truncation=True, # enforce length (default) sentence_detection=False, # skip pySBD to save ~2ms per note ) ``` When you need full-control over tokenizer behaviour: - Pass `max_length`/`truncation` via `pipeline_kwargs`. If you skip truncation, the helper sets the tokenizer max length to unlimited (0) so HF pipelines accept longer inputs. - Provide `batch_size` or `num_workers` in `pipeline_kwargs` and they will be forwarded to the pipeline call but not to the constructor. - Enable medical token remapping with `OpenMedConfig(use_medical_tokenizer=True)` to group outputs onto clinical-friendly tokens without changing the model tokenizer. ## Loading from a local path Pass an existing model directory to `model_name` or `model_id` when the model files are already present on disk: ```python import os from openmed import OpenMedConfig, analyze_text local_path = os.path.abspath("./models/OpenMed-NER-DiseaseDetect-SuperClinical-434M") config = OpenMedConfig(device="cpu") result = analyze_text( "Patient presents with chronic myeloid leukemia and Type 2 diabetes.", model_id=local_path, config=config, ) for entity in result.entities: print(entity.text, entity.label) legacy_payload = result.to_dict() print(legacy_payload["model_name"]) ``` When the identifier points to an existing local path, OpenMed asks Transformers to load with `local_files_only=True` by default. That keeps air-gapped deployments from validating or downloading the model from the Hugging Face Hub. If any required tokenizer, config, or weight file is missing, loading fails locally with the underlying Transformers error. ## Streaming multiple texts `analyze_text` is optimized for single inputs. For batch jobs, keep a `ModelLoader` instance around and reuse its pipelines: ```python from openmed import ModelLoader, format_predictions loader = ModelLoader() pipeline = loader.create_pipeline("disease_detection_superclinical") for note in notes: raw = pipeline(note, batch_size=16) formatted = format_predictions(raw, note, model_name="Disease Detection") print(formatted.entities[:3]) ``` See [ModelLoader & Pipelines](https://openmed.life/docs/model-loader/index.md) for details on caching, GPU selection, and tokenizer reuse. ## HTML/CSV rendering ```python html = analyze_text( text, model_name="oncology_detection_superclinical", output_format="html", formatter_kwargs={ "html_class": "openmed-highlights", "tag_colors": {"CANCER": "#d97706"}, }, ) csv_rows = analyze_text( text, model_name="pharma_detection_superclinical", output_format="csv", ) ``` The HTML formatter emits a ready-to-embed snippet for dashboards; CSV mode writes row strings (header + body). Both respect `confidence_threshold` and `group_entities`. ## Validation behaviours Behind the scenes `analyze_text` calls: - `validate_input` — trims whitespace and enforces max lengths. - `validate_model_name` — normalizes registry aliases. - Sentence detection (`openmed.processing.sentences`) — optional pySBD segmentation with language hints. - `OutputFormatter` — see [Advanced NER & Output Formatting](https://openmed.life/docs/output-formatting/index.md) for available kwargs. If you need custom validation or logging, inject your own `OpenMedConfig` or reuse a configured `ModelLoader`. # API Reference ## extract_pii Extract PII entities from text with intelligent entity merging. Uses token classification models to detect personally identifiable information including names, emails, phone numbers, addresses, and other HIPAA-protected identifiers. The smart merging feature uses regex patterns to identify semantic units (dates, SSN, phone numbers, etc.) and merges fragmented model predictions into complete entities with dominant label selection. Parameters: | Name | Type | Description | Default | | ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | | `text` | `str` | Input text to analyze | *required* | | `model_name` | `str` | PII detection model (registry key or HuggingFace ID). When the default is used and lang is not "en", the language-appropriate default model is selected automatically. | `_DEFAULT_EN_MODEL` | | `confidence_threshold` | `float` | Minimum confidence score (0-1) | `0.5` | | `config` | `Optional[OpenMedConfig]` | Optional configuration override | `None` | | `use_smart_merging` | `bool` | Enable regex-based semantic unit merging (recommended) | `True` | | `lang` | `str` | ISO 639-1 language code (en, fr, de, it, es, nl, hi, te, pt, ar, ja, tr). Controls which default model and regex patterns are used. Mixed Latin/Devanagari or Latin/Telugu notes automatically use script-aware India clinical routing for hi and te. | `'en'` | | `normalize_accents` | `Optional[bool]` | Strip diacritical marks before model inference so that models trained on accent-free text still detect accented names. Entity spans in the result reference the original (accented) text. None (default) auto-enables for languages in \_ACCENT_NORMALIZE_LANGS (currently Spanish). | `None` | | `preserve_whitespace` | `bool` | Preserve leading and trailing source whitespace so returned entity offsets refer to the exact input string. | `False` | | `loader` | `Optional['ModelLoader']` | Optional shared model loader to reuse warmed pipelines. | `None` | | `batch_size` | `Optional[int]` | Optional backend inference batch size. | `None` | | `num_workers` | `Optional[int]` | Optional backend inference worker count. | `None` | | `custom_recognizer` | `Any` | Optional deny-list/allow-list recognizer config, CustomRecognizer instance, or JSON/YAML config path. Deny-list matches are added with custom:deny provenance; allow-list matches suppress overlapping spans from any detector. | `None` | | `abdm` | `Optional[bool]` | Enable the India ABDM identifier bundle. None auto-enables it for Hindi/Telugu and India locales; False explicitly disables that automatic activation. | `None` | | `code_mixed` | `bool` | Enable the explicit English/Hinglish route. This preserves English model detection while adding Roman-Hindi context patterns. | `False` | | `token_language_tags` | `Optional[Sequence[Any]]` | Required with code_mixed=True. Ordered, non-overlapping records with start, end, and label (en, hi, ne, univ, or other). Tags are consumed as offsets/labels only and are never copied with raw token surfaces. | `None` | | `transliterated_name_config` | `Any` | Optional configuration for the conservative Latin-script Indian given/family-name allow/deny bridge. | `None` | | `cache_results` | `bool` | Whether to cache this result in the in-process LRU cache. Cached results may contain PHI, but are never saved to disk. | `False` | | `max_cache_entries` | `int` | Maximum number of cached results. | `128` | Returns: | Type | Description | | ------------------ | ------------------------------------------- | | `PredictionResult` | PredictionResult with detected PII entities | Example > > > from unittest.mock import patch from openmed.core.pii import extract_pii from openmed.processing.outputs import EntityPrediction, PredictionResult fake_result = PredictionResult( ... text="Patient Casey Example called.", ... entities=[ ... EntityPrediction( ... text="Casey Example", ... label="NAME", ... confidence=0.98, ... start=8, ... end=21, ... ) ... ], ... model_name="fixture-pii-model", ... timestamp="2026-01-01T00:00:00", ... ) with patch("openmed.analyze_text", return_value=fake_result): ... result = extract_pii( ... "Patient Casey Example called.", ... model_name="fixture-pii-model", ... use_smart_merging=False, ... ) next((entity.text, entity.label) for entity in result.entities) ('Casey Example', 'NAME') ## deidentify De-identify text by detecting and redacting PII with intelligent merging. Implements multiple de-identification strategies for HIPAA compliance: - **mask**: Replace with placeholders like [NAME], [EMAIL], etc. - **aadhaar_mask**: Render valid Aadhaar values as `XXXX XXXX NNNN`; use ordinary placeholders for every other entity - **remove**: Remove PII text entirely (empty string) - **replace**: Replace with fake but realistic data - **hash**: Replace with consistent hashed values for entity linking - **format_preserve**: Replace structured identifiers with synthetic values that keep shape and separators, masking unsupported labels - **shift_dates**: Shift dates by random offset while preserving intervals Smart merging uses regex patterns to merge fragmented entities (e.g., dates split into '01' and '/15/1970' are merged into complete '01/15/1970'). Code-mixed mode is explicit and offset driven. With `code_mixed=True` and per-token language tags, the English NER path remains active while a separate Roman-script Hindi pattern bank detects cues such as `naam`, `umar`, `pata`, `mobile`, and `janm`. The combined spans pass through the normal entity merger and final safety sweep before redaction. Parameters: | Name | Type | Description | Default | | ----------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `text` | `str` | Input text to de-identify | *required* | | `method` | `DeidentificationMethod` | De-identification method (mask, aadhaar_mask, remove, replace, hash, shift_dates, format_preserve) | `'mask'` | | `model_name` | `str` | PII detection model | `_DEFAULT_EN_MODEL` | | `confidence_threshold` | `float` | Minimum confidence for redaction (default 0.7 for safety) | `0.7` | | `keep_year` | `bool` | For dates, keep the year unchanged | `False` | | `shift_dates` | `Optional[bool]` | Deprecated alias for method="shift_dates". | `None` | | `date_shift_days` | `Optional[int]` | Specific number of days to shift when patient_key is omitted. When patient_key is supplied, this is treated as a legacy maximum absolute offset bound unless date_shift_max_days is also supplied. | `None` | | `patient_key` | \`Optional\[str | bytes\]\` | Optional stable patient identifier used only to derive a deterministic HMAC date-shift offset. Raw keys are not logged, persisted, or returned. | | `date_shift_max_days` | `Optional[int]` | Maximum absolute offset for random or patient-keyed date shifting. Defaults to 365 when patient_key is supplied and neither this nor date_shift_days is set. | `None` | | `date_shift_secret` | \`Optional\[str | bytes\]\` | Required HMAC key material for patient-keyed offsets. Reuse the same value across sessions to keep offsets stable. | | `keep_mapping` | `bool` | Keep mapping for re-identification | `False` | | `config` | `Optional[OpenMedConfig]` | Optional configuration override | `None` | | `use_smart_merging` | `bool` | Enable regex-based semantic unit merging (recommended) | `True` | | `use_safety_sweep` | `bool` | Run a deterministic structured-identifier sweep after model detection and before redaction. | `True` | | `lang` | `str` | ISO 639-1 language code (en, fr, de, it, es, nl, hi, te, pt, ar, ja, tr). Controls model selection, regex patterns, and fake data for replacement. Mixed Latin/Devanagari or Latin/Telugu notes automatically use the script-aware India clinical route for hi and te. | `'en'` | | `normalize_accents` | `Optional[bool]` | Strip diacritical marks before model inference. None (default) auto-enables for Spanish. | `None` | | `loader` | `Optional['ModelLoader']` | Optional shared model loader to reuse warmed pipelines. | `None` | | `consistent` | `bool` | When method="replace" or method="format_preserve", generate stable surrogates (same input -> same surrogate within the call). Lets repeated mentions of the same name resolve to one fake identity instead of a different one each time. | `False` | | `seed` | `Optional[int]` | Optional integer seed for cross-run reproducibility of consistent=True replacements. Implies consistent=True. | `None` | | `locale` | `Optional[str]` | Faker locale override (pt_BR, en_GB, ...) for method="replace" and method="format_preserve". When None, derived from lang. | `None` | | `surrogate_vault` | `Optional['SurrogateVault']` | Optional cross-document surrogate vault. When provided with method="replace", OpenMed stores only HMAC source hashes and reuses the same surrogate for the same label/language/source identifier across calls. | `None` | | `policy` | `Optional[str]` | Optional policy profile name controlling arbitration, action selection, mandatory safety sweep behavior, and reversible mapping. | `None` | | `calibration_thresholds_path` | \`Optional\[str | Path\]\` | Optional thresholds.json artifact path or artifact directory. When provided, per-label calibrated thresholds filter model detections and appear in audit output. | | `custom_recognizer` | `Any` | Optional deny-list/allow-list recognizer config, CustomRecognizer instance, or JSON/YAML config path. Deny-list matches are redacted with custom:deny provenance; allow-list matches suppress overlapping spans from any detector. | `None` | | `abdm` | `Optional[bool]` | Enable the India ABDM recognizer bundle. None auto-enables it for policy="india_dpdp_act", Hindi/Telugu, or an India locale. Pass False to opt out of automatic activation. | `None` | | `code_mixed` | `bool` | Enable the explicit English/Hinglish de-identification path. | `False` | | `token_language_tags` | `Optional[Sequence[Any]]` | Required with code_mixed=True. Ordered token records with exact start/end offsets and an en, hi, ne, univ, or other label. A pure-English tag stream does not activate Roman-Hindi patterns. | `None` | | `transliterated_name_config` | `Any` | Optional configuration for the Latin-script Indian name allow/deny bridge. The default bridge is conservative and can be replaced or extended by configuration. | `None` | | `audit` | `bool` | Return a deterministic AuditReport instead of the DeidentificationResult. | `False` | | `cache_results` | `bool` | Whether to cache this result in the in-process LRU cache. Cached results may contain PHI, but are never saved to disk. | `False` | | `max_cache_entries` | `int` | Maximum number of cached results. | `128` | Returns: | Type | Description | | ------------------------ | --------------- | | \`DeidentificationResult | 'AuditReport'\` | | \`DeidentificationResult | 'AuditReport'\` | Example > > > from datetime import datetime from types import SimpleNamespace from unittest.mock import patch from openmed.core.pii import ( ... DeidentificationResult, ... PIIEntity, ... deidentify, ... ) fixture = DeidentificationResult( ... original_text="Patient Casey Example", ... deidentified_text="Patient [NAME]", ... pii_entities=\[ ... PIIEntity( ... text="Casey Example", ... label="NAME", ... start=8, ... end=21, ... confidence=0.98, ... redacted_text="[NAME]", ... ) ... \], ... method="mask", ... timestamp=datetime(2026, 1, 1, 0, 0, 0), ... mapping={"[NAME]": "Casey Example"}, ... ) with patch("openmed.core.pipeline.Pipeline") as pipeline_cls: ... pipeline_cls.return_value.run.return_value = SimpleNamespace( ... deidentification_result=fixture ... ) ... result = deidentify( ... "Patient Casey Example", ... method="mask", ... keep_mapping=True, ... ) result.deidentified_text 'Patient [NAME]' result.mapping ## reidentify Re-identify text using stored mapping. Restores original PII from de-identified text using the mapping created during de-identification. Only works if keep_mapping=True was used. Parameters: | Name | Type | Description | Default | | ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `deidentified_text` | `str` | De-identified text | *required* | | `mapping` | `Mapping[str, str]` | Mapping from redacted to original text, optionally including occurrence-aware entries emitted for colliding replacement values. | *required* | Returns: | Type | Description | | ----- | --------------------------------------------- | | `str` | Re-identified text with original PII restored | Example > > > from openmed.core.pii import reidentify reidentify( ... "Patient [NAME] has record [ID]", ... {"[NAME]": "Casey Example", "[ID]": "MRN-0001"}, ... ) 'Patient Casey Example has record MRN-0001' Note Only works if keep_mapping=True was used during de-identification. Requires proper authorization and audit logging in production. ## analyze_text Run a token-classification model on `text` and format the predictions. Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `text` | `str` | Clinical or biomedical text to analyse. | *required* | | `model_name` | `str` | Registry key, fully-qualified Hugging Face model id, or local model path. | `'disease_detection_superclinical'` | | `model_id` | `Optional[str]` | Alias for model_name. Useful for APIs and examples that name model identifiers as model_id. | `None` | | `config` | `Optional[OpenMedConfig]` | Optional :class:~openmed.core.config.OpenMedConfig instance. | `None` | | `loader` | `Optional[ModelLoader]` | Reuse an existing :class:~openmed.core.models.ModelLoader. | `None` | | `aggregation_strategy` | `Optional[str]` | Hugging Face aggregation strategy ("simple" by default). Set to None to work with raw token outputs. | `'simple'` | | `output_format` | `str` | "dict" (default), "json", "html" or "csv". | `'dict'` | | `include_confidence` | `bool` | Whether to include confidence scores in formatted output. | `True` | | `confidence_threshold` | `Optional[float]` | Minimum confidence for entities. None keeps all. | `0.0` | | `group_entities` | `bool` | Merge adjacent entities of the same label in the formatted output. | `False` | | `formatter_kwargs` | `Optional[Dict[str, Any]]` | Extra keyword arguments forwarded to :func:openmed.processing.format_predictions. | `None` | | `metadata` | `Optional[Dict[str, Any]]` | Optional metadata to attach to the result. | `None` | | `use_fast_tokenizer` | `bool` | Prefer fast tokenizers when available. | `True` | | `sentence_detection` | `bool` | Enable pySBD-powered sentence detection (default: True). | `True` | | `sentence_language` | `str` | Language hint for the sentence detector. | `'en'` | | `sentence_clean` | `bool` | Whether to enable pySBD's cleaning heuristics. | `False` | | `sentence_segmenter` | `Optional[Any]` | Optional preconstructed pySBD segmenter to reuse. | `None` | | `cache_results` | `bool` | Whether to cache this result in the in-process LRU cache. Cached results may contain PHI, but are never saved to disk. | `False` | | `max_cache_entries` | `int` | Maximum number of cached results. | `128` | | `**pipeline_kwargs` | `Any` | Additional arguments passed to :meth:openmed.core.models.ModelLoader.create_pipeline. | `{}` | Returns: | Type | Description | | ------------------------------------------------- | ------------------------------------------------------------------ | | `Union[AnalyzeResult, str, List[Dict[str, Any]]]` | Analyze result for "dict" output, otherwise the requested rendered | | `Union[AnalyzeResult, str, List[Dict[str, Any]]]` | format. | Example > > > class FixtureLoader: ... config = None ... ... def create_pipeline(self, model_name, **kwargs): ... def pipeline(text,** call_kwargs): ... return [ ... { ... "entity_group": "CONDITION", ... "score": 0.99, ... "start": 11, ... "end": 17, ... "word": "asthma", ... } ... ] ... ... return pipeline ... ... def get_max_sequence_length(self, model_name, tokenizer=None): ... return 128 result = analyze_text( ... "History of asthma.", ... model_name="fixture-ner-model", ... loader=FixtureLoader(), ... sentence_detection=False, ... ) next((entity.text, entity.label) for entity in result.entities) ('asthma', 'CONDITION') ## list_models Return available OpenMed model identifiers. Parameters: | Name | Type | Description | Default | | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------- | ------- | | `include_registry` | `bool` | Include entries from the bundled registry in addition to entries in the committed manifest. | `True` | | `include_remote` | `bool` | Retained for compatibility; no live discovery is performed. | `True` | | `config` | `Optional[OpenMedConfig]` | Optional custom configuration for model discovery. | `None` | ## BatchProcessor Process multiple texts efficiently with progress tracking. Example usage > > > from openmed import BatchProcessor, OpenMedConfig processor = BatchProcessor(model_name="disease_detection_superclinical") texts = ["Patient has diabetes.", "No significant findings."] result = processor.process_texts(texts) print(result.summary()) ## `__init__(model_name='disease_detection_superclinical', operation='analyze_text', batch_size=8, config=None, loader=None, aggregation_strategy='simple', confidence_threshold=None, group_entities=False, continue_on_error=True, checkpoint_interval=_DEFAULT_CHECKPOINT_INTERVAL, _atomic_write_hook=None, **analyze_kwargs)` Initialize batch processor. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `model_name` | `str` | Model registry key or HuggingFace identifier. | `'disease_detection_superclinical'` | | `operation` | `BatchOperation` | Which function to call per item: "analyze_text" (default), "extract_pii" or "deidentify". Extra kwargs passed via \*\*analyze_kwargs are passed to the selected function. | `'analyze_text'` | | `batch_size` | `int` | Number of documents to process together per batch. | `8` | | `config` | `Optional[Any]` | Optional OpenMedConfig instance. | `None` | | `loader` | `Optional[Any]` | Optional ModelLoader instance to reuse. | `None` | | `aggregation_strategy` | `Optional[str]` | HuggingFace aggregation strategy (analyze_text operation only). | `'simple'` | | `confidence_threshold` | `Optional[float]` | Minimum confidence for entities. When not provided, defaults match the selected operation: 0.0 for analyze_text, 0.5 for extract_pii, and 0.7 for deidentify. | `None` | | `group_entities` | `bool` | Whether to group adjacent entities (analyze_text operation only). | `False` | | `continue_on_error` | `bool` | Continue processing on individual item errors. | `True` | | `checkpoint_interval` | `int` | Maximum number of items processed between durable checkpoints. | `_DEFAULT_CHECKPOINT_INTERVAL` | | `**analyze_kwargs` | `Any` | Additional arguments passed to the selected function. | `{}` | ## `iter_process(texts, ids=None, *, on_progress=None)` Process texts as an iterator, yielding results one at a time. This is useful for streaming results or processing very large batches where you don't want to hold all results in memory. Parameters: | Name | Type | Description | Default | | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | | `texts` | `Sequence[str]` | Sequence of texts to analyze. | *required* | | `ids` | `Optional[Sequence[str]]` | Optional identifiers for each text. | `None` | | `on_progress` | `Optional[BatchProgressCallback]` | Optional PHI-safe callback that receives a BatchProgress record after each completed item. | `None` | Yields: | Type | Description | | ----------------- | ---------------------------------------- | | `BatchItemResult` | BatchItemResult for each processed text. | ## `process_directory(directory, pattern='*.txt', recursive=False, encoding='utf-8', progress_callback=None, *, on_progress=None, output_path=None, checkpoint_path=None, resume_from_checkpoint=False, checkpoint_interval=None, output_format='json')` Process all matching files in a directory. Parameters: | Name | Type | Description | Default | | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | | `directory` | `Union[str, Path]` | Directory path. | *required* | | `pattern` | `str` | Glob pattern for file matching. | `'*.txt'` | | `recursive` | `bool` | Whether to search recursively. | `False` | | `encoding` | `str` | File encoding. | `'utf-8'` | | `progress_callback` | `Optional[ProgressCallback]` | Optional callback for progress updates. | `None` | | `on_progress` | `Optional[BatchProgressCallback]` | Optional PHI-safe callback that receives a BatchProgress record after each completed item. | `None` | | `output_path` | `Optional[Union[str, Path]]` | Optional atomically written final result file. | `None` | | `checkpoint_path` | `Optional[Union[str, Path]]` | Optional PHI-free durable checkpoint file. | `None` | | `resume_from_checkpoint` | `bool` | Resume the committed result prefix. | `False` | | `checkpoint_interval` | `Optional[int]` | Per-run override for checkpoint frequency. | `None` | | `output_format` | `str` | "json" or "summary" for output_path. | `'json'` | Returns: | Type | Description | | ------------- | ---------------------------------------- | | `BatchResult` | BatchResult with all processing results. | ## `process_files(file_paths, encoding='utf-8', progress_callback=None, *, on_progress=None, output_path=None, checkpoint_path=None, resume_from_checkpoint=False, checkpoint_interval=None, output_format='json')` Process multiple files. Parameters: | Name | Type | Description | Default | | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | | `file_paths` | `Sequence[Union[str, Path]]` | Paths to text files. | *required* | | `encoding` | `str` | File encoding. | `'utf-8'` | | `progress_callback` | `Optional[ProgressCallback]` | Optional callback for progress updates. | `None` | | `on_progress` | `Optional[BatchProgressCallback]` | Optional PHI-safe callback that receives a BatchProgress record after each completed item. | `None` | | `output_path` | `Optional[Union[str, Path]]` | Optional atomically written final result file. | `None` | | `checkpoint_path` | `Optional[Union[str, Path]]` | Optional PHI-free durable checkpoint file. | `None` | | `resume_from_checkpoint` | `bool` | Resume the committed result prefix. | `False` | | `checkpoint_interval` | `Optional[int]` | Per-run override for checkpoint frequency. | `None` | | `output_format` | `str` | "json" or "summary" for output_path. | `'json'` | Returns: | Type | Description | | ------------- | ---------------------------------------- | | `BatchResult` | BatchResult with all processing results. | ## `process_files_to_directory(file_paths, *, input_root, output_dir, encoding='utf-8', checkpoint_path=None, resume_from_checkpoint=False, checkpoint_interval=None, progress_callback=None, on_progress=None)` De-identify files into an atomic, checkpointed output directory. Output paths preserve each input's location relative to `input_root`. Committed files are hashed in the PHI-free checkpoint and verified before a resumed run skips them. ## `process_items(items, progress_callback=None, *, on_progress=None, output_path=None, checkpoint_path=None, resume_from_checkpoint=False, checkpoint_interval=None, output_format='json')` Process a sequence of BatchItem objects. Parameters: | Name | Type | Description | Default | | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | | `items` | `Sequence[BatchItem]` | Sequence of BatchItem objects. | *required* | | `progress_callback` | `Optional[ProgressCallback]` | Optional callback for progress updates. | `None` | | `on_progress` | `Optional[BatchProgressCallback]` | Optional PHI-safe callback that receives a BatchProgress record after each completed item. | `None` | | `output_path` | `Optional[Union[str, Path]]` | Optional atomically written final result file. | `None` | | `checkpoint_path` | `Optional[Union[str, Path]]` | Optional PHI-free durable checkpoint file. | `None` | | `resume_from_checkpoint` | `bool` | Resume the committed result prefix. | `False` | | `checkpoint_interval` | `Optional[int]` | Per-run override for checkpoint frequency. | `None` | | `output_format` | `str` | "json" or "summary" for output_path. | `'json'` | Returns: | Type | Description | | ------------- | ---------------------------------------- | | `BatchResult` | BatchResult with all processing results. | ## `process_texts(texts, ids=None, progress_callback=None, *, on_progress=None, output_path=None, checkpoint_path=None, resume_from_checkpoint=False, checkpoint_interval=None, output_format='json')` Process multiple texts. Parameters: | Name | Type | Description | Default | | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------- | ---------- | | `texts` | `Sequence[str]` | Sequence of texts to analyze. | *required* | | `ids` | `Optional[Sequence[str]]` | Optional identifiers for each text. | `None` | | `progress_callback` | `Optional[ProgressCallback]` | Optional callback for progress updates. Signature: callback(completed_count, total_count, result) | `None` | | `on_progress` | `Optional[BatchProgressCallback]` | Optional PHI-safe callback that receives a BatchProgress record after each completed item. | `None` | | `output_path` | `Optional[Union[str, Path]]` | Optional atomically written final result file. | `None` | | `checkpoint_path` | `Optional[Union[str, Path]]` | Optional PHI-free durable checkpoint file. | `None` | | `resume_from_checkpoint` | `bool` | Resume the committed result prefix instead of starting a new checkpoint. | `False` | | `checkpoint_interval` | `Optional[int]` | Per-run override for checkpoint frequency. | `None` | | `output_format` | `str` | "json" or "summary" for output_path. | `'json'` | Returns: | Type | Description | | ------------- | ---------------------------------------- | | `BatchResult` | BatchResult with all processing results. | ## `resume_from_checkpoint(items, *, checkpoint_path, output_path=None, progress_callback=None, on_progress=None, output_format='json')` Resume `items` from a previously committed batch checkpoint. ## PIIEntity Bases: `EntityPrediction` Extended Entity with PII-specific metadata. Attributes: | Name | Type | Description | | --------------- | --------------- | ------------------------------------------- | | `text` | `str` | The entity text span | | `label` | `str` | PII category (NAME, EMAIL, PHONE, etc.) | | `start` | `Optional[int]` | Character start position | | `end` | `Optional[int]` | Character end position | | `confidence` | `float` | Model confidence score (0-1) | | `entity_type` | `str` | PII category (same as label) | | `redacted_text` | `Optional[str]` | Replacement text after de-identification | | `original_text` | `Optional[str]` | Original text before redaction | | `hash_value` | `Optional[str]` | Consistent hash for entity linking | | `reversible_id` | `Optional[str]` | Optional reversible pseudonymization handle | ## `__post_init__()` Initialize entity_type from label if not set. ## DeidentificationResult Result of de-identification operation. Attributes: | Name | Type | Description | | ------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `original_text` | `str` | Input text before de-identification | | `deidentified_text` | `str` | Output text with PII redacted | | `pii_entities` | `list[PIIEntity]` | List of detected and redacted PII entities | | `method` | `str` | De-identification method used | | `timestamp` | `datetime` | When de-identification was performed | | `mapping` | `Optional[dict[str, str]]` | Optional mapping for re-identification. Colliding replacement surfaces use private occurrence keys so separate source spellings remain reversible without changing the de-identified text. | ## `to_dataframe()` Convert detected PII entities to a pandas DataFrame. Returns: | Type | Description | | ----- | --------------------------------------------------------------- | | `Any` | A pandas DataFrame with one row per detected entity and columns | | `Any` | text, label, entity_type, start, end, | | `Any` | confidence, action, and result_id. | Raises: | Type | Description | | ------------- | --------------------------- | | `ImportError` | If pandas is not installed. | ## `to_dict()` Convert result to dictionary format. Returns: | Type | Description | | ------ | ---------------------------------------------- | | `dict` | Dictionary with all result fields and metadata | # De-identification API The `deidentify()` helper returns a typed `DeidentificationResult` with the redacted text, detected `PIIEntity` spans, result metadata, and a `to_dict()` representation for JSON-style workflows. ## Entity Table Export Use `DeidentificationResult.to_dataframe()` when you want to inspect the entities detected in a single de-identification result as a table: ```python from openmed import deidentify result = deidentify("Patient John Doe called 555-1234", method="mask") entities = result.to_dataframe() ``` The method imports pandas lazily, so importing `openmed` or `openmed.core.pii` does not import pandas. If pandas is not installed, calling `to_dataframe()` raises an actionable `ImportError` with the install command. The returned DataFrame has one row per detected entity and always uses this column order: | Column | Description | | ------------- | ---------------------------------------------------------------------- | | `text` | Entity text span captured by the detector. | | `label` | Detector label for the span. | | `entity_type` | Normalized PII entity type stored on the result entity. | | `start` | Character start offset in the original text. | | `end` | Character end offset in the original text. | | `confidence` | Detector confidence score. | | `action` | De-identification policy action applied to the entity, when available. | | `result_id` | Stable hash-derived identifier shared by every row from the result. | When no PII entities are present, `to_dataframe()` returns an empty DataFrame with the same columns. ## India ABDM and ABHA identifiers Enable the ABDM recognizer bundle for Indian clinical records to detect and replace ABHA numbers and addresses, Aadhaar numbers, PAN values, and contextual HPR/HFR registry identifiers: ```python from openmed import deidentify result = deidentify( note, method="replace", lang="en", locale="en_IN", abdm=True, ) ``` The bundle also activates automatically for Hindi or Telugu, an India locale, or the `india_dpdp_act` policy profile. Pass `abdm=False` to opt out explicitly. It is inactive for other languages and locales by default. Recognized source labels (`ABHA_NUMBER`, `ABHA_ADDRESS`, `AADHAAR`, `PAN`, `ABDM_HPR_ID`, and `ABDM_HFR_ID`) normalize to `ID_NUM` and the `DIRECT_IDENTIFIER` policy class. Replacement mode produces synthetic values only; it does not call ABDM, verify an identifier, or store or resolve a real ABHA number. ABHA numbers are validated by their publicly documented 14-digit shape; the public NHA materials do not specify an offline checksum algorithm. ABHA-linked record sharing requires the individual's informed consent. This mode de-identifies recognized identifiers, but it does not collect or record consent and does not decide whether a disclosure is permitted. Applications must enforce their own consent and disclosure workflow before sharing records. See the [official ABDM FAQ](https://abdm.gov.in/FAQ) for the ABHA identity and consent model. # Advanced NER & Output Formatting Post-processing matters as much as the base model. OpenMed codifies the heuristics from the public demos so you can go from noisy token output to high-quality, copy-pasteable spans in a few lines. ## Advanced NER processor `openmed.processing.advanced_ner.AdvancedNERProcessor` applies the same filtering stack used in the OpenMed Gradio app: - Confidence filtering with a configurable threshold (`min_confidence`). - Punctuation-only and short-span removal. - Regex-based exclusions for common false positives. - Optional edge stripping and gap-aware merging of adjacent entities. - Smart BIO grouping fixes overlapping spans when `aggregation_strategy=None`. ```python from openmed.processing.advanced_ner import create_advanced_processor processor = create_advanced_processor( min_confidence=0.65, merge_adjacent=True, max_merge_gap=8, ) raw = pipeline(text) # HF token-classification output entities = processor.process_pipeline_output(text, raw) for span in entities: print(span.label, span.text, span.score) ``` Use it when you need deterministic filtering outside of `analyze_text` or when you operate on raw tokens. ## OutputFormatter & PredictionResult `openmed.processing.OutputFormatter` normalizes predictions into dictionaries, JSON strings, HTML snippets, or CSV rows. The dataclasses in `openmed/processing/outputs.py` ensure the payload stays type-safe and ready for logging. ```python from openmed.processing import format_predictions formatted = format_predictions( raw_predictions, original_text, model_name="Disease Detection", include_confidence=True, confidence_threshold=0.6, group_entities=True, ) print(formatted.entities[0].to_dict()) print(formatted.to_dict()) ``` ### HTML output ```python from openmed.processing.outputs import OutputFormatter formatter = OutputFormatter(group_entities=True) result = formatter.format_predictions(raw_predictions, text, model_name="Oncology") html = formatter.to_html(result, tag_colors={"Cancer": "#f97316"}) ``` The HTML helper wraps highlighted spans with semantic tags (`data-entity="Cancer"`) so your dashboards can apply custom styles or tooltips. ### CSV output ```python csv_lines = formatter.to_csv(result) print("\n".join(csv_lines[:5])) ``` CSV export is handy when you need to feed BI tools or spreadsheets without additional ETL code. ## Sentence spans & metadata - `analyze_text` attaches sentence spans (when pySBD is enabled) and forwards `metadata` objects so each entity can carry extra context (e.g., the originating service, clinical section, or ontological hints). - The formatter ensures `confidence`, `start`, and `end` offsets are normalized to built-in `float`/`int` so serializing to JSON never fails due to NumPy/PyTorch dtypes. ## Guardrails Pair the formatter with validation helpers from `openmed.utils.validation`: ```python from openmed.utils.validation import ( validate_confidence_threshold, validate_output_format, validate_batch_size, ) ``` These guardrails keep API endpoints resilient against out-of-range parameters and malformed payloads. ## CLI JSON output contract Every `openmed` subcommand accepts a uniform `--json` flag for scripting and agent use. When set, the command writes a single machine-readable document to stdout with stable top-level keys; without it, the command keeps its human-readable output. ### Success envelope ```json { "ok": true, "command": "models list", "data": { "count": 12, "models": [ /* command-specific payload */ ] } } ``` - `ok` — always `true` on success. - `command` — the space-joined subcommand path. - `data` — the command-specific payload with stable keys (documented per command in `--help`). ### Error envelope On failure the command writes the error envelope (to stdout in `--json` mode so an agent can parse a single stream) and exits non-zero: ```json { "ok": false, "command": "audit verify", "error": { "code": "input_not_found", "message": "Input file not found: report.json" } } ``` Without `--json`, the same failure prints the message to stderr. Either way the process exit code follows the table below. ### Exit codes | Code | Meaning | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | failure: runtime error (I/O, model load), or a gate/verification negative result — e.g. `--strict` / `--fail-on-*` violations, matching the repository's release-gate convention | | `2` | usage / validation error (also argparse's own parse-error code) | Scriptability: no `openmed` subcommand blocks on interactive input, so every command is safe to run non-interactively in a pipeline. This is enforced by `tests/unit/cli/test_cli_json_output.py`. ## Tool schema registry & drift guard The agent-facing tool schemas (used by the MCP server and the framework adapters) are defined once in `openmed.mcp.tool_registry.TOOL_REGISTRY`. A static bundle is published at `openmed/interop/tools.json` for offline consumers, generated from `render_tool_registry_document()`. `tests/unit/interop/test_tool_schema_sync.py` guards against drift: it asserts the registry, the MCP server's registered tools, every framework adapter's rendered definitions, and the committed `tools.json` bundle all agree on tool names and input/output schemas. Regenerate the bundle whenever the registry changes: ```python import json from openmed.mcp.tool_registry import render_tool_registry_document with open("openmed/interop/tools.json", "w", encoding="utf-8") as fh: json.dump(render_tool_registry_document(), fh, indent=2, sort_keys=True) fh.write("\n") ``` # Agents and tool surfaces # Connect MCP clients to OpenMed OpenMed exposes its clinical NLP, PII extraction, and de-identification tools through the Model Context Protocol (MCP). Use the `openmed-mcp` command for local stdio clients or the Streamable HTTP transport for clients that connect to a URL. All examples on this page use synthetic text. Only send real PHI to an OpenMed runtime that you operate and trust. ## Install and verify Install OpenMed with the optional MCP dependency: ```bash python -m pip install "openmed[mcp]" openmed-mcp --version ``` For a checkout managed with `uv`: ```bash uv sync --extra mcp uv run openmed-mcp --version ``` ## Local stdio connections Stdio is the recommended transport when the MCP client and OpenMed run on the same machine. The client launches the server as a child process, so no TCP port is exposed. ### JSON command-map clients Use this shape for desktop and IDE clients that accept an `mcpServers` command map: ```json { "mcpServers": { "openmed": { "command": "uvx", "args": [ "--from", "openmed[mcp]", "openmed-mcp", "--transport", "stdio" ] } } } ``` If OpenMed is already installed in a virtual environment, set `command` to the absolute path of that environment's `openmed-mcp` executable and omit the `uvx`, `--from`, and package arguments. ### TOML server-table clients Terminal coding clients commonly use a TOML server table: ```toml [mcp_servers.openmed] command = "uvx" args = ["--from", "openmed[mcp]", "openmed-mcp", "--transport", "stdio"] ``` ### Typed local blocks Clients that distinguish local and remote servers with a `type` field usually accept the equivalent typed block: ```json { "name": "openmed", "type": "stdio", "command": "uvx", "args": ["--from", "openmed[mcp]", "openmed-mcp", "--transport", "stdio"] } ``` Client field names vary, but the command and argument sequence is the same. ## Streamable HTTP connections Start a loopback-only server for a local URL-based client: ```bash openmed-mcp \ --transport streamable-http \ --host 127.0.0.1 \ --port 8081 \ --streamable-http-path /mcp ``` The MCP endpoint is `http://127.0.0.1:8081/mcp`. A typed remote configuration typically looks like this: ```json { "name": "openmed", "type": "http", "url": "http://127.0.0.1:8081/mcp" } ``` Hosted-assistant developer connectors use the same URL. They cannot reach a loopback address on your workstation; deploy through a private network or an authenticated HTTPS gateway instead of exposing the OpenMed process directly. ## Remote authentication and protocol headers The built-in MCP server does not validate API keys or bearer tokens. Never bind it to a public interface without a TLS-terminating reverse proxy or gateway that authenticates every request. Configure the client to send the gateway's bearer token, for example: ```json { "name": "openmed", "type": "http", "url": "https://openmed.example.org/mcp", "headers": { "Authorization": "Bearer ${OPENMED_MCP_TOKEN}" } } ``` Keep the token in the client's secret or environment-variable store rather than committing it to a configuration file. Streamable HTTP clients also send the `MCP-Protocol-Version` header on requests after initialization. SDK-based clients set it automatically to the version negotiated during the `initialize` exchange. If a gateway allowlists headers, forward `MCP-Protocol-Version`, `Mcp-Session-Id`, `Content-Type`, `Accept`, and `Authorization`; do not replace the negotiated protocol version with a fixed value at the proxy. ## Environment-variable defaults The command-line flags can also be configured with environment variables: | Variable | Default | Purpose | | ----------------------- | ----------- | ----------------------------- | | `OPENMED_MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` | | `OPENMED_MCP_HOST` | `127.0.0.1` | HTTP bind address | | `OPENMED_MCP_PORT` | `8081` | HTTP port | | `OPENMED_MCP_PATH` | `/mcp` | Streamable HTTP endpoint path | Command-line flags override these values. Keep `OPENMED_MCP_HOST=127.0.0.1` unless an authenticated network boundary is already in place. ## Troubleshooting - **Command not found:** install the `mcp` extra and ensure the selected Python environment's executable directory is on `PATH`. - **Client starts and immediately disconnects:** keep stdio reserved for MCP protocol messages; avoid wrapper scripts that print banners to stdout. - **HTTP client receives 404:** confirm the URL includes the configured `--streamable-http-path`, which defaults to `/mcp`. - **Remote client cannot connect:** loopback is intentionally local-only. Use a private route or authenticated HTTPS gateway rather than changing the bind address without access controls. # REST Service OpenMed's FastAPI service provides shared model reuse, explicit model unloading, streaming PII extraction, asynchronous jobs, privacy-gateway egress, and SMART Backend Services ingestion. The generated OpenAPI document is the source of truth for exact request and response schemas. Its current public operations are: - `GET /health` - `GET /livez` - `GET /readyz` - `GET /models/loaded` - `POST /models/unload` - `POST /analyze` - `POST /pii/extract` - `POST /pii/extract/stream` - `POST /pii/deidentify` - `POST /fhir/smart-backend/ingestions` - `GET /fhir/smart-backend/ingestions/{job_id}` - `GET /fhir/smart-backend/ingestions/{job_id}/summary` - `POST /jobs` - `GET /jobs/{job_id}` - `POST /privacy-gateway/complete` The opt-in `GET /metrics` route is intentionally excluded from the generated schema and returns `404` unless metrics are enabled. The service includes stricter request validation, shared model/pipeline reuse, optional startup preload, bounded warm-pool residency, model keep-alive controls, optional no-PHI OpenTelemetry tracing, and a consistent application error envelope. For ready-to-run `curl` and Python `requests` snippets covering the common calls, see the task-oriented [REST API Recipes](https://openmed.life/docs/rest-recipes/index.md) page. For large de-identification batches that should not hold a client connection open, use [Async REST Jobs & Webhooks](https://openmed.life/docs/serving/async-jobs/index.md). ## Run Locally Install the service dependencies: ```bash uv pip install -e ".[hf,service]" ``` Start the API server: ```bash uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Keep this loopback bind for local use. Before exposing the service on a network, configure [authentication](https://openmed.life/docs/serving/authentication/index.md), TLS at the ingress or reverse proxy, and an exact trusted-host allowlist. ## Postman Collection A ready-to-import [Postman collection](https://openmed.life/docs/api/openmed.postman_collection.json) (`docs/api/openmed.postman_collection.json`) ships with one example request per endpoint group so you can exercise the API without writing any client code. Import it into Postman (or any tool that reads the Postman v2.1 schema), then set the `base_url` collection variable to your server (it defaults to `http://127.0.0.1:8080`, matching the Python client's safe loopback default). Use an `https://` base URL for every non-loopback deployment; never send credentials or clinical data over plaintext HTTP. For an authenticated deployment, configure collection-level authorization in Postman or add either an `X-API-Key` header or an `Authorization: Bearer ` header. The collection intentionally stores no credentials. Every example body uses synthetic clinical text only — no real PHI. Keep those bodies synthetic because real patient text can persist in Postman history, console output, shared workspaces, exports, or screenshots. Before polling, copy the identifier returned by the async-job or SMART-ingestion POST request into `job_id`. The SMART request leaves `{{smart_private_key_pem}}` unresolved on purpose. Define it only in a secure local-client environment; in Postman, the secure variable can be backed by Local Vault. Escape PEM newlines as `\n` for the JSON body, and never replace the reference with a real key in a saved or exported collection. ## Python Client The service extra includes the typed sync client and its `httpx` dependency: ```bash uv pip install -e ".[hf,service]" ``` Use `OpenMedClient` against a running service: All patient-like content in the examples below is synthetic. Do not paste real patient text, reversible mappings, or credentials into documentation, issues, logs, or screenshots. ```python from openmed.service.client import OpenMedAPIError, OpenMedClient with OpenMedClient("http://127.0.0.1:8080", timeout=300.0) as client: result = client.analyze( "Patient started imatinib for CML.", model_name="disease_detection_superclinical", ) pii = client.extract_pii("Paciente: Maria Garcia", lang="es") redacted = client.deidentify( "Paciente: Maria Garcia", method="mask", ) llm = client.privacy_gateway( "Patient Maria Garcia called 555-0100.", confidence_threshold=0.9, ) loaded = client.loaded_models() ``` Non-2xx responses raise `OpenMedAPIError` with the service error `code`, `message`, optional `details`, HTTP status, and any `X-Request-ID` returned by the service or proxy: ```python try: client.unload_model("disease_detection_superclinical") except OpenMedAPIError as exc: print(exc.status_code, exc.code, exc.message, exc.request_id) ``` ## Static OpenAPI Spec The committed OpenAPI document lives at `docs/api/openapi.json`. Regenerate it after changing REST routes, request schemas, response schemas, or service metadata: ```bash .venv/bin/python scripts/export_openapi.py ``` The export command imports `openmed.service.app.create_app()`, calls `app.openapi()`, stamps `info.version` from `openmed.__version__`, and writes deterministic JSON with sorted keys. The unit test suite includes a drift guard that compares the committed artifact byte-for-byte against a fresh in-memory export. Optional profile selection (defaults to `prod`): ```bash OPENMED_PROFILE=dev uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` ## Browser and Host Allowlists The service is strict by default: - CORS is off unless `OPENMED_SERVICE_CORS_ORIGINS` is set. Cross-origin browser requests are not granted `Access-Control-Allow-Origin` by default. - Trusted host checking is always on. `OPENMED_SERVICE_TRUSTED_HOSTS` defaults to `localhost,127.0.0.1,[::1]`, so loopback clients pass and unexpected Host headers are rejected with the standard error envelope. Both variables accept comma-separated allowlists. CORS origins must be exact scheme/host/port origins and cannot use wildcards. Setting `OPENMED_SERVICE_TRUSTED_HOSTS` replaces the loopback default, so include every host the service should accept. Example browser front-end configuration: ```bash OPENMED_SERVICE_CORS_ORIGINS=http://localhost:5173 \ OPENMED_SERVICE_TRUSTED_HOSTS=127.0.0.1,localhost \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` For a reverse proxy in front of the service, configure the public browser origin and the host header forwarded to Uvicorn: ```bash OPENMED_SERVICE_CORS_ORIGINS=https://clinic-ui.example.com \ OPENMED_SERVICE_TRUSTED_HOSTS=api.example.com \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Optional shared model preload at startup: ```bash OPENMED_SERVICE_PRELOAD_MODELS=disease_detection_superclinical,OpenMed/OpenMed-PII-SuperClinical-Small-44M-v1 \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_PRELOAD_MODELS` is a comma-separated list of registry aliases or full Hugging Face ids. Empty entries are ignored and duplicates are removed. Optional warm-pool resident model limit: ```bash OPENMED_SERVICE_PRELOAD_MODELS=disease_detection_superclinical \ OPENMED_SERVICE_MAX_RESIDENT_MODELS=2 \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_MAX_RESIDENT_MODELS` bounds how many models remain resident in the shared warm-pool. When the limit is exceeded, the least-recently-used idle model is unloaded. Omit it for unbounded resident model caching. Optional default model keep-alive: ```bash OPENMED_SERVICE_KEEP_ALIVE=10m uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_KEEP_ALIVE` accepts seconds as a number or duration strings such as `30s`, `5m`, `1h30m`, or `1d`. Omit it for indefinite caching, use `0` for unload-after-request behavior, or use request-level `keep_alive` to override the default for one call. Optional request text cap: ```bash OPENMED_SERVICE_MAX_TEXT_LENGTH=250000 uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_MAX_TEXT_LENGTH` caps the `text` field accepted by `/analyze`, `/pii/extract`, `/pii/extract/stream`, `/pii/deidentify`, `/jobs`, and `/privacy-gateway/complete`. The default is `1,000,000` characters. Oversized requests return the standard `422` validation envelope; split larger documents client-side or route them through batch processing. Optional privacy-gateway egress endpoint: ```bash OPENMED_SERVICE_PRIVACY_GATEWAY_ENDPOINT=https://llm-proxy.example.com/complete \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `POST /privacy-gateway/complete` refuses to call an external LLM unless `OPENMED_SERVICE_PRIVACY_GATEWAY_ENDPOINT` is configured by the operator or the FastAPI app is given an explicit `app.state.privacy_gateway_transport` callable. The request body never accepts an arbitrary URL. The gateway redacts locally, forwards only the redacted prompt, keeps the placeholder map in process memory for that request, runs an independent outbound tripwire scan, and records only PHI-free audit metadata. Optional dynamic request batching: ```bash OPENMED_SERVICE_BATCHING_ENABLED=true \ OPENMED_SERVICE_BATCH_MAX_SIZE=8 \ OPENMED_SERVICE_BATCH_MAX_WAIT_MS=25 \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Dynamic batching is off by default. When enabled, `/pii/extract` groups compatible requests and dispatches them through the PII batch helper; models with true batch backends get one backend batch, while model families whose batch helper falls back to per-text analysis still preserve per-request results. `/analyze` uses one backend pipeline call for compatible requests with `sentence_detection=false`; requests that need sentence segmentation or other non-batch-compatible settings are still coalesced but executed independently. `OPENMED_SERVICE_BATCH_MAX_SIZE` must be a positive integer. `OPENMED_SERVICE_BATCH_MAX_WAIT_MS` is a non-negative wait window in milliseconds. Optional request coalescing: ```bash OPENMED_SERVICE_COALESCING_ENABLED=true \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Request coalescing is off by default. When enabled, identical concurrent `/analyze`, `/pii/extract`, and `/pii/deidentify` requests share one in-flight model computation keyed by endpoint, normalized text, and request options. The single result, or the leader error, is fanned out to all joined waiters. This is not a persistent response cache; entries are evicted shortly after completion. Optional model resilience controls: ```bash OPENMED_SERVICE_RETRY_MAX_ATTEMPTS=3 \ OPENMED_SERVICE_RETRY_BACKOFF_INITIAL_SECONDS=0.05 \ OPENMED_SERVICE_CIRCUIT_BREAKER_FAILURE_THRESHOLD=3 \ OPENMED_SERVICE_CIRCUIT_BREAKER_RECOVERY_TIMEOUT_SECONDS=30 \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Model load and inference work is retried with bounded exponential backoff and jitter, then counted against an in-process circuit breaker keyed by resolved model/backend. While a breaker is open, model-backed endpoints return `503` with error code `circuit_breaker_open` and a `Retry-After` header. See [`Serving Resilience`](https://openmed.life/docs/serving/resilience/index.md) for the full set of knobs. Optional graceful-shutdown drain timeout: ```bash OPENMED_SERVICE_SHUTDOWN_DRAIN_SECONDS=30 uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_SHUTDOWN_DRAIN_SECONDS` is a non-negative number of seconds. During shutdown, readiness is flipped off, new model-backed work is rejected, and the service waits up to this timeout for in-flight `/analyze`, `/pii/extract`, `/pii/deidentify`, and `/privacy-gateway/complete` requests to finish. The default is `30`. Optional pull-only Prometheus metrics endpoint: ```bash OPENMED_SERVICE_METRICS_ENABLED=true uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `GET /metrics` is disabled by default and returns `404` unless `OPENMED_SERVICE_METRICS_ENABLED` is set to a truthy value such as `true` or `1` before the app starts. When enabled, the endpoint renders Prometheus 0.0.4 text exposition for aggregate request counts, request duration histograms, in-flight request count, warm-pool model load/eviction counters, and aggregate circuit-breaker state gauges. Metrics are pull-only: OpenMed does not push them to any remote service. Scrape it from a locally scoped Prometheus or sidecar, and avoid exposing it directly to untrusted networks. Metric labels are limited to static route templates and HTTP status codes; text, model outputs, entities, client identity, document content, and PHI are never used as label values. Optional MLX-LM paged KV-cache budget for long-note generation: ```bash OPENMED_SERVICE_MLX_PAGED_KV_CACHE_BUDGET=512MiB \ OPENMED_SERVICE_MLX_PAGED_KV_CACHE_PAGE_TOKENS=128 \ OPENMED_SERVICE_MLX_PAGED_KV_CACHE_CHUNK_TOKENS=512 \ OPENMED_SERVICE_MLX_PAGED_KV_CACHE_BYTES_PER_TOKEN=65536 \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` `OPENMED_SERVICE_MLX_PAGED_KV_CACHE_BUDGET` is disabled when unset. It accepts raw bytes or size suffixes such as `MiB` and `GiB`. The exact dense-equivalent context is `floor(budget / (page_tokens * bytes_per_token)) * page_tokens`. If `OPENMED_SERVICE_MLX_PAGED_KV_CACHE_WINDOW_TOKENS` is lower than that capacity, the lower window is used. Longer prompts remain bounded to the configured resident window and surface aggregate occupancy/eviction counters through `/metrics`: - `openmed_service_mlx_paged_kv_cache_occupancy_pages` - `openmed_service_mlx_paged_kv_cache_capacity_pages` - `openmed_service_mlx_paged_kv_cache_peak_pages` - `openmed_service_mlx_paged_kv_cache_eviction_total` - `openmed_service_mlx_paged_kv_cache_budget_bytes` Optional OpenTelemetry tracing: ```bash OPENMED_SERVICE_OTLP_ENDPOINT=http://otel-collector:4318/v1/traces \ uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` Tracing is disabled by default and the OTLP exporter is created only when `OPENMED_SERVICE_TRACING_ENABLED=true` or `OPENMED_SERVICE_OTLP_ENDPOINT` is set. Request spans honor incoming W3C `traceparent` headers and include only no-PHI attributes such as route templates, status codes, request IDs, stage names, counts, labels, lengths, and durations. See [REST Tracing](https://openmed.life/docs/serving/tracing/index.md) for the full configuration contract. ## Reliability Changes - Requests now run against one shared service runtime per process, including a shared `OpenMedConfig` and bounded warm-pool loader. - Blocking inference is executed off the event loop and guarded by the active profile timeout (`prod=300s`, `test=60s`, etc.). - Text-bearing inference requests are capped before model execution to bound memory use. - Loaded model pipelines can be released manually with `POST /models/unload`. - `/privacy-gateway/complete` redacts PHI before the configured external LLM egress and re-identifies only after validating returned placeholders. - `OPENMED_SERVICE_MAX_RESIDENT_MODELS` evicts the least-recently-used idle model when mixed-model traffic exceeds the configured resident limit. - Inference requests accept `keep_alive` to schedule model unloading after the model becomes idle. - Dynamic request batching can be enabled for compatible `/analyze` and `/pii/extract` traffic with `OPENMED_SERVICE_BATCHING_ENABLED=true`. - CORS remains disabled unless exact origins are listed, and Host headers are checked against the configured trusted-host allowlist. - Identical in-flight inference requests can be coalesced with `OPENMED_SERVICE_COALESCING_ENABLED=true`. - Model load and inference work uses bounded retry and an in-process circuit breaker. Open breakers fail fast with `503` and `Retry-After`. - `/livez` reports process liveness, `/readyz` reports startup readiness, and `/health` remains the backward-compatible health alias. - Graceful shutdown rejects new model-backed requests and drains in-flight model-backed requests for up to `OPENMED_SERVICE_SHUTDOWN_DRAIN_SECONDS`. - `/metrics` is opt-in, pull-only, and exposes aggregate counts, gauges, and latency histograms without PHI-derived labels. - OpenTelemetry tracing is opt-in, honors incoming trace context, and exports only no-PHI span attributes when configured. - Application endpoint errors and invalid Host headers use one JSON envelope across validation, bad-request, timeout, and internal errors. A CORS preflight rejected by Starlette's middleware can instead use its native plain-text response. - `/pii/deidentify` still accepts the legacy `shift_dates` boolean, but it is now a deprecated alias for `method="shift_dates"`. ## Endpoints ### `GET /health` Health response: ```json { "status": "ok", "service": "openmed-rest", "version": "", "profile": "prod" } ``` `GET /health` remains the backward-compatible health alias. ### `GET /livez` Liveness response: ```json { "status": "ok", "service": "openmed-rest" } ``` ### `GET /readyz` Readiness response after startup preload completes: ```json { "status": "ready", "service": "openmed-rest" } ``` Before startup readiness, `/readyz` returns `503` with `error.code` set to `not_ready`. ### `GET /models/loaded` Returns currently cached model resources and idle-unload status: ```json { "default_keep_alive_seconds": 600.0, "max_resident_models": 2, "warm_models": ["disease_detection_superclinical"], "models": { "OpenMed/OpenMed-NER-DiseaseDetect-SuperClinical-434M": { "models": 0, "tokenizers": 0, "pipelines": 1, "active_requests": 0, "keep_alive_seconds_remaining": 287.4, "resident": true } } } ``` ### `POST /models/unload` Unload one inactive model: ```json { "model_name": "disease_detection_superclinical" } ``` Unload all inactive models: ```json { "all": true } ``` If a model has active requests, the service leaves it loaded and reports the active request count. ### `POST /analyze` Request body: ```json { "text": "Patient started imatinib for CML.", "model_name": "disease_detection_superclinical", "confidence_threshold": 0.0, "group_entities": false, "aggregation_strategy": "simple", "keep_alive": "5m" } ``` Returns the same shape as OpenMed `analyze_text(..., output_format="dict")`. ### `POST /pii/extract` Request body: ```json { "text": "Paciente: Maria Garcia, DNI: 12345678Z", "lang": "es", "use_smart_merging": true, "keep_alive": "10m" } ``` Returns the same shape as `extract_pii(...).to_dict()`. ### `POST /pii/deidentify` Request body: ```json { "text": "Paciente: Maria Garcia, DNI: 12345678Z", "method": "mask", "lang": "es", "keep_mapping": true, "keep_alive": "10m" } ``` Date shifting: ```json { "text": "Paciente: Maria Garcia, fecha: 15/01/2020", "method": "shift_dates", "date_shift_days": 30, "lang": "es" } ``` The deprecated `shift_dates: true` boolean is still accepted as an alias for `method: "shift_dates"`. Returns `deidentify(...).to_dict()`. When `keep_mapping=true` and mapping data exists, a `mapping` field is included. ### `POST /privacy-gateway/complete` Request body: ```json { "text": "Patient Maria Garcia called 555-0100.", "confidence_threshold": 0.9, "detector_confidence_floor": 0.0, "policy": "strict", "disallowed_entity_categories": [], "lang": "en", "keep_alive": "10m" } ``` The service detects PHI locally, replaces spans with `OPENMED_PHI` placeholder tokens, runs an independent outbound tripwire scan, sends only the redacted prompt to the operator-configured transport, and substitutes known placeholders back into the external response. Unknown or mangled placeholders fail closed. Successful response shape: ```json { "request_id": "4e22b0c3-4b56-4d2f-9c0d-9f2c9d331c21", "redacted_prompt": "Patient <> called <>.", "external_response": "Echo Patient <> called <>.", "reidentified_text": "Echo Patient Maria Garcia called 555-0100.", "entity_counts": { "NAME": 1, "PHONE": 1 }, "placeholder_hashes": ["..."], "audit": { "record_hash": "...", "verified": true } } ``` ## Error Envelope Application endpoint errors and invalid Host headers use this shape: A CORS preflight is handled before the endpoint. If Starlette rejects its origin, method, or requested headers, the response can be plain text rather than this JSON envelope. Clients should check the status and content type before decoding an error body. ```json { "error": { "code": "", "message": "human-readable summary", "details": null } } ``` Validation example: ```json { "error": { "code": "validation_error", "message": "Request validation failed", "details": [ { "field": "body.text", "message": "Text must not be blank", "type": "value_error" } ] } } ``` Timeout example: ```json { "error": { "code": "timeout", "message": "Request exceeded configured timeout of 300 seconds", "details": { "timeout_seconds": 300 } } } ``` ## Docker Build: ```bash docker build -t openmed:local . ``` Run: ```bash docker run --rm -p 127.0.0.1:8080:8080 \ -e OPENMED_PROFILE=prod \ -e OPENMED_SERVICE_KEEP_ALIVE=10m \ -e OPENMED_SERVICE_PRELOAD_MODELS=disease_detection_superclinical \ -e OPENMED_SERVICE_MAX_RESIDENT_MODELS=2 \ -e OPENMED_SERVICE_CORS_ORIGINS=http://localhost:5173 \ -e OPENMED_SERVICE_TRUSTED_HOSTS=127.0.0.1,localhost \ openmed:local ``` ### Docker Compose Use the provided `docker-compose.yml` to build and start the service with a single command. The Compose setup publishes host port **8080**, sets `OPENMED_PROFILE=prod`, and persists the standard Hugging Face cache directory in a named volume so downloads placed in that cache are reused across restarts. `OPENMED_CACHE_DIR` is also passed through for data and deployment workflows that explicitly read it; it is not a generic `OpenMedConfig.cache_dir` override. The checked-in Compose port mapping listens on the host's interfaces. Treat it as network exposure: for local-only troubleshooting, change the mapping to `127.0.0.1:8080:8080`. Before intentional network exposure, configure authentication, TLS, and the trusted-host allowlist. ```bash docker compose up -d ``` Verify the service started correctly: ```bash docker compose ps # The STATUS column should show "(healthy)" ``` Stop the container: ```bash docker compose down ``` To remove the persisted model cache too, delete the named volume: ```bash docker compose down --volumes ``` Smoke check: ```bash curl http://127.0.0.1:8080/health ``` Optional values such as `HF_TOKEN`, `OPENMED_PROFILE`, `OPENMED_CACHE_DIR`, `OPENMED_SERVICE_PRELOAD_MODELS`, and `OPENMED_SERVICE_MAX_RESIDENT_MODELS`, `OPENMED_SERVICE_CORS_ORIGINS`, and `OPENMED_SERVICE_TRUSTED_HOSTS` can be supplied from a local `.env` file. Keep `.env` ignored and never commit secrets to version control. # REST API Recipes Copy-paste recipes for the OpenMed REST service. Each primary request below is a ready-to-run `curl` command paired with an equivalent Python [`requests`](https://requests.readthedocs.io/) snippet. Additional variants may be shown in one form; the real response shapes let callers wire up parsing and error handling in one pass. This page is the task-oriented companion to the endpoint reference in [REST Service](https://openmed.life/docs/rest-service/index.md). Use the reference for the full field-by-field contract and configuration knobs; use this page to make a successful call fast. All examples use **synthetic data only**. Never paste real patient text into a shared terminal, shell history, or issue tracker. Model-backed responses can echo the request text and detected entity text. Treat request and response payloads as PHI in real deployments: do not put them in application logs, telemetry, caches, or unencrypted artifacts. The examples print fields only because every value shown here is synthetic. ## Start the service Install the service extra and start Uvicorn: ```bash uv pip install -e ".[hf,service]" uvicorn openmed.service.app:app --host 127.0.0.1 --port 8080 ``` The standalone Python snippets below use `requests`, which is intentionally not a server dependency. Install it explicitly in the client environment: ```bash uv pip install requests ``` The first model-backed request downloads model weights unless they are already cached. After download, inference stays local. For an air-gapped deployment, pre-seed the cache and follow the [local-only offline configuration](https://openmed.life/docs/configuration/#local-only-offline-mode). Or run the packaged container with Docker Compose (maps port `8080`, persists the Hugging Face cache in a named volume). See [REST Service — Docker Compose](https://openmed.life/docs/rest-service/#docker-compose) for the full setup: ```bash docker compose up -d ``` The checked-in Compose mapping publishes port `8080` on every host interface. For local-only use, prefer the loopback-bound Uvicorn command above or restrict the published port to loopback with the host firewall or a Compose override. Before allowing remote access, terminate TLS at a reverse proxy, enable [REST authentication](https://openmed.life/docs/serving/authentication/index.md), and restrict the trusted-host allowlist. CORS is a browser policy, not authentication or transport security. The examples below assume the service is reachable at `http://127.0.0.1:8080`. Export it once so the `curl` snippets stay short: ```bash export OPENMED_URL="http://127.0.0.1:8080" ``` The Python snippets share one base URL: ```python import requests BASE_URL = "http://127.0.0.1:8080" ``` By default the service only trusts loopback `Host` headers (`localhost,127.0.0.1,[::1]`) and CORS is off. If you call it from another host configure `OPENMED_SERVICE_TRUSTED_HOSTS`; for a browser front-end, also configure `OPENMED_SERVICE_CORS_ORIGINS` — see [Browser and Host Allowlists](https://openmed.life/docs/rest-service/#browser-and-host-allowlists). These allowlists do not encrypt or authenticate traffic; remote deployments still need HTTPS and authentication. ## Health check — `GET /health` The cheapest call to confirm the service is up. It never loads a model. ```bash curl --max-time 10 "$OPENMED_URL/health" ``` ```python response = requests.get(f"{BASE_URL}/health", timeout=10) response.raise_for_status() print(response.json()) ``` Response: ```json { "status": "ok", "service": "openmed-rest", "version": "1.9.1", "profile": "prod" } ``` `GET /health` is the backward-compatible health alias. For orchestrators, use `GET /livez` (process liveness) and `GET /readyz` (startup readiness). Before startup completes, `/readyz` returns `503` with `error.code` set to `not_ready`. ## Analyze clinical text — `POST /analyze` Run a medical NER model over free text. `model_name` defaults to `disease_detection_superclinical`; `confidence_threshold` defaults to `0.0`. ```bash curl -sS --max-time 310 -X POST "$OPENMED_URL/analyze" \ -H "Content-Type: application/json" \ -d '{ "text": "Patient started imatinib for CML.", "model_name": "disease_detection_superclinical", "confidence_threshold": 0.5 }' ``` ```python payload = { "text": "Patient started imatinib for CML.", "model_name": "disease_detection_superclinical", "confidence_threshold": 0.5, } response = requests.post(f"{BASE_URL}/analyze", json=payload, timeout=310) response.raise_for_status() result = response.json() for entity in result["entities"]: print(entity["label"], entity["text"], round(entity["confidence"], 3)) ``` Representative response (same shape as `analyze_text(..., output_format="dict")`; scores and timings vary by hardware): ```json { "text": "Patient started imatinib for CML.", "entities": [ { "text": "CML", "label": "DISEASE", "confidence": 0.957, "start": 29, "end": 32, "metadata": { "sentence_index": 0, "sentence_text": "Patient started imatinib for CML.", "sentence_start": 0, "sentence_end": 33, "span_valid": true } } ], "model_name": "disease_detection_superclinical", "timestamp": "2026-07-11T16:58:55.987165", "processing_time": 1.527, "metadata": { "sentence_detection": true, "sentence_count": 1, "sentence_language": "en", "medical_tokenizer": true, "max_length": 512 } } ``` ## Extract PII — `POST /pii/extract` Detect personally identifiable information. Unless `model_name` is set, OpenMed selects the recommended PII model for `lang`. The 22 supported PII language codes: `am`, `ar`, `de`, `en`, `es`, `fr`, `he`, `hi`, `id`, `it`, `ja`, `ko`, `nl`, `pt`, `ro`, `sw`, `te`, `th`, `tr`, `xh`, `zh`, and `zu`. Chinese currently uses the documented multilingual default-model placeholder. The API also accepts nine optional Indic routes (`as`, `bn`, `gu`, `kn`, `ml`, `mr`, `or`, `pa`, and `ta`) when `OPENMED_INDIC_NER_MODEL` or an explicit model is configured; Hindi and Telugu can use that adapter too. `confidence_threshold` defaults to `0.5`. ```bash curl -sS --max-time 310 -X POST "$OPENMED_URL/pii/extract" \ -H "Content-Type: application/json" \ -d '{ "text": "Patient Jordan Ramirez, MRN 4482910, called from 555-0147.", "lang": "en", "use_smart_merging": true }' ``` ```python payload = { "text": "Patient Jordan Ramirez, MRN 4482910, called from 555-0147.", "lang": "en", "use_smart_merging": True, } response = requests.post(f"{BASE_URL}/pii/extract", json=payload, timeout=310) response.raise_for_status() for entity in response.json()["entities"]: print(entity["label"], entity["start"], entity["end"], entity["text"]) ``` Representative response (same shape as `extract_pii(...).to_dict()`; scores and timings vary by hardware): ```json { "text": "Patient Jordan Ramirez, MRN 4482910, called from 555-0147.", "entities": [ { "text": "Jordan", "label": "first_name", "confidence": 0.999, "start": 8, "end": 14, "metadata": {"span_valid": true} }, { "text": "Ramirez", "label": "last_name", "confidence": 0.999, "start": 15, "end": 22, "metadata": {"span_valid": true} }, { "text": "MRN 4482910", "label": "medical_record_number", "confidence": 0.708, "start": 24, "end": 35, "metadata": {"span_valid": true} }, { "text": "555-0147", "label": "phone_number", "confidence": 0.992, "start": 49, "end": 57, "metadata": {"span_valid": true} } ], "model_name": "OpenMed/OpenMed-PII-SuperClinical-Small-44M-v1", "timestamp": "2026-07-11T16:58:02.718948", "processing_time": 1.772, "metadata": { "sentence_detection": true, "sentence_count": 1, "sentence_language": "en", "medical_tokenizer": true, "max_length": 512, "clinical_protection": { "source": "openmed/core/data/clinical_protect_terms.txt", "version": "clinical-protect-terms-v1", "protected_term_count": 71, "checked_spans": 2, "suppressed_spans": 0, "enabled": true } } } ``` ## De-identify text — `POST /pii/deidentify` Redact detected PII. `method` is one of `mask`, `remove`, `replace`, `hash`, or `shift_dates` (default `mask`). `keep_mapping` defaults to `false` and is intentionally omitted here. Enabling it returns original identifiers in a placeholder-to-original mapping and should be reserved for controlled, reversible workflows. `confidence_threshold` defaults to `0.7`. ```bash curl -sS --max-time 310 -X POST "$OPENMED_URL/pii/deidentify" \ -H "Content-Type: application/json" \ -d '{ "text": "Call 555-0147 to confirm the appointment.", "method": "mask", "lang": "en" }' ``` ```python payload = { "text": "Call 555-0147 to confirm the appointment.", "method": "mask", "lang": "en", } response = requests.post(f"{BASE_URL}/pii/deidentify", json=payload, timeout=310) response.raise_for_status() result = response.json() print(result["deidentified_text"]) print("redacted:", result["num_entities_redacted"]) ``` Representative abridged response (`deidentify(...).to_dict()`; scores, timings, and nested provenance metadata vary by runtime): ```json { "original_text": "Call 555-0147 to confirm the appointment.", "deidentified_text": "Call [phone_number] to confirm the appointment.", "pii_entities": [ { "text": "555-0147", "label": "phone_number", "entity_type": "phone_number", "start": 5, "end": 13, "confidence": 0.986, "redacted_text": "[phone_number]", "canonical_label": "PHONE", "sources": ["ml"], "evidence": { "raw_label": "phone_number", "language": "en", "model_id": "OpenMed/OpenMed-PII-SuperClinical-Small-44M-v1" }, "threshold": 0.7, "action": "mask", "surrogate": "[phone_number]", "metadata": {"span_valid": true} } ], "method": "mask", "timestamp": "2026-07-11T16:58:43.445519", "num_entities_redacted": 1, "metadata": { "sentence_detection": true, "sentence_count": 1, "sentence_language": "en", "medical_tokenizer": true, "max_length": 512, "safety_sweep": { "source": "safety_sweep", "patterns_version": "safety-sweep-v1", "spans_added": 0 } }, "audit_report": null } ``` To shift dates instead of masking, use `method: "shift_dates"` with an optional `date_shift_days`: ```bash curl -sS --max-time 310 -X POST "$OPENMED_URL/pii/deidentify" \ -H "Content-Type: application/json" \ -d '{ "text": "Patient Jordan Ramirez was admitted on 2026-01-02.", "method": "shift_dates", "date_shift_days": 30, "lang": "en" }' ``` The deprecated `shift_dates: true` boolean is still accepted as an alias for `method: "shift_dates"`. ## Inspect loaded models — `GET /models/loaded` Report the warm-pool cache, resident models, and idle-unload countdown. No model is loaded by this call. ```bash curl --max-time 10 "$OPENMED_URL/models/loaded" ``` ```python response = requests.get(f"{BASE_URL}/models/loaded", timeout=10) response.raise_for_status() state = response.json() print("warm:", state["warm_models"]) print("resident cap:", state["max_resident_models"]) ``` Response immediately after an unconfigured local service starts: ```json { "default_keep_alive_seconds": null, "max_resident_models": null, "memory_budget_bytes": null, "resident_memory_bytes": 0, "pending_memory_bytes": 0, "memory_admission_wait_seconds": 0.05, "warm_models": [], "models": {} } ``` After a model-backed request, `models` contains one entry per resolved model and reports its cached resources, active requests, residency, idle-unload countdown, and memory footprint. `warm_models` lists only the configured preload set. `default_keep_alive_seconds`, `max_resident_models`, and `memory_budget_bytes` are `null` when their optional env vars are unset. ## Unload a model — `POST /models/unload` Free one inactive model, or all of them. If a model still has active requests, the service leaves it loaded and reports the count. Unload one model: ```bash curl -sS --max-time 30 -X POST "$OPENMED_URL/models/unload" \ -H "Content-Type: application/json" \ -d '{"model_name": "disease_detection_superclinical"}' ``` ```python payload = {"model_name": "disease_detection_superclinical"} response = requests.post(f"{BASE_URL}/models/unload", json=payload, timeout=30) response.raise_for_status() print(response.json()) ``` Representative response after the analyze recipe has loaded the default disease model (released resource counts vary by backend): ```json { "unloaded": true, "model_name": "OpenMed/OpenMed-NER-DiseaseDetect-SuperClinical-434M", "active_requests": 0, "loading": false, "released": {"models": 0, "tokenizers": 0, "pipelines": 1} } ``` Unload every inactive model: ```bash curl -sS --max-time 30 -X POST "$OPENMED_URL/models/unload" \ -H "Content-Type: application/json" \ -d '{"all": true}' ``` ```python response = requests.post( f"{BASE_URL}/models/unload", json={"all": True}, timeout=30 ) response.raise_for_status() print(response.json()) ``` Representative response after both an analysis pipeline and a PII pipeline were cached (released resource counts vary by backend): ```json { "unloaded": true, "released": {"models": 0, "tokenizers": 0, "pipelines": 2}, "active_models": {} } ``` Send `model_name` to unload one model or `all: true` to unload all inactive models. Sending neither returns the validation error envelope described below. Do not send both: the current schema treats `all: true` as the all-model operation when both fields are present. ## Handling errors After a request passes the configured host and CORS middleware, application endpoint errors use one JSON envelope, so a single handler covers validation, bad-request, timeout, and internal errors: ```json { "error": { "code": "validation_error", "message": "Request validation failed", "details": [ { "field": "body.text", "message": "Value error, Text must not be blank", "type": "value_error" } ], "request_id": "recipe-validation-error" } } ``` Common `error.code` values include `validation_error`, `bad_request`, `timeout`, `not_ready`, `rate_limited`, `backpressure`, `service_busy`, `circuit_breaker_open`, and `internal_error`. Authentication and privacy-gateway features add their own documented codes. `details` may be a list (validation errors), an object (for example `{"timeout_seconds": 300}`), or `null`. Every HTTP response carries an `X-Request-ID` header. Application-generated error envelopes also echo it as `error.request_id` for correlating logs; middleware rejections may provide only the response header. Reproduce the validation envelope with a blank `text`: ```bash curl -sS --max-time 60 -X POST "$OPENMED_URL/analyze" \ -H "Content-Type: application/json" \ -H "X-Request-ID: recipe-validation-error" \ -d '{"text": " "}' ``` ```python def analyze(text: str) -> dict: response = requests.post( f"{BASE_URL}/analyze", json={"text": text}, headers={"X-Request-ID": "recipe-validation-error"}, timeout=60, ) if response.status_code >= 400: error = response.json()["error"] request_id = response.headers.get("X-Request-ID") raise RuntimeError( f"{response.status_code} {error['code']}: {error['message']} " f"(request_id={request_id})" ) return response.json() analyze(" ") # raises RuntimeError with code "validation_error" ``` ## Typed Python client The `service` extra ships a typed synchronous client, `openmed.service.client.OpenMedClient`, built on `httpx`. It maps the analysis, PII, privacy-gateway, and model-cache endpoints to methods and raises `OpenMedAPIError` on any non-2xx response, so you skip the manual status checks above: ```python from openmed.service.client import OpenMedAPIError, OpenMedClient with OpenMedClient("http://127.0.0.1:8080", timeout=310.0) as client: analysis = client.analyze( "Patient started imatinib for CML.", model_name="disease_detection_superclinical", confidence_threshold=0.5, ) pii = client.extract_pii( "Patient Jordan Ramirez, MRN 4482910, called from 555-0147.", lang="en", ) redacted = client.deidentify( "Patient Jordan Ramirez was admitted on 2026-01-02.", method="mask", ) loaded = client.loaded_models() try: client.unload_model("disease_detection_superclinical") except OpenMedAPIError as exc: print(exc.status_code, exc.code, exc.message, exc.request_id) ``` `OpenMedAPIError` exposes the service error `code`, `message`, optional `details`, HTTP `status_code`, and the `request_id` from the response header (or the outgoing client request ID when the response has none). Use `client.unload_all_models()` to drop every inactive model in one call. The `310`-second model-call timeout is intentionally just above the production profile's default `300`-second service deadline, so the client can receive the service's structured timeout envelope. Raise both values together if your deployment uses a longer server deadline. ## Related pages - [REST Service](https://openmed.life/docs/rest-service/index.md) — full endpoint reference, configuration env vars, allowlists, and the Docker/Compose run path. - [REST Authentication](https://openmed.life/docs/serving/authentication/index.md) — optional API-key and bearer-token enforcement in front of these endpoints. - [Async REST Jobs & Webhooks](https://openmed.life/docs/serving/async-jobs/index.md) — for large de-identification batches that should not hold a client connection open. # Detector plugin SDK stability policy OpenMed detector plugins add span-producing privacy detectors without adding a runtime dependency to OpenMed core. This page defines the supported plugin contract, its compatibility guarantees, and the rules third-party packages must follow. The public implementation lives in `openmed.core.detector_plugins`. APIs not documented on this page remain implementation details. ## Supported contract Plugins publish an entry point in the stable `openmed.detectors` group. The loaded value may be: - one `DetectorSpec` instance; - a callable that returns a `DetectorSpec`, an iterable of specs, or `None`; - an iterable containing supported values from the previous two forms. Discovery is lazy and process-local. OpenMed loads entry points on the first detector lookup, registers valid specs, and logs and skips a plugin that fails to load. Plugin packages must not depend on import order or mutate OpenMed's internal registry directly. ### Stable `DetectorSpec` fields | Field | Contract | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `name` | Non-empty identifier unique within its stage. A colon is not allowed. | | `stage` | One of `deterministic`, `fast_pii`, or `clinical_phi`. | | `languages` | A language tag, sequence of tags, or wildcard (`*`, `all`, `any`). Tags are normalized to lowercase BCP 47-style hyphenated values. | | `detect` | Callable invoked with normalized text and keyword context including `lang`. It returns a sequence of `OpenMedSpan` records. | | `provenance_prefix` | Non-empty prefix used with `name` to produce the stable detector identifier. Defaults to `plugin`. | | `covered_labels` | Optional canonical-label declaration used by policy capability checks. Wildcards expand to all canonical labels. | `DetectorSpec.capability()` and the resulting `DetectorCapability` record are also public. A capability is emitted only when `covered_labels` is non-empty. ## Component kinds and execution stages The current SDK supports detector plugins only. A detector must select the stage that matches its cost and purpose: - `deterministic` for patterns, validators, and dictionary checks; - `fast_pii` for broad PII/PHI detection suitable for the fast privacy pass; - `clinical_phi` for clinically specialized PHI detection. Other OpenMed extension surfaces are not implicitly detector plugins. Do not register model loaders, exporters, anonymizers, service middleware, or network clients in `openmed.detectors`. ## Span and label requirements Detector callables must return `OpenMedSpan` values whose offsets refer to the normalized text supplied to the plugin. Spans must satisfy these rules: - `start` and `end` are valid character offsets and `start < end`; - `entity_type` and `canonical_label` normalize to an OpenMed canonical label; - `score` is a finite confidence value appropriate for arbitration; - metadata and evidence never contain raw PHI or PII; - the detector does not retain input text after the call completes. OpenMed rewrites document identity, text hashes, and detector provenance before arbitration. Plugins must not rely on placeholder `doc_id` or `text_hash` values surviving pipeline execution. ## Minimal package Declare the entry point in `pyproject.toml`: ```toml [project] name = "example-openmed-detector" dependencies = ["openmed>=1.9,<2"] [project.entry-points."openmed.detectors"] example_mrn = "example_openmed_detector:detector" ``` Return a `DetectorSpec` from the referenced object: ```python import re from openmed.core.detector_plugins import DetectorSpec from openmed.core.schemas.span import OpenMedSpan, hmac_text_hash _MRN_PATTERN = re.compile(r"\bMRN:\s*([A-Za-z0-9-]+)\b") def detect(text: str, *, lang: str, context=None): match = _MRN_PATTERN.search(text) if match is None: return () start, end = match.span(1) return ( OpenMedSpan( doc_id="plugin-placeholder", start=start, end=end, text_hash=hmac_text_hash(text[start:end], "plugin-placeholder"), entity_type="ID_NUM", canonical_label="ID_NUM", score=0.95, ), ) def detector() -> DetectorSpec: return DetectorSpec( name="example_mrn", stage="deterministic", languages=("en",), detect=detect, covered_labels=("ID_NUM",), ) ``` A typical source layout is: ```text example-openmed-detector/ pyproject.toml src/example_openmed_detector/__init__.py tests/test_detector.py LICENSE README.md ``` ## Local-first and opt-in rules Plugin installation is an explicit user opt-in. After installation, plugins must preserve OpenMed's local-first guarantees: - no telemetry or background network calls by default; - no automatic model or dataset download during import or discovery; - no raw PHI in logs, exceptions, caches, temporary files, or traces; - remote services require explicit configuration and must be disabled by default; - restricted datasets and credentials remain user-supplied and are never bundled; - core-compatible dependencies use permissive licenses. Heavy runtimes, remote adapters, and license-restricted integrations belong in optional extras. A missing optional dependency should produce a clear setup error only when the related detector is selected, not when OpenMed discovers the package. ## Semantic-versioning policy The following changes to the documented plugin protocol require an OpenMed major release unless an earlier deprecation path preserves compatibility: - renaming or removing the `openmed.detectors` entry-point group; - removing or renaming a stable `DetectorSpec` field; - making an optional field required or changing its accepted value shape; - removing an execution stage or changing its established meaning; - changing the detector callable's required arguments or return type; - changing offset interpretation away from normalized-text character offsets; - removing a canonical label without a compatibility alias; - changing discovery so a previously valid entry-point return shape is rejected; - weakening the local-first, no-telemetry, or no-raw-PHI defaults. Additive optional fields with backward-compatible defaults, new canonical labels, new stages, and new entry-point return conveniences may ship in a minor release. Documentation clarifications and stricter rejection of inputs that were already invalid may ship in a patch release. Deprecated fields or values remain available for at least two minor releases, emit a `DeprecationWarning`, identify their replacement, and appear in the changelog before removal. ## Upgrade and conformance checks Before widening the supported OpenMed version range, plugin authors should: 1. construct every exported `DetectorSpec` under the oldest and newest supported OpenMed versions; 1. exercise discovery through installed entry-point metadata, not only direct registration; 1. run synthetic positive, negative, overlap, Unicode, and invalid-span cases; 1. verify every declared `covered_labels` value normalizes successfully; 1. confirm logs and exceptions contain no input surface text; 1. test with network access disabled unless the user explicitly enables a remote integration. The repository tests in `tests/unit/core/test_detector_plugins.py` demonstrate the current discovery and validation contract. A standalone example plugin and published conformance kit are planned; until they are available, use those tests and this page as the compatibility baseline.