Skip to content

Patchling API Reference

Patchling everywhere: this library also ships for browser and Node as patchling — see it running live in nanoodle.com, a no-server visual AI workflow editor built on it.

Core Functions

generate_diff

def generate_diff(
    environment: str,
    goal: str,
    model: str | None = None,  # GPTDIFF_MODEL, else patchling.DEFAULT_MODEL ("deepseek/deepseek-v4.1-flash-1017")
    temperature: float = 1.0,
    max_tokens: int = 32000,
    api_key: str | None = None,  # Can also set via GPTDIFF_LLM_API_KEY or set_env
    base_url: str | None = None,  # GPTDIFF_LLM_BASE_URL, else DEFAULT_BASE_URL
    prepend: str | None = None,  # Path to file or URL whose content will be prepended to the system prompt
    anthropic_budget_tokens: int | None = None,
    images: list[str] | None = None,  # List of image file paths encoded and sent with the prompt
) -> str

Note: Built with the AI Agent Toolbox for reliable tool parsing across models and frameworks

Transforms environments through natural language instructions

  • environment: Multi-file codebase representation using File: [path] headers
  • goal: Natural language instruction for desired code changes
  • images: Optional list of image file paths to provide additional visual context (each image is base64-encoded and included in the request)
  • Returns unified diff string sometimes compatible with smartapply

Example:

from patchling import generate_diff, build_environment  # Uses AI Agent Toolbox for reliable parsing

# Multi-file environment example
files = {
    "main.py": "def greet():\n    print('Hello World')",
    "tests/test_main.py": "def test_greet():\n    pass" 
}
env = build_environment(files)

# Coordinated change across implementation and tests
diff = generate_diff(env, 
    "Add return type hints and update tests to match"
)

files = {"main.py": "def greet():\n    print('Hello World')"}
env = build_environment(files)
diff = generate_diff(env, "Translate greeting to Spanish")

Pro Tip: Use build_environment() with file dictionaries for safer environment creation

smartapply

def smartapply(  # AI-powered patch resolver
    diff_text: str,
    files: dict[str, str],
    model: str | None = None,  # GPTDIFF_MODEL, else DEFAULT_MODEL
    api_key: str | None = None,
    base_url: str | None = None,
    max_tokens: int | None = None,  # sized from the file when omitted
) -> dict[str, str]

Applies diffs with AI-powered conflict resolution - Safe: Preserves file encoding and line endings - Robust: Handles ambiguous hunks through context-aware reconciliation - Uses LLM to preserve context around changes - Handles new file creation and complex patches

  • diff_text: Unified diff generated by generate_diff. A diff wrapped in one or more fenced diff code blocks is unwrapped first.
  • files: Dictionary of {file_path: content} to modify
  • Returns updated file dictionary with changes applied
  • Raises SmartApplyError when the model returns an empty or drastically shorter file (unless the diff deletes that file), or when the rewrite drops lines the diff did not delete. Whitespace, trailing semicolons, and trivially drifted context still count as the original line.
  • Raises the provider's APIError for timeouts, HTTP 402, and auth failures. Those are not swallowed.

Implementation Notes: - Uses per-file processing with concurrent execution - Maintains original file encodings and line endings - Handles ambiguous hunks through LLM-powered reconciliation

Basic Example:

original_files = {"main.py": "def greet():\n    print('Hello World')"}
updated_files = smartapply(diff, original_files)  # Returns new dict

Advanced Example (Multi-file Modification):

original_files = {
    "file1.py": "def func1():\n    print('Old func1')",
    "file2.py": "def func2():\n    print('Old func2')",
    "unrelated.py": "def unrelated():\n    pass"
}

diff = '''diff --git a/file1.py b/file1.py
--- a/file1.py
+++ b/file1.py
@@ -1,2 +1,2 @@
 def func1():
-    print("Old func1")
+    print("New func1")
diff --git a/file2.py b/file2.py
--- a/file2.py
+++ b/file2.py
@@ -1,2 +1,2 @@
 def func2():
-    print("Old func2")
+    print("New func2")'''

