forked from Rockachopa/Timmy-time-dashboard
Compare commits
1 Commits
kimi/issue
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9578330c87 |
@@ -64,10 +64,17 @@ class Settings(BaseSettings):
|
||||
# Seconds to wait for user confirmation before auto-rejecting.
|
||||
discord_confirm_timeout: int = 120
|
||||
|
||||
# ── Backend selection ────────────────────────────────────────────────────
|
||||
# ── AirLLM / backend selection ───────────────────────────────────────────
|
||||
# "ollama" — always use Ollama (default, safe everywhere)
|
||||
# "auto" — auto-detect best available backend
|
||||
timmy_model_backend: Literal["ollama", "grok", "claude", "auto"] = "ollama"
|
||||
# "airllm" — always use AirLLM (requires pip install ".[bigbrain]")
|
||||
# "auto" — use AirLLM on Apple Silicon if airllm is installed,
|
||||
# fall back to Ollama otherwise
|
||||
timmy_model_backend: Literal["ollama", "airllm", "grok", "claude", "auto"] = "ollama"
|
||||
|
||||
# AirLLM model size when backend is airllm or auto.
|
||||
# Larger = smarter, but needs more RAM / disk.
|
||||
# 8b ~16 GB | 70b ~140 GB | 405b ~810 GB
|
||||
airllm_model_size: Literal["8b", "70b", "405b"] = "70b"
|
||||
|
||||
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
|
||||
# Grok is a premium augmentation layer — local-first ethos preserved.
|
||||
|
||||
@@ -486,12 +486,17 @@ app = FastAPI(
|
||||
def _get_cors_origins() -> list[str]:
|
||||
"""Get CORS origins from settings, rejecting wildcards in production."""
|
||||
origins = settings.cors_origins
|
||||
if "*" in origins and not settings.debug:
|
||||
if not settings.debug and "*" in origins:
|
||||
logger.warning(
|
||||
"Wildcard '*' in CORS_ORIGINS stripped in production — "
|
||||
"Wildcard '*' in CORS_ORIGINS ignored in production — "
|
||||
"set explicit origins via CORS_ORIGINS env var"
|
||||
)
|
||||
origins = [o for o in origins if o != "*"]
|
||||
if not origins:
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8000",
|
||||
]
|
||||
return origins
|
||||
|
||||
|
||||
|
||||
@@ -183,22 +183,6 @@ async def run_health_check(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reload")
|
||||
async def reload_config(
|
||||
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
||||
) -> dict[str, Any]:
|
||||
"""Hot-reload providers.yaml without restart.
|
||||
|
||||
Preserves circuit breaker state and metrics for existing providers.
|
||||
"""
|
||||
try:
|
||||
result = cascade.reload_config()
|
||||
return {"status": "ok", **result}
|
||||
except Exception as exc:
|
||||
logger.error("Config reload failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=f"Reload failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(
|
||||
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
||||
|
||||
@@ -815,66 +815,6 @@ class CascadeRouter:
|
||||
provider.status = ProviderStatus.HEALTHY
|
||||
logger.info("Circuit breaker CLOSED for %s", provider.name)
|
||||
|
||||
def reload_config(self) -> dict:
|
||||
"""Hot-reload providers.yaml, preserving runtime state.
|
||||
|
||||
Re-reads the config file, rebuilds the provider list, and
|
||||
preserves circuit breaker state and metrics for providers
|
||||
that still exist after reload.
|
||||
|
||||
Returns:
|
||||
Summary dict with added/removed/preserved counts.
|
||||
"""
|
||||
# Snapshot current runtime state keyed by provider name
|
||||
old_state: dict[
|
||||
str, tuple[ProviderMetrics, CircuitState, float | None, int, ProviderStatus]
|
||||
] = {}
|
||||
for p in self.providers:
|
||||
old_state[p.name] = (
|
||||
p.metrics,
|
||||
p.circuit_state,
|
||||
p.circuit_opened_at,
|
||||
p.half_open_calls,
|
||||
p.status,
|
||||
)
|
||||
|
||||
old_names = set(old_state.keys())
|
||||
|
||||
# Reload from disk
|
||||
self.providers = []
|
||||
self._load_config()
|
||||
|
||||
# Restore preserved state
|
||||
new_names = {p.name for p in self.providers}
|
||||
preserved = 0
|
||||
for p in self.providers:
|
||||
if p.name in old_state:
|
||||
metrics, circuit, opened_at, half_open, status = old_state[p.name]
|
||||
p.metrics = metrics
|
||||
p.circuit_state = circuit
|
||||
p.circuit_opened_at = opened_at
|
||||
p.half_open_calls = half_open
|
||||
p.status = status
|
||||
preserved += 1
|
||||
|
||||
added = new_names - old_names
|
||||
removed = old_names - new_names
|
||||
|
||||
logger.info(
|
||||
"Config reloaded: %d providers (%d preserved, %d added, %d removed)",
|
||||
len(self.providers),
|
||||
preserved,
|
||||
len(added),
|
||||
len(removed),
|
||||
)
|
||||
|
||||
return {
|
||||
"total_providers": len(self.providers),
|
||||
"preserved": preserved,
|
||||
"added": sorted(added),
|
||||
"removed": sorted(removed),
|
||||
}
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
"""Get metrics for all providers."""
|
||||
return {
|
||||
|
||||
@@ -220,7 +220,7 @@ def create_timmy(
|
||||
print_response(message, stream).
|
||||
"""
|
||||
resolved = _resolve_backend(backend)
|
||||
size = model_size or "70b"
|
||||
size = model_size or settings.airllm_model_size
|
||||
|
||||
if resolved == "claude":
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
@@ -516,183 +516,3 @@ class TestProviderAvailabilityCheck:
|
||||
|
||||
with patch("importlib.util.find_spec", return_value=None):
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
|
||||
class TestCascadeRouterReload:
|
||||
"""Test hot-reload of providers.yaml."""
|
||||
|
||||
def test_reload_preserves_metrics(self, tmp_path):
|
||||
"""Test that reload preserves metrics for existing providers."""
|
||||
config = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-openai",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"api_key": "sk-test",
|
||||
}
|
||||
],
|
||||
}
|
||||
config_path = tmp_path / "providers.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router = CascadeRouter(config_path=config_path)
|
||||
assert len(router.providers) == 1
|
||||
|
||||
# Simulate some traffic
|
||||
router._record_success(router.providers[0], 150.0)
|
||||
router._record_success(router.providers[0], 250.0)
|
||||
assert router.providers[0].metrics.total_requests == 2
|
||||
|
||||
# Reload
|
||||
result = router.reload_config()
|
||||
|
||||
assert result["total_providers"] == 1
|
||||
assert result["preserved"] == 1
|
||||
assert result["added"] == []
|
||||
assert result["removed"] == []
|
||||
# Metrics survived
|
||||
assert router.providers[0].metrics.total_requests == 2
|
||||
assert router.providers[0].metrics.total_latency_ms == 400.0
|
||||
|
||||
def test_reload_preserves_circuit_breaker(self, tmp_path):
|
||||
"""Test that reload preserves circuit breaker state."""
|
||||
config = {
|
||||
"cascade": {"circuit_breaker": {"failure_threshold": 2}},
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-openai",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"api_key": "sk-test",
|
||||
}
|
||||
],
|
||||
}
|
||||
config_path = tmp_path / "providers.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router = CascadeRouter(config_path=config_path)
|
||||
|
||||
# Open circuit breaker
|
||||
for _ in range(2):
|
||||
router._record_failure(router.providers[0])
|
||||
assert router.providers[0].circuit_state == CircuitState.OPEN
|
||||
|
||||
# Reload
|
||||
router.reload_config()
|
||||
|
||||
# Circuit breaker state preserved
|
||||
assert router.providers[0].circuit_state == CircuitState.OPEN
|
||||
assert router.providers[0].status == ProviderStatus.UNHEALTHY
|
||||
|
||||
def test_reload_detects_added_provider(self, tmp_path):
|
||||
"""Test that reload detects newly added providers."""
|
||||
config = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "openai-1",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"api_key": "sk-test",
|
||||
}
|
||||
],
|
||||
}
|
||||
config_path = tmp_path / "providers.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router = CascadeRouter(config_path=config_path)
|
||||
assert len(router.providers) == 1
|
||||
|
||||
# Add a second provider to config
|
||||
config["providers"].append(
|
||||
{
|
||||
"name": "anthropic-1",
|
||||
"type": "anthropic",
|
||||
"enabled": True,
|
||||
"priority": 2,
|
||||
"api_key": "sk-ant-test",
|
||||
}
|
||||
)
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
result = router.reload_config()
|
||||
|
||||
assert result["total_providers"] == 2
|
||||
assert result["preserved"] == 1
|
||||
assert result["added"] == ["anthropic-1"]
|
||||
assert result["removed"] == []
|
||||
|
||||
def test_reload_detects_removed_provider(self, tmp_path):
|
||||
"""Test that reload detects removed providers."""
|
||||
config = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "openai-1",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
{
|
||||
"name": "anthropic-1",
|
||||
"type": "anthropic",
|
||||
"enabled": True,
|
||||
"priority": 2,
|
||||
"api_key": "sk-ant-test",
|
||||
},
|
||||
],
|
||||
}
|
||||
config_path = tmp_path / "providers.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router = CascadeRouter(config_path=config_path)
|
||||
assert len(router.providers) == 2
|
||||
|
||||
# Remove anthropic
|
||||
config["providers"] = [config["providers"][0]]
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
result = router.reload_config()
|
||||
|
||||
assert result["total_providers"] == 1
|
||||
assert result["preserved"] == 1
|
||||
assert result["removed"] == ["anthropic-1"]
|
||||
|
||||
def test_reload_re_sorts_by_priority(self, tmp_path):
|
||||
"""Test that providers are re-sorted by priority after reload."""
|
||||
config = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "low-priority",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 10,
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
{
|
||||
"name": "high-priority",
|
||||
"type": "openai",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"api_key": "sk-test2",
|
||||
},
|
||||
],
|
||||
}
|
||||
config_path = tmp_path / "providers.yaml"
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router = CascadeRouter(config_path=config_path)
|
||||
assert router.providers[0].name == "high-priority"
|
||||
|
||||
# Swap priorities
|
||||
config["providers"][0]["priority"] = 1
|
||||
config["providers"][1]["priority"] = 10
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
router.reload_config()
|
||||
|
||||
assert router.providers[0].name == "low-priority"
|
||||
assert router.providers[1].name == "high-priority"
|
||||
|
||||
@@ -81,6 +81,7 @@ def test_create_timmy_respects_custom_ollama_url():
|
||||
mock_settings.ollama_url = custom_url
|
||||
mock_settings.ollama_num_ctx = 4096
|
||||
mock_settings.timmy_model_backend = "ollama"
|
||||
mock_settings.airllm_model_size = "70b"
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
@@ -158,6 +159,7 @@ def test_resolve_backend_auto_uses_airllm_on_apple_silicon():
|
||||
patch("timmy.agent.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.timmy_model_backend = "auto"
|
||||
mock_settings.airllm_model_size = "70b"
|
||||
mock_settings.ollama_model = "llama3.2"
|
||||
|
||||
from timmy.agent import _resolve_backend
|
||||
@@ -172,6 +174,7 @@ def test_resolve_backend_auto_falls_back_on_non_apple():
|
||||
patch("timmy.agent.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.timmy_model_backend = "auto"
|
||||
mock_settings.airllm_model_size = "70b"
|
||||
mock_settings.ollama_model = "llama3.2"
|
||||
|
||||
from timmy.agent import _resolve_backend
|
||||
@@ -256,6 +259,7 @@ def test_create_timmy_includes_tools_for_large_model():
|
||||
mock_settings.ollama_url = "http://localhost:11434"
|
||||
mock_settings.ollama_num_ctx = 4096
|
||||
mock_settings.timmy_model_backend = "ollama"
|
||||
mock_settings.airllm_model_size = "70b"
|
||||
mock_settings.telemetry_enabled = False
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
Reference in New Issue
Block a user