From 770096f4a5f9b8a677811f09fb95db4f0b236ff7 Mon Sep 17 00:00:00 2001 From: Ahmed Cool Projects <72823374+AhmedCoolProjects@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:31:27 +0200 Subject: [PATCH 1/4] feat(llm): add fallback model chain support to LLMClient --- src/markpdfdown/core/llm_client.py | 95 +++++++++++++++++++----------- tests/test_llm_client.py | 47 +++++++++++++++ 2 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/markpdfdown/core/llm_client.py b/src/markpdfdown/core/llm_client.py index 1858147..aba1424 100644 --- a/src/markpdfdown/core/llm_client.py +++ b/src/markpdfdown/core/llm_client.py @@ -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 @@ -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 "" diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py index fee68f7..251c052 100644 --- a/tests/test_llm_client.py +++ b/tests/test_llm_client.py @@ -142,6 +142,53 @@ def test_completion_no_choices_raises(self): with pytest.raises(Exception, match="No response from API"): client.completion("Hello", retry_times=1) + def test_completion_fallback_to_second_model(self): + """Test completion switches to fallback model when primary model fails""" + with patch("markpdfdown.core.llm_client.completion") as mock_completion: + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Fallback Success" + + # First model fails on all retries, second model succeeds on first attempt + mock_completion.side_effect = [ + Exception("Rate limit 429"), + Exception("Rate limit 429"), + mock_response, + ] + + client = LLMClient( + model_name="gpt-4o", + fallback_models=["openrouter/anthropic/claude-3.5-sonnet"], + ) + with patch("markpdfdown.core.llm_client.time.sleep"): + result = client.completion("Hello", retry_times=2) + + assert result == "Fallback Success" + assert mock_completion.call_count == 3 + # Verify that the active model was updated to the working fallback model + assert client.active_model == "openrouter/anthropic/claude-3.5-sonnet" + # Last call was made with fallback model + assert ( + mock_completion.call_args_list[-1].kwargs["model"] + == "openrouter/anthropic/claude-3.5-sonnet" + ) + + def test_completion_all_fallback_models_fail_raises(self): + """Test completion raises exception if primary and all fallbacks fail""" + with patch("markpdfdown.core.llm_client.completion") as mock_completion: + mock_completion.side_effect = Exception("Quota exceeded") + + client = LLMClient( + model_name="gpt-4o", + fallback_models=["claude-3-5-sonnet", "gemini-2.0-flash"], + ) + with patch("markpdfdown.core.llm_client.time.sleep"): + with pytest.raises(Exception, match="Quota exceeded"): + client.completion("Hello", retry_times=1) + + # 3 models x 1 retry each = 3 calls + assert mock_completion.call_count == 3 + class TestLLMClientEncodeImage: """Tests for LLMClient._encode_image method""" From 018d839ee5b3e9acc7aa3daf3f773ae5ec45e366 Mon Sep 17 00:00:00 2001 From: Ahmed Cool Projects <72823374+AhmedCoolProjects@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:36:21 +0200 Subject: [PATCH 2/4] feat(core): add page-level checkpointing and resume cache to main conversion --- src/markpdfdown/config.py | 31 ++++++++++- src/markpdfdown/core/utils.py | 14 +++++ src/markpdfdown/main.py | 96 +++++++++++++++++++++++++++++------ tests/test_main.py | 51 +++++++++++++++++++ tests/test_utils.py | 18 +++++++ 5 files changed, 192 insertions(+), 18 deletions(-) diff --git a/src/markpdfdown/config.py b/src/markpdfdown/config.py index 6381ed0..6d4184a 100644 --- a/src/markpdfdown/config.py +++ b/src/markpdfdown/config.py @@ -3,6 +3,7 @@ """ import os +from typing import Optional from dotenv import load_dotenv from pydantic import BaseModel, Field @@ -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" ) @@ -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() diff --git a/src/markpdfdown/core/utils.py b/src/markpdfdown/core/utils.py index ad3bc86..0916dc5 100644 --- a/src/markpdfdown/core/utils.py +++ b/src/markpdfdown/core/utils.py @@ -2,6 +2,7 @@ Utility functions for MarkPDFDown """ +import hashlib import re from typing import Optional @@ -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]: diff --git a/src/markpdfdown/main.py b/src/markpdfdown/main.py index 3f2d0bd..60d53b7 100644 --- a/src/markpdfdown/main.py +++ b/src/markpdfdown/main.py @@ -12,7 +12,7 @@ from .config import config from .core.file_worker import create_worker from .core.llm_client import LLMClient -from .core.utils import detect_file_type, remove_markdown_wrap +from .core.utils import compute_file_hash, detect_file_type, remove_markdown_wrap logger = logging.getLogger(__name__) @@ -70,9 +70,13 @@ def convert_to_markdown( input_filename: Optional[str] = None, output_dir: Optional[str] = None, cleanup: bool = True, + model_name: Optional[str] = None, + fallback_models: Optional[list[str]] = None, + cache_dir: Optional[str] = None, + resume: bool = True, ) -> str: """ - Convert PDF or image data to Markdown format + Convert PDF or image data to Markdown format. Args: input_data: Binary file data @@ -81,6 +85,10 @@ def convert_to_markdown( input_filename: Original filename (for type detection) output_dir: Output directory (if None, creates temporary directory) cleanup: Whether to clean up temporary files + model_name: Primary LLM model name (defaults to config.model_name) + fallback_models: Optional list of fallback models + cache_dir: Directory to store/reuse converted pages (defaults to .cache/markpdfdown) + resume: Whether to reuse previously completed pages from cache Returns: Converted Markdown content @@ -131,19 +139,47 @@ def convert_to_markdown( logger.info(f"Generated {len(img_paths)} images") - # Initialize LLM client - llm_client = LLMClient(config.model_name) + # Initialize LLM client with primary and fallback models + primary_model = model_name or config.model_name + effective_fallbacks = ( + fallback_models if fallback_models is not None else config.fallback_models + ) + llm_client = LLMClient( + model_name=primary_model, fallback_models=effective_fallbacks + ) - # Convert images to markdown + # Set up cache directory if resume is enabled and cache_dir is provided + doc_cache_dir = None + effective_cache_dir = cache_dir if cache_dir is not None else config.cache_dir + if resume and effective_cache_dir: + file_hash = compute_file_hash(input_data) + doc_cache_dir = os.path.join(effective_cache_dir, file_hash) + os.makedirs(doc_cache_dir, exist_ok=True) markdown_parts = [] for img_path in sorted(img_paths): - logger.info(f"Converting image: {os.path.basename(img_path)}") - content = convert_image_to_markdown(img_path, llm_client) + img_basename = os.path.basename(img_path) + cached_page_md = ( + os.path.join(doc_cache_dir, f"{img_basename}.md") + if doc_cache_dir + else None + ) + + # Check if page was already completed in cache + if cached_page_md and os.path.exists(cached_page_md): + logger.info(f"Reusing cached page: {img_basename}") + with open(cached_page_md, encoding="utf-8") as f: + content = f.read() + else: + logger.info(f"Converting image: {img_basename}") + content = convert_image_to_markdown(img_path, llm_client) + if content and cached_page_md: + # Persist page markdown in cache + with open(cached_page_md, "w", encoding="utf-8") as f: + f.write(content) + if content: - # Save individual page markdown (optional) - page_md_path = os.path.join( - output_dir, f"{os.path.basename(img_path)}.md" - ) + # Save individual page markdown in current output_dir + page_md_path = os.path.join(output_dir, f"{img_basename}.md") with open(page_md_path, "w", encoding="utf-8") as f: f.write(content) @@ -169,9 +205,14 @@ def convert_to_markdown( logger.warning(f"Failed to cleanup directory {output_dir}: {e}") -def convert_from_stdin() -> str: +def convert_from_stdin( + model_name: Optional[str] = None, + fallback_models: Optional[list[str]] = None, + cache_dir: Optional[str] = None, + resume: bool = True, +) -> str: """ - Convert file data from stdin to Markdown + Convert file data from stdin to Markdown. Returns: Converted Markdown content @@ -186,17 +227,36 @@ def convert_from_stdin() -> str: if input_filename == "": input_filename = None - return convert_to_markdown(input_data, input_filename=input_filename) + return convert_to_markdown( + input_data, + input_filename=input_filename, + model_name=model_name, + fallback_models=fallback_models, + cache_dir=cache_dir, + resume=resume, + ) -def convert_from_file(input_path: str, start_page: int = 1, end_page: int = 0) -> str: +def convert_from_file( + input_path: str, + start_page: int = 1, + end_page: int = 0, + model_name: Optional[str] = None, + fallback_models: Optional[list[str]] = None, + cache_dir: Optional[str] = None, + resume: bool = True, +) -> str: """ - Convert file to Markdown + Convert file to Markdown. Args: input_path: Path to input file start_page: Starting page number end_page: Ending page number + model_name: Primary LLM model name + fallback_models: Optional list of fallback models + cache_dir: Directory to store/reuse converted pages + resume: Whether to reuse previously completed pages from cache Returns: Converted Markdown content @@ -214,4 +274,8 @@ def convert_from_file(input_path: str, start_page: int = 1, end_page: int = 0) - end_page=end_page, input_filename=os.path.basename(input_path), cleanup=True, + model_name=model_name, + fallback_models=fallback_models, + cache_dir=cache_dir, + resume=resume, ) diff --git a/tests/test_main.py b/tests/test_main.py index 8782048..f21be0d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -226,6 +226,57 @@ def test_cleanup_handles_exception( ) assert "# Content" in result + @patch("markpdfdown.main.LLMClient") + @patch("markpdfdown.main.create_worker") + def test_convert_resume_skips_cached_pages( + self, mock_create_worker, mock_llm_class, tmp_path + ): + """Test that already completed pages are loaded from cache without calling LLM""" + cache_dir = tmp_path / "cache" + page1 = tmp_path / "page_01.png" + page2 = tmp_path / "page_02.png" + page1.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + page2.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + mock_worker = MagicMock() + mock_worker.convert_to_images.return_value = [str(page1), str(page2)] + mock_create_worker.return_value = mock_worker + + mock_llm = MagicMock() + # Only page2 should need to be converted + mock_llm.completion.return_value = "# Page 2 Content" + mock_llm_class.return_value = mock_llm + + input_data = b"%PDF-1.4 mock pdf content" + from markpdfdown.core.utils import compute_file_hash + + file_hash = compute_file_hash(input_data) + doc_cache = cache_dir / file_hash + doc_cache.mkdir(parents=True) + # Pre-seed page_01.png.md in cache + (doc_cache / "page_01.png.md").write_text( + "# Page 1 Cached Content", encoding="utf-8" + ) + + result = convert_to_markdown( + input_data, + input_filename="test.pdf", + cache_dir=str(cache_dir), + resume=True, + cleanup=False, + ) + + # Page 1 was read from cache, Page 2 was completed by LLM + assert "# Page 1 Cached Content" in result + assert "# Page 2 Content" in result + # LLM completion should only be called once (for page 2) + assert mock_llm.completion.call_count == 1 + # Page 2 should now also be cached + assert (doc_cache / "page_02.png.md").exists() + assert (doc_cache / "page_02.png.md").read_text( + encoding="utf-8" + ) == "# Page 2 Content" + class TestConvertFromFile: """Tests for convert_from_file function""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 4d2d4cf..f7a97b6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,9 +2,12 @@ Tests for markpdfdown.core.utils module """ +import hashlib + import pytest from markpdfdown.core.utils import ( + compute_file_hash, detect_file_type, remove_markdown_wrap, validate_page_range, @@ -157,3 +160,18 @@ def test_last_page_only(self): start, end = validate_page_range(10, 10, 10) assert start == 10 assert end == 10 + + +class TestComputeFileHash: + """Tests for compute_file_hash function""" + + def test_compute_file_hash_deterministic(self): + """Test hash computation is deterministic and correct""" + data = b"Hello, MarkPDFDown!" + expected = hashlib.sha256(data).hexdigest() + assert compute_file_hash(data) == expected + + def test_compute_file_hash_empty_data(self): + """Test hash computation on empty data""" + expected = hashlib.sha256(b"").hexdigest() + assert compute_file_hash(b"") == expected From 1b35ab91271fbce1b5cff413c4f292a0c39ac6cb Mon Sep 17 00:00:00 2001 From: Ahmed Cool Projects <72823374+AhmedCoolProjects@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:38:56 +0200 Subject: [PATCH 3/4] feat(cli): add model fallback and checkpoint resume CLI arguments --- .env.sample | 8 +++- .github/workflows/changelog.yml | 2 +- src/markpdfdown/cli.py | 60 +++++++++++++++++++++++-- tests/test_cli.py | 80 ++++++++++++++++++++++++++++++++- tests/test_config.py | 17 +++++++ 5 files changed, 160 insertions(+), 7 deletions(-) diff --git a/.env.sample b/.env.sample index 8c83710..4ff4b93 100644 --- a/.env.sample +++ b/.env.sample @@ -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) @@ -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/ \ No newline at end of file +# OPENAI_API_BASE=https://ark.cn-beijing.volces.com/api/v3/ diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index ad98087..f216272 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -41,4 +41,4 @@ jobs: with: branch: master commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]' - file_pattern: CHANGELOG.md \ No newline at end of file + file_pattern: CHANGELOG.md diff --git a/src/markpdfdown/cli.py b/src/markpdfdown/cli.py index 0b4535e..c2d2bc8 100644 --- a/src/markpdfdown/cli.py +++ b/src/markpdfdown/cli.py @@ -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 @@ -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 @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index d61ea23..d446805 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -101,6 +101,33 @@ def test_no_arguments(self): assert args.start == 1 assert args.end == 0 + def test_model_and_fallback_arguments(self): + """Test parsing --model and --fallback-models arguments""" + parser = create_parser() + args = parser.parse_args( + [ + "-m", + "gpt-4o", + "--fallback-models", + "claude-3-5-sonnet,gemini-2.0-flash", + ] + ) + assert args.model == "gpt-4o" + assert args.fallback_models == "claude-3-5-sonnet,gemini-2.0-flash" + + def test_cache_and_resume_arguments(self): + """Test parsing --cache-dir and --no-resume arguments""" + parser = create_parser() + args = parser.parse_args( + [ + "--cache-dir", + "/tmp/my_cache", + "--no-resume", + ] + ) + assert args.cache_dir == "/tmp/my_cache" + assert args.no_resume is True + class TestValidateArgs: """Tests for validate_args function""" @@ -245,7 +272,13 @@ def test_file_mode_with_page_range(self, mock_convert, tmp_path): main() mock_convert.assert_called_once_with( - input_path=str(input_file), start_page=2, end_page=5 + input_path=str(input_file), + start_page=2, + end_page=5, + model_name=None, + fallback_models=None, + cache_dir=None, + resume=True, ) @patch("markpdfdown.cli.convert_from_stdin") @@ -256,9 +289,54 @@ def test_pipe_mode_success(self, mock_convert, capsys): with patch.object(sys, "argv", ["markpdfdown"]): main() + mock_convert.assert_called_once_with( + model_name=None, + fallback_models=None, + cache_dir=None, + resume=True, + ) captured = capsys.readouterr() assert "# Pipe Content" in captured.out + @patch("markpdfdown.cli.convert_from_file") + def test_file_mode_with_model_and_cache(self, mock_convert, tmp_path): + """Test file mode passes model, fallback models, and cache arguments""" + input_file = tmp_path / "input.pdf" + output_file = tmp_path / "output.md" + input_file.write_bytes(b"%PDF-1.4") + + mock_convert.return_value = "# Model Content" + + with patch.object( + sys, + "argv", + [ + "markpdfdown", + "-i", + str(input_file), + "-o", + str(output_file), + "-m", + "gemini-2.0-flash", + "--fallback-models", + "gpt-4o-mini,claude-3-5-sonnet", + "--cache-dir", + "/tmp/cache", + "--no-resume", + ], + ): + main() + + mock_convert.assert_called_once_with( + input_path=str(input_file), + start_page=1, + end_page=0, + model_name="gemini-2.0-flash", + fallback_models=["gpt-4o-mini", "claude-3-5-sonnet"], + cache_dir="/tmp/cache", + resume=False, + ) + @patch("markpdfdown.cli.convert_from_file") def test_conversion_exception_exits(self, mock_convert, tmp_path): """Test conversion exception causes exit""" diff --git a/tests/test_config.py b/tests/test_config.py index 2c84052..988bae4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,9 @@ def test_default_values(self): """Test default configuration values""" config = Config() assert config.model_name == "gpt-4o" + assert config.fallback_models == [] + assert config.cache_dir is None + assert config.resume is True assert config.temperature == 0.3 assert config.max_tokens == 8192 assert config.retry_times == 3 @@ -117,3 +120,17 @@ def test_from_env_openrouter_model(self, monkeypatch): config = Config.from_env() assert config.model_name == "openrouter/anthropic/claude-3.5-sonnet" + + def test_from_env_fallback_models_and_cache(self, monkeypatch): + """Test from_env with FALLBACK_MODELS, CACHE_DIR, and RESUME""" + monkeypatch.setenv("FALLBACK_MODELS", "claude-3-5-sonnet, gemini-2.0-flash ") + monkeypatch.setenv("CACHE_DIR", "/custom/cache") + monkeypatch.setenv("RESUME", "false") + + config = Config.from_env() + assert config.fallback_models == [ + "claude-3-5-sonnet", + "gemini-2.0-flash", + ] + assert config.cache_dir == "/custom/cache" + assert config.resume is False From 607b94b3f4be27d0dc4d454078af52cd43c94e5f Mon Sep 17 00:00:00 2001 From: Ahmed Cool Projects <72823374+AhmedCoolProjects@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:39:38 +0200 Subject: [PATCH 4/4] docs: update README with model fallback and resume cache instructions --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d551d04..641f9da 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -154,7 +166,6 @@ for file in *.pdf; do markpdfdown --input "$file" --output "${file%.pdf}.md" done ``` - ## Docker Usage ```bash