updated_files = smartapply(diff, original_files)

print(updated_files["file1.py"])  # Contains 'print("New func1")'
print(updated_files["file2.py"])  # Contains 'print("New func2")'
print(updated_files["unrelated.py"])  # Remains unchanged

Real-World Refactor Example:

# Coordinated database schema and ORM update
original_files = {
    "models.py": "class User:\n    name = CharField()",
    "migrations/0001_initial.py": "# Existing migration",
    "tests/test_models.py": "def test_user_creation():\n    User(name='Test').save()"
}

diff = generate_diff(
    build_environment(original_files),
    "Rename 'name' field to 'username' across all layers"
)

updated_files = smartapply(diff, original_files)

Conflict Resolution & Error Handling:

from openai import APIError
from patchling import parse_environment

diff = '''diff --git a/core.py b/core.py
@@ -10,3 +10,3 @@
+-def old_name():  # Existing line being removed
++def new_name():'''

try:
    smartapply(diff, {"core.py": "def old_name():\n    pass"}) 
except APIError as e:  # Built-in error handling
    updated = smartapply(diff, files, model='gemini-2.5-flash-lite')  # Retry

Building blocks

These match the JavaScript package (255BITS/patchling) in snake_case. Existing names such as generate_diff and smartapply are unchanged.

from patchling import (
    apply_diff,            # write a unified diff onto a project directory
    parse_diff_per_file,   # split a multi-file diff into (path, patch) pairs
    extract_diff_blocks,   # bodies of fenced diff blocks; prose outside is ignored
    color_code_diff,       # red '-' / green '+' for a terminal
    strip_think_tags,      # drop <think> blocks and "> Reasoning" preambles
    swallow_reasoning,     # (text_without_reasoning, reasoning)
    strip_bad_output,      # unwrap a markdown fence around a rewritten file
    call_llm,              # one chat completion (OpenAI-compatible or Anthropic)
    call_llm_for_diff,     # prompt a model and collect diff tool output
    resolve_api_key,       # explicit key, else GPTDIFF_LLM_API_KEY
    resolve_base_url,      # explicit URL, else GPTDIFF_LLM_BASE_URL, else DEFAULT_BASE_URL
    get_env,               # set_env override, then the process environment
    set_env,               # register an override; pass None to clear it
    DEFAULT_MODEL,         # "deepseek/deepseek-v4.1-flash-1017"
    DEFAULT_BASE_URL,      # "https://nano-gpt.com/api/v1/"
)

get_env / set_env are consulted by resolve_api_key, resolve_base_url, and the model default. An override wins over os.environ, which is how a host without a process environment (or a test) can inject credentials.

from patchling import set_env, resolve_api_key, DEFAULT_MODEL

set_env("GPTDIFF_LLM_API_KEY", "key_123")
set_env("GPTDIFF_MODEL", "xiaomi/mimo-v2.6-pro")
assert resolve_api_key() == "key_123"
set_env("GPTDIFF_MODEL", None)  # fall back to the process environment, then DEFAULT_MODEL

extract_diff_blocks returns every fence whose language is exactly diff, including when the fence is surrounded by prose. Text with no diff fence is left for parse_diff_per_file to read as a raw diff.

filter_relevant

def filter_relevant(
    files: Dict[str, str],
    task: str,
    model: str = None,          # $GPTDIFF_FILTER_MODEL or xiaomi/mimo-v2.6-flash
                                # Flask recall 1.0 / ~0.8x overhead: UNVERIFIED
    concurrency: int = 8,
    cache=None,                 # RelevanceCache, path, None, or False
    api_key: Optional[str] = None,
    base_url: Optional[str] = None,
    max_bytes: Optional[int] = 200_000,
    max_tokens: Optional[int] = 12_000,
    on_oversize: str = "truncate",  # include, exclude, or truncate
    on_binary: str = "exclude",     # include, exclude, or truncate
    skip_patterns=None,         # lockfile/generated globs; () disables
) -> FilterRelevantResult

