Implements markitect/llm/ package with concrete LLMAdapter implementations:
- OpenRouterAdapter: HTTP via urllib with retry/backoff on 429/5xx
- ClaudeCodeAdapter: subprocess-based Claude CLI with stdin piping
- Factory pattern: create_adapter("openrouter") or create_adapter("claude-code")
- API key resolution chain: constructor > env var > project-root key file
- 42 unit tests, 2 integration tests (gated on API key / CLI availability)
Also adds the infospace-with-history example with Wealth of Nations VSM
analysis pipeline, templates, schemas, source chapters, and processed
output for chapters 1-2. process_chapters.py now supports --provider
and --model flags for automatic LLM-driven processing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
106 lines
3.2 KiB
Python
106 lines
3.2 KiB
Python
"""Tests for markitect.llm.config."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from markitect.llm.config import (
|
|
LLMConfig,
|
|
resolve_api_key,
|
|
find_project_root,
|
|
load_config,
|
|
)
|
|
|
|
|
|
class TestResolveApiKey:
|
|
def test_explicit_key_wins(self):
|
|
key = resolve_api_key(explicit="sk-explicit")
|
|
assert key == "sk-explicit"
|
|
|
|
def test_env_var_fallback(self):
|
|
with mock.patch.dict(os.environ, {"MY_KEY": "sk-env"}):
|
|
key = resolve_api_key(explicit=None, env_var="MY_KEY")
|
|
assert key == "sk-env"
|
|
|
|
def test_env_var_stripped(self):
|
|
with mock.patch.dict(os.environ, {"MY_KEY": " sk-env \n"}):
|
|
key = resolve_api_key(explicit=None, env_var="MY_KEY")
|
|
assert key == "sk-env"
|
|
|
|
def test_file_fallback(self, tmp_path):
|
|
key_file = tmp_path / "key.txt"
|
|
key_file.write_text("sk-file\n")
|
|
key = resolve_api_key(
|
|
explicit=None,
|
|
env_var="NONEXISTENT_VAR_XYZ",
|
|
key_file_paths=[key_file],
|
|
)
|
|
assert key == "sk-file"
|
|
|
|
def test_file_skips_empty(self, tmp_path):
|
|
empty = tmp_path / "empty.txt"
|
|
empty.write_text("")
|
|
good = tmp_path / "good.txt"
|
|
good.write_text("sk-good")
|
|
key = resolve_api_key(
|
|
explicit=None,
|
|
env_var="NONEXISTENT_VAR_XYZ",
|
|
key_file_paths=[empty, good],
|
|
)
|
|
assert key == "sk-good"
|
|
|
|
def test_file_skips_missing(self, tmp_path):
|
|
missing = tmp_path / "no-such-file.txt"
|
|
key = resolve_api_key(
|
|
explicit=None,
|
|
env_var="NONEXISTENT_VAR_XYZ",
|
|
key_file_paths=[missing],
|
|
)
|
|
assert key is None
|
|
|
|
def test_returns_none_when_nothing_found(self):
|
|
key = resolve_api_key(
|
|
explicit=None,
|
|
env_var="NONEXISTENT_VAR_XYZ",
|
|
key_file_paths=[],
|
|
)
|
|
assert key is None
|
|
|
|
|
|
class TestFindProjectRoot:
|
|
def test_finds_root(self, tmp_path):
|
|
(tmp_path / "pyproject.toml").write_text("[project]")
|
|
sub = tmp_path / "a" / "b"
|
|
sub.mkdir(parents=True)
|
|
assert find_project_root(sub) == tmp_path
|
|
|
|
def test_returns_none_if_no_marker(self, tmp_path):
|
|
sub = tmp_path / "a" / "b"
|
|
sub.mkdir(parents=True)
|
|
# tmp_path itself won't have pyproject.toml
|
|
result = find_project_root(sub)
|
|
# Could be None or could find the real project root above;
|
|
# the important thing is it doesn't crash
|
|
assert result is None or (result / "pyproject.toml").exists()
|
|
|
|
|
|
class TestLoadConfig:
|
|
def test_returns_llmconfig(self):
|
|
cfg = load_config(provider="claude-code")
|
|
assert isinstance(cfg, LLMConfig)
|
|
assert cfg.provider == "claude-code"
|
|
|
|
def test_model_override(self):
|
|
cfg = load_config(model="my-model")
|
|
assert cfg.model == "my-model"
|
|
|
|
def test_explicit_key(self):
|
|
cfg = load_config(api_key="sk-test")
|
|
assert cfg.api_key == "sk-test"
|
|
|
|
def test_extra_overrides(self):
|
|
cfg = load_config(timeout_seconds=60)
|
|
assert cfg.timeout_seconds == 60
|