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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pedalpoint-server = pedalpoint.server:main