Opt-in relevance filter for large directories, and the Python counterpart of the JS library's filterRelevant. For each file, the full contents and the task are sent to a cheap OpenAI-compatible model (same GPTDIFF_LLM_API_KEY / GPTDIFF_LLM_BASE_URL resolution as generate_diff). The model is asked whether the file is relevant. Only yes files are returned in result.files for generate_diff and smartapply. The default model is xiaomi/mimo-v2.6-flash. A pallets/flask pass reported recall 1.0 and about 0.8x the token overhead of the next model tried. Those figures are UNVERIFIED.

result.decisions has one record per input file: path, decision (yes or no), reason, raw (the model text), tokens, cached, skipped_reason (binary, oversize, truncated, or ignored), error when that file's call failed or the answer was empty or malformed, and failed_open for those includes. result.failed_open lists them; result.to_dict() counts them. Malformed answers are failed open too, so the count matches the number of fallbacks.

Shared contract (prompt v3)

Python and the JS library use the same rules:

  • Prompt version 3. The system prompt is the relevance-filter paragraph in patchling/relevance.py (_SYSTEM_PROMPT). The user message is the task (intent), one file path, an optional truncation note, and the file contents.
  • Every call requests max_tokens=1024 and reasoning_effort="low". A 4xx that names reasoning_effort is retried once without the parameter. The decision is cached under the effort that was actually sent (low, or empty when the retry omitted it). A later lookup tries the low key first, then the empty-effort key.
  • Timeouts, HTTP 5xx, and billing-reservation errors are retried with backoff, then the file fails open.
  • The cache key is the SHA-256 of length-prefixed UTF-8 fields. Each field is <utf-8 byte length>:<bytes> followed by a newline, in order: prompt version, model, output-token limit, effort sent, task, file path, mode (full, truncated, or binary), the preview note (empty when there is none), and the exact body sent. A full file whose text is abc does not share a key with a truncated preview that is also abc. Decisions are per path. Identical contents at two paths are two calls, even when the cache is off.
  • The decision is the leading token after whitespace, one <think> block, one code fence, markdown ** / __ / backticks, straight or curly quotes, and an optional Answer: or Decision: prefix. That token must be exactly yes or no, then the end of the string, whitespace, or one of . , ! : ; ). A quoted or bold decision followed by a reason (**no** unrelated, "no" unrelated, “no” — unrelated) is that decision. Anything else is malformed: failed_open=true, the file is included, and the decision is not cached.
  • A missing choices list, an empty list, a missing message or content field, non-string message content (such as ["no"]), and a non-JSON body are failed open inside that same handling and are not cached.
  • A cache read error is a miss: the model is called normally and the run does not abort. A cache write error is logged and does not abort the run. When the OpenAI client rejects reasoning_effort, the retry omits it and the decision is cached under the empty effort.
  • Valid UTF-8 bytes are decoded to text, including a leading U+FEFF BOM, and are not replaced with an empty string. An incomplete trailing sequence of at most 3 bytes keeps the valid prefix at any length (b"a\xc3" decodes to "a"). Any other invalid UTF-8, including a bad byte after the first 8192 bytes, takes the binary path. A NUL is binary. A non-empty buffer that decodes to empty text is binary. on_binary=include returns the original bytes unchanged. Ignored paths are excluded before those bytes are decoded.
  • Token truncation keeps the first maxTokens * 4 Unicode code points and never splits a code point or a surrogate pair. The byte cap cuts on a code-point boundary in UTF-8 and never emits U+FFFD. The shared fixture is tests/fixtures/parity/relevance.json.
  • The token estimate is ceil(code points / 4). Empty text is 0. tiktoken is not used for this check.
  • Skip globs are case-insensitive. ** crosses directories and * does not. skipped_reason for a skip is ignored. The list includes **/*.min.js and **/*.min.mjs.

