| |
|
|
| """ |
| Unit tests for Dynamic Model Resolution System. |
| |
| Tests 5-layer model resolution architecture: |
| 0. Resolve Aliases - custom name mappings (NEW!) |
| 1. Normalize Name - convert client formats to Kiro format |
| 2. Check Dynamic Cache - models from /ListAvailableModels API |
| 3. Check Hidden Models - manual config for undocumented models |
| 4. Pass-through - unknown models are sent to Kiro |
| """ |
|
|
| import pytest |
| from dataclasses import FrozenInstanceError |
|
|
| from kiro.model_resolver import ( |
| normalize_model_name, |
| get_model_id_for_kiro, |
| extract_model_family, |
| ModelResolver, |
| ModelResolution, |
| ) |
| from kiro.cache import ModelInfoCache |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture |
| def mock_model_cache(): |
| """ |
| Creates ModelInfoCache with pre-populated models. |
| Simulates data from Kiro /ListAvailableModels API. |
| """ |
| print("Setup: Creating ModelInfoCache with test models...") |
| cache = ModelInfoCache() |
| |
| cache._cache = { |
| "auto": {"modelId": "auto", "modelName": "Auto"}, |
| "claude-sonnet-4.5": {"modelId": "claude-sonnet-4.5", "modelName": "Claude Sonnet 4.5"}, |
| "claude-sonnet-4": {"modelId": "claude-sonnet-4", "modelName": "Claude Sonnet 4"}, |
| "claude-haiku-4.5": {"modelId": "claude-haiku-4.5", "modelName": "Claude Haiku 4.5"}, |
| "claude-opus-4.5": {"modelId": "claude-opus-4.5", "modelName": "Claude Opus 4.5"}, |
| } |
| return cache |
|
|
|
|
| @pytest.fixture |
| def empty_model_cache(): |
| """Creates empty ModelInfoCache.""" |
| print("Setup: Creating empty ModelInfoCache...") |
| return ModelInfoCache() |
|
|
|
|
| @pytest.fixture |
| def hidden_models(): |
| """Hidden models for tests.""" |
| return { |
| "claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0", |
| } |
|
|
|
|
| @pytest.fixture |
| def model_resolver(mock_model_cache, hidden_models): |
| """Ready-to-use ModelResolver for tests.""" |
| print("Setup: Creating ModelResolver with cache and hidden models...") |
| return ModelResolver(cache=mock_model_cache, hidden_models=hidden_models) |
|
|
|
|
| @pytest.fixture |
| def resolver_without_hidden(mock_model_cache): |
| """ModelResolver without hidden models.""" |
| print("Setup: Creating ModelResolver without hidden models...") |
| return ModelResolver(cache=mock_model_cache, hidden_models={}) |
|
|
|
|
| |
| |
| |
|
|
| class TestNormalizeModelName: |
| """ |
| Tests for normalize_model_name() function. |
| |
| Checks conversion of client formats to Kiro format: |
| - Dashes → dots for minor versions |
| - Removal of date suffix (20251001) |
| - Removal of 'latest' suffix |
| - Legacy format (claude-3-7-sonnet) |
| """ |
| |
| |
| |
| def test_normalizes_haiku_dash_to_dot(self): |
| """ |
| What it does: claude-haiku-4-5 → claude-haiku-4.5 |
| Goal: Check dash-to-dot conversion for Haiku. |
| """ |
| print("Action: Normalizing 'claude-haiku-4-5'...") |
| result = normalize_model_name("claude-haiku-4-5") |
| |
| print(f"Comparing result: Expected 'claude-haiku-4.5', Got '{result}'") |
| assert result == "claude-haiku-4.5" |
| |
| def test_normalizes_sonnet_dash_to_dot(self): |
| """ |
| What it does: claude-sonnet-4-5 → claude-sonnet-4.5 |
| Goal: Check dash-to-dot conversion for Sonnet. |
| """ |
| print("Action: Normalizing 'claude-sonnet-4-5'...") |
| result = normalize_model_name("claude-sonnet-4-5") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4.5', Got '{result}'") |
| assert result == "claude-sonnet-4.5" |
| |
| def test_normalizes_opus_dash_to_dot(self): |
| """ |
| What it does: claude-opus-4-5 → claude-opus-4.5 |
| Goal: Check dash-to-dot conversion for Opus. |
| """ |
| print("Action: Normalizing 'claude-opus-4-5'...") |
| result = normalize_model_name("claude-opus-4-5") |
| |
| print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'") |
| assert result == "claude-opus-4.5" |
| |
| |
| |
| def test_strips_date_suffix_haiku(self): |
| """ |
| What it does: claude-haiku-4-5-20251001 → claude-haiku-4.5 |
| Goal: Check date suffix removal for Haiku (Claude Code format). |
| """ |
| print("Action: Normalizing 'claude-haiku-4-5-20251001'...") |
| result = normalize_model_name("claude-haiku-4-5-20251001") |
| |
| print(f"Comparing result: Expected 'claude-haiku-4.5', Got '{result}'") |
| assert result == "claude-haiku-4.5" |
| |
| def test_strips_date_suffix_sonnet(self): |
| """ |
| What it does: claude-sonnet-4-5-20250929 → claude-sonnet-4.5 |
| Goal: Check date suffix removal for Sonnet. |
| """ |
| print("Action: Normalizing 'claude-sonnet-4-5-20250929'...") |
| result = normalize_model_name("claude-sonnet-4-5-20250929") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4.5', Got '{result}'") |
| assert result == "claude-sonnet-4.5" |
| |
| def test_strips_date_suffix_opus(self): |
| """ |
| What it does: claude-opus-4-5-20251101 → claude-opus-4.5 |
| Goal: Check date suffix removal for Opus. |
| """ |
| print("Action: Normalizing 'claude-opus-4-5-20251101'...") |
| result = normalize_model_name("claude-opus-4-5-20251101") |
| |
| print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'") |
| assert result == "claude-opus-4.5" |
| |
| |
| |
| def test_strips_latest_suffix(self): |
| """ |
| What it does: claude-haiku-4-5-latest → claude-haiku-4.5 |
| Goal: Check 'latest' suffix removal. |
| """ |
| print("Action: Normalizing 'claude-haiku-4-5-latest'...") |
| result = normalize_model_name("claude-haiku-4-5-latest") |
| |
| print(f"Comparing result: Expected 'claude-haiku-4.5', Got '{result}'") |
| assert result == "claude-haiku-4.5" |
| |
| |
| |
| def test_keeps_model_without_minor(self): |
| """ |
| What it does: claude-sonnet-4 → claude-sonnet-4 |
| Goal: Check that models without minor version are unchanged. |
| """ |
| print("Action: Normalizing 'claude-sonnet-4'...") |
| result = normalize_model_name("claude-sonnet-4") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4', Got '{result}'") |
| assert result == "claude-sonnet-4" |
| |
| def test_strips_date_from_model_without_minor(self): |
| """ |
| What it does: claude-sonnet-4-20250514 → claude-sonnet-4 |
| Goal: Check date suffix removal for model without minor version. |
| """ |
| print("Action: Normalizing 'claude-sonnet-4-20250514'...") |
| result = normalize_model_name("claude-sonnet-4-20250514") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4', Got '{result}'") |
| assert result == "claude-sonnet-4" |
| |
| |
| |
| def test_normalizes_legacy_format(self): |
| """ |
| What it does: claude-3-7-sonnet → claude-3.7-sonnet |
| Goal: Check legacy format normalization. |
| """ |
| print("Action: Normalizing 'claude-3-7-sonnet'...") |
| result = normalize_model_name("claude-3-7-sonnet") |
| |
| print(f"Comparing result: Expected 'claude-3.7-sonnet', Got '{result}'") |
| assert result == "claude-3.7-sonnet" |
| |
| def test_normalizes_legacy_format_with_date(self): |
| """ |
| What it does: claude-3-7-sonnet-20250219 → claude-3.7-sonnet |
| Goal: Check legacy format normalization with date suffix. |
| """ |
| print("Action: Normalizing 'claude-3-7-sonnet-20250219'...") |
| result = normalize_model_name("claude-3-7-sonnet-20250219") |
| |
| print(f"Comparing result: Expected 'claude-3.7-sonnet', Got '{result}'") |
| assert result == "claude-3.7-sonnet" |
| |
| def test_normalizes_legacy_haiku(self): |
| """ |
| What it does: claude-3-5-haiku → claude-3.5-haiku |
| Goal: Check legacy format normalization for Haiku. |
| """ |
| print("Action: Normalizing 'claude-3-5-haiku'...") |
| result = normalize_model_name("claude-3-5-haiku") |
| |
| print(f"Comparing result: Expected 'claude-3.5-haiku', Got '{result}'") |
| assert result == "claude-3.5-haiku" |
| |
| def test_normalizes_legacy_opus(self): |
| """ |
| What it does: claude-3-0-opus → claude-3.0-opus |
| Goal: Check legacy format normalization for Opus. |
| """ |
| print("Action: Normalizing 'claude-3-0-opus'...") |
| result = normalize_model_name("claude-3-0-opus") |
| |
| print(f"Comparing result: Expected 'claude-3.0-opus', Got '{result}'") |
| assert result == "claude-3.0-opus" |
| |
| |
| |
| def test_inverted_format_with_high_suffix(self): |
| """ |
| What it does: claude-4.5-opus-high → claude-opus-4.5 |
| Goal: Check inverted format normalization with 'high' suffix (Cursor IDE). |
| |
| Cursor IDE sends model names in inverted format with priority suffix. |
| This is Pattern 5 from PR #49. |
| """ |
| print("Action: Normalizing 'claude-4.5-opus-high'...") |
| result = normalize_model_name("claude-4.5-opus-high") |
| |
| print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'") |
| assert result == "claude-opus-4.5" |
| |
| def test_inverted_format_with_low_suffix(self): |
| """ |
| What it does: claude-4.5-sonnet-low → claude-sonnet-4.5 |
| Goal: Check inverted format normalization with 'low' suffix (Cursor IDE). |
| """ |
| print("Action: Normalizing 'claude-4.5-sonnet-low'...") |
| result = normalize_model_name("claude-4.5-sonnet-low") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4.5', Got '{result}'") |
| assert result == "claude-sonnet-4.5" |
| |
| def test_inverted_format_with_thinking_suffix(self): |
| """ |
| What it does: claude-4.5-opus-high-thinking → claude-opus-4.5 |
| Goal: Check inverted format with compound suffix (high-thinking). |
| |
| The pattern strips ALL suffixes after the family name. |
| """ |
| print("Action: Normalizing 'claude-4.5-opus-high-thinking'...") |
| result = normalize_model_name("claude-4.5-opus-high-thinking") |
| |
| print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'") |
| assert result == "claude-opus-4.5" |
| |
| def test_inverted_format_all_families(self): |
| """ |
| What it does: Verifies inverted format works for all families. |
| Goal: Check haiku, sonnet, opus all work with inverted format. |
| """ |
| print("Action: Normalizing inverted format for all families...") |
| |
| print(" Testing haiku...") |
| result_haiku = normalize_model_name("claude-4.5-haiku-high") |
| print(f" Comparing: Expected 'claude-haiku-4.5', Got '{result_haiku}'") |
| assert result_haiku == "claude-haiku-4.5" |
| |
| print(" Testing sonnet...") |
| result_sonnet = normalize_model_name("claude-4.5-sonnet-low") |
| print(f" Comparing: Expected 'claude-sonnet-4.5', Got '{result_sonnet}'") |
| assert result_sonnet == "claude-sonnet-4.5" |
| |
| print(" Testing opus...") |
| result_opus = normalize_model_name("claude-4.5-opus-high") |
| print(f" Comparing: Expected 'claude-opus-4.5', Got '{result_opus}'") |
| assert result_opus == "claude-opus-4.5" |
| |
| def test_inverted_format_requires_suffix(self): |
| """ |
| What it does: Verifies that suffix is required (doesn't match claude-3.7-sonnet). |
| Goal: CRITICAL - ensure Pattern 5 doesn't break already-normalized formats. |
| |
| This is the most important test for Pattern 5. The regex MUST require a suffix |
| to avoid matching already-normalized formats like claude-3.7-sonnet. |
| """ |
| print("Action: Normalizing 'claude-3.7-sonnet' (should NOT match Pattern 5)...") |
| result = normalize_model_name("claude-3.7-sonnet") |
| |
| print(f"Comparing result: Expected 'claude-3.7-sonnet' (unchanged), Got '{result}'") |
| assert result == "claude-3.7-sonnet" |
| |
| print("Action: Normalizing 'claude-4.5-sonnet' (should NOT match Pattern 5)...") |
| result2 = normalize_model_name("claude-4.5-sonnet") |
| |
| print(f"Comparing result: Expected 'claude-4.5-sonnet' (unchanged), Got '{result2}'") |
| assert result2 == "claude-4.5-sonnet" |
| |
| def test_inverted_format_case_insensitive(self): |
| """ |
| What it does: CLAUDE-4.5-OPUS-HIGH → claude-opus-4.5 |
| Goal: Check case insensitivity for inverted format. |
| """ |
| print("Action: Normalizing 'CLAUDE-4.5-OPUS-HIGH'...") |
| result = normalize_model_name("CLAUDE-4.5-OPUS-HIGH") |
| |
| print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'") |
| assert result == "claude-opus-4.5" |
| |
| |
| |
| def test_passthrough_already_normalized_haiku(self): |
| """ |
| What it does: claude-haiku-4.5 → claude-haiku-4.5 |
| Goal: Check that already normalized models are unchanged. |
| """ |
| print("Action: Normalizing 'claude-haiku-4.5'...") |
| result = normalize_model_name("claude-haiku-4.5") |
| |
| print(f"Comparing result: Expected 'claude-haiku-4.5', Got '{result}'") |
| assert result == "claude-haiku-4.5" |
| |
| def test_passthrough_already_normalized_sonnet(self): |
| """ |
| What it does: claude-sonnet-4.5 → claude-sonnet-4.5 |
| Goal: Check passthrough for Sonnet. |
| """ |
| print("Action: Normalizing 'claude-sonnet-4.5'...") |
| result = normalize_model_name("claude-sonnet-4.5") |
| |
| print(f"Comparing result: Expected 'claude-sonnet-4.5', Got '{result}'") |
| assert result == "claude-sonnet-4.5" |
| |
| def test_passthrough_auto(self): |
| """ |
| What it does: auto → auto |
| Goal: Check passthrough for 'auto'. |
| """ |
| print("Action: Normalizing 'auto'...") |
| result = normalize_model_name("auto") |
| |
| print(f"Comparing result: Expected 'auto', Got '{result}'") |
| assert result == "auto" |
| |
| |
| |
| def test_handles_empty_string(self): |
| """ |
| What it does: "" → "" |
| Goal: Check empty string handling. |
| """ |
| print("Action: Normalizing empty string...") |
| result = normalize_model_name("") |
| |
| print(f"Comparing result: Expected '', Got '{result}'") |
| assert result == "" |
| |
| def test_handles_unknown_format(self): |
| """ |
| What it does: gpt-4 → gpt-4 (passthrough) |
| Goal: Check passthrough for unknown formats. |
| """ |
| print("Action: Normalizing 'gpt-4'...") |
| result = normalize_model_name("gpt-4") |
| |
| print(f"Comparing result: Expected 'gpt-4', Got '{result}'") |
| assert result == "gpt-4" |
| |
| def test_handles_random_model_name(self): |
| """ |
| What it does: some-random-model → some-random-model |
| Goal: Check passthrough for arbitrary names. |
| """ |
| print("Action: Normalizing 'some-random-model'...") |
| result = normalize_model_name("some-random-model") |
| |
| print(f"Comparing result: Expected 'some-random-model', Got '{result}'") |
| assert result == "some-random-model" |
|
|
|
|
| |
| |
| |
|
|
| class TestNormalizeModelNameParametrized: |
| """Parametrized tests for complete coverage of scenarios.""" |
| |
| @pytest.mark.parametrize("input_model,expected", [ |
| |
| ("claude-haiku-4-5", "claude-haiku-4.5"), |
| ("claude-haiku-4-5-20251001", "claude-haiku-4.5"), |
| ("claude-haiku-4-5-latest", "claude-haiku-4.5"), |
| ("claude-sonnet-4-5", "claude-sonnet-4.5"), |
| ("claude-sonnet-4-5-20250929", "claude-sonnet-4.5"), |
| ("claude-opus-4-5", "claude-opus-4.5"), |
| ("claude-opus-4-5-20251101", "claude-opus-4.5"), |
| |
| ("claude-sonnet-4", "claude-sonnet-4"), |
| ("claude-sonnet-4-20250514", "claude-sonnet-4"), |
| ("claude-haiku-4", "claude-haiku-4"), |
| ("claude-opus-4", "claude-opus-4"), |
| |
| ("claude-3-7-sonnet", "claude-3.7-sonnet"), |
| ("claude-3-7-sonnet-20250219", "claude-3.7-sonnet"), |
| ("claude-3-5-haiku", "claude-3.5-haiku"), |
| ("claude-3-0-opus", "claude-3.0-opus"), |
| |
| ("claude-haiku-4.5", "claude-haiku-4.5"), |
| ("claude-sonnet-4.5", "claude-sonnet-4.5"), |
| ("claude-opus-4.5", "claude-opus-4.5"), |
| ("claude-3.7-sonnet", "claude-3.7-sonnet"), |
| ("auto", "auto"), |
| |
| ("gpt-4", "gpt-4"), |
| ("gpt-4-turbo", "gpt-4-turbo"), |
| ("unknown-model", "unknown-model"), |
| ]) |
| def test_normalize_model_name_all_scenarios(self, input_model, expected): |
| """ |
| What it does: Checks all normalization scenarios. |
| Goal: Complete coverage of scenario table. |
| """ |
| print(f"Action: Normalizing '{input_model}'...") |
| result = normalize_model_name(input_model) |
| |
| print(f"Comparing result: Expected '{expected}', Got '{result}'") |
| assert result == expected |
|
|
|
|
| |
| |
| |
|
|
| class TestExtractModelFamily: |
| """ |
| Tests for extract_model_family() function. |
| |
| Checks extraction of model family (haiku, sonnet, opus) from name. |
| """ |
| |
| def test_extracts_haiku_from_standard_format(self): |
| """ |
| What it does: claude-haiku-4.5 → haiku |
| Goal: Check Haiku family extraction. |
| """ |
| print("Action: Extracting family from 'claude-haiku-4.5'...") |
| result = extract_model_family("claude-haiku-4.5") |
| |
| print(f"Comparing result: Expected 'haiku', Got '{result}'") |
| assert result == "haiku" |
| |
| def test_extracts_sonnet_from_standard_format(self): |
| """ |
| What it does: claude-sonnet-4.5 → sonnet |
| Goal: Check Sonnet family extraction. |
| """ |
| print("Action: Extracting family from 'claude-sonnet-4.5'...") |
| result = extract_model_family("claude-sonnet-4.5") |
| |
| print(f"Comparing result: Expected 'sonnet', Got '{result}'") |
| assert result == "sonnet" |
| |
| def test_extracts_opus_from_standard_format(self): |
| """ |
| What it does: claude-opus-4.5 → opus |
| Goal: Check Opus family extraction. |
| """ |
| print("Action: Extracting family from 'claude-opus-4.5'...") |
| result = extract_model_family("claude-opus-4.5") |
| |
| print(f"Comparing result: Expected 'opus', Got '{result}'") |
| assert result == "opus" |
| |
| def test_extracts_sonnet_from_legacy_format(self): |
| """ |
| What it does: claude-3.7-sonnet → sonnet |
| Goal: Check family extraction from legacy format. |
| """ |
| print("Action: Extracting family from 'claude-3.7-sonnet'...") |
| result = extract_model_family("claude-3.7-sonnet") |
| |
| print(f"Comparing result: Expected 'sonnet', Got '{result}'") |
| assert result == "sonnet" |
| |
| def test_extracts_haiku_from_unnormalized(self): |
| """ |
| What it does: claude-haiku-4-5-20251001 → haiku |
| Goal: Check family extraction from unnormalized name. |
| """ |
| print("Action: Extracting family from 'claude-haiku-4-5-20251001'...") |
| result = extract_model_family("claude-haiku-4-5-20251001") |
| |
| print(f"Comparing result: Expected 'haiku', Got '{result}'") |
| assert result == "haiku" |
| |
| def test_returns_none_for_non_claude(self): |
| """ |
| What it does: gpt-4 → None |
| Goal: Check None return for non-Claude models. |
| """ |
| print("Action: Extracting family from 'gpt-4'...") |
| result = extract_model_family("gpt-4") |
| |
| print(f"Comparing result: Expected None, Got {result}") |
| assert result is None |
| |
| def test_returns_none_for_auto(self): |
| """ |
| What it does: auto → None |
| Goal: Check None return for 'auto'. |
| """ |
| print("Action: Extracting family from 'auto'...") |
| result = extract_model_family("auto") |
| |
| print(f"Comparing result: Expected None, Got {result}") |
| assert result is None |
| |
| def test_case_insensitive(self): |
| """ |
| What it does: CLAUDE-HAIKU-4.5 → haiku |
| Goal: Check case insensitivity. |
| """ |
| print("Action: Extracting family from 'CLAUDE-HAIKU-4.5'...") |
| result = extract_model_family("CLAUDE-HAIKU-4.5") |
| |
| print(f"Comparing result: Expected 'haiku', Got '{result}'") |
| assert result == "haiku" |
|
|
|
|
| |
| |
| |
|
|
| class TestGetModelIdForKiro: |
| """ |
| Tests for get_model_id_for_kiro() function. |
| |
| Checks getting model ID for sending to Kiro API. |
| """ |
| |
| def test_normalizes_without_hidden_models(self): |
| """ |
| What it does: Normalizes model without hidden models. |
| Goal: Check basic normalization. |
| """ |
| print("Action: get_model_id_for_kiro('claude-haiku-4-5-20251001', {})...") |
| result = get_model_id_for_kiro("claude-haiku-4-5-20251001", {}) |
| |
| print(f"Comparing result: Expected 'claude-haiku-4.5', Got '{result}'") |
| assert result == "claude-haiku-4.5" |
| |
| def test_returns_internal_id_for_hidden_model(self): |
| """ |
| What it does: Returns internal ID for hidden model. |
| Goal: Check hidden model resolution. |
| """ |
| hidden = {"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0"} |
| |
| print("Action: get_model_id_for_kiro('claude-3.7-sonnet', hidden)...") |
| result = get_model_id_for_kiro("claude-3.7-sonnet", hidden) |
| |
| print(f"Comparing result: Expected 'CLAUDE_3_7_SONNET_20250219_V1_0', Got '{result}'") |
| assert result == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| |
| def test_normalizes_then_checks_hidden(self): |
| """ |
| What it does: Normalizes first, then checks hidden. |
| Goal: Check operation order. |
| """ |
| hidden = {"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0"} |
| |
| print("Action: get_model_id_for_kiro('claude-3-7-sonnet', hidden)...") |
| result = get_model_id_for_kiro("claude-3-7-sonnet", hidden) |
| |
| print(f"Comparing result: Expected 'CLAUDE_3_7_SONNET_20250219_V1_0', Got '{result}'") |
| assert result == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| |
| def test_normalizes_with_date_then_checks_hidden(self): |
| """ |
| What it does: Normalizes with date suffix, then checks hidden. |
| Goal: Check full normalization chain. |
| """ |
| hidden = {"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0"} |
| |
| print("Action: get_model_id_for_kiro('claude-3-7-sonnet-20250219', hidden)...") |
| result = get_model_id_for_kiro("claude-3-7-sonnet-20250219", hidden) |
| |
| print(f"Comparing result: Expected 'CLAUDE_3_7_SONNET_20250219_V1_0', Got '{result}'") |
| assert result == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| |
| def test_passthrough_unknown_model(self): |
| """ |
| What it does: Passthrough for unknown models. |
| Goal: Check that unknown models pass through normalized. |
| """ |
| print("Action: get_model_id_for_kiro('claude-unknown-model', {})...") |
| result = get_model_id_for_kiro("claude-unknown-model", {}) |
| |
| print(f"Comparing result: Expected 'claude-unknown-model', Got '{result}'") |
| assert result == "claude-unknown-model" |
|
|
|
|
| |
| |
| |
|
|
| class TestModelResolverInitialization: |
| """Tests for ModelResolver initialization.""" |
| |
| def test_init_with_cache_and_hidden_models(self, mock_model_cache, hidden_models): |
| """ |
| What it does: Creates ModelResolver with cache and hidden models. |
| Goal: Check correct initialization. |
| """ |
| print("Action: Creating ModelResolver...") |
| resolver = ModelResolver(cache=mock_model_cache, hidden_models=hidden_models) |
| |
| print("Check: Attributes set correctly...") |
| assert resolver.cache is mock_model_cache |
| assert resolver.hidden_models == hidden_models |
| |
| def test_init_with_empty_hidden_models(self, mock_model_cache): |
| """ |
| What it does: Creates ModelResolver without hidden models. |
| Goal: Check work with empty dict. |
| """ |
| print("Action: Creating ModelResolver without hidden models...") |
| resolver = ModelResolver(cache=mock_model_cache, hidden_models={}) |
| |
| print("Check: hidden_models is empty...") |
| assert resolver.hidden_models == {} |
| |
| def test_init_with_none_hidden_models(self, mock_model_cache): |
| """ |
| What it does: Creates ModelResolver with hidden_models=None. |
| Goal: Check default value. |
| """ |
| print("Action: Creating ModelResolver with hidden_models=None...") |
| resolver = ModelResolver(cache=mock_model_cache, hidden_models=None) |
| |
| print("Check: hidden_models initialized as empty dict...") |
| assert resolver.hidden_models == {} |
|
|
|
|
| class TestModelResolverResolve: |
| """Tests for resolve() method of ModelResolver class.""" |
| |
| def test_resolve_finds_model_in_cache(self, model_resolver): |
| """ |
| What it does: Finds model in cache. |
| Goal: Check Layer 2 (Dynamic Cache). |
| """ |
| print("Action: Resolving 'claude-haiku-4-5'...") |
| result = model_resolver.resolve("claude-haiku-4-5") |
| |
| print(f"Check result: {result}") |
| print(f"Comparing internal_id: Expected 'claude-haiku-4.5', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-haiku-4.5" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| print(f"Comparing is_verified: Expected True, Got {result.is_verified}") |
| assert result.is_verified is True |
| |
| print(f"Comparing normalized: Expected 'claude-haiku-4.5', Got '{result.normalized}'") |
| assert result.normalized == "claude-haiku-4.5" |
| |
| print(f"Comparing original_request: Expected 'claude-haiku-4-5', Got '{result.original_request}'") |
| assert result.original_request == "claude-haiku-4-5" |
| |
| def test_resolve_finds_model_in_hidden(self, model_resolver): |
| """ |
| What it does: Finds model in hidden models. |
| Goal: Check Layer 3 (Hidden Models). |
| """ |
| print("Action: Resolving 'claude-3-7-sonnet'...") |
| result = model_resolver.resolve("claude-3-7-sonnet") |
| |
| print(f"Check result: {result}") |
| print(f"Comparing internal_id: Expected 'CLAUDE_3_7_SONNET_20250219_V1_0', Got '{result.internal_id}'") |
| assert result.internal_id == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| |
| print(f"Comparing source: Expected 'hidden', Got '{result.source}'") |
| assert result.source == "hidden" |
| |
| print(f"Comparing is_verified: Expected True, Got {result.is_verified}") |
| assert result.is_verified is True |
| |
| def test_resolve_passthrough_for_unknown(self, model_resolver): |
| """ |
| What it does: Passthrough for unknown model. |
| Goal: Check Layer 4 (Pass-through). |
| """ |
| print("Action: Resolving 'claude-haiku-4-6' (does not exist)...") |
| result = model_resolver.resolve("claude-haiku-4-6") |
| |
| print(f"Check result: {result}") |
| print(f"Comparing internal_id: Expected 'claude-haiku-4.6', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-haiku-4.6" |
| |
| print(f"Comparing source: Expected 'passthrough', Got '{result.source}'") |
| assert result.source == "passthrough" |
| |
| print(f"Comparing is_verified: Expected False, Got {result.is_verified}") |
| assert result.is_verified is False |
| |
| def test_resolve_normalizes_before_lookup(self, model_resolver): |
| """ |
| What it does: Normalizes name before cache lookup. |
| Goal: Check Layer 1 (Normalize Name). |
| """ |
| print("Action: Resolving 'claude-haiku-4-5-20251001'...") |
| result = model_resolver.resolve("claude-haiku-4-5-20251001") |
| |
| print(f"Comparing normalized: Expected 'claude-haiku-4.5', Got '{result.normalized}'") |
| assert result.normalized == "claude-haiku-4.5" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| def test_resolve_never_raises(self, model_resolver): |
| """ |
| What it does: Never raises exception. |
| Goal: Check that resolve() always returns ModelResolution. |
| """ |
| print("Action: Resolving strange input data...") |
| |
| |
| result1 = model_resolver.resolve("") |
| print(f"Empty string: {result1}") |
| assert isinstance(result1, ModelResolution) |
| |
| |
| result2 = model_resolver.resolve("!@#$%^&*()") |
| print(f"Special characters: {result2}") |
| assert isinstance(result2, ModelResolution) |
| |
| |
| result3 = model_resolver.resolve("a" * 1000) |
| print(f"Long name: source={result3.source}") |
| assert isinstance(result3, ModelResolution) |
| |
| def test_resolve_auto_model(self, model_resolver): |
| """ |
| What it does: Resolves 'auto' model. |
| Goal: Check that 'auto' is in cache. |
| """ |
| print("Action: Resolving 'auto'...") |
| result = model_resolver.resolve("auto") |
| |
| print(f"Comparing internal_id: Expected 'auto', Got '{result.internal_id}'") |
| assert result.internal_id == "auto" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| def test_resolve_with_empty_cache(self, empty_model_cache, hidden_models): |
| """ |
| What it does: Resolves model with empty cache. |
| Goal: Check work with only hidden models. |
| """ |
| print("Setup: Creating resolver with empty cache...") |
| resolver = ModelResolver(cache=empty_model_cache, hidden_models=hidden_models) |
| |
| print("Action: Resolving 'claude-3.7-sonnet'...") |
| result = resolver.resolve("claude-3.7-sonnet") |
| |
| print(f"Comparing source: Expected 'hidden', Got '{result.source}'") |
| assert result.source == "hidden" |
| |
| print("Action: Resolving 'claude-haiku-4.5' (not in cache)...") |
| result2 = resolver.resolve("claude-haiku-4.5") |
| |
| print(f"Comparing source: Expected 'passthrough', Got '{result2.source}'") |
| assert result2.source == "passthrough" |
|
|
|
|
| class TestModelResolverGetAvailableModels: |
| """Tests for get_available_models() method.""" |
| |
| def test_get_available_models_combines_cache_and_hidden(self, model_resolver): |
| """ |
| What it does: Returns models from cache and hidden. |
| Goal: Check combining sources. |
| """ |
| print("Action: Getting list of available models...") |
| models = model_resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| |
| |
| print("Check: Cache models present...") |
| assert "claude-haiku-4.5" in models |
| assert "claude-sonnet-4.5" in models |
| assert "claude-opus-4.5" in models |
| assert "auto" in models |
| |
| |
| print("Check: Hidden models present...") |
| assert "claude-3.7-sonnet" in models |
| |
| def test_get_available_models_returns_sorted_list(self, model_resolver): |
| """ |
| What it does: Returns sorted list. |
| Goal: Check sorting. |
| """ |
| print("Action: Getting list of available models...") |
| models = model_resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print(f"Sorted: {sorted(models)}") |
| |
| assert models == sorted(models) |
| |
| def test_get_available_models_no_duplicates(self, mock_model_cache): |
| """ |
| What it does: Does not return duplicates. |
| Goal: Check uniqueness. |
| """ |
| |
| hidden = {"claude-haiku-4.5": "SOME_INTERNAL_ID"} |
| resolver = ModelResolver(cache=mock_model_cache, hidden_models=hidden) |
| |
| print("Action: Getting list with potential duplicate...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| |
| |
| assert len(models) == len(set(models)) |
|
|
|
|
| class TestModelResolverGetModelsByFamily: |
| """Tests for get_models_by_family() method.""" |
| |
| def test_get_models_by_family_haiku(self, model_resolver): |
| """ |
| What it does: Returns only Haiku models. |
| Goal: Check filtering by family. |
| """ |
| print("Action: Getting Haiku models...") |
| models = model_resolver.get_models_by_family("haiku") |
| |
| print(f"Received models: {models}") |
| |
| assert "claude-haiku-4.5" in models |
| assert "claude-sonnet-4.5" not in models |
| assert "claude-opus-4.5" not in models |
| |
| def test_get_models_by_family_sonnet(self, model_resolver): |
| """ |
| What it does: Returns only Sonnet models. |
| Goal: Check Sonnet filtering. |
| """ |
| print("Action: Getting Sonnet models...") |
| models = model_resolver.get_models_by_family("sonnet") |
| |
| print(f"Received models: {models}") |
| |
| assert "claude-sonnet-4.5" in models |
| assert "claude-sonnet-4" in models |
| assert "claude-3.7-sonnet" in models |
| assert "claude-haiku-4.5" not in models |
| |
| def test_get_models_by_family_opus(self, model_resolver): |
| """ |
| What it does: Returns only Opus models. |
| Goal: Check Opus filtering. |
| """ |
| print("Action: Getting Opus models...") |
| models = model_resolver.get_models_by_family("opus") |
| |
| print(f"Received models: {models}") |
| |
| assert "claude-opus-4.5" in models |
| assert "claude-sonnet-4.5" not in models |
| |
| def test_get_models_by_family_case_insensitive(self, model_resolver): |
| """ |
| What it does: Filtering is case insensitive. |
| Goal: Check case-insensitivity. |
| """ |
| print("Action: Getting HAIKU models (uppercase)...") |
| models = model_resolver.get_models_by_family("HAIKU") |
| |
| print(f"Received models: {models}") |
| |
| assert "claude-haiku-4.5" in models |
|
|
|
|
| class TestModelResolverGetSuggestionsForModel: |
| """Tests for get_suggestions_for_model() method.""" |
| |
| def test_get_suggestions_returns_same_family(self, model_resolver): |
| """ |
| What it does: Returns models of same family. |
| Goal: Check that suggestions are from same family. |
| """ |
| print("Action: Getting suggestions for 'claude-haiku-4-6'...") |
| suggestions = model_resolver.get_suggestions_for_model("claude-haiku-4-6") |
| |
| print(f"Received suggestions: {suggestions}") |
| |
| |
| for s in suggestions: |
| print(f"Check: '{s}' contains 'haiku'...") |
| assert "haiku" in s.lower() |
| |
| def test_get_suggestions_no_cross_family(self, model_resolver): |
| """ |
| What it does: NEVER suggests models from other family. |
| Goal: Critical check - Opus never becomes Sonnet! |
| """ |
| print("Action: Getting suggestions for 'claude-opus-5'...") |
| suggestions = model_resolver.get_suggestions_for_model("claude-opus-5") |
| |
| print(f"Received suggestions: {suggestions}") |
| |
| |
| for s in suggestions: |
| print(f"Check: '{s}' does NOT contain 'sonnet' or 'haiku'...") |
| assert "sonnet" not in s.lower() |
| assert "haiku" not in s.lower() |
| |
| def test_get_suggestions_returns_all_for_unknown_family(self, model_resolver): |
| """ |
| What it does: Returns all models for unknown family. |
| Goal: Check fallback for non-Claude models. |
| """ |
| print("Action: Getting suggestions for 'gpt-4'...") |
| suggestions = model_resolver.get_suggestions_for_model("gpt-4") |
| |
| print(f"Received suggestions: {suggestions}") |
| |
| |
| all_models = model_resolver.get_available_models() |
| assert set(suggestions) == set(all_models) |
|
|
|
|
| |
| |
| |
|
|
| class TestModelResolution: |
| """Tests for ModelResolution dataclass.""" |
| |
| def test_model_resolution_fields(self): |
| """ |
| What it does: Checks all ModelResolution fields. |
| Goal: Ensure correct structure. |
| """ |
| print("Action: Creating ModelResolution...") |
| resolution = ModelResolution( |
| internal_id="claude-haiku-4.5", |
| source="cache", |
| original_request="claude-haiku-4-5", |
| normalized="claude-haiku-4.5", |
| is_verified=True |
| ) |
| |
| print(f"Check fields: {resolution}") |
| assert resolution.internal_id == "claude-haiku-4.5" |
| assert resolution.source == "cache" |
| assert resolution.original_request == "claude-haiku-4-5" |
| assert resolution.normalized == "claude-haiku-4.5" |
| assert resolution.is_verified is True |
| |
| def test_model_resolution_is_frozen(self): |
| """ |
| What it does: Checks that ModelResolution is immutable. |
| Goal: Ensure immutability (frozen=True). |
| """ |
| print("Action: Creating ModelResolution...") |
| resolution = ModelResolution( |
| internal_id="test", |
| source="cache", |
| original_request="test", |
| normalized="test", |
| is_verified=True |
| ) |
| |
| print("Check: Attempt to modify field should raise error...") |
| with pytest.raises(FrozenInstanceError): |
| resolution.internal_id = "changed" |
| |
| def test_model_resolution_equality(self): |
| """ |
| What it does: Checks comparison of two ModelResolution objects. |
| Goal: Ensure correct __eq__ implementation. |
| """ |
| print("Action: Creating two identical ModelResolution objects...") |
| resolution1 = ModelResolution( |
| internal_id="test", |
| source="cache", |
| original_request="test", |
| normalized="test", |
| is_verified=True |
| ) |
| resolution2 = ModelResolution( |
| internal_id="test", |
| source="cache", |
| original_request="test", |
| normalized="test", |
| is_verified=True |
| ) |
| |
| print(f"Comparing: {resolution1} == {resolution2}") |
| assert resolution1 == resolution2 |
| |
| def test_model_resolution_inequality(self): |
| """ |
| What it does: Checks inequality of different ModelResolution objects. |
| Goal: Ensure correct __eq__ implementation. |
| """ |
| print("Action: Creating two different ModelResolution objects...") |
| resolution1 = ModelResolution( |
| internal_id="test1", |
| source="cache", |
| original_request="test", |
| normalized="test", |
| is_verified=True |
| ) |
| resolution2 = ModelResolution( |
| internal_id="test2", |
| source="hidden", |
| original_request="test", |
| normalized="test", |
| is_verified=True |
| ) |
| |
| print(f"Comparing: {resolution1} != {resolution2}") |
| assert resolution1 != resolution2 |
|
|
|
|
| |
| |
| |
|
|
| class TestModelInfoCacheIsValidModel: |
| """Tests for is_valid_model() method in ModelInfoCache.""" |
| |
| @pytest.mark.asyncio |
| async def test_is_valid_model_returns_true_for_cached(self): |
| """ |
| What it does: Returns True for model in cache. |
| Goal: Check basic functionality. |
| """ |
| print("Setup: Creating and populating cache...") |
| cache = ModelInfoCache() |
| await cache.update([{"modelId": "claude-sonnet-4.5"}]) |
| |
| print("Action: Checking is_valid_model('claude-sonnet-4.5')...") |
| result = cache.is_valid_model("claude-sonnet-4.5") |
| |
| print(f"Comparing result: Expected True, Got {result}") |
| assert result is True |
| |
| @pytest.mark.asyncio |
| async def test_is_valid_model_returns_false_for_unknown(self): |
| """ |
| What it does: Returns False for unknown model. |
| Goal: Check negative case. |
| """ |
| print("Setup: Creating and populating cache...") |
| cache = ModelInfoCache() |
| await cache.update([{"modelId": "claude-sonnet-4.5"}]) |
| |
| print("Action: Checking is_valid_model('unknown-model')...") |
| result = cache.is_valid_model("unknown-model") |
| |
| print(f"Comparing result: Expected False, Got {result}") |
| assert result is False |
| |
| def test_is_valid_model_on_empty_cache(self): |
| """ |
| What it does: Returns False for empty cache. |
| Goal: Check edge case. |
| """ |
| print("Setup: Creating empty cache...") |
| cache = ModelInfoCache() |
| |
| print("Action: Checking is_valid_model('any-model')...") |
| result = cache.is_valid_model("any-model") |
| |
| print(f"Comparing result: Expected False, Got {result}") |
| assert result is False |
|
|
|
|
| class TestModelInfoCacheAddHiddenModel: |
| """Tests for add_hidden_model() method in ModelInfoCache.""" |
| |
| def test_add_hidden_model_adds_to_cache(self): |
| """ |
| What it does: Adds hidden model to cache. |
| Goal: Check basic functionality. |
| """ |
| print("Setup: Creating empty cache...") |
| cache = ModelInfoCache() |
| |
| print("Action: Adding hidden model...") |
| cache.add_hidden_model("claude-3.7-sonnet", "CLAUDE_3_7_SONNET_20250219_V1_0") |
| |
| print("Check: Model added to cache...") |
| assert cache.is_valid_model("claude-3.7-sonnet") is True |
| |
| def test_add_hidden_model_stores_internal_id(self): |
| """ |
| What it does: Stores internal ID in _internal_id field. |
| Goal: Check data structure. |
| """ |
| print("Setup: Creating empty cache...") |
| cache = ModelInfoCache() |
| |
| print("Action: Adding hidden model...") |
| cache.add_hidden_model("claude-3.7-sonnet", "CLAUDE_3_7_SONNET_20250219_V1_0") |
| |
| print("Check: _internal_id saved...") |
| model_info = cache.get("claude-3.7-sonnet") |
| print(f"model_info: {model_info}") |
| |
| assert model_info["_internal_id"] == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| assert model_info["_is_hidden"] is True |
| |
| def test_add_hidden_model_sets_model_id(self): |
| """ |
| What it does: Sets modelId equal to display_name. |
| Goal: Check data consistency. |
| """ |
| print("Setup: Creating empty cache...") |
| cache = ModelInfoCache() |
| |
| print("Action: Adding hidden model...") |
| cache.add_hidden_model("claude-3.7-sonnet", "INTERNAL_ID") |
| |
| print("Check: modelId set...") |
| model_info = cache.get("claude-3.7-sonnet") |
| |
| assert model_info["modelId"] == "claude-3.7-sonnet" |
| assert model_info["modelName"] == "claude-3.7-sonnet" |
| |
| @pytest.mark.asyncio |
| async def test_add_hidden_model_does_not_overwrite_existing(self): |
| """ |
| What it does: Does not overwrite existing model. |
| Goal: Check protection from overwriting. |
| """ |
| print("Setup: Creating cache with model...") |
| cache = ModelInfoCache() |
| await cache.update([{ |
| "modelId": "claude-3.7-sonnet", |
| "modelName": "Original Name", |
| "tokenLimits": {"maxInputTokens": 200000} |
| }]) |
| |
| print("Action: Attempting to add hidden model with same ID...") |
| cache.add_hidden_model("claude-3.7-sonnet", "NEW_INTERNAL_ID") |
| |
| print("Check: Original data preserved...") |
| model_info = cache.get("claude-3.7-sonnet") |
| |
| assert model_info["modelName"] == "Original Name" |
| assert "_internal_id" not in model_info |
| |
| def test_add_hidden_model_appears_in_get_all_model_ids(self): |
| """ |
| What it does: Hidden model appears in list of all models. |
| Goal: Check integration with get_all_model_ids(). |
| """ |
| print("Setup: Creating empty cache...") |
| cache = ModelInfoCache() |
| |
| print("Action: Adding hidden model...") |
| cache.add_hidden_model("claude-3.7-sonnet", "INTERNAL_ID") |
| |
| print("Check: Model in list...") |
| model_ids = cache.get_all_model_ids() |
| |
| assert "claude-3.7-sonnet" in model_ids |
|
|
|
|
| |
| |
| |
|
|
| class TestCriticalSafetyPrinciple: |
| """ |
| Critical security tests: Family Isolation. |
| |
| IMPORTANT: Resolver MUST NEVER cross model family boundaries! |
| """ |
| |
| def test_opus_never_becomes_sonnet(self, model_resolver): |
| """ |
| What it does: Opus request NEVER becomes Sonnet. |
| Goal: Critical check for Family Isolation. |
| """ |
| print("Action: Resolving non-existent Opus model...") |
| result = model_resolver.resolve("claude-opus-5") |
| |
| print(f"Result: {result}") |
| |
| |
| print("Check: Does NOT contain 'sonnet'...") |
| assert "sonnet" not in result.internal_id.lower() |
| |
| print("Check: Does NOT contain 'haiku'...") |
| assert "haiku" not in result.internal_id.lower() |
| |
| def test_haiku_never_becomes_opus(self, model_resolver): |
| """ |
| What it does: Haiku request NEVER becomes Opus. |
| Goal: Critical check for Family Isolation. |
| """ |
| print("Action: Resolving non-existent Haiku model...") |
| result = model_resolver.resolve("claude-haiku-5") |
| |
| print(f"Result: {result}") |
| |
| |
| print("Check: Does NOT contain 'opus'...") |
| assert "opus" not in result.internal_id.lower() |
| |
| print("Check: Does NOT contain 'sonnet'...") |
| assert "sonnet" not in result.internal_id.lower() |
| |
| def test_sonnet_never_becomes_haiku(self, model_resolver): |
| """ |
| What it does: Sonnet request NEVER becomes Haiku. |
| Goal: Critical check for Family Isolation. |
| """ |
| print("Action: Resolving non-existent Sonnet model...") |
| result = model_resolver.resolve("claude-sonnet-5") |
| |
| print(f"Result: {result}") |
| |
| |
| print("Check: Does NOT contain 'haiku'...") |
| assert "haiku" not in result.internal_id.lower() |
| |
| print("Check: Does NOT contain 'opus'...") |
| assert "opus" not in result.internal_id.lower() |
| |
| def test_suggestions_respect_family_boundaries(self, model_resolver): |
| """ |
| What it does: Suggestions only from same family. |
| Goal: Check that get_suggestions_for_model() respects boundaries. |
| """ |
| families = ["haiku", "sonnet", "opus"] |
| |
| for family in families: |
| print(f"Check family: {family}...") |
| suggestions = model_resolver.get_suggestions_for_model(f"claude-{family}-99") |
| |
| for suggestion in suggestions: |
| print(f" Suggestion: {suggestion}") |
| |
| assert family in suggestion.lower(), \ |
| f"Suggestion '{suggestion}' not from family '{family}'!" |
|
|
|
|
| |
| |
| |
|
|
| class TestModelAliasSystemBasics: |
| """ |
| Tests for model alias system (Layer 0). |
| |
| Aliases allow custom names that map to real model IDs. |
| Use case: Avoid conflicts with IDE-specific model names (e.g., Cursor's "auto"). |
| """ |
| |
| def test_alias_resolves_to_target_model(self, mock_model_cache): |
| """ |
| What it does: Alias resolves to target model. |
| Purpose: Basic alias functionality. |
| """ |
| print("Setup: Creating resolver with alias...") |
| aliases = {"auto-kiro": "auto"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'auto-kiro'...") |
| result = resolver.resolve("auto-kiro") |
| |
| print(f"Comparing internal_id: Expected 'auto', Got '{result.internal_id}'") |
| assert result.internal_id == "auto" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| print(f"Comparing original_request: Expected 'auto-kiro', Got '{result.original_request}'") |
| assert result.original_request == "auto-kiro" |
| |
| def test_non_aliased_models_work_normally(self, mock_model_cache): |
| """ |
| What it does: Non-aliased models work as before. |
| Purpose: Ensure aliases don't break existing functionality. |
| """ |
| print("Setup: Creating resolver with alias...") |
| aliases = {"auto-kiro": "auto"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'claude-haiku-4.5' (not aliased)...") |
| result = resolver.resolve("claude-haiku-4.5") |
| |
| print(f"Comparing internal_id: Expected 'claude-haiku-4.5', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-haiku-4.5" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| def test_alias_with_normalization_chain(self, mock_model_cache): |
| """ |
| What it does: Alias → Normalization → Cache. |
| Purpose: Ensure alias works with normalization. |
| """ |
| print("Setup: Creating resolver with alias to unnormalized name...") |
| aliases = {"my-haiku": "claude-haiku-4-5"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'my-haiku'...") |
| result = resolver.resolve("my-haiku") |
| |
| print(f"Comparing internal_id: Expected 'claude-haiku-4.5', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-haiku-4.5" |
| |
| print(f"Comparing normalized: Expected 'claude-haiku-4.5', Got '{result.normalized}'") |
| assert result.normalized == "claude-haiku-4.5" |
| |
| def test_multiple_aliases(self, mock_model_cache): |
| """ |
| What it does: Multiple aliases work correctly. |
| Purpose: Ensure multiple aliases don't interfere. |
| """ |
| print("Setup: Creating resolver with multiple aliases...") |
| aliases = { |
| "auto-kiro": "auto", |
| "my-opus": "claude-opus-4.5", |
| "fast": "claude-haiku-4.5" |
| } |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving all aliases...") |
| result1 = resolver.resolve("auto-kiro") |
| result2 = resolver.resolve("my-opus") |
| result3 = resolver.resolve("fast") |
| |
| print(f"Comparing: auto-kiro → {result1.internal_id}") |
| assert result1.internal_id == "auto" |
| |
| print(f"Comparing: my-opus → {result2.internal_id}") |
| assert result2.internal_id == "claude-opus-4.5" |
| |
| print(f"Comparing: fast → {result3.internal_id}") |
| assert result3.internal_id == "claude-haiku-4.5" |
|
|
|
|
| class TestModelAliasSystemEdgeCases: |
| """Edge cases and boundary conditions for alias system.""" |
| |
| def test_alias_to_non_existent_model(self, mock_model_cache): |
| """ |
| What it does: Alias pointing to non-existent model. |
| Purpose: Ensure passthrough works for aliased non-existent models. |
| """ |
| print("Setup: Creating resolver with alias to non-existent model...") |
| aliases = {"future-model": "claude-haiku-5.0"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'future-model'...") |
| result = resolver.resolve("future-model") |
| |
| print(f"Comparing internal_id: Expected 'claude-haiku-5.0', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-haiku-5.0" |
| |
| print(f"Comparing source: Expected 'passthrough', Got '{result.source}'") |
| assert result.source == "passthrough" |
| |
| print(f"Comparing is_verified: Expected False, Got {result.is_verified}") |
| assert result.is_verified is False |
| |
| def test_alias_to_hidden_model(self, mock_model_cache): |
| """ |
| What it does: Alias pointing to hidden model. |
| Purpose: Ensure alias works with hidden models. |
| """ |
| print("Setup: Creating resolver with alias to hidden model...") |
| hidden = {"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0"} |
| aliases = {"legacy-sonnet": "claude-3.7-sonnet"} |
| resolver = ModelResolver(cache=mock_model_cache, hidden_models=hidden, aliases=aliases) |
| |
| print("Action: Resolving 'legacy-sonnet'...") |
| result = resolver.resolve("legacy-sonnet") |
| |
| print(f"Comparing internal_id: Expected 'CLAUDE_3_7_SONNET_20250219_V1_0', Got '{result.internal_id}'") |
| assert result.internal_id == "CLAUDE_3_7_SONNET_20250219_V1_0" |
| |
| print(f"Comparing source: Expected 'hidden', Got '{result.source}'") |
| assert result.source == "hidden" |
| |
| def test_empty_aliases_dict(self, mock_model_cache): |
| """ |
| What it does: Empty aliases dict works correctly. |
| Purpose: Ensure empty dict doesn't break anything. |
| """ |
| print("Setup: Creating resolver with empty aliases...") |
| resolver = ModelResolver(cache=mock_model_cache, aliases={}) |
| |
| print("Action: Resolving 'auto'...") |
| result = resolver.resolve("auto") |
| |
| print(f"Comparing internal_id: Expected 'auto', Got '{result.internal_id}'") |
| assert result.internal_id == "auto" |
| |
| def test_none_aliases(self, mock_model_cache): |
| """ |
| What it does: None aliases parameter works correctly. |
| Purpose: Ensure None is handled as empty dict. |
| """ |
| print("Setup: Creating resolver with aliases=None...") |
| resolver = ModelResolver(cache=mock_model_cache, aliases=None) |
| |
| print("Check: aliases initialized as empty dict...") |
| assert resolver.aliases == {} |
| |
| def test_alias_with_same_name_as_real_model(self, mock_model_cache): |
| """ |
| What it does: Alias with same name as real model. |
| Purpose: CRITICAL - alias should take precedence! |
| """ |
| print("Setup: Creating resolver with alias shadowing real model...") |
| |
| aliases = {"auto": "claude-sonnet-4.5"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'auto'...") |
| result = resolver.resolve("auto") |
| |
| print(f"Comparing internal_id: Expected 'claude-sonnet-4.5', Got '{result.internal_id}'") |
| assert result.internal_id == "claude-sonnet-4.5" |
| |
| print("CRITICAL: Alias takes precedence over cache!") |
| |
| def test_alias_case_sensitivity(self, mock_model_cache): |
| """ |
| What it does: Aliases are case-sensitive. |
| Purpose: Ensure case sensitivity is preserved. |
| """ |
| print("Setup: Creating resolver with lowercase alias...") |
| aliases = {"auto-kiro": "auto"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'AUTO-KIRO' (uppercase)...") |
| result = resolver.resolve("AUTO-KIRO") |
| |
| print(f"Comparing internal_id: Expected 'AUTO-KIRO' (passthrough preserves case), Got '{result.internal_id}'") |
| |
| assert result.internal_id == "AUTO-KIRO" |
| assert result.source == "passthrough" |
|
|
|
|
| class TestHiddenFromListFunctionality: |
| """Tests for HIDDEN_FROM_LIST feature.""" |
| |
| def test_hidden_model_not_in_available_list(self, mock_model_cache): |
| """ |
| What it does: Hidden model doesn't appear in get_available_models(). |
| Purpose: Basic HIDDEN_FROM_LIST functionality. |
| """ |
| print("Setup: Creating resolver with hidden_from_list...") |
| hidden_from_list = ["auto"] |
| resolver = ModelResolver(cache=mock_model_cache, hidden_from_list=hidden_from_list) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print("Check: 'auto' NOT in list...") |
| assert "auto" not in models |
| |
| print("Check: Other models still present...") |
| assert "claude-haiku-4.5" in models |
| assert "claude-sonnet-4.5" in models |
| |
| def test_hidden_model_still_works_when_requested(self, mock_model_cache): |
| """ |
| What it does: Hidden model still works when requested directly. |
| Purpose: CRITICAL - hiding from list doesn't disable the model! |
| """ |
| print("Setup: Creating resolver with hidden_from_list...") |
| hidden_from_list = ["auto"] |
| resolver = ModelResolver(cache=mock_model_cache, hidden_from_list=hidden_from_list) |
| |
| print("Action: Resolving 'auto' directly...") |
| result = resolver.resolve("auto") |
| |
| print(f"Comparing internal_id: Expected 'auto', Got '{result.internal_id}'") |
| assert result.internal_id == "auto" |
| |
| print(f"Comparing source: Expected 'cache', Got '{result.source}'") |
| assert result.source == "cache" |
| |
| print("CRITICAL: Hidden model still works!") |
| |
| def test_alias_appears_when_original_hidden(self, mock_model_cache): |
| """ |
| What it does: Alias appears in list when original is hidden. |
| Purpose: This is the main use case - show alias, hide original. |
| """ |
| print("Setup: Creating resolver with alias and hidden original...") |
| aliases = {"auto-kiro": "auto"} |
| hidden_from_list = ["auto"] |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases, hidden_from_list=hidden_from_list) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print("Check: 'auto-kiro' (alias) IS in list...") |
| assert "auto-kiro" in models |
| |
| print("Check: 'auto' (original) NOT in list...") |
| assert "auto" not in models |
| |
| print("SUCCESS: Alias visible, original hidden!") |
| |
| def test_multiple_hidden_models(self, mock_model_cache): |
| """ |
| What it does: Multiple models can be hidden. |
| Purpose: Ensure list works with multiple entries. |
| """ |
| print("Setup: Creating resolver with multiple hidden models...") |
| hidden_from_list = ["auto", "claude-sonnet-4"] |
| resolver = ModelResolver(cache=mock_model_cache, hidden_from_list=hidden_from_list) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print("Check: Both hidden models NOT in list...") |
| assert "auto" not in models |
| assert "claude-sonnet-4" not in models |
| |
| print("Check: Other models still present...") |
| assert "claude-haiku-4.5" in models |
| |
| def test_empty_hidden_from_list(self, mock_model_cache): |
| """ |
| What it does: Empty hidden_from_list works correctly. |
| Purpose: Ensure empty list doesn't break anything. |
| """ |
| print("Setup: Creating resolver with empty hidden_from_list...") |
| resolver = ModelResolver(cache=mock_model_cache, hidden_from_list=[]) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print("Check: All models present...") |
| assert "auto" in models |
| assert "claude-haiku-4.5" in models |
| |
| def test_none_hidden_from_list(self, mock_model_cache): |
| """ |
| What it does: None hidden_from_list parameter works correctly. |
| Purpose: Ensure None is handled as empty set. |
| """ |
| print("Setup: Creating resolver with hidden_from_list=None...") |
| resolver = ModelResolver(cache=mock_model_cache, hidden_from_list=None) |
| |
| print("Check: hidden_from_list initialized as empty set...") |
| assert resolver.hidden_from_list == set() |
|
|
|
|
| class TestAliasSystemIntegration: |
| """Integration tests for alias system with existing layers.""" |
| |
| def test_cursor_auto_conflict_solution(self, mock_model_cache): |
| """ |
| What it does: Solves Cursor IDE "auto" conflict. |
| Purpose: This is the MAIN use case from issue #59! |
| """ |
| print("Setup: Simulating Cursor conflict solution...") |
| aliases = {"auto-kiro": "auto"} |
| hidden_from_list = ["auto"] |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases, hidden_from_list=hidden_from_list) |
| |
| print("Action: Getting available models (what Cursor sees)...") |
| models = resolver.get_available_models() |
| |
| print(f"Models visible to Cursor: {models}") |
| print("Check: 'auto' NOT visible (no conflict)...") |
| assert "auto" not in models |
| |
| print("Check: 'auto-kiro' IS visible...") |
| assert "auto-kiro" in models |
| |
| print("Action: User requests 'auto-kiro' in Cursor...") |
| result = resolver.resolve("auto-kiro") |
| |
| print(f"Comparing: 'auto-kiro' resolves to '{result.internal_id}'") |
| assert result.internal_id == "auto" |
| |
| print("Action: Old code with 'auto' still works...") |
| result2 = resolver.resolve("auto") |
| |
| print(f"Comparing: 'auto' still resolves to '{result2.internal_id}'") |
| assert result2.internal_id == "auto" |
| |
| print("SUCCESS: Cursor conflict solved! ✅") |
| |
| def test_no_duplicates_in_available_models(self, mock_model_cache): |
| """ |
| What it does: No duplicates when alias points to existing model. |
| Purpose: Ensure set logic works correctly. |
| """ |
| print("Setup: Creating resolver with alias to existing model...") |
| aliases = {"my-haiku": "claude-haiku-4.5"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| print("Check: No duplicates...") |
| assert len(models) == len(set(models)) |
| |
| print("Check: Both alias and original present...") |
| assert "my-haiku" in models |
| assert "claude-haiku-4.5" in models |
| |
| def test_alias_with_hidden_models_and_hidden_from_list(self, mock_model_cache): |
| """ |
| What it does: Complex scenario with all features. |
| Purpose: Ensure all features work together. |
| """ |
| print("Setup: Creating resolver with all features...") |
| hidden_models = {"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0"} |
| aliases = {"auto-kiro": "auto", "legacy": "claude-3.7-sonnet"} |
| hidden_from_list = ["auto"] |
| |
| resolver = ModelResolver( |
| cache=mock_model_cache, |
| hidden_models=hidden_models, |
| aliases=aliases, |
| hidden_from_list=hidden_from_list |
| ) |
| |
| print("Action: Getting available models...") |
| models = resolver.get_available_models() |
| |
| print(f"Received models: {models}") |
| |
| print("Check: Alias 'auto-kiro' present...") |
| assert "auto-kiro" in models |
| |
| print("Check: Original 'auto' hidden...") |
| assert "auto" not in models |
| |
| print("Check: Hidden model 'claude-3.7-sonnet' present...") |
| assert "claude-3.7-sonnet" in models |
| |
| print("Check: Alias to hidden model 'legacy' present...") |
| assert "legacy" in models |
| |
| print("Check: Cache models present...") |
| assert "claude-haiku-4.5" in models |
|
|
|
|
| class TestAliasSystemSecurity: |
| """Security tests - ensure aliases don't break safety principles.""" |
| |
| def test_alias_cannot_bypass_family_isolation(self, mock_model_cache): |
| """ |
| What it does: Alias doesn't break family isolation. |
| Purpose: CRITICAL - ensure aliases don't create security holes! |
| """ |
| print("Setup: Creating resolver with alias...") |
| aliases = {"my-model": "claude-opus-5"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Resolving 'my-model' (points to non-existent Opus)...") |
| result = resolver.resolve("my-model") |
| |
| print(f"Result: {result}") |
| print("Check: Does NOT fallback to Sonnet or Haiku...") |
| assert "sonnet" not in result.internal_id.lower() |
| assert "haiku" not in result.internal_id.lower() |
| |
| print("Check: Passthrough preserves family...") |
| assert "opus" in result.internal_id.lower() |
| |
| def test_alias_suggestions_respect_target_family(self, mock_model_cache): |
| """ |
| What it does: Suggestions for aliased model respect target family. |
| Purpose: Ensure get_suggestions_for_model() works with aliases. |
| """ |
| print("Setup: Creating resolver with alias...") |
| aliases = {"fast": "claude-haiku-4.5"} |
| resolver = ModelResolver(cache=mock_model_cache, aliases=aliases) |
| |
| print("Action: Getting suggestions for 'fast'...") |
| |
| |
| suggestions = resolver.get_suggestions_for_model("fast") |
| |
| print(f"Received suggestions: {suggestions}") |
| |
| assert len(suggestions) > 0 |
|
|