fix(runners): use resolve_model_id() for model resolution instead of hardcoded fallbacks (ACS-294) (#1170)

* fix(runners): use resolve_model_id() for model resolution instead of hardcoded fallbacks (ACS-294)

This fix ensures that custom model configurations via environment variables
(ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, etc.) are
properly respected in PR reviewer services, instead of falling back to
hardcoded Claude model IDs.

Changes:
- parallel_orchestrator_reviewer.py: Import and use resolve_model_id() from
  phase_config, with fallback to "sonnet" shorthand instead of hardcoded model
- parallel_followup_reviewer.py: Same pattern as above
- models.py: Update GitHubRunnerConfig default model from hardcoded
  "claude-sonnet-4-20250514" to "sonnet" shorthand (respects env overrides)
- batch_validator.py: Change DEFAULT_MODEL to "sonnet" shorthand and add
  _resolve_model() method to resolve via phase_config.resolve_model_id()
- batch_issues.py: Change validation_model default to "sonnet" shorthand
- Add comprehensive tests in test_model_resolution.py to verify model
  resolution behavior

This resolves the issue where logs would display hardcoded model IDs
(e.g., "claude-opus-4-5-20251101") instead of the actual configured model
(e.g., "glm-4.7"), which caused confusion about which model was being used.

Refs: ACS-294

* test: extract environment cleanup into pytest fixture to address PR review feedback

- Add clean_env pytest fixture to handle environment variable cleanup
- Remove duplicated environment backup/restore logic from 3 tests
- Fix import: use collections.abc.Generator instead of typing.Generator

This addresses review feedback from PR #1170:
- CodeRabbit: Extract duplicated environment cleanup into fixture
- ruff: Import Generator from collections.abc

The pytest fixture approach is the Pythonic way to handle setup/teardown
and reduces code duplication while maintaining test isolation.

* test: address CodeRabbit PR review feedback

- Fix absolute import issue in batch_validator.py: Use importlib.util.find_spec
  instead of "from phase_config import resolve_model_id" for more robust
  imports that don't rely on sys.path
- Improve test quality: Add behavioral tests for resolve_model_id() function
  (11 tests with full coverage of environment variable overrides)
- Add documentation explaining why source inspection is used for some tests
  (to avoid complex import dependencies while still verifying critical patterns)
- Add test for importlib.util pattern in batch_validator.py
- Add negative assertions to verify old hardcoded fallbacks are not present

Addresses CodeRabbit feedback from PR #1170:
- Comment #5: Absolute import style (fixed with importlib.util)
- Comments #7-11: Brittle source code tests (improved with behavioral tests
  and documentation explaining why source inspection is used where necessary)

All 21 tests pass, ruff checks pass.

Refs: ACS-294

* fix: add explanatory comment for empty except and broaden exception handling

- Add detailed comment explaining why the empty 'pass' is necessary
  (ensures BatchValidator remains functional even if phase_config has errors)
- Change from except (ImportError, AttributeError) to except Exception
  to catch broader exceptions for robustness
- Addresses GitHub Advanced Security alert about empty except clause
- Addresses CodeRabbit feedback about broader exception handling

All 21 tests pass, ruff checks pass.

Refs: ACS-294

* fix: add resolve_model_id to fallback imports in parallel reviewers

- Add resolve_model_id to fallback import in parallel_followup_reviewer.py:64
- Add resolve_model_id to fallback import in parallel_orchestrator_reviewer.py:60
- Fix hardcoded fallback in followup_reviewer.py:688 - use shorthand + resolve_model_id

This addresses 3 HIGH priority findings from Auto Claude PR Review:
- [e4d8064b75ce] Missing resolve_model_id import in fallback block (parallel_followup_reviewer.py)
- [f4beb99bb5d1] Missing resolve_model_id import in fallback block (parallel_orchestrator_reviewer.py)
- [c059fa0540e0] Missed hardcoded model fallback (followup_reviewer.py)

All 21 tests pass, ruff checks pass.

Refs: ACS-294

* fix(batch_issues): resolve model shorthand via resolve_model_id() in ClaudeBatchAnalyzer

This fixes the last hardcoded model fallback in ACS-294. The
ClaudeBatchAnalyzer.analyze_and_batch_issues() method was using a
hardcoded 'claude-sonnet-4-5-20250929' model ID instead of resolving
the 'sonnet' shorthand via resolve_model_id(), which prevented
environment variable overrides from being respected.

