Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
# OpenRouter models: openrouter/anthropic/claude-3.5-sonnet, openrouter/google/gemini-pro-vision
# OpenAI-Compatible models: openai/hunyuan-turbo-vision, openai/doubao-1-5-vision-pro-32k-250115
MODEL_NAME=gpt-4o
# Optional fallback models (comma-separated) to automatically try if the primary model fails or hits rate limits
# FALLBACK_MODELS=openrouter/anthropic/claude-3.5-sonnet,openrouter/google/gemini-pro-vision

# Optional page-level checkpointing and resume cache
# CACHE_DIR=/path/to/cache
# RESUME=true

# =============================================================================
# API Keys (LiteLLM automatically detects these environment variables)
Expand Down Expand Up @@ -52,4 +58,4 @@ RETRY_TIMES=3
# For Doubao 1.5 Vision Pro:
# MODEL_NAME=openai/doubao-1-5-vision-pro-32k-250115
# OPENAI_API_KEY=sk-...
# OPENAI_API_BASE=https://ark.cn-beijing.volces.com/api/v3/
# OPENAI_API_BASE=https://ark.cn-beijing.volces.com/api/v3/
2 changes: 1 addition & 1 deletion .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ jobs:
with:
branch: master
commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]'
file_pattern: CHANGELOG.md
file_pattern: CHANGELOG.md
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,18 @@ Edit the `.env` file with your settings:
```bash
# Model Configuration
MODEL_NAME=gpt-4o
# Optional Fallback Models (automatically failover on rate limits or errors)
FALLBACK_MODELS=openrouter/anthropic/claude-3.5-sonnet,openrouter/google/gemini-pro-vision

# API Keys (LiteLLM automatically detects these)
OPENAI_API_KEY=your-openai-api-key
# or for OpenRouter
OPENROUTER_API_KEY=your-openrouter-api-key

# Optional Checkpoint & Resume
# CACHE_DIR=~/.cache/markpdfdown
# RESUME=true

# Optional Parameters
TEMPERATURE=0.3
MAX_TOKENS=8192
Expand Down Expand Up @@ -143,9 +149,15 @@ markpdfdown < document.pdf > output.md
python -m markpdfdown < document.pdf > output.md
```

### Advanced Usage
### Advanced Usage & Model Switching

```bash
# Specify primary model and automatic fallback chain
markpdfdown --input doc.pdf --output out.md -m gpt-4o --fallback-models "openrouter/anthropic/claude-3.5-sonnet,openrouter/google/gemini-2.0-flash"

# Resume a previous partial run (automatically reuses finished pages from cache)
markpdfdown --input doc.pdf --output out.md -m openrouter/anthropic/claude-3.5-sonnet --cache-dir .cache

# Convert pages 5-15 of a PDF
markpdfdown --input large_document.pdf --output chapter.md --start 5 --end 15

Expand All @@ -154,7 +166,6 @@ for file in *.pdf; do
markpdfdown --input "$file" --output "${file%.pdf}.md"
done
```

## Docker Usage

