Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Stage 1 — Decouple:
- Move RunConfig + LLMResponse to markitect/llm/models.py (canonical)
- Move LLMAdapter + Mock/ErrorLLMAdapter to markitect/llm/adapter.py
- markitect/prompts/execution/models.py and llm_adapter.py become re-export shims
- All 4 adapters + factory.py updated to import from markitect.llm.*
- Parameterize app_name in toml_config.py (resolve_llm, get_default_layers,
get_preference_layers): paths and env var now derived from app_name arg
- Add tests/test_llm_isolation.py: 7 isolation + backward-compat tests
Stage 2 — Extract:
- Standalone llm-connect package created at ~/llm-connect/
- All 18 llm files copied; markitect.* imports replaced with llm_connect.*
- LLMError base inlined in llm_connect/exceptions.py (no markitect dep)
- llm-connect installed into markitect-venv; declared in pyproject.toml
Smoke test: markitect llm-check succeeds (live Gemini API call).
Backward compat: markitect.prompts.execution.{models,llm_adapter} still work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
8.4 KiB
Python
272 lines
8.4 KiB
Python
"""
|
|
TOML-based LLM configuration: defaults, preferences, and resolution.
|
|
|
|
Config files:
|
|
- Directory: ``<dir-with-pyproject.toml>/.markitect.toml``
|
|
- User: ``~/.config/markitect/config.toml``
|
|
|
|
Resolution order (highest → lowest):
|
|
1. CLI flags (``--provider``, ``--model``)
|
|
2. ``MARKITECT_HELPER_MODEL`` env var (model only)
|
|
3. User preference (``[llm.preference]`` in user config)
|
|
4. Directory preference (``[llm.preference]`` in directory config)
|
|
5. Directory default (``[llm.default]`` in directory config)
|
|
6. User default (``[llm.default]`` in user config)
|
|
7. Hardcoded fallback
|
|
"""
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import toml
|
|
|
|
from markitect.llm.config import find_project_root
|
|
|
|
# ── Constants ─────────────────────────────────────────────────────────────
|
|
|
|
HARDCODED_PROVIDER = "gemini"
|
|
HARDCODED_MODEL = "gemini-2.5-flash"
|
|
|
|
# Default (markitect) values kept for backward compatibility.
|
|
MODEL_ENV_VAR = "MARKITECT_HELPER_MODEL"
|
|
USER_CONFIG_DIR = Path.home() / ".config" / "markitect"
|
|
USER_CONFIG_PATH = USER_CONFIG_DIR / "config.toml"
|
|
DIR_CONFIG_NAME = ".markitect.toml"
|
|
|
|
|
|
# ── App-name helpers ───────────────────────────────────────────────────────
|
|
|
|
def _model_env_var(app_name: str) -> str:
|
|
return f"{app_name.upper()}_HELPER_MODEL"
|
|
|
|
|
|
def _user_config_path(app_name: str) -> Path:
|
|
return Path.home() / ".config" / app_name / "config.toml"
|
|
|
|
|
|
def _dir_config_name(app_name: str) -> str:
|
|
return f".{app_name}.toml"
|
|
|
|
|
|
# ── Data classes ──────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class LLMLayer:
|
|
"""One layer of provider/model configuration (may be partial)."""
|
|
provider: Optional[str] = None
|
|
model: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ResolvedLLM:
|
|
"""Fully-resolved provider + model with source attribution."""
|
|
provider: str
|
|
model: str
|
|
provider_source: str
|
|
model_source: str
|
|
|
|
|
|
# ── Read / Write / Clear ─────────────────────────────────────────────────
|
|
|
|
def _read_llm_section(path: Path, section: str) -> LLMLayer:
|
|
"""Read ``[llm.<section>]`` from a TOML file. Returns empty layer on error."""
|
|
try:
|
|
data = toml.load(path)
|
|
except (OSError, toml.TomlDecodeError):
|
|
return LLMLayer()
|
|
llm = data.get("llm", {})
|
|
sec = llm.get(section, {})
|
|
return LLMLayer(
|
|
provider=sec.get("provider"),
|
|
model=sec.get("model"),
|
|
)
|
|
|
|
|
|
def _write_llm_section(path: Path, section: str, layer: LLMLayer) -> None:
|
|
"""Merge ``[llm.<section>]`` into a TOML file. Creates dirs as needed."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
data = toml.load(path)
|
|
except (OSError, toml.TomlDecodeError):
|
|
data = {}
|
|
|
|
llm = data.setdefault("llm", {})
|
|
sec = llm.setdefault(section, {})
|
|
|
|
if layer.provider is not None:
|
|
sec["provider"] = layer.provider
|
|
if layer.model is not None:
|
|
sec["model"] = layer.model
|
|
|
|
with open(path, "w") as f:
|
|
toml.dump(data, f)
|
|
|
|
|
|
def _clear_llm_section(path: Path, section: str) -> bool:
|
|
"""Remove ``[llm.<section>]``. Returns True if something was cleared."""
|
|
try:
|
|
data = toml.load(path)
|
|
except (OSError, toml.TomlDecodeError):
|
|
return False
|
|
|
|
llm = data.get("llm")
|
|
if not isinstance(llm, dict) or section not in llm:
|
|
return False
|
|
|
|
del llm[section]
|
|
|
|
# Clean up empty [llm] table.
|
|
if not llm:
|
|
del data["llm"]
|
|
|
|
with open(path, "w") as f:
|
|
toml.dump(data, f)
|
|
return True
|
|
|
|
|
|
# ── Directory config path helper ─────────────────────────────────────────
|
|
|
|
def _dir_config_path(app_name: str = "markitect") -> Optional[Path]:
|
|
root = find_project_root()
|
|
if root is None:
|
|
return None
|
|
return root / _dir_config_name(app_name)
|
|
|
|
|
|
# ── Resolution ───────────────────────────────────────────────────────────
|
|
|
|
def resolve_llm(
|
|
cli_provider: Optional[str] = None,
|
|
cli_model: Optional[str] = None,
|
|
app_name: str = "markitect",
|
|
) -> ResolvedLLM:
|
|
"""Walk the 7-level priority chain and return a fully resolved config.
|
|
|
|
Provider and model are resolved independently — each takes the value
|
|
from its highest-priority source.
|
|
|
|
Args:
|
|
cli_provider: Provider override from CLI.
|
|
cli_model: Model override from CLI.
|
|
app_name: Application name used to derive config paths and the
|
|
env-var prefix (e.g. ``"railiance"`` → ``RAILIANCE_HELPER_MODEL``
|
|
and ``~/.config/railiance/config.toml``).
|
|
"""
|
|
dir_path = _dir_config_path(app_name)
|
|
user_cfg = _user_config_path(app_name)
|
|
env_var = _model_env_var(app_name)
|
|
|
|
# Build the layers (highest priority first).
|
|
layers: list[tuple[str, LLMLayer]] = []
|
|
|
|
# 1. CLI flags
|
|
layers.append(("CLI flag", LLMLayer(provider=cli_provider, model=cli_model)))
|
|
|
|
# 2. Env var (model only)
|
|
env_model = os.environ.get(env_var) or None
|
|
layers.append((f"env {env_var}", LLMLayer(model=env_model)))
|
|
|
|
# 3. User preference
|
|
layers.append((
|
|
"user preference",
|
|
_read_llm_section(user_cfg, "preference"),
|
|
))
|
|
|
|
# 4. Directory preference
|
|
if dir_path:
|
|
layers.append((
|
|
"directory preference",
|
|
_read_llm_section(dir_path, "preference"),
|
|
))
|
|
|
|
# 5. Directory default
|
|
if dir_path:
|
|
layers.append((
|
|
"directory default",
|
|
_read_llm_section(dir_path, "default"),
|
|
))
|
|
|
|
# 6. User default
|
|
layers.append((
|
|
"user default",
|
|
_read_llm_section(user_cfg, "default"),
|
|
))
|
|
|
|
# 7. Hardcoded
|
|
layers.append(("hardcoded", LLMLayer(provider=HARDCODED_PROVIDER, model=HARDCODED_MODEL)))
|
|
|
|
# Resolve provider and model independently (first non-None wins).
|
|
provider = HARDCODED_PROVIDER
|
|
provider_source = "hardcoded"
|
|
model = HARDCODED_MODEL
|
|
model_source = "hardcoded"
|
|
|
|
for source, layer in layers:
|
|
if layer.provider:
|
|
provider = layer.provider
|
|
provider_source = source
|
|
break
|
|
|
|
for source, layer in layers:
|
|
if layer.model:
|
|
model = layer.model
|
|
model_source = source
|
|
break
|
|
|
|
return ResolvedLLM(
|
|
provider=provider,
|
|
model=model,
|
|
provider_source=provider_source,
|
|
model_source=model_source,
|
|
)
|
|
|
|
|
|
def get_default_layers(app_name: str = "markitect") -> list[tuple[str, LLMLayer]]:
|
|
"""Return only the default layers for display."""
|
|
dir_path = _dir_config_path(app_name)
|
|
user_cfg = _user_config_path(app_name)
|
|
dir_cfg_name = _dir_config_name(app_name)
|
|
layers: list[tuple[str, LLMLayer]] = []
|
|
|
|
if dir_path:
|
|
layers.append((
|
|
f"Directory default ({dir_cfg_name})",
|
|
_read_llm_section(dir_path, "default"),
|
|
))
|
|
|
|
layers.append((
|
|
f"User default ({user_cfg})",
|
|
_read_llm_section(user_cfg, "default"),
|
|
))
|
|
|
|
layers.append((
|
|
"Hardcoded",
|
|
LLMLayer(provider=HARDCODED_PROVIDER, model=HARDCODED_MODEL),
|
|
))
|
|
|
|
return layers
|
|
|
|
|
|
def get_preference_layers(app_name: str = "markitect") -> list[tuple[str, LLMLayer]]:
|
|
"""Return only the preference layers for display."""
|
|
dir_path = _dir_config_path(app_name)
|
|
user_cfg = _user_config_path(app_name)
|
|
dir_cfg_name = _dir_config_name(app_name)
|
|
layers: list[tuple[str, LLMLayer]] = []
|
|
|
|
layers.append((
|
|
f"User preference ({user_cfg})",
|
|
_read_llm_section(user_cfg, "preference"),
|
|
))
|
|
|
|
if dir_path:
|
|
layers.append((
|
|
f"Directory preference ({dir_cfg_name})",
|
|
_read_llm_section(dir_path, "preference"),
|
|
))
|
|
|
|
return layers
|