Changes:
- Import resolve_model_id from phase_config
- Replace hardcoded model with resolve_model_id('sonnet')
- Add tests to verify the fix

This completes the fix for all hardcoded model fallbacks in the
GitHub runner services.

* test: add UTF-8 encoding to read_text() calls for Windows compatibility

On Windows, Path.read_text() defaults to the system encoding (cp1252)
which can cause UnicodeDecodeError for files with UTF-8 content. This
fixes the CI test failure by explicitly specifying encoding='utf-8' for
all read_text() calls in the test file.

Fixes test failure:
- test_parallel_reviewers_use_sonnet_fallback

* test: document UTF-8 encoding requirement for Windows compatibility

Adds explanatory comment for encoding parameter in source inspection tests.
This clarifies why explicit UTF-8 is needed (platform-dependent defaults).

* fix: address PR review findings - import pattern consistency and test improvements

MEDIUM: Replace importlib.util pattern with established try/except import pattern
- batch_validator.py now uses relative imports with absolute fallback
- Matches the convention used across runners/github/services/
- Ensures proper module caching in sys.modules

LOW: Add debug logging to exception handler
- _resolve_model now logs failures at debug level for diagnosis
- Helps identify actual bugs vs expected fallbacks

LOW: Extract duplicate file paths to pytest fixtures
- Added GITHUB_RUNNER_DIR constant and fixtures for common file paths
- Reduces 11 duplicate path constructions to reusable fixtures
- Easier maintenance if directory structure changes

LOW: Add explanatory comment for implementation-detail test
- test_uses_try_except_import_pattern now documents why it tests implementation
- Explains the guard against circular dependency import patterns

All 23 tests pass.

* fix: resolve model shorthand in triage_engine and pr_review_engine (CRITICAL)

CRITICAL BUG FIX: Model shorthands (e.g., "sonnet") were being passed
directly to create_client() without resolving to full model IDs. The
Anthropic API does not recognize shorthands, causing runtime errors.

Fixed files:
- triage_engine.py: Added resolve_model_id() import and resolution
- pr_review_engine.py: Added resolve_model_id() import and resolution at 3 call sites
  - run_review_pass() (main review pass)
  - _run_structural_pass() (structural analysis)
  - _run_ai_triage_pass() (AI comment triage)

All changes follow the established pattern used in:
- parallel_orchestrator_reviewer.py
- parallel_followup_reviewer.py
- followup_reviewer.py

All 23 tests pass.

* fix: address PR review findings - correct import paths and hardcoded models

MEDIUM: Fix incorrect relative import paths for phase_config
- batch_validator.py: Changed .phase_config to ..phase_config (2 dots from runners/github/)
- triage_engine.py: Changed ..phase_config to ...phase_config (3 dots from services/)
- pr_review_engine.py: Changed ..phase_config to ...phase_config (3 dots from services/)
- Updated test to reflect correct import pattern for batch_validator.py

MEDIUM: Fix hardcoded model IDs in batch_processor.py
- Changed validation_model="claude-sonnet-4-5-20250929" to "sonnet" on lines 97 and 223
- Ensures consistency with model shorthand resolution pattern

LOW: Move inline import to module-level in batch_issues.py
- Moved resolve_model_id import to module-level try/except block
- Matches established codebase convention for consistency

All 23 tests pass.

* fix: correct import block ordering for ruff I001 compliance

Reordered imports in try/except blocks to comply with ruff's I001 rule:
- batch_issues.py: Parent directory imports before same-directory
- triage_engine.py: Parent directory imports before same-directory
- pr_review_engine.py: Parent directory imports before same-directory

All 23 tests pass and ruff checks pass.

* fix: improve exception handling robustness in BatchValidator._resolve_model

Wrap the fallback import in its own try/except block to ensure any
exception during the fallback is caught and logged before returning the
original model as a final fallback. This prevents unexpected exceptions
from propagating when the absolute import fails.

All 23 tests pass and ruff checks pass.

