Compare commits
3 Commits
gemini/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45344ca5af | ||
| 510d890eb2 | |||
| 852fec3681 |
1367
poetry.lock
generated
1367
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ typer = ">=0.12.0"
|
||||
rich = ">=13.0.0"
|
||||
pydantic-settings = ">=2.0.0,<3.0"
|
||||
mcp = ">=1.0.0"
|
||||
serpapi = ">=2.0.0"
|
||||
# Optional extras
|
||||
redis = { version = ">=5.0.0", optional = true }
|
||||
celery = { version = ">=5.3.0", extras = ["redis"], optional = true }
|
||||
@@ -69,7 +68,7 @@ voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
|
||||
celery = ["celery"]
|
||||
embeddings = ["sentence-transformers", "numpy"]
|
||||
git = ["GitPython"]
|
||||
research = ["requests", "trafilatura", "google-search-results", "serpapi"]
|
||||
research = ["requests", "trafilatura", "google-search-results"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-randomly", "pytest-xdist", "selenium"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
@@ -82,7 +81,6 @@ pytest-randomly = "^4.0.1"
|
||||
pytest-xdist = "^3.8.0"
|
||||
ruff = ">=0.8.0"
|
||||
mypy = ">=1.0.0"
|
||||
pytest-httpx = "^0.36.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
timmy = "timmy.cli:main"
|
||||
|
||||
@@ -196,7 +196,7 @@ async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db
|
||||
if not journal_entry:
|
||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||
return templates.TemplateResponse(
|
||||
"calm/evening_ritual_form.html", {"request": request, "journal_entry": journal_entry}
|
||||
request, "calm/evening_ritual_form.html", {"journal_entry": journal_entry}
|
||||
)
|
||||
|
||||
|
||||
@@ -257,8 +257,9 @@ async def create_new_task(
|
||||
# After creating a new task, we might need to re-evaluate NOW/NEXT/LATER, but for simplicity
|
||||
# and given the spec, new tasks go to LATER. Promotion happens on completion/deferral.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/later_count.html",
|
||||
{"request": request, "later_tasks_count": len(get_later_tasks(db))},
|
||||
{"later_tasks_count": len(get_later_tasks(db))},
|
||||
)
|
||||
|
||||
|
||||
@@ -287,9 +288,9 @@ async def start_task(
|
||||
promote_tasks(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/now_next_later.html",
|
||||
{
|
||||
"request": request,
|
||||
"now_task": get_now_task(db),
|
||||
"next_task": get_next_task(db),
|
||||
"later_tasks_count": len(get_later_tasks(db)),
|
||||
@@ -316,9 +317,9 @@ async def complete_task(
|
||||
promote_tasks(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/now_next_later.html",
|
||||
{
|
||||
"request": request,
|
||||
"now_task": get_now_task(db),
|
||||
"next_task": get_next_task(db),
|
||||
"later_tasks_count": len(get_later_tasks(db)),
|
||||
@@ -345,9 +346,9 @@ async def defer_task(
|
||||
promote_tasks(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/now_next_later.html",
|
||||
{
|
||||
"request": request,
|
||||
"now_task": get_now_task(db),
|
||||
"next_task": get_next_task(db),
|
||||
"later_tasks_count": len(get_later_tasks(db)),
|
||||
@@ -360,8 +361,9 @@ async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
|
||||
"""Render the expandable list of LATER tasks."""
|
||||
later_tasks = get_later_tasks(db)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/later_tasks_list.html",
|
||||
{"request": request, "later_tasks": later_tasks},
|
||||
{"later_tasks": later_tasks},
|
||||
)
|
||||
|
||||
|
||||
@@ -404,9 +406,9 @@ async def reorder_tasks(
|
||||
|
||||
# Re-render the relevant parts of the UI
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"calm/partials/now_next_later.html",
|
||||
{
|
||||
"request": request,
|
||||
"now_task": get_now_task(db),
|
||||
"next_task": get_next_task(db),
|
||||
"later_tasks_count": len(get_later_tasks(db)),
|
||||
|
||||
@@ -40,9 +40,9 @@ async def tools_page(request: Request):
|
||||
total_calls = 0
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"tools.html",
|
||||
{
|
||||
"request": request,
|
||||
"available_tools": available_tools,
|
||||
"agent_tools": agent_tools,
|
||||
"total_calls": total_calls,
|
||||
|
||||
@@ -485,18 +485,26 @@ class CascadeRouter:
|
||||
def _quota_allows_cloud(self, provider: Provider) -> bool:
|
||||
"""Check quota before routing to a cloud provider.
|
||||
|
||||
Uses the metabolic protocol: cloud calls are gated by 5-hour quota.
|
||||
Uses the metabolic protocol via select_model(): cloud calls are only
|
||||
allowed when the quota monitor recommends a cloud model (BURST tier).
|
||||
Returns True (allow cloud) if quota monitor is unavailable or returns None.
|
||||
"""
|
||||
if _quota_monitor is None:
|
||||
return True
|
||||
try:
|
||||
# Map provider type to task_value heuristic
|
||||
task_value = "high" # conservative default
|
||||
status = _quota_monitor.check()
|
||||
if status is None:
|
||||
return True # No credentials — caller decides based on config
|
||||
return _quota_monitor.should_use_cloud(task_value)
|
||||
suggested = _quota_monitor.select_model("high")
|
||||
# Cloud is allowed only when select_model recommends the cloud model
|
||||
allows = suggested == "claude-sonnet-4-6"
|
||||
if not allows:
|
||||
status = _quota_monitor.check()
|
||||
tier = status.recommended_tier.value if status else "unknown"
|
||||
logger.info(
|
||||
"Metabolic protocol: %s tier — downshifting %s to local (%s)",
|
||||
tier,
|
||||
provider.name,
|
||||
suggested,
|
||||
)
|
||||
return allows
|
||||
except Exception as exc:
|
||||
logger.warning("Quota check failed, allowing cloud: %s", exc)
|
||||
return True
|
||||
|
||||
@@ -664,10 +664,10 @@ class TestVllmMlxProvider:
|
||||
)
|
||||
router.providers = [provider]
|
||||
|
||||
# Quota monitor returns False (block cloud) — vllm_mlx should still be tried
|
||||
# Quota monitor downshifts to local (ACTIVE tier) — vllm_mlx should still be tried
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
mock_qm.check.return_value = object()
|
||||
mock_qm.should_use_cloud.return_value = False
|
||||
mock_qm.select_model.return_value = "qwen3:14b"
|
||||
mock_qm.check.return_value = None
|
||||
|
||||
with patch.object(router, "_call_vllm_mlx") as mock_call:
|
||||
mock_call.return_value = {
|
||||
@@ -681,6 +681,115 @@ class TestVllmMlxProvider:
|
||||
assert result["content"] == "Local MLX response"
|
||||
|
||||
|
||||
class TestMetabolicProtocol:
|
||||
"""Test metabolic protocol: cloud providers skip when quota is ACTIVE/RESTING."""
|
||||
|
||||
def _make_anthropic_provider(self) -> "Provider":
|
||||
return Provider(
|
||||
name="anthropic-primary",
|
||||
type="anthropic",
|
||||
enabled=True,
|
||||
priority=1,
|
||||
api_key="test-key",
|
||||
models=[{"name": "claude-sonnet-4-6", "default": True}],
|
||||
)
|
||||
|
||||
async def test_cloud_provider_allowed_in_burst_tier(self):
|
||||
"""BURST tier (quota healthy): cloud provider is tried."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.providers = [self._make_anthropic_provider()]
|
||||
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
# select_model returns cloud model → BURST tier
|
||||
mock_qm.select_model.return_value = "claude-sonnet-4-6"
|
||||
mock_qm.check.return_value = None
|
||||
|
||||
with patch.object(router, "_call_anthropic") as mock_call:
|
||||
mock_call.return_value = {"content": "Cloud response", "model": "claude-sonnet-4-6"}
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "hard question"}],
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
assert result["content"] == "Cloud response"
|
||||
|
||||
async def test_cloud_provider_skipped_in_active_tier(self):
|
||||
"""ACTIVE tier (5-hour >= 50%): cloud provider is skipped."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.providers = [self._make_anthropic_provider()]
|
||||
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
# select_model returns local 14B → ACTIVE tier
|
||||
mock_qm.select_model.return_value = "qwen3:14b"
|
||||
mock_qm.check.return_value = None
|
||||
|
||||
with patch.object(router, "_call_anthropic") as mock_call:
|
||||
with pytest.raises(RuntimeError, match="All providers failed"):
|
||||
await router.complete(
|
||||
messages=[{"role": "user", "content": "question"}],
|
||||
)
|
||||
|
||||
mock_call.assert_not_called()
|
||||
|
||||
async def test_cloud_provider_skipped_in_resting_tier(self):
|
||||
"""RESTING tier (7-day >= 80%): cloud provider is skipped."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.providers = [self._make_anthropic_provider()]
|
||||
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
# select_model returns local 8B → RESTING tier
|
||||
mock_qm.select_model.return_value = "qwen3:8b"
|
||||
mock_qm.check.return_value = None
|
||||
|
||||
with patch.object(router, "_call_anthropic") as mock_call:
|
||||
with pytest.raises(RuntimeError, match="All providers failed"):
|
||||
await router.complete(
|
||||
messages=[{"role": "user", "content": "simple question"}],
|
||||
)
|
||||
|
||||
mock_call.assert_not_called()
|
||||
|
||||
async def test_local_provider_always_tried_regardless_of_quota(self):
|
||||
"""Local (ollama/vllm_mlx) providers bypass the metabolic protocol."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
provider = Provider(
|
||||
name="ollama-local",
|
||||
type="ollama",
|
||||
enabled=True,
|
||||
priority=1,
|
||||
url="http://localhost:11434",
|
||||
models=[{"name": "qwen3:14b", "default": True}],
|
||||
)
|
||||
router.providers = [provider]
|
||||
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
mock_qm.select_model.return_value = "qwen3:8b" # RESTING tier
|
||||
|
||||
with patch.object(router, "_call_ollama") as mock_call:
|
||||
mock_call.return_value = {"content": "Local response", "model": "qwen3:14b"}
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
assert result["content"] == "Local response"
|
||||
|
||||
async def test_no_quota_monitor_allows_cloud(self):
|
||||
"""When quota monitor is None (unavailable), cloud providers are allowed."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.providers = [self._make_anthropic_provider()]
|
||||
|
||||
with patch("infrastructure.router.cascade._quota_monitor", None):
|
||||
with patch.object(router, "_call_anthropic") as mock_call:
|
||||
mock_call.return_value = {"content": "Cloud response", "model": "claude-sonnet-4-6"}
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "question"}],
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
assert result["content"] == "Cloud response"
|
||||
|
||||
|
||||
class TestCascadeRouterReload:
|
||||
"""Test hot-reload of providers.yaml."""
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Tests for the Paperclip integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.paperclip import (
|
||||
PaperclipClient,
|
||||
PaperclipPoller,
|
||||
PaperclipTask,
|
||||
ResearchOrchestrator,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(monkeypatch):
|
||||
"""Mock the settings for the Paperclip integration."""
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_enabled", True)
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_url", "http://localhost:3100")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_api_key", "test-key")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_agent_id", "test-agent")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_company_id", "test-company")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_poll_interval", 1)
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_url", "http://localhost:3000")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_token", "test-token")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_repo", "test/repo")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_client_get_tasks(mock_settings, httpx_mock):
|
||||
"""Test that the Paperclip client can get tasks."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3100/api/tasks?agent_id=test-agent&company_id=test-company&status=queued",
|
||||
json=[
|
||||
{
|
||||
"id": "1",
|
||||
"kind": "research",
|
||||
"context": {"issue_number": 123},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
client = PaperclipClient()
|
||||
tasks = await client.get_tasks()
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].id == "1"
|
||||
assert tasks[0].kind == "research"
|
||||
assert tasks[0].context == {"issue_number": 123}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_client_update_task_status(mock_settings, httpx_mock):
|
||||
"""Test that the Paperclip client can update a task's status."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3100/api/tasks/1",
|
||||
method="PATCH",
|
||||
)
|
||||
|
||||
client = PaperclipClient()
|
||||
await client.update_task_status("1", "running")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_research_orchestrator_run(mock_settings, httpx_mock):
|
||||
"""Test that the ResearchOrchestrator can run a research task."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3000/api/v1/repos/test/repo/issues/123",
|
||||
json={"title": "Test Issue", "body": "This is a test issue."},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3000/api/v1/repos/test/repo/issues/123/comments",
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with patch("timmy.paperclip.triage_research_report", new_callable=AsyncMock) as mock_triage:
|
||||
mock_triage.return_value = []
|
||||
orchestrator = ResearchOrchestrator()
|
||||
result = await orchestrator.run({"issue_number": 123})
|
||||
|
||||
assert result == "Research complete for issue #123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_poller_poll(mock_settings):
|
||||
"""Test that the Paperclip poller can poll for tasks."""
|
||||
with patch("timmy.paperclip.PaperclipClient.get_tasks", new_callable=AsyncMock) as mock_get_tasks:
|
||||
mock_get_tasks.return_value = [
|
||||
PaperclipTask(
|
||||
id="1",
|
||||
kind="research",
|
||||
context={"issue_number": 123},
|
||||
)
|
||||
]
|
||||
with patch("timmy.paperclip.PaperclipPoller.run_research_task", new_callable=AsyncMock):
|
||||
poller = PaperclipPoller()
|
||||
poller.poll_interval = 0.1
|
||||
task = asyncio.create_task(poller.poll())
|
||||
await asyncio.sleep(0.2)
|
||||
task.cancel()
|
||||
|
||||
mock_get_tasks.assert_called()
|
||||
|
||||
Reference in New Issue
Block a user