```bash
Expand Down
60 changes: 56 additions & 4 deletions src/markpdfdown/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,40 @@ def create_parser() -> argparse.ArgumentParser:
help="Ending page number (default: 0, means last page)",
)

# Model options
parser.add_argument(
"--model",
"-m",
type=str,
default=None,
help="Primary LLM model name (e.g., gpt-4o, openrouter/anthropic/claude-3.5-sonnet)",
)

parser.add_argument(
"--fallback-models",
type=str,
default=None,
help="Comma-separated fallback model names to try if primary fails",
)

# Checkpoint and caching options
parser.add_argument(
"--cache-dir",
type=str,
default=None,
help="Directory to cache page conversions for resuming",
)

parser.add_argument(
"--no-resume",
action="store_true",
help="Disable reusing cached page conversions",
)

# Version argument
parser.add_argument(
"--version", action="version", version=f"markpdfdown {__version__}"
)

return parser


Expand Down Expand Up @@ -115,8 +144,21 @@ def main() -> None:
f"Page range: {args.start} to {args.end if args.end != 0 else 'last'}"
)

# Parse fallback models
fallbacks = None
if args.fallback_models:
fallbacks = [
m.strip() for m in args.fallback_models.split(",") if m.strip()
]

markdown_content = convert_from_file(
input_path=args.input, start_page=args.start, end_page=args.end
input_path=args.input,
start_page=args.start,
end_page=args.end,
model_name=args.model,
fallback_models=fallbacks,
cache_dir=args.cache_dir,
resume=not args.no_resume,
)

# Write output
Expand All @@ -129,13 +171,23 @@ def main() -> None:
# Pipe mode: read from stdin, write to stdout
logger.info("Reading from stdin, writing to stdout")

markdown_content = convert_from_stdin()
fallbacks = None
if args.fallback_models:
fallbacks = [
m.strip() for m in args.fallback_models.split(",") if m.strip()
]

markdown_content = convert_from_stdin(
model_name=args.model,
fallback_models=fallbacks,
cache_dir=args.cache_dir,
resume=not args.no_resume,
)

# Write to stdout
print(markdown_content)

logger.info("Conversion completed")

except KeyboardInterrupt:
logger.info("Operation cancelled by user")
sys.exit(1)
Expand Down
31 changes: 29 additions & 2 deletions src/markpdfdown/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import os
from typing import Optional

from dotenv import load_dotenv
from pydantic import BaseModel, Field
Expand All @@ -20,11 +21,26 @@ class Config(BaseModel):
description="LLM model name (e.g., gpt-4o, openrouter/anthropic/claude-3.5-sonnet)",
)

fallback_models: list[str] = Field(
default_factory=list,
description="Fallback LLM models to use when rate limits or errors occur",
)

# Checkpoint and resume configuration
cache_dir: Optional[str] = Field(
default=None,
description="Directory for caching page conversions to enable resuming",
)

resume: bool = Field(
default=True,
description="Whether to reuse completed page conversions from cache",
)

# Generation parameters
temperature: float = Field(
default=0.3, ge=0.0, le=2.0, description="Temperature for text generation"
)

max_tokens: int = Field(
default=8192, gt=0, description="Maximum number of tokens for generated text"
)
Expand All @@ -36,13 +52,24 @@ class Config(BaseModel):
@classmethod
def from_env(cls) -> "Config":
"""Create configuration from environment variables"""
fallback_env = os.getenv("FALLBACK_MODELS", "")
fallbacks = (
[m.strip() for m in fallback_env.split(",") if m.strip()]
if fallback_env
else []
)
cache_env = os.getenv("CACHE_DIR")
resume_env = os.getenv("RESUME", "true").lower() in ("true", "1", "yes")

return cls(
model_name=os.getenv("MODEL_NAME", "gpt-4o"),
fallback_models=fallbacks,
cache_dir=cache_env if cache_env else None,
resume=resume_env,
temperature=float(os.getenv("TEMPERATURE", "0.3")),
max_tokens=int(os.getenv("MAX_TOKENS", "8192")),
retry_times=int(os.getenv("RETRY_TIMES", "3")),
)


# Global configuration instance
config = Config.from_env()
95 changes: 60 additions & 35 deletions src/markpdfdown/core/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,26 @@

class LLMClient:
"""
Unified LLM client using LiteLLM
Supports OpenAI and OpenRouter automatically
Unified LLM client using LiteLLM.
Supports OpenAI, OpenRouter, Anthropic, Gemini, Ollama, etc.
Supports fallback models when rate limits or errors occur.
"""

def __init__(self, model_name: str):
def __init__(
self,
model_name: str,
fallback_models: Optional[list[str]] = None,
):
"""
Initialize LLM client
Initialize LLM client.

Args:
model_name: Model name (e.g., "gpt-4o", "openrouter/anthropic/claude-3.5-sonnet")
model_name: Primary model name (e.g., "gpt-4o")
fallback_models: Optional list of fallback model names to try if primary fails
"""
self.model_name = model_name

self.fallback_models = fallback_models or []
self.active_model = model_name
# Configure LiteLLM logging
litellm.set_verbose = False

Expand Down Expand Up @@ -73,35 +80,53 @@ def completion(
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_content})

# Retry mechanism
for attempt in range(retry_times):
try:
response = completion(
model=self.model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
# Add custom headers for tracking
extra_headers={
"X-Title": "MarkPDFdown",
"HTTP-Referer": "https://github.com/MarkPDFdown/markpdfdown.git",
},
)

if not response.choices:
raise Exception("No response from API")

return response.choices[0].message.content

except Exception as e:
logger.error(
f"API request failed (attempt {attempt + 1}/{retry_times}): {str(e)}"
)
if attempt < retry_times - 1:
# Wait before retry
time.sleep(0.5 * (attempt + 1))
else:
raise e
# Candidate model chain: active model first, then remaining fallback models
models_to_try = [self.active_model] + [
m for m in self.fallback_models if m != self.active_model
]

last_exception: Optional[Exception] = None

for model in models_to_try:
for attempt in range(retry_times):
try:
response = completion(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
extra_headers={
"X-Title": "MarkPDFdown",
"HTTP-Referer": "https://github.com/MarkPDFdown/markpdfdown.git",
},
)

if not response.choices:
raise Exception(f"No response from API for model {model}")

# Switch active model to this working model for future calls
if self.active_model != model:
logger.info(
f"Switched active model from {self.active_model} to fallback model: {model}"
)
self.active_model = model

return response.choices[0].message.content

except Exception as e:
last_exception = e
logger.warning(
f"API request failed on model '{model}' (attempt {attempt + 1}/{retry_times}): {e}"
)
if attempt < retry_times - 1:
time.sleep(0.5 * (attempt + 1))

logger.warning(f"All {retry_times} retries failed for model '{model}'.")
if model != models_to_try[-1]:
logger.info(f"Falling back from '{model}' to next candidate model...")

if last_exception:
raise last_exception

return ""

Expand Down
14 changes: 14 additions & 0 deletions src/markpdfdown/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Utility functions for MarkPDFDown
"""

import hashlib
import re
from typing import Optional

Expand Down Expand Up @@ -70,6 +71,19 @@ def detect_file_type(file_data: bytes) -> Optional[str]:
return None


def compute_file_hash(data: bytes) -> str:
"""
Compute SHA-256 hash of binary data.

Args:
data: Binary content

Returns:
Hexadecimal hash string
"""
return hashlib.sha256(data).hexdigest()


def validate_page_range(
start_page: int, end_page: int, total_pages: int
) -> tuple[int, int]:
Expand Down
Loading