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 usingFile: [path]headersgoal: Natural language instruction for desired code changesimages: 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 bygenerate_diff. A diff wrapped in one or more fenceddiffcode blocks is unwrapped first.files: Dictionary of {file_path: content} to modify- Returns updated file dictionary with changes applied
- Raises
SmartApplyErrorwhen 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
APIErrorfor 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 inpatchling/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=1024andreasoning_effort="low". A 4xx that namesreasoning_effortis 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 thelowkey 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, orbinary), the preview note (empty when there is none), and the exact body sent. A full file whose text isabcdoes not share a key with a truncated preview that is alsoabc. 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 optionalAnswer:orDecision:prefix. That token must be exactlyyesorno, 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
choiceslist, 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=includereturns the original bytes unchanged. Ignored paths are excluded before those bytes are decoded. - Token truncation keeps the first
maxTokens * 4Unicode 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 istests/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_reasonfor a skip isignored. The list includes**/*.min.jsand**/*.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. cacheis in-memory for the call. Pass aRelevanceCacheto reuse it, or a filesystem path to persist it. Only a clearyesornois stored.- Binary files and files over the size or token threshold are not sent in full.
includekeeps them without a call,excludedrops them,truncateasks about a truncated or redacted preview. Ayeson 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
-
Environment Formatting:
python from patchling import build_environment env = build_environment(files_dict) # Safely handles edge cases -
Model Selection:
deepseek/deepseek-v4.1-flash-1017: the default (DEFAULT_MODEL) for generating diffsopenai/gpt-5-mini: a cheap choice for smartapply (pass it asmodel=; the CLI readsGPTDIFF_SMARTAPPLY_MODEL)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.