pedalpoint 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pedalpoint/__init__.py +0 -0
- pedalpoint/circuit_breaker.py +74 -0
- pedalpoint/config.py +27 -0
- pedalpoint/error_classifier.py +16 -0
- pedalpoint/server.py +72 -0
- pedalpoint-0.1.0.dist-info/METADATA +107 -0
- pedalpoint-0.1.0.dist-info/RECORD +9 -0
- pedalpoint-0.1.0.dist-info/WHEEL +4 -0
- pedalpoint-0.1.0.dist-info/entry_points.txt +2 -0
pedalpoint/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
CircuitState = dict[str, Any]
|
|
7
|
+
|
|
8
|
+
CLOSED_STATE: CircuitState = {"circuit": "closed"}
|
|
9
|
+
|
|
10
|
+
DEFAULT_STATE_PATH = Path.home() / ".pedalpoint" / "state.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_state(path: Path = DEFAULT_STATE_PATH) -> CircuitState:
|
|
14
|
+
if not path.exists():
|
|
15
|
+
return CLOSED_STATE.copy()
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(path.read_text())
|
|
18
|
+
except (json.JSONDecodeError, OSError):
|
|
19
|
+
return CLOSED_STATE.copy()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_state(state: CircuitState, path: Path = DEFAULT_STATE_PATH) -> None:
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
tmp = path.with_suffix(".tmp")
|
|
25
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
26
|
+
tmp.rename(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_duration(failure_count: int, initial_minutes: int = 60) -> int:
|
|
30
|
+
return min(initial_minutes * (2 ** (failure_count - 1)), 1440)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_expired(state: CircuitState) -> bool:
|
|
34
|
+
expires = state.get("expires")
|
|
35
|
+
if not expires:
|
|
36
|
+
return True
|
|
37
|
+
return datetime.now(timezone.utc) >= datetime.fromisoformat(expires)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transition_to_open(
|
|
41
|
+
reason: str,
|
|
42
|
+
failure_count: int = 1,
|
|
43
|
+
initial_minutes: int = 60,
|
|
44
|
+
tasks_rerouted: int = 0,
|
|
45
|
+
) -> CircuitState:
|
|
46
|
+
now = datetime.now(timezone.utc)
|
|
47
|
+
duration = compute_duration(failure_count, initial_minutes)
|
|
48
|
+
return {
|
|
49
|
+
"circuit": "open",
|
|
50
|
+
"reason": reason,
|
|
51
|
+
"opened_at": now.isoformat(),
|
|
52
|
+
"expires": (now + timedelta(minutes=duration)).isoformat(),
|
|
53
|
+
"failure_count": failure_count,
|
|
54
|
+
"fallback_duration_minutes": duration,
|
|
55
|
+
"tasks_rerouted": tasks_rerouted,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def transition_to_closed() -> CircuitState:
|
|
60
|
+
return CLOSED_STATE.copy()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def double_duration(state: CircuitState, initial_minutes: int = 60) -> CircuitState:
|
|
64
|
+
failure_count = state.get("failure_count", 1) + 1
|
|
65
|
+
return transition_to_open(
|
|
66
|
+
reason=state.get("reason", "unknown"),
|
|
67
|
+
failure_count=failure_count,
|
|
68
|
+
initial_minutes=initial_minutes,
|
|
69
|
+
tasks_rerouted=state.get("tasks_rerouted", 0),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def increment_rerouted(state: CircuitState) -> CircuitState:
|
|
74
|
+
return {**state, "tasks_rerouted": state.get("tasks_rerouted", 0) + 1}
|
pedalpoint/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Config:
|
|
8
|
+
base_url: str
|
|
9
|
+
model: str
|
|
10
|
+
mode: str
|
|
11
|
+
timeout: float
|
|
12
|
+
initial_fallback_minutes: int
|
|
13
|
+
context_limit: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@lru_cache(maxsize=1)
|
|
17
|
+
def get_config() -> Config:
|
|
18
|
+
return Config(
|
|
19
|
+
base_url=os.getenv("PEDALPOINT_BASE_URL", "http://localhost:11434/v1"),
|
|
20
|
+
model=os.getenv("PEDALPOINT_MODEL", "gemma4:e4b"),
|
|
21
|
+
mode=os.getenv("PEDALPOINT_MODE", "hybrid"),
|
|
22
|
+
timeout=float(os.getenv("PEDALPOINT_TIMEOUT", "120")),
|
|
23
|
+
initial_fallback_minutes=int(
|
|
24
|
+
os.getenv("PEDALPOINT_INITIAL_FALLBACK_MINUTES", "60")
|
|
25
|
+
),
|
|
26
|
+
context_limit=int(os.getenv("PEDALPOINT_CONTEXT_LIMIT", "16000")),
|
|
27
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
4
|
+
(re.compile(r"402|quota|billing|insufficient", re.IGNORECASE), "hard_quota"),
|
|
5
|
+
(re.compile(r"429|rate.?limit", re.IGNORECASE), "rate_limit"),
|
|
6
|
+
(re.compile(r"529|overload|capacity", re.IGNORECASE), "overload"),
|
|
7
|
+
(re.compile(r"timeout|connection", re.IGNORECASE), "network"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def classify_error(text: str) -> str:
|
|
12
|
+
"""Return the error category for a given error message string."""
|
|
13
|
+
for pattern, error_type in _PATTERNS:
|
|
14
|
+
if pattern.search(text):
|
|
15
|
+
return error_type
|
|
16
|
+
return "unknown"
|
pedalpoint/server.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from .config import Config, get_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@asynccontextmanager
|
|
10
|
+
async def lifespan(app): # type: ignore[type-arg]
|
|
11
|
+
cfg = get_config()
|
|
12
|
+
async with httpx.AsyncClient(
|
|
13
|
+
base_url=cfg.base_url,
|
|
14
|
+
timeout=httpx.Timeout(connect=5.0, read=cfg.timeout, write=10.0, pool=5.0),
|
|
15
|
+
) as client:
|
|
16
|
+
yield {"http": client, "cfg": cfg}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
mcp = FastMCP("pedalpoint", lifespan=lifespan)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _call_local_llm(
|
|
23
|
+
client: httpx.AsyncClient,
|
|
24
|
+
prompt: str,
|
|
25
|
+
system: str | None,
|
|
26
|
+
model: str,
|
|
27
|
+
cfg: Config,
|
|
28
|
+
) -> str:
|
|
29
|
+
messages: list[dict[str, str]] = []
|
|
30
|
+
if system:
|
|
31
|
+
messages.append({"role": "system", "content": system})
|
|
32
|
+
messages.append({"role": "user", "content": prompt})
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
resp = await client.post(
|
|
36
|
+
"/chat/completions",
|
|
37
|
+
json={"model": model, "messages": messages},
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()["choices"][0]["message"]["content"]
|
|
41
|
+
except httpx.ConnectError:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Ollama not reachable at {cfg.base_url} — is it running?"
|
|
44
|
+
) from None
|
|
45
|
+
except httpx.TimeoutException:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Model timed out after {cfg.timeout}s — increase PEDALPOINT_TIMEOUT"
|
|
48
|
+
) from None
|
|
49
|
+
except httpx.HTTPStatusError as exc:
|
|
50
|
+
if exc.response.status_code == 404:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Model `{model}` not found — run: ollama pull {model}"
|
|
53
|
+
) from exc
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"HTTP {exc.response.status_code}: {exc.response.text}"
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
async def local_llm(
|
|
61
|
+
ctx, # type: ignore[type-arg]
|
|
62
|
+
prompt: str,
|
|
63
|
+
system: str | None = None,
|
|
64
|
+
model: str | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
client: httpx.AsyncClient = ctx.request_context.lifespan_context["http"]
|
|
67
|
+
cfg: Config = ctx.request_context.lifespan_context["cfg"]
|
|
68
|
+
return await _call_local_llm(client, prompt, system, model or cfg.model, cfg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
mcp.run()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pedalpoint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that routes mechanical dev tasks to a local LLM
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: mcp>=1.0.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.21.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# pedalpoint
|
|
18
|
+
|
|
19
|
+
Route mechanical development tasks to a local LLM (Ollama, LM Studio, Jan) via Claude Code. Automatically falls back to local-only mode when Claude quota is exhausted.
|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
- **Hybrid routing:** Claude classifies each task. Mechanical work (scaffolding, CRUD, test stubs) goes to your local LLM. Judgment work (architecture, debugging, integration) stays with Claude.
|
|
24
|
+
- **Quota fallback:** When Claude returns a quota error, pedalpoint automatically switches to local-only mode and continues working. Switches back when quota restores.
|
|
25
|
+
- **Context Courier:** Before sending any task to the local LLM, pedalpoint greps your codebase for existing patterns and injects them into the prompt. Local LLMs only get tasks they have templates for.
|
|
26
|
+
- **Single install:** One script installs the MCP server, pulls the default model, and registers the companion skill.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Claude Code
|
|
31
|
+
- Python 3.11+
|
|
32
|
+
- [Ollama](https://ollama.ai) (or any OpenAI-compatible inference server)
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
curl -fsSL https://raw.githubusercontent.com/EMSwank/pedalpoint/main/install.sh | sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
With a custom model or endpoint:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
curl -fsSL https://raw.githubusercontent.com/EMSwank/pedalpoint/main/install.sh | \
|
|
44
|
+
sh -s -- --model llama3 --base-url http://localhost:11434/v1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Restart Claude Code after install.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Instead of `superpowers:subagent-driven-development`, invoke:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
pedalpoint:route-tasks
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
pedalpoint handles routing automatically. No manual flagging needed.
|
|
58
|
+
|
|
59
|
+
## Modes
|
|
60
|
+
|
|
61
|
+
| Mode | Set via | Behavior |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `hybrid` | default | Claude classifies each task |
|
|
64
|
+
| `local-only` | `PEDALPOINT_MODE=local-only` | All tasks → local LLM (use when quota exhausted preemptively) |
|
|
65
|
+
| `passthrough` | `PEDALPOINT_MODE=passthrough` | All tasks → Claude Agent (disables pedalpoint) |
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Variable | Default | Description |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `PEDALPOINT_BASE_URL` | `http://localhost:11434/v1` | OpenAI-compatible endpoint |
|
|
72
|
+
| `PEDALPOINT_MODEL` | `gemma4:e4b` | Default local model |
|
|
73
|
+
| `PEDALPOINT_MODE` | `hybrid` | Routing mode |
|
|
74
|
+
| `PEDALPOINT_TIMEOUT` | `120` | Read timeout in seconds |
|
|
75
|
+
| `PEDALPOINT_INITIAL_FALLBACK_MINUTES` | `60` | Initial circuit-open duration (doubles on each probe failure) |
|
|
76
|
+
|
|
77
|
+
## Uninstall
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
curl -fsSL https://raw.githubusercontent.com/EMSwank/pedalpoint/main/install.sh | sh -s -- --uninstall
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
git clone https://github.com/EMSwank/pedalpoint
|
|
87
|
+
cd pedalpoint
|
|
88
|
+
pip install -e ".[dev]" # requires pip >= 21.3; run `pip install --upgrade pip` if needed
|
|
89
|
+
pytest tests/unit/ -v
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Integration tests (requires Ollama running):
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
PEDALPOINT_INTEGRATION=true pytest tests/integration/ -v
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Install script tests (requires [bats-core](https://github.com/bats-core/bats-core)):
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
brew install bats-core # macOS; use apt-get install bats on Linux
|
|
102
|
+
bats tests/install/
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pedalpoint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pedalpoint/circuit_breaker.py,sha256=YtvEy2jgi20cSTUbNUY3lsnYoo35mPMztzWItkhc7LI,2226
|
|
3
|
+
pedalpoint/config.py,sha256=9SCj-Q7m_uAClRebr7mysnb57tYgcG6QXgf2CdSMsVA,762
|
|
4
|
+
pedalpoint/error_classifier.py,sha256=nHyk-t2wr7ZpsAn3fw8G1qXZK1RkF--XItQfQc5cO0E,585
|
|
5
|
+
pedalpoint/server.py,sha256=wGp-m4mK-hhv2dHwZSrbzn0Dz4VW-fcALu42wia2Gc8,2104
|
|
6
|
+
pedalpoint-0.1.0.dist-info/METADATA,sha256=wpzX-2A0PaPouIsaUxSGLXzFKeTHyDM_Fx_YpdjrMZg,3432
|
|
7
|
+
pedalpoint-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
pedalpoint-0.1.0.dist-info/entry_points.txt,sha256=LLPpcY6fbbPrOSBzy5LXnDbrsK_mhFEKfU2Fjrf8ND4,61
|
|
9
|
+
pedalpoint-0.1.0.dist-info/RECORD,,
|