Canonical skip list:

**/package-lock.json
**/yarn.lock
**/pnpm-lock.yaml
**/bun.lock
**/bun.lockb
**/npm-shrinkwrap.json
**/Cargo.lock
**/Gemfile.lock
**/poetry.lock
**/Pipfile.lock
**/composer.lock
**/go.sum
**/uv.lock
**/pdm.lock
**/flake.lock
**/pubspec.lock
**/packages.lock.json
**/Package.resolved
**/*.min.js
**/*.min.mjs
**/*.min.css
**/*.map
**/dist/**
**/build/**
**/.next/**
**/coverage/**
**/__pycache__/**
**/*.pyc
**/*.pyo
**/*.generated.*
  • Checks run with bounded concurrency.
  • cache is in-memory for the call. Pass a RelevanceCache to reuse it, or a filesystem path to persist it. Only a clear yes or no is stored.
  • Binary files and files over the size or token threshold are not sent in full. include keeps them without a call, exclude drops them, truncate asks about a truncated or redacted preview. A yes on a truncated file still forwards the original contents.
  • Pass skip_patterns=() to disable the skip list, or a list of globs to replace it. build_environment(..., skip_patterns=) forwards the same option.
from patchling import filter_relevant, generate_diff, smartapply, build_environment

result = filter_relevant(files, "Rename add() to sum_numbers()")
for decision in result.decisions:
    print(decision.path, decision.decision, decision.skipped_reason or decision.error or "")

diff = generate_diff(build_environment(result.files), "Rename add() to sum_numbers()")
updated = smartapply(diff, result.files)

build_environment(..., filter_relevant=True, task=...) runs the same filter before serializing. Pass a decisions list to collect the records. The CLI flag is --filter-relevant.

Authentication & Configuration

# Option 1: process environment
import os
os.environ['GPTDIFF_LLM_API_KEY'] = 'key_123'
os.environ['GPTDIFF_LLM_BASE_URL'] = 'https://api.example.com/v1'

# Option 2: set_env overrides (checked before the process environment)
from patchling import set_env
set_env('GPTDIFF_LLM_API_KEY', 'key_123')
set_env('GPTDIFF_LLM_BASE_URL', 'https://api.example.com/v1')

# Option 3: direct parameters. These win over both of the above.
generate_diff(
    environment=env,
    goal=instruction,
    api_key='key_123',
    base_url='https://api.example.com/v1'
)

Error Handling & Recovery

from openai import APIError, AuthenticationError
from patchling import SmartApplyError

try:
    diff = generate_diff(environment, "Modernize error handling")
    updated = smartapply(diff, files)
except SmartApplyError as e:
    print(f"Refusing to write a bad rewrite: {e}")
except AuthenticationError as e:
    print(f"Invalid credentials: {e.user_message}")
except APIError as e:
    print(f"API failure: {e.message}")
    if e.status_code == 429:
        print("Rate limit exceeded - add delay")
except Exception as e:
    print(f"Unexpected error: {str(e)}")

Best Practices

  1. Environment Formatting: python from patchling import build_environment env = build_environment(files_dict) # Safely handles edge cases

  2. Model Selection:

  3. deepseek/deepseek-v4.1-flash-1017: the default (DEFAULT_MODEL) for generating diffs
  4. openai/gpt-5-mini: a cheap choice for smartapply (pass it as model=; the CLI reads GPTDIFF_SMARTAPPLY_MODEL)
  5. gemini-2.5-flash-lite: a cheap choice for simple text modifications

Edge Case Handling

New File Creation:

# smartapply automatically creates missing files
diff = '''diff --git a/new.py b/new.py
@@ -0,0 +1,2 @@
+def initialize():
+    print("New module")'''  # Correct diff syntax