* style: add blank line after import for ruff format compliance

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-16 16:25:14 +02:00
committed by GitHub
parent 3a1966bd91
commit 5199fdbf9a
10 changed files with 401 additions and 20 deletions
+7 -2
View File
@@ -23,6 +23,7 @@ logger = logging.getLogger(__name__)
# Import validators
try:
from ..phase_config import resolve_model_id
from .batch_validator import BatchValidator
from .duplicates import SIMILAR_THRESHOLD
from .file_lock import locked_json_write
@@ -30,6 +31,7 @@ except (ImportError, ValueError, SystemError):
from batch_validator import BatchValidator
from duplicates import SIMILAR_THRESHOLD
from file_lock import locked_json_write
from phase_config import resolve_model_id
class ClaudeBatchAnalyzer:
@@ -150,11 +152,13 @@ Respond with JSON only:
)
# Using Sonnet for better analysis (still just 1 call)
# Note: Model shorthand resolved via resolve_model_id() to respect env overrides
from core.simple_client import create_simple_client
model = resolve_model_id("sonnet")
client = create_simple_client(
agent_type="batch_analysis",
model="claude-sonnet-4-5-20250929",
model=model,
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
cwd=self.project_dir,
)
@@ -408,7 +412,8 @@ class IssueBatcher:
api_key: str | None = None,
# AI validation settings
validate_batches: bool = True,
validation_model: str = "claude-sonnet-4-5-20250929",
# Note: validation_model uses shorthand which gets resolved via BatchValidator._resolve_model()
validation_model: str = "sonnet",
validation_thinking_budget: int = 10000, # Medium thinking
):
self.github_dir = github_dir
+34 -2
View File
@@ -21,7 +21,9 @@ logger = logging.getLogger(__name__)
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
# Default model and thinking configuration
DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
# Note: Default uses shorthand "sonnet" which gets resolved via resolve_model_id()
# to respect environment variable overrides (e.g., ANTHROPIC_DEFAULT_SONNET_MODEL)
DEFAULT_MODEL = "sonnet"
DEFAULT_THINKING_BUDGET = 10000 # Medium thinking
@@ -112,7 +114,8 @@ class BatchValidator:
model: str = DEFAULT_MODEL,
thinking_budget: int = DEFAULT_THINKING_BUDGET,
):
self.model = model
# Resolve model shorthand via environment variable override if configured
self.model = self._resolve_model(model)
self.thinking_budget = thinking_budget
self.project_dir = project_dir or Path.cwd()
@@ -121,6 +124,35 @@ class BatchValidator:
"claude-agent-sdk not available. Batch validation will be skipped."
)
def _resolve_model(self, model: str) -> str:
"""Resolve model shorthand via phase_config.resolve_model_id()."""
try:
# Use the established try/except pattern for imports (matching
# parallel_orchestrator_reviewer.py and other files in runners/github/services/)
# This ensures consistency across the codebase and proper caching in sys.modules.
from ..phase_config import resolve_model_id
return resolve_model_id(model)
except (ImportError, ValueError, SystemError):
# Fallback to absolute import - wrap in try/except for safety
try:
from phase_config import resolve_model_id
return resolve_model_id(model)
except Exception as e:
# Log and return original model as final fallback
logger.debug(
f"Fallback import failed, using original model '{model}': {e}"
)
return model
except Exception as e:
# Log at debug level to aid diagnosis without polluting normal output
logger.debug(
f"Model resolution via phase_config failed, using original model '{model}': {e}"
)
# Fallback to returning the original model string
return model
def _format_issues(self, issues: list[dict[str, Any]]) -> str:
"""Format issues for the prompt."""
formatted = []
+5 -2
View File
@@ -841,7 +841,9 @@ class GitHubRunnerConfig:
)
# Model settings
model: str = "claude-sonnet-4-5-20250929"
# Note: Default uses shorthand "sonnet" which gets resolved via resolve_model_id()
# to respect environment variable overrides (e.g., ANTHROPIC_DEFAULT_SONNET_MODEL)
model: str = "sonnet"
thinking_level: str = "medium"
def to_dict(self) -> dict:
@@ -915,6 +917,7 @@ class GitHubRunnerConfig:
review_own_prs=settings.get("review_own_prs", False),
auto_post_reviews=settings.get("auto_post_reviews", False),
allow_fix_commits=settings.get("allow_fix_commits", True),
model=settings.get("model", "claude-sonnet-4-5-20250929"),
# Note: model is stored as shorthand and resolved via resolve_model_id()
model=settings.get("model", "sonnet"),
thinking_level=settings.get("thinking_level", "medium"),
)
@@ -94,7 +94,7 @@ class BatchProcessor:
min_batch_size=1,
max_batch_size=5,
validate_batches=True,
validation_model="claude-sonnet-4-5-20250929",
validation_model="sonnet",
validation_thinking_budget=10000,
)
@@ -220,7 +220,7 @@ class BatchProcessor:
min_batch_size=1,
max_batch_size=5,
validate_batches=True,
validation_model="claude-sonnet-4-5-20250929",
validation_model="sonnet",
validation_thinking_budget=10000,
)
@@ -683,9 +683,10 @@ Analyze this follow-up review context and provide your structured response.
# Use Claude Agent SDK query() with structured outputs
# Reference: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
from claude_agent_sdk import ClaudeAgentOptions, query
from phase_config import get_thinking_budget
from phase_config import get_thinking_budget, resolve_model_id
model = self.config.model or "claude-sonnet-4-5-20250929"
model_shorthand = self.config.model or "sonnet"
model = resolve_model_id(model_shorthand)
thinking_level = self.config.thinking_level or "medium"
thinking_budget = get_thinking_budget(thinking_level)
@@ -31,7 +31,7 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ...phase_config import get_thinking_budget, resolve_model_id
from ..context_gatherer import _validate_git_ref
from ..gh_client import GHClient
from ..models import (
@@ -61,7 +61,7 @@ except (ImportError, ValueError, SystemError):
PRReviewResult,
ReviewSeverity,
)
from phase_config import get_thinking_budget
from phase_config import get_thinking_budget, resolve_model_id
from services.category_utils import map_category
from services.io_utils import safe_print
from services.pr_worktree_manager import PRWorktreeManager
@@ -488,7 +488,9 @@ The SDK will run invoked agents in parallel automatically.
)
# Use model and thinking level from config (user settings)
model = self.config.model or "claude-sonnet-4-5-20250929"
# Resolve model shorthand via environment variable override if configured
model_shorthand = self.config.model or "sonnet"
model = resolve_model_id(model_shorthand)
thinking_level = self.config.thinking_level or "medium"
thinking_budget = get_thinking_budget(thinking_level)
@@ -27,7 +27,7 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ...phase_config import get_thinking_budget, resolve_model_id
from ..context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
from ..gh_client import GHClient
from ..models import (
@@ -57,7 +57,7 @@ except (ImportError, ValueError, SystemError):
PRReviewResult,
ReviewSeverity,
)
from phase_config import get_thinking_budget
from phase_config import get_thinking_budget, resolve_model_id
from services.category_utils import map_category
from services.io_utils import safe_print
from services.pr_worktree_manager import PRWorktreeManager
@@ -598,7 +598,9 @@ The SDK will run invoked agents in parallel automatically.
prompt = self._build_orchestrator_prompt(context)
# Use model and thinking level from config (user settings)
model = self.config.model or "claude-sonnet-4-5-20250929"
# Resolve model shorthand via environment variable override if configured
model_shorthand = self.config.model or "sonnet"
model = resolve_model_id(model_shorthand)
thinking_level = self.config.thinking_level or "medium"
thinking_budget = get_thinking_budget(thinking_level)
@@ -13,6 +13,7 @@ from pathlib import Path
from typing import Any
try:
from ...phase_config import resolve_model_id
from ..context_gatherer import PRContext
from ..models import (
AICommentTriage,
@@ -33,6 +34,7 @@ except (ImportError, ValueError, SystemError):
ReviewPass,
StructuralIssue,
)
from phase_config import resolve_model_id
from services.io_utils import safe_print
from services.prompt_manager import PromptManager
from services.response_parsers import ResponseParser
@@ -228,10 +230,12 @@ class PRReviewEngine:
else self.project_dir
)
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
model = resolve_model_id(self.config.model or "sonnet")
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=self.config.model,
model=model,
agent_type="pr_reviewer", # Read-only - no bash, no edits
)
@@ -491,10 +495,12 @@ class PRReviewEngine:
else self.project_dir
)
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
model = resolve_model_id(self.config.model or "sonnet")
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=self.config.model,
model=model,
agent_type="pr_reviewer", # Read-only - no bash, no edits
)
@@ -549,10 +555,12 @@ class PRReviewEngine:
else self.project_dir
)
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
model = resolve_model_id(self.config.model or "sonnet")
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=self.config.model,
model=model,
agent_type="pr_reviewer", # Read-only - no bash, no edits
)
@@ -10,11 +10,13 @@ from __future__ import annotations
from pathlib import Path
try:
from ...phase_config import resolve_model_id
from ..models import GitHubRunnerConfig, TriageCategory, TriageResult
from .prompt_manager import PromptManager
from .response_parsers import ResponseParser
except (ImportError, ValueError, SystemError):
from models import GitHubRunnerConfig, TriageCategory, TriageResult
from phase_config import resolve_model_id
from services.prompt_manager import PromptManager
from services.response_parsers import ResponseParser
@@ -71,10 +73,12 @@ class TriageEngine:
full_prompt = prompt + "\n\n---\n\n" + context
# Run AI
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
model = resolve_model_id(self.config.model or "sonnet")
client = create_client(
project_dir=self.project_dir,
spec_dir=self.github_dir,
model=self.config.model,
model=model,
agent_type="qa_reviewer",
)
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Tests for Model Resolution
===========================
Tests the model resolution functionality including:
- resolve_model_id() function from phase_config
- Environment variable overrides
- Model shorthand to full ID mapping
- Default model values in GitHub runner services
This ensures custom model configurations (e.g., ANTHROPIC_DEFAULT_SONNET_MODEL)
are properly respected instead of falling back to hardcoded values.
Note: Some tests use source code inspection to avoid complex import dependencies
while still verifying the critical implementation patterns that prevent regression
of the hardcoded fallback bug (ACS-294).
"""
import os
import sys
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
import pytest
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
from phase_config import MODEL_ID_MAP, resolve_model_id
# Common paths - extracted to avoid duplication and ease maintenance
GITHUB_RUNNER_DIR = (
Path(__file__).parent.parent / "apps" / "backend" / "runners" / "github"
)
GITHUB_RUNNER_SERVICES_DIR = GITHUB_RUNNER_DIR / "services"
@pytest.fixture
def models_file() -> Path:
"""Path to models.py in GitHub runner directory."""
return GITHUB_RUNNER_DIR / "models.py"
@pytest.fixture
def batch_validator_file() -> Path:
"""Path to batch_validator.py in GitHub runner directory."""
return GITHUB_RUNNER_DIR / "batch_validator.py"
@pytest.fixture
def batch_issues_file() -> Path:
"""Path to batch_issues.py in GitHub runner directory."""
return GITHUB_RUNNER_DIR / "batch_issues.py"
@pytest.fixture
def orchestrator_file() -> Path:
"""Path to parallel_orchestrator_reviewer.py in GitHub runner services."""
return GITHUB_RUNNER_SERVICES_DIR / "parallel_orchestrator_reviewer.py"
@pytest.fixture
def followup_file() -> Path:
"""Path to parallel_followup_reviewer.py in GitHub runner services."""
return GITHUB_RUNNER_SERVICES_DIR / "parallel_followup_reviewer.py"
@pytest.fixture
def clean_env() -> Generator[None, None, None]:
"""Fixture that provides a clean environment without model override variables.
This fixture clears all ANTHROPIC_DEFAULT_*_MODEL environment variables
before each test and restores them afterward. This ensures tests don't
interfere with each other when the user has custom model mappings configured.
Yields:
None
"""
# Clear any environment variables that might interfere
env_vars = [
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
]
env_backup = {k: os.environ.pop(k, None) for k in env_vars}
yield
# Restore environment variables
for k, v in env_backup.items():
if v is not None:
os.environ[k] = v
class TestResolveModelId:
"""Tests for resolve_model_id function - behavioral tests."""
def test_resolves_sonnet_shorthand_to_full_id(self, clean_env):
"""Sonnet shorthand resolves to full model ID."""
result = resolve_model_id("sonnet")
assert result == MODEL_ID_MAP["sonnet"]
def test_resolves_opus_shorthand_to_full_id(self, clean_env):
"""Opus shorthand resolves to full model ID."""
result = resolve_model_id("opus")
assert result == MODEL_ID_MAP["opus"]
def test_resolves_haiku_shorthand_to_full_id(self, clean_env):
"""Haiku shorthand resolves to full model ID."""
result = resolve_model_id("haiku")
assert result == MODEL_ID_MAP["haiku"]
def test_passes_through_full_model_id(self):
"""Full model IDs are passed through unchanged."""
custom_model = "glm-4.7"
result = resolve_model_id(custom_model)
assert result == custom_model
def test_passes_through_unknown_shorthand(self):
"""Unknown shorthands are passed through unchanged."""
unknown = "unknown-model"
result = resolve_model_id(unknown)
assert result == unknown
def test_environment_variable_override_sonnet(self):
"""ANTHROPIC_DEFAULT_SONNET_MODEL overrides sonnet shorthand."""
custom_model = "glm-4.7"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_SONNET_MODEL": custom_model}):
result = resolve_model_id("sonnet")
assert result == custom_model
def test_environment_variable_override_opus(self):
"""ANTHROPIC_DEFAULT_OPUS_MODEL overrides opus shorthand."""
custom_model = "glm-4.7"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_OPUS_MODEL": custom_model}):
result = resolve_model_id("opus")
assert result == custom_model
def test_environment_variable_override_haiku(self):
"""ANTHROPIC_DEFAULT_HAIKU_MODEL overrides haiku shorthand."""
custom_model = "glm-4.7"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_HAIKU_MODEL": custom_model}):
result = resolve_model_id("haiku")
assert result == custom_model
def test_environment_variable_takes_precedence_over_hardcoded_map(self):
"""Environment variable overrides take precedence over MODEL_ID_MAP."""
custom_model = "custom-sonnet-model"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_SONNET_MODEL": custom_model}):
result = resolve_model_id("sonnet")
assert result == custom_model
assert result != MODEL_ID_MAP["sonnet"]
def test_empty_environment_variable_is_ignored(self):
"""Empty environment variable is ignored, falls back to MODEL_ID_MAP."""
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_SONNET_MODEL": ""}):
result = resolve_model_id("sonnet")
assert result == MODEL_ID_MAP["sonnet"]
def test_full_model_id_not_affected_by_environment_variable(self):
"""Full model IDs are not affected by environment variables."""
custom_model = "my-custom-model-123"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7"}):
result = resolve_model_id(custom_model)
assert result == custom_model
class TestGitHubRunnerConfigModelDefaults:
"""Tests for GitHubRunnerConfig default model values.
Uses source inspection to avoid complex import dependencies while
verifying the critical pattern: default is shorthand "sonnet", not a
hardcoded full model ID.
"""
def test_default_model_is_shorthand(self, models_file: Path):
"""GitHubRunnerConfig default model uses shorthand 'sonnet'."""
# Explicit UTF-8 encoding required for Windows compatibility (default encoding varies by platform)
content = models_file.read_text(encoding="utf-8")
# Verify the default is "sonnet" (shorthand), not a hardcoded full model ID
assert 'model: str = "sonnet"' in content
# Verify the old hardcoded fallback is NOT present
assert 'model: str = "claude-sonnet-4-5-20250929"' not in content
def test_load_settings_default_model_is_shorthand(self, models_file: Path):
"""GitHubRunnerConfig.load_settings() uses shorthand 'sonnet' as default."""
content = models_file.read_text(encoding="utf-8")
# Verify load_settings uses "sonnet" (shorthand) as fallback
assert 'model=settings.get("model", "sonnet")' in content
class TestBatchValidatorModelResolution:
"""Tests for BatchValidator model resolution.
Tests verify the try/except import pattern (matching the established
codebase convention) and that the shorthand "sonnet" is used as default.
"""
def test_default_model_is_shorthand(self, batch_validator_file: Path):
"""BatchValidator DEFAULT_MODEL uses shorthand 'sonnet'."""
content = batch_validator_file.read_text(encoding="utf-8")
# Verify DEFAULT_MODEL is "sonnet" (shorthand)
assert 'DEFAULT_MODEL = "sonnet"' in content
def test_uses_try_except_import_pattern(self, batch_validator_file: Path):
"""BatchValidator uses try/except import pattern (established codebase convention).
This is an implementation-detail test that guards against import patterns
causing circular dependencies. The try/except pattern (relative imports
falling back to absolute imports) is the established convention across
runners/github/ and ensures proper module caching in sys.modules.
Note: batch_validator.py is in runners/github/ (not services/), so it uses
..phase_config (2 dots) to reach apps/backend/phase_config.py.
"""
content = batch_validator_file.read_text(encoding="utf-8")
# Verify the try/except pattern IS present (relative import first)
assert "from ..phase_config import resolve_model_id" in content
# Verify fallback to absolute import is present
assert "except (ImportError, ValueError, SystemError):" in content
assert 'from phase_config import resolve_model_id' in content
# Verify debug logging is present for error diagnosis
assert "logger.debug" in content
def test_has_resolve_model_method(self, batch_validator_file: Path):
"""BatchValidator has _resolve_model method that resolves models."""
content = batch_validator_file.read_text(encoding="utf-8")
# Verify _resolve_model method exists
assert "def _resolve_model(self, model: str)" in content
# Verify it calls resolve_model_id
assert "return resolve_model_id(model)" in content
def test_init_calls_resolve_model(self, batch_validator_file: Path):
"""BatchValidator.__init__ calls _resolve_model to resolve the model."""
content = batch_validator_file.read_text(encoding="utf-8")
# Verify __init__ resolves the model
assert "self.model = self._resolve_model(model)" in content
class TestBatchIssuesModelResolution:
"""Tests for batch_issues.py validation_model default.
Uses source inspection to verify shorthand "sonnet" is used as default.
"""
def test_validation_model_default_is_shorthand(self, batch_issues_file: Path):
"""IssueBatcher validation_model default uses shorthand 'sonnet'."""
content = batch_issues_file.read_text(encoding="utf-8")
# Verify validation_model default is "sonnet" (shorthand)
assert 'validation_model: str = "sonnet"' in content
class TestClaudeBatchAnalyzerModelResolution:
"""Tests for ClaudeBatchAnalyzer model resolution in batch_issues.py.
Verifies that the hardcoded model ID in analyze_and_batch_issues()
has been replaced with resolve_model_id() pattern.
"""
def test_batch_analyzer_resolves_model(self, batch_issues_file: Path):
"""ClaudeBatchAnalyzer uses resolve_model_id() instead of hardcoded model ID."""
content = batch_issues_file.read_text(encoding="utf-8")
# Verify the old hardcoded model is NOT present
assert 'model="claude-sonnet-4-5-20250929"' not in content
assert 'model = "claude-sonnet-4-5-20250929"' not in content
# Verify resolve_model_id is imported and used
assert "from phase_config import resolve_model_id" in content
assert "model = resolve_model_id" in content
def test_batch_analyzer_uses_sonnet_shorthand(self, batch_issues_file: Path):
"""ClaudeBatchAnalyzer uses 'sonnet' shorthand, not full model ID."""
content = batch_issues_file.read_text(encoding="utf-8")
# Verify the pattern: model = resolve_model_id("sonnet")
assert 'model = resolve_model_id("sonnet")' in content
class TestParallelReviewerImportResolution:
"""Tests that parallel reviewers use proper model resolution patterns.
Includes both behavioral tests (simulating the pattern) and source
inspection tests (to verify hardcoded fallbacks are not present).
"""
def test_parallel_reviewers_resolve_models(self, clean_env):
"""Parallel reviewers correctly resolve model shorthands using resolve_model_id pattern."""
# Simulate the pattern used in parallel reviewers
config_model = None
model_shorthand = config_model or "sonnet"
model = resolve_model_id(model_shorthand)
# Should resolve to the full model ID
assert model == MODEL_ID_MAP["sonnet"]
def test_parallel_reviewers_respect_environment_variables(self):
"""Parallel reviewers respect environment variable overrides."""
custom_model = "glm-4.7"
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_SONNET_MODEL": custom_model}):
config_model = None
model_shorthand = config_model or "sonnet"
model = resolve_model_id(model_shorthand)
assert model == custom_model
def test_parallel_reviewers_use_sonnet_fallback(self, orchestrator_file: Path, followup_file: Path):
"""Parallel reviewers use 'sonnet' shorthand as fallback, not hardcoded model IDs."""
orchestrator_content = orchestrator_file.read_text(encoding="utf-8")
followup_content = followup_file.read_text(encoding="utf-8")
# Verify the old hardcoded fallback is NOT present (negative assertion)
assert 'or "claude-sonnet-4-5-20250929"' not in orchestrator_content
assert 'or "claude-sonnet-4-5-20250929"' not in followup_content
# Verify the new pattern IS present (shorthand fallback)
assert 'model_shorthand = self.config.model or "sonnet"' in orchestrator_content
assert 'model_shorthand = self.config.model or "sonnet"' in followup_content
# Verify resolve_model_id is imported and used
assert "resolve_model_id" in orchestrator_content
assert "resolve_model_id" in followup_content