Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ obj/
dist/
build/
output/
.next/

# Logs
logs/
Expand Down
128 changes: 86 additions & 42 deletions amplifier_foundation/spawn_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,37 @@ def _build_provider_lookup(
return lookup


def _select_provider_preference(
providers: list[dict[str, Any]],
preferences: list[ProviderPreference],
) -> tuple[ProviderPreference, int] | None:
"""Select the first available provider preference.

Exact-name lookup retains its existing aliases and precedence. If an exact
lookup misses, the preference is matched as a case-sensitive glob against
each provider's module, short module name, and optional instance id in mount
order.
"""
lookup = _build_provider_lookup(providers)

for pref in preferences:
target_idx = lookup.get(pref.provider)
if target_idx is not None:
return pref, target_idx

for i, provider in enumerate(providers):
module_id = provider.get("module", "")
names = [module_id, module_id.replace("provider-", "")]
instance_id = provider.get("id")
if instance_id:
names.append(instance_id)

if any(fnmatch.fnmatchcase(name, pref.provider) for name in names):
return pref, i

return None


def apply_provider_preferences(
mount_plan: dict[str, Any],
preferences: list[ProviderPreference],
Expand Down Expand Up @@ -484,16 +515,12 @@ def apply_provider_preferences(
logger.warning("Provider preferences specified but no providers in mount plan")
return mount_plan

# Build lookup for efficient matching
lookup = _build_provider_lookup(providers)

# Find first matching preference
for pref in preferences:
if pref.provider in lookup:
target_idx = lookup[pref.provider]
return _apply_single_override(
mount_plan, providers, target_idx, pref.model, pref.config
)
selection = _select_provider_preference(providers, preferences)
if selection:
pref, target_idx = selection
return _apply_single_override(
mount_plan, providers, target_idx, pref.model, pref.config
)

# No preferences matched
logger.warning(
Expand Down Expand Up @@ -591,47 +618,64 @@ async def apply_provider_preferences_with_resolution(
logger.warning("Provider preferences specified but no providers in mount plan")
return mount_plan

# Build lookup for efficient matching
selection = _select_provider_preference(providers, preferences)
if selection:
pref, target_idx = selection

# Iterate preferences in order: for each preference, try exact-name lookup
# first, then fall back to a mount-order, case-sensitive glob match over
# canonical module, short name, and instance id. If the model is a glob
# pattern, resolve it against the selected mount's canonical module and
# advance to the next preference if resolution fails (do NOT apply raw,
# unresolved glob strings into the mount plan).
lookup = _build_provider_lookup(providers)

# Find first matching preference whose model actually resolves, and
# apply it. A preference whose provider is present but whose glob
# pattern fails to resolve (no matching models) is NOT applied with
# the raw, unresolved pattern -- that would send a literal glob string
# to the provider's API. Instead we advance to the next preference in
# the ordered list, mirroring resolve_model_role()'s `continue`
# behavior in the sibling routing-matrix resolver.
for pref in preferences:
if pref.provider in lookup:
target_idx = lookup[pref.provider]

# Resolve model pattern if it's a glob
resolved_model = pref.model
if is_glob_pattern(pref.model):
result = await resolve_model_pattern(
pref.model, pref.provider, coordinator
# Exact-name match (fast path)
target_idx = lookup.get(pref.provider)
if target_idx is None:
# Mount-order glob match over module, short name, and id
for i, provider in enumerate(providers):
module_id = provider.get("module", "")
names = [module_id, module_id.replace("provider-", "")]
instance_id = provider.get("id")
if instance_id:
names.append(instance_id)
if any(fnmatch.fnmatchcase(name, pref.provider) for name in names):
target_idx = i
break

if target_idx is None:
# Preference's provider not present in this mount plan, try next
continue

# Resolve model pattern if it's a glob, using the selected mount's
# canonical module id (e.g., 'provider-openai'), not the flexible alias
# the preference might have used to select the provider.
resolved_model = pref.model
if is_glob_pattern(pref.model):
canonical_module = providers[target_idx].get("module", "")
result = await resolve_model_pattern(pref.model, canonical_module, coordinator)
if result.resolved_model is None:
logger.warning(
"Preference for provider '%s' failed to resolve model pattern '%s' - trying next preference",
pref.provider,
pref.model,
)
if result.resolved_model is None:
logger.warning(
"Preference for provider '%s' failed to resolve model "
"pattern '%s' - trying next preference",
pref.provider,
pref.model,
)
continue
resolved_model = result.resolved_model

return _apply_single_override(
mount_plan, providers, target_idx, resolved_model, pref.config
)
continue
resolved_model = result.resolved_model

return _apply_single_override(
mount_plan, providers, target_idx, resolved_model, pref.config
)

# No preferences matched -- either no preference's provider was present
# in the mount plan, or every candidate's model pattern failed to
# resolve. Either way, leave the mount plan unmodified rather than
# writing an unresolved pattern string into it.
# in the mount plan, or every candidate's model pattern failed to resolve.
# Leave the mount plan unmodified rather than writing an unresolved pattern.
logger.warning(
"No preferred providers found in mount plan. Preferences: %s, Available: %s",
[p.provider for p in preferences],
list({p.get("module", "?") for p in providers}),
)
return mount_plan

161 changes: 161 additions & 0 deletions tests/test_spawn_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,133 @@ def test_mount_plan_not_mutated(self) -> None:
assert result["providers"][0]["config"]["priority"] == 0
assert result["providers"][0]["config"]["default_model"] == "claude-haiku-3"

@pytest.mark.parametrize(
("provider_pattern", "expected_index"),
[
("*", 0),
("provider-anth*", 0),
("anthrop?c", 0),
("team-[ab]", 0),
],
)
def test_provider_glob_matches_module_short_name_or_id(
self,
provider_pattern: str,
expected_index: int,
) -> None:
"""Provider globs match all contracted provider names."""
mount_plan = {
"providers": [
{
"module": "provider-anthropic",
"id": "team-a",
"config": {"priority": 10},
},
{
"module": "provider-openai",
"id": "team-b",
"config": {"priority": 20},
},
]
}

result = apply_provider_preferences(
mount_plan,
[ProviderPreference(provider=provider_pattern, model="selected-model")],
)

assert result["providers"][expected_index]["config"]["priority"] == 0
assert (
result["providers"][expected_index]["config"]["default_model"]
== "selected-model"
)

def test_provider_glob_uses_first_provider_in_mount_order(self) -> None:
"""A glob matching multiple providers deterministically selects the first."""
mount_plan = {
"providers": [
{"module": "provider-openai-first", "config": {"priority": 10}},
{"module": "provider-openai-second", "config": {"priority": 20}},
]
}

result = apply_provider_preferences(
mount_plan,
[ProviderPreference(provider="openai-*", model="gpt-selected")],
)

assert result["providers"][0]["config"]["priority"] == 0
assert result["providers"][0]["config"]["default_model"] == "gpt-selected"
assert result["providers"][1]["config"] == {"priority": 20}

def test_exact_lookup_precedence_is_preserved(self) -> None:
"""Exact duplicate aliases retain the existing last-entry lookup behavior."""
mount_plan = {
"providers": [
{"module": "provider-openai", "config": {"priority": 10}},
{"module": "provider-openai", "config": {"priority": 20}},
]
}

result = apply_provider_preferences(
mount_plan,
[ProviderPreference(provider="openai", model="gpt-selected")],
)

assert result["providers"][0]["config"] == {"priority": 10}
assert result["providers"][1]["config"]["priority"] == 0
assert result["providers"][1]["config"]["default_model"] == "gpt-selected"

def test_provider_glob_applies_model_and_config_without_warning(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A glob match applies the full override and suppresses no-match warnings."""
mount_plan = {
"providers": [
{
"module": "provider-openai",
"config": {"priority": 10, "reasoning_effort": "low"},
}
]
}

result = apply_provider_preferences(
mount_plan,
[
ProviderPreference(
provider="open*",
model="gpt-5",
config={"reasoning_effort": "high", "temperature": 0.2},
)
],
)

config = result["providers"][0]["config"]
assert config["priority"] == 0
assert config["default_model"] == "gpt-5"
assert config["reasoning_effort"] == "high"
assert config["temperature"] == 0.2
assert "No preferred providers found" not in caplog.text

def test_provider_glob_is_case_sensitive_and_non_match_is_unchanged(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Provider glob matching is case-sensitive and preserves non-match behavior."""
mount_plan = {
"providers": [
{"module": "provider-openai", "config": {"priority": 10}},
]
}

result = apply_provider_preferences(
mount_plan,
[ProviderPreference(provider="Open*", model="gpt-5")],
)

assert result is mount_plan
assert result["providers"][0]["config"] == {"priority": 10}
assert "No preferred providers found" in caplog.text


class TestResolveModelPattern:
"""Tests for resolve_model_pattern function."""
Expand Down Expand Up @@ -704,6 +831,40 @@ async def test_all_preferences_fail_leaves_mount_plan_unmodified(self) -> None:
for p in result["providers"]:
assert "default_model" not in p["config"]

@pytest.mark.asyncio
async def test_provider_glob_resolves_model_with_canonical_module(self) -> None:
"""Model resolution uses the selected mount's canonical provider module."""
mount_plan = {
"providers": [
{
"module": "provider-openai",
"id": "production-openai",
"config": {"priority": 10},
},
]
}
mock_provider = AsyncMock()
mock_provider.list_models = AsyncMock(return_value=["gpt-5-2025", "gpt-5-2026"])
mock_coordinator = MagicMock()
mock_coordinator.get.return_value = {"provider-openai": mock_provider}

result = await apply_provider_preferences_with_resolution(
mount_plan,
[
ProviderPreference(
provider="production-*",
model="gpt-5-*",
config={"reasoning_effort": "high"},
)
],
mock_coordinator,
)

mock_provider.list_models.assert_awaited_once()
config = result["providers"][0]["config"]
assert config["default_model"] == "gpt-5-2026"
assert config["reasoning_effort"] == "high"


class TestProviderPreferenceConfig:
"""Tests for ProviderPreference config field."""
Expand Down