Skip to content
Merged
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
494 changes: 494 additions & 0 deletions PRPs/PRP-reliability-E1-doubled-provider-prefixes.md

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ def validate_model_identifier(v: str) -> str:
The validated model identifier, unchanged.

Raises:
ValueError: If the format is invalid, the model name is blank, or the
provider is not in :data:`VALID_MODEL_PROVIDERS`.
ValueError: If the format is invalid, the model name is blank, the
provider is not in :data:`VALID_MODEL_PROVIDERS`, or the model
name nests another provider prefix (e.g.
``google-gla:google-gla:gemini-3-flash-preview``). Multi-colon
Ollama tag identifiers stay valid (``ollama:llama3.1:8b`` —
``llama3.1`` is not a provider).
"""
if ":" not in v:
raise ValueError(
Expand All @@ -56,6 +60,19 @@ def validate_model_identifier(v: str) -> str:
raise ValueError(
f"Unknown provider '{provider}'. Valid providers: {list(VALID_MODEL_PROVIDERS)}"
)

# Reject a nested/doubled provider prefix inside the model name
# (issue #334: 'google-gla:google-gla:gemini-…' → Gemini 404 at run time).
# Ollama tags ('ollama:llama3.1:8b') stay valid: 'llama3.1' is not a provider.
if ":" in model_name:
nested = model_name.split(":", 1)[0]
if nested in VALID_MODEL_PROVIDERS:
suggested = f"{provider}:{model_name.split(':', 1)[1]}"
raise ValueError(
f"Nested provider prefix '{nested}:' inside the model name of '{v}'. "
f"Did you mean '{suggested}'? "
"Expected format: 'provider:model-name' with the provider given once."
)
return v


Expand Down
13 changes: 13 additions & 0 deletions app/features/agents/tests/test_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ def test_invalid_model_identifier_empty_provider(self):
with pytest.raises(ValidationError, match="Unknown provider"):
Settings(agent_default_model=":model-name")

def test_doubled_prefix_rejected_at_settings_boot(self):
"""A doubled provider prefix fails Settings construction (issue #334)."""
with pytest.raises(ValidationError, match="Nested provider"):
Settings(
agent_default_model="anthropic:anthropic:claude-sonnet-4-5",
_env_file=None,
)

def test_ollama_tag_accepted_at_settings_boot(self):
"""A multi-colon Ollama name:tag identifier constructs Settings fine."""
settings = Settings(agent_default_model="ollama:llama3.1:8b", _env_file=None)
assert settings.agent_default_model == "ollama:llama3.1:8b"


class TestAPIKeyValidation:
"""Test API key validation for models."""
Expand Down
18 changes: 17 additions & 1 deletion app/features/config/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import get_settings
from app.core.config import get_settings, validate_model_identifier
from app.core.logging import get_logger
from app.features.agents.agents.base import reset_agent_caches
from app.features.config.models import AppConfig
Expand All @@ -46,6 +46,10 @@
# Scalar types an ``app_config`` override value may hold.
OverrideValue = str | int | float | bool

# Override keys holding a "provider:model-name" identifier — re-validated at
# startup so a malformed persisted row never goes live (issue #334).
_MODEL_ID_KEYS = frozenset({"agent_default_model", "agent_fallback_model"})


# =============================================================================
# Helpers
Expand Down Expand Up @@ -190,6 +194,18 @@ async def apply_overrides_on_startup(db: AsyncSession) -> None:
for key, value in overrides.items():
if key not in ALLOWED_OVERRIDE_KEYS:
continue
if key in _MODEL_ID_KEYS and isinstance(value, str):
try:
validate_model_identifier(value)
except ValueError as exc:
# Skip-and-warn: leave the env/default value in effect — the
# function's no-crash-on-startup contract stands.
logger.warning(
"config.override_invalid_model_id",
key=key,
error=str(exc),
)
continue
setattr(settings, key, value)
if key in SECRET_KEYS and isinstance(value, str):
os.environ[SECRET_ENV_NAMES[key]] = value
Expand Down
12 changes: 12 additions & 0 deletions app/features/config/tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ def test_patch_rejects_invalid_model(self, client):
response = client.patch("/config/ai", json={"agent_default_model": "nope"})
assert response.status_code == 422

def test_patch_rejects_doubled_provider_prefix(self, client):
"""A doubled provider prefix is a 422 problem+json naming the field (#334)."""
response = client.patch(
"/config/ai",
json={"agent_default_model": "google-gla:google-gla:gemini-3-flash-preview"},
)
assert response.status_code == 422
body = response.json()
assert body["code"] == "VALIDATION_ERROR"
assert body["errors"][0]["field"] == "agent_default_model"
assert "Nested provider prefix" in body["errors"][0]["message"]

def test_patch_surfaces_dimension_conflict(self, client):
"""A 409 from the dimension guard propagates to the caller."""
with patch(
Expand Down
50 changes: 50 additions & 0 deletions app/features/config/tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ def test_validate_model_identifier_rejects_unknown_provider(self):
with pytest.raises(ValueError, match="Unknown provider"):
validate_model_identifier("pinecone:model")

@pytest.mark.parametrize(
"identifier",
[
"google-gla:google-gla:gemini-3-flash-preview",
"anthropic:anthropic:claude-sonnet-4-5",
"openai:openai:gpt-4o",
"google-vertex:google-vertex:gemini-2.0",
"ollama:ollama:llama3.1",
],
)
def test_rejects_doubled_provider_prefix(self, identifier):
"""A doubled provider prefix inside the model name is rejected (issue #334)."""
with pytest.raises(ValueError, match="Nested provider prefix"):
validate_model_identifier(identifier)

def test_rejects_mixed_provider_prefix(self):
"""A different known provider nested in the model name is also rejected."""
with pytest.raises(ValueError, match="Nested provider prefix"):
validate_model_identifier("openai:anthropic:claude-x")

def test_error_message_suggests_correction(self):
"""The rejection message tells the operator the likely intended id."""
with pytest.raises(ValueError) as exc:
validate_model_identifier("google-gla:google-gla:gemini-3-flash-preview")
assert "Did you mean 'google-gla:gemini-3-flash-preview'?" in str(exc.value)

@pytest.mark.parametrize(
"identifier",
["ollama:llama3.1:8b", "ollama:gemma4:e2b", "ollama:qwen3:8b"],
)
def test_accepts_ollama_tag_identifiers(self, identifier):
"""Multi-colon Ollama name:tag identifiers stay valid."""
assert validate_model_identifier(identifier) == identifier

def test_accepts_model_named_like_provider(self):
"""A model literally named like a provider (no colon after it) stays valid."""
assert validate_model_identifier("ollama:openai") == "ollama:openai"


class TestAIModelConfigUpdate:
"""Tests for the PATCH /config/ai request body."""
Expand Down Expand Up @@ -93,6 +131,18 @@ def test_model_validate_json_path(self):
assert upd.agent_temperature == 0.5
assert upd.agent_default_model == "ollama:llama3.1"

def test_rejects_doubled_prefix_via_model_validate(self):
"""A doubled prefix fails through the validate_python (JSON) path too."""
with pytest.raises(ValidationError, match="Nested provider prefix"):
AIModelConfigUpdate.model_validate({"agent_default_model": "google-gla:google-gla:x"})

def test_fallback_model_also_guarded(self):
"""The fallback-model field is guarded by the same nested-prefix rule."""
with pytest.raises(ValidationError, match="Nested provider prefix"):
AIModelConfigUpdate.model_validate(
{"agent_fallback_model": "anthropic:anthropic:claude-sonnet-4-5"}
)


class TestResponseSchemas:
"""Tests for the response-only schemas."""
Expand Down
43 changes: 43 additions & 0 deletions app/features/config/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,49 @@ async def test_update_config_secret_mirrored_to_environment(self):
assert os.environ["OPENAI_API_KEY"] == "sk-new-openai-key-123"


# =============================================================================
# Unit tests — apply_overrides_on_startup
# =============================================================================


class TestApplyOverridesOnStartup:
"""Tests for the startup override loader's model-id guard (issue #334)."""

@pytest.mark.asyncio
async def test_startup_skips_invalid_model_id_override(self):
"""A malformed persisted model id is skipped with a warning; valid keys apply."""
settings = get_settings()
# Snapshot mutated fields and restore in finally so the mutation never
# leaks into another test (settings-singleton precedent, 24ed5cd).
original_model = settings.agent_default_model
original_temperature = settings.agent_temperature
try:
with (
patch(
"app.features.config.service._load_overrides",
new=AsyncMock(
return_value={
"agent_default_model": "google-gla:google-gla:x",
"agent_temperature": 0.5,
}
),
),
patch.object(service.logger, "warning") as mock_warning,
):
await service.apply_overrides_on_startup(_mock_db())

# Malformed model id skipped — env/default value stays in effect.
assert settings.agent_default_model == original_model
# Valid keys in the same batch are still applied.
assert settings.agent_temperature == 0.5
mock_warning.assert_called_once()
assert mock_warning.call_args.args[0] == "config.override_invalid_model_id"
assert mock_warning.call_args.kwargs["key"] == "agent_default_model"
finally:
settings.agent_default_model = original_model
settings.agent_temperature = original_temperature


# =============================================================================
# Unit tests — provider health + ollama models
# =============================================================================
Expand Down