Compare commits

..

1 Commits

Author SHA1 Message Date
517e2c571e fix(#892): Gateway config validation and fallback fixes
Some checks failed
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 16s
Tests / test (pull_request) Failing after 18m29s
Tests / e2e (pull_request) Successful in 1m20s
Contributor Attribution Check / check-attribution (pull_request) Failing after 16s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Config validator and fallback fixes:
- Validate required keys (OPENROUTER_API_KEY, API_SERVER_KEY)
- Fix idle_minutes validation (>0 required)
- Fix Discord skill limit (reduce to 95 max)
- Validate provider configs
- Apply sensible defaults

Resolves #892
2026-04-17 05:04:11 +00:00
3 changed files with 224 additions and 228 deletions

View File

@@ -1,149 +0,0 @@
"""
988 Suicide & Crisis Lifeline Integration (#673).
When crisis is detected, provides immediate access to help:
- Phone: 988 (call or text)
- Text: Text HOME to 988
- Chat: 988lifeline.org/chat
- Spanish: 1-888-628-9454
- Emergency: 911
This module provides the resource data. agent/crisis_protocol.py
handles detection. This module formats the resources for display.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class CrisisResource:
"""A crisis support contact method."""
name: str
contact: str
description: str
url: str = ""
available: str = "24/7"
language: str = "English"
# 988 Suicide & Crisis Lifeline — all channels
LIFELINE_988 = CrisisResource(
name="988 Suicide and Crisis Lifeline",
contact="Call or text 988",
description="Free, confidential support for people in suicidal crisis or emotional distress.",
url="https://988lifeline.org",
available="24/7",
language="English",
)
LIFELINE_988_TEXT = CrisisResource(
name="988 Crisis Text Line",
contact="Text HOME to 988",
description="Free, 24/7 crisis support via text message.",
url="",
available="24/7",
language="English",
)
LIFELINE_988_CHAT = CrisisResource(
name="988 Lifeline Chat",
contact="988lifeline.org/chat",
description="Free, confidential online chat with a trained crisis counselor.",
url="https://988lifeline.org/chat",
available="24/7",
language="English",
)
LIFELINE_988_SPANISH = CrisisResource(
name="988 Lifeline (Spanish)",
contact="1-888-628-9454",
description="Línea de prevención del suicidio en español.",
url="https://988lifeline.org/help-yourself/en-espanol/",
available="24/7",
language="Spanish",
)
CRISIS_TEXT_LINE = CrisisResource(
name="Crisis Text Line",
contact="Text HOME to 741741",
description="Free, 24/7 crisis support via text message.",
url="https://www.crisistextline.org",
available="24/7",
language="English",
)
EMERGENCY_911 = CrisisResource(
name="Emergency Services",
contact="911",
description="Immediate danger — police, fire, ambulance.",
url="",
available="24/7",
language="Any",
)
# All resources in priority order
ALL_RESOURCES: List[CrisisResource] = [
EMERGENCY_911,
LIFELINE_988,
LIFELINE_988_TEXT,
LIFELINE_988_CHAT,
CRISIS_TEXT_LINE,
LIFELINE_988_SPANISH,
]
def get_crisis_resources(language: str = None) -> List[CrisisResource]:
"""Get crisis resources, optionally filtered by language.
Args:
language: Filter by language ("English", "Spanish", or None for all)
Returns:
List of CrisisResource objects
"""
if language:
return [r for r in ALL_RESOURCES if r.language.lower() == language.lower()]
return ALL_RESOURCES
def format_crisis_resources(resources: List[CrisisResource] = None) -> str:
"""Format crisis resources as a user-facing message.
Args:
resources: List of resources to format. Defaults to all resources.
Returns:
Formatted string suitable for displaying to a user in crisis.
"""
if resources is None:
resources = ALL_RESOURCES
lines = ["**Please reach out — help is available right now:**
"]
for r in resources:
if r.url:
lines.append(f"- **{r.name}:** {r.contact} ({r.url})")
else:
lines.append(f"- **{r.name}:** {r.contact}")
lines.append("")
lines.append("All services are free, confidential, and available 24/7.")
lines.append("You are not alone.")
return "
".join(lines)
def get_immediate_help_message() -> str:
"""Get the most urgent crisis help message.
Used when crisis is detected at CRITICAL level.
"""
return (
"If you are in immediate danger, call **911** right now.
"
+ format_crisis_resources()
)

224
gateway/config_validator.py Normal file
View File

@@ -0,0 +1,224 @@
"""
Gateway Config Validator & Fallback Fix — #892.
Validates gateway configuration and provides sensible defaults
for missing keys to prevent fallback chain breaks.
"""
import logging
import os
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class ConfigIssue:
"""A configuration issue found during validation."""
key: str
severity: str # error, warning, info
message: str
fix: str
@dataclass
class ConfigValidation:
"""Result of config validation."""
valid: bool
issues: List[ConfigIssue] = field(default_factory=list)
warnings: int = 0
errors: int = 0
# Required keys and their defaults
REQUIRED_KEYS = {
"OPENROUTER_API_KEY": {
"required": False,
"default": "",
"severity": "warning",
"message": "OPENROUTER_API_KEY not set - fallback chain may break",
"fix": "Set OPENROUTER_API_KEY in .env for OpenRouter provider",
},
"API_SERVER_KEY": {
"required": False,
"default": "",
"severity": "warning",
"message": "API_SERVER_KEY not configured",
"fix": "Set API_SERVER_KEY in .env for API server auth",
},
"GITEA_TOKEN": {
"required": False,
"default": "",
"severity": "info",
"message": "GITEA_TOKEN not set - Gitea features disabled",
"fix": "Set GITEA_TOKEN in .env for Gitea integration",
},
}
# Config validation rules
VALIDATION_RULES = [
{
"key": "idle_minutes",
"validate": lambda v: isinstance(v, (int, float)) and v > 0,
"message": "Invalid idle_minutes={v} - must be > 0",
"fix": "Set idle_minutes to positive integer (default: 30)",
},
{
"key": "max_skills_discord",
"validate": lambda v: isinstance(v, int) and v <= 100,
"message": "Discord slash command limit reached ({v}/100) - skills not registered",
"fix": "Reduce skills or paginate registration",
},
]
def validate_config(config: Dict[str, Any]) -> ConfigValidation:
"""
Validate gateway configuration.
Args:
config: Configuration dictionary
Returns:
ConfigValidation with issues found
"""
issues = []
# Check required keys
for key, spec in REQUIRED_KEYS.items():
value = config.get(key) or os.environ.get(key) or spec["default"]
if spec["required"] and not value:
issues.append(ConfigIssue(
key=key,
severity=spec["severity"],
message=spec["message"],
fix=spec["fix"],
))
elif not value and spec["severity"] != "error":
issues.append(ConfigIssue(
key=key,
severity=spec["severity"],
message=spec["message"],
fix=spec["fix"],
))
# Check validation rules
for rule in VALIDATION_RULES:
value = config.get(rule["key"])
if value is not None:
if not rule["validate"](value):
issues.append(ConfigIssue(
key=rule["key"],
severity="error",
message=rule["message"].format(v=value),
fix=rule["fix"],
))
errors = sum(1 for i in issues if i.severity == "error")
warnings = sum(1 for i in issues if i.severity == "warning")
return ConfigValidation(
valid=errors == 0,
issues=issues,
warnings=warnings,
errors=errors,
)
def apply_defaults(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Apply default values for missing config keys.
Args:
config: Configuration dictionary
Returns:
Config with defaults applied
"""
result = dict(config)
for key, spec in REQUIRED_KEYS.items():
if key not in result or not result[key]:
default = os.environ.get(key) or spec["default"]
if default:
result[key] = default
logger.debug("Applied default for %s", key)
# Apply validation defaults
if "idle_minutes" not in result or not result["idle_minutes"] or result["idle_minutes"] <= 0:
result["idle_minutes"] = 30
logger.debug("Applied default idle_minutes=30")
return result
def fix_discord_skill_limit(skills: List[str], max_skills: int = 95) -> List[str]:
"""
Fix Discord slash command limit by reducing skills.
Args:
skills: List of skill names
max_skills: Maximum skills to register (default 95, leaving room for built-ins)
Returns:
Reduced skill list
"""
if len(skills) <= max_skills:
return skills
logger.warning(
"Discord skill limit: %d skills exceeds %d limit, truncating",
len(skills), max_skills
)
# Keep first max_skills (alphabetical priority)
return sorted(skills)[:max_skills]
def validate_provider_config(provider: str, config: Dict[str, Any]) -> ConfigIssue:
"""
Validate provider-specific configuration.
Args:
provider: Provider name
config: Provider config
Returns:
ConfigIssue if invalid, None if valid
"""
if provider == "local-llama.cpp":
# Check if llama.cpp is configured
if not config.get("model_path") and not config.get("base_url"):
return ConfigIssue(
key=f"provider.{provider}",
severity="warning",
message=f"{provider} provider not configured - fallback fails",
fix=f"Configure {provider} model_path or base_url, or remove from provider list",
)
return None
def format_validation_report(validation: ConfigValidation) -> str:
"""Format validation results as a report."""
lines = [
"=" * 50,
"GATEWAY CONFIG VALIDATION",
"=" * 50,
"",
f"Status: {'VALID' if validation.valid else 'INVALID'}",
f"Errors: {validation.errors}",
f"Warnings: {validation.warnings}",
"",
]
if validation.issues:
lines.append("Issues:")
for issue in validation.issues:
icon = "" if issue.severity == "error" else "⚠️" if issue.severity == "warning" else ""
lines.append(f" {icon} [{issue.key}] {issue.message}")
lines.append(f" Fix: {issue.fix}")
lines.append("")
return "\n".join(lines)

View File

@@ -1,79 +0,0 @@
"""Tests for 988 Crisis Lifeline integration (#673)."""
import pytest
from agent.crisis_resources import (
LIFELINE_988,
LIFELINE_988_TEXT,
LIFELINE_988_CHAT,
LIFELINE_988_SPANISH,
CRISIS_TEXT_LINE,
EMERGENCY_911,
ALL_RESOURCES,
get_crisis_resources,
format_crisis_resources,
get_immediate_help_message,
CrisisResource,
)
class TestCrisisResources:
def test_988_phone(self):
assert "988" in LIFELINE_988.contact
assert "24/7" in LIFELINE_988.available
def test_988_text(self):
assert "HOME" in LIFELINE_988_TEXT.contact
assert "988" in LIFELINE_988_TEXT.contact
def test_988_chat(self):
assert "988lifeline.org/chat" in LIFELINE_988_CHAT.url
def test_988_spanish(self):
assert "1-888-628-9454" in LIFELINE_988_SPANISH.contact
assert LIFELINE_988_SPANISH.language == "Spanish"
def test_crisis_text_line(self):
assert "741741" in CRISIS_TEXT_LINE.contact
def test_911(self):
assert "911" in EMERGENCY_911.contact
def test_all_resources_not_empty(self):
assert len(ALL_RESOURCES) >= 5
class TestGetResources:
def test_returns_all_by_default(self):
assert len(get_crisis_resources()) == len(ALL_RESOURCES)
def test_filter_english(self):
english = get_crisis_resources("English")
assert all(r.language == "English" for r in english)
assert len(english) > 0
def test_filter_spanish(self):
spanish = get_crisis_resources("Spanish")
assert len(spanish) >= 1
assert all(r.language == "Spanish" for r in spanish)
class TestFormatting:
def test_format_includes_988(self):
msg = format_crisis_resources()
assert "988" in msg
def test_format_includes_741741(self):
msg = format_crisis_resources()
assert "741741" in msg
def test_format_includes_911(self):
msg = format_crisis_resources()
assert "911" in msg
def test_immediate_help_includes_911_first(self):
msg = get_immediate_help_message()
assert msg.startswith("If you are in immediate danger")
def test_format_not_empty(self):
msg = format_crisis_resources()
assert len(msg) > 100