assert 'new.py' in smartapply(diff, {})

Modified File Validation:

# Maintains surrounding context during patching
original = {"utils.py": "def calc():\n    return 42"}
diff = '''diff --git a/utils.py b/utils.py
@@ -1,2 +1,2 @@
+-def calc():
++def calculate():
+     return 42'''

updated = smartapply(diff, original)
assert "calculate()" in updated["utils.py"]

File Deletion Handling:

diff = '''diff --git a/old.py b/old.py
deleted file mode 100644
--- a/old.py
++++ /dev/null
@@ -1,3 +0,0 @@
+-def deprecated():
+-    print("Remove me")'''

# smartapply will remove file from returned dictionary
original = {"old.py": "def deprecated():\n    print('Remove me')"}
updated = smartapply(diff, original)
assert "old.py" not in updated

# Verify unrelated files preserved
assert all(f not in updated for f in ["utils.py", "config.ini"])

Note: Deleted files are omitted from the returned dictionary

Edge Case Handling: - New files are created with proper directory structure - Binary files are automatically skipped

Advanced Use Cases

Coordinated Multi-File Refactor:

original_files = {
    "api.py": "def old_name(): pass",
    "test_api.py": "def test_old_name(): pass"
}

diff = generate_diff(build_environment(original_files), "Rename old_name to new_name everywhere")
updated_files = smartapply(diff, original_files)

Batch Processing Pipeline:

from patchling import parse_environment, build_environment

# Process multiple transformations sequentially
files = load_project_files()  # Your custom loader
env = build_environment(files)

for task in ["Add type hints", "Convert to f-strings"]:
    diff = generate_diff(env, task)
    files = smartapply(diff, files)
    env = build_environment(files)  # Refresh environment

save_files(files)  # Your custom saver

Agent Loop Integration:

import time
from patchling import generate_diff, smartapply, build_environment

def improvement_loop(files, goal, cycles=None):
    """Run Patchling continuously until stopped or cycle limit reached."""
    count = 0
    while cycles is None or count < cycles:
        env = build_environment(files)
        diff = generate_diff(env, goal)
        if diff.strip():
            files = smartapply(diff, files)
            count += 1
            print(f"Cycle {count}: Applied improvements")
        time.sleep(5)
    return files

# Example: Continuously improve test coverage
files = load_project_files()
files = improvement_loop(files, "Add missing test cases for edge conditions", cycles=10)
save_files(files)

Why Agent Loops Deliver ROI:

Metric Manual Approach Agent Loop
Test coverage expansion Hours of developer time Runs overnight, 10+ test cases added
Security vulnerability fixes Expensive audit + remediation Continuous scanning and patching
Tech debt reduction Never prioritized Measurable complexity reduction per cycle

Agent loops transform Patchling from a productivity tool into an autonomous code improvement system.

See Agent Loops for battle-tested patterns and bash recipes.

Library smartapply() does not read any GPTDIFF_SMARTAPPLY_* variable. Its model is the explicit model argument, else GPTDIFF_MODEL, else DEFAULT_MODEL. The API key and base URL come from the api_key / base_url arguments, else GPTDIFF_LLM_API_KEY and GPTDIFF_LLM_BASE_URL.

CLI only (patchling --apply). The apply step reads separate variables: - GPTDIFF_SMARTAPPLY_MODEL: Model for the apply step. --applymodel overrides it. If it is unset or blank, the CLI uses openai/gpt-4.1-mini. It never falls back to GPTDIFF_MODEL or --model. - GPTDIFF_SMARTAPPLY_API_KEY: API key for the apply step (defaults to GPTDIFF_LLM_API_KEY if not set) - GPTDIFF_SMARTAPPLY_BASE_URL: Base URL for the apply step (defaults to GPTDIFF_LLM_BASE_URL, then https://nano-gpt.com/api/v1/, if not set)

See the CLI reference for the full --apply behavior.