Skip to content

Commit f30835b

Browse files
dmikushinclaude
andcommitted
fix: switch build backend to hatchling for PEP 660 editable install support
setuptools 59.6.0 (system) lacks the build_editable hook added in 64.0, so pip install -e . failed unconditionally. Switching to hatchling which ships build_editable natively and is already used by all three sub-packages. To give hatchling a single source root (its editable .pth mechanism supports only one path), introduce src/repowise/ with symlinks into each sub-package: src/repowise/core -> packages/core/src/repowise/core src/repowise/cli -> packages/cli/src/repowise/cli src/repowise/server -> packages/server/src/repowise/server Also includes the free-code provider (FreeCodeProvider) committed alongside. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f43d0cf commit f30835b

6 files changed

Lines changed: 311 additions & 33 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""free-code provider for repowise.
2+
3+
Uses a locally running free-code serve instance as a gateway to Claude.
4+
free-code serve exposes an Anthropic-compatible API at http://localhost:3180
5+
and proxies requests to api.anthropic.com using the user's credentials.
6+
7+
This provider lets repowise call Claude without directly holding an
8+
Anthropic API key — the key lives in the free-code session instead.
9+
10+
Prerequisites:
11+
1. Install free-code (Claude Code CLI)
12+
2. Run: claude serve [--port 3180]
13+
14+
Usage:
15+
provider = FreeCodeProvider(model="claude-sonnet-4-6")
16+
response = await provider.generate(system_prompt="...", user_prompt="...")
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
23+
import structlog
24+
from anthropic import AsyncAnthropic
25+
from anthropic import RateLimitError as _AnthropicRateLimitError
26+
from anthropic import APIStatusError as _AnthropicAPIStatusError
27+
from tenacity import (
28+
retry,
29+
retry_if_exception_type,
30+
stop_after_attempt,
31+
wait_exponential_jitter,
32+
RetryError,
33+
)
34+
35+
from repowise.core.providers.llm.base import (
36+
BaseProvider,
37+
ChatStreamEvent,
38+
ChatToolCall,
39+
GeneratedResponse,
40+
ProviderError,
41+
RateLimitError,
42+
)
43+
44+
from typing import Any, AsyncIterator
45+
from repowise.core.rate_limiter import RateLimiter
46+
47+
log = structlog.get_logger(__name__)
48+
49+
_MAX_RETRIES = 3
50+
_MIN_WAIT = 1.0
51+
_MAX_WAIT = 8.0
52+
53+
_DEFAULT_BASE_URL = "http://localhost:3180"
54+
55+
# Dummy key value — free-code serve doesn't check the incoming key,
56+
# but the Anthropic SDK requires a non-empty string.
57+
_PLACEHOLDER_KEY = "free-code-local"
58+
59+
60+
class FreeCodeProvider(BaseProvider):
61+
"""Anthropic provider that routes through a local free-code serve instance.
62+
63+
free-code serve (``claude serve``) must be running before using this provider.
64+
It exposes an Anthropic-compatible API and proxies requests to api.anthropic.com
65+
using the credentials of the logged-in Claude Code user.
66+
67+
Args:
68+
model: Claude model identifier (e.g., 'claude-sonnet-4-6').
69+
base_url: URL of the local free-code serve instance.
70+
Defaults to http://localhost:3180.
71+
Override via FREE_CODE_BASE_URL env var.
72+
rate_limiter: Optional RateLimiter instance.
73+
"""
74+
75+
def __init__(
76+
self,
77+
model: str = "claude-sonnet-4-6",
78+
base_url: str | None = None,
79+
rate_limiter: RateLimiter | None = None,
80+
) -> None:
81+
resolved_url = (
82+
base_url
83+
or os.environ.get("FREE_CODE_BASE_URL")
84+
or _DEFAULT_BASE_URL
85+
)
86+
self._client = AsyncAnthropic(
87+
api_key=_PLACEHOLDER_KEY,
88+
base_url=resolved_url,
89+
)
90+
self._model = model
91+
self._rate_limiter = rate_limiter
92+
self._base_url = resolved_url
93+
94+
@property
95+
def provider_name(self) -> str:
96+
return "free-code"
97+
98+
@property
99+
def model_name(self) -> str:
100+
return self._model
101+
102+
async def generate(
103+
self,
104+
system_prompt: str,
105+
user_prompt: str,
106+
max_tokens: int = 4096,
107+
temperature: float = 0.3,
108+
request_id: str | None = None,
109+
) -> GeneratedResponse:
110+
if self._rate_limiter:
111+
await self._rate_limiter.acquire(estimated_tokens=max_tokens)
112+
113+
log.debug(
114+
"free_code.generate.start",
115+
model=self._model,
116+
base_url=self._base_url,
117+
max_tokens=max_tokens,
118+
request_id=request_id,
119+
)
120+
121+
try:
122+
return await self._generate_with_retry(
123+
system_prompt=system_prompt,
124+
user_prompt=user_prompt,
125+
max_tokens=max_tokens,
126+
temperature=temperature,
127+
request_id=request_id,
128+
)
129+
except RetryError as exc:
130+
raise ProviderError(
131+
"free-code",
132+
f"All {_MAX_RETRIES} retries exhausted: {exc}",
133+
) from exc
134+
135+
@retry(
136+
retry=retry_if_exception_type(ProviderError),
137+
stop=stop_after_attempt(_MAX_RETRIES),
138+
wait=wait_exponential_jitter(initial=_MIN_WAIT, max=_MAX_WAIT),
139+
reraise=True,
140+
)
141+
async def _generate_with_retry(
142+
self,
143+
system_prompt: str,
144+
user_prompt: str,
145+
max_tokens: int,
146+
temperature: float,
147+
request_id: str | None,
148+
) -> GeneratedResponse:
149+
try:
150+
response = await self._client.messages.create(
151+
model=self._model,
152+
max_tokens=max_tokens,
153+
temperature=temperature,
154+
system=system_prompt,
155+
messages=[{"role": "user", "content": user_prompt}],
156+
)
157+
except _AnthropicRateLimitError as exc:
158+
raise RateLimitError("free-code", str(exc), status_code=429) from exc
159+
except _AnthropicAPIStatusError as exc:
160+
raise ProviderError(
161+
"free-code", str(exc), status_code=exc.status_code
162+
) from exc
163+
except Exception as exc:
164+
# Catch connection errors (free-code serve not running, wrong port, etc.)
165+
raise ProviderError(
166+
"free-code",
167+
f"Connection to {self._base_url} failed: {exc}. "
168+
"Is 'claude serve' running?",
169+
) from exc
170+
171+
cached = getattr(response.usage, "cache_read_input_tokens", 0) or 0
172+
result = GeneratedResponse(
173+
content=response.content[0].text,
174+
input_tokens=response.usage.input_tokens,
175+
output_tokens=response.usage.output_tokens,
176+
cached_tokens=cached,
177+
usage={
178+
"input_tokens": response.usage.input_tokens,
179+
"output_tokens": response.usage.output_tokens,
180+
"cache_read_input_tokens": cached,
181+
},
182+
)
183+
log.debug(
184+
"free_code.generate.done",
185+
input_tokens=result.input_tokens,
186+
output_tokens=result.output_tokens,
187+
request_id=request_id,
188+
)
189+
return result
190+
191+
# --- ChatProvider protocol implementation ---
192+
193+
async def stream_chat(
194+
self,
195+
messages: list[dict[str, Any]],
196+
tools: list[dict[str, Any]],
197+
system_prompt: str,
198+
max_tokens: int = 8192,
199+
temperature: float = 0.7,
200+
request_id: str | None = None,
201+
tool_executor: Any | None = None,
202+
) -> AsyncIterator[ChatStreamEvent]:
203+
"""Stream chat via free-code serve (Anthropic-compatible endpoint)."""
204+
import json as _json
205+
206+
# Convert OpenAI-format tools to Anthropic format
207+
anthropic_tools = []
208+
for t in tools:
209+
fn = t.get("function", t)
210+
anthropic_tools.append({
211+
"name": fn["name"],
212+
"description": fn.get("description", ""),
213+
"input_schema": fn.get("parameters", {}),
214+
})
215+
216+
# Convert OpenAI-format messages to Anthropic format
217+
from repowise.core.providers.llm.anthropic import _to_anthropic_messages
218+
anthropic_messages = _to_anthropic_messages(messages)
219+
220+
kwargs: dict[str, Any] = {
221+
"model": self._model,
222+
"max_tokens": max_tokens,
223+
"temperature": temperature,
224+
"system": system_prompt,
225+
"messages": anthropic_messages,
226+
}
227+
if anthropic_tools:
228+
kwargs["tools"] = anthropic_tools
229+
230+
try:
231+
async with self._client.messages.stream(**kwargs) as stream:
232+
current_tool_id: str | None = None
233+
current_tool_name: str | None = None
234+
current_tool_input_json = ""
235+
236+
async for event in stream:
237+
if event.type == "content_block_start":
238+
block = event.content_block
239+
if hasattr(block, "type") and block.type == "tool_use":
240+
current_tool_id = block.id
241+
current_tool_name = block.name
242+
current_tool_input_json = ""
243+
elif event.type == "content_block_delta":
244+
delta = event.delta
245+
if hasattr(delta, "type"):
246+
if delta.type == "text_delta":
247+
yield ChatStreamEvent(type="text_delta", text=delta.text)
248+
elif delta.type == "input_json_delta":
249+
current_tool_input_json += delta.partial_json
250+
elif event.type == "content_block_stop":
251+
if current_tool_name:
252+
try:
253+
args = _json.loads(current_tool_input_json) if current_tool_input_json else {}
254+
except Exception:
255+
args = {}
256+
yield ChatStreamEvent(
257+
type="tool_start",
258+
tool_call=ChatToolCall(
259+
id=current_tool_id or "",
260+
name=current_tool_name,
261+
arguments=args,
262+
),
263+
)
264+
current_tool_id = None
265+
current_tool_name = None
266+
current_tool_input_json = ""
267+
elif event.type == "message_delta":
268+
stop = getattr(event.delta, "stop_reason", None)
269+
usage = getattr(event, "usage", None)
270+
if usage:
271+
yield ChatStreamEvent(
272+
type="usage",
273+
input_tokens=getattr(usage, "input_tokens", 0) or 0,
274+
output_tokens=getattr(usage, "output_tokens", 0) or 0,
275+
)
276+
if stop:
277+
yield ChatStreamEvent(type="stop", stop_reason=stop)
278+
elif event.type == "message_stop":
279+
pass
280+
except _AnthropicRateLimitError as exc:
281+
raise RateLimitError("free-code", str(exc), status_code=429) from exc
282+
except _AnthropicAPIStatusError as exc:
283+
raise ProviderError("free-code", str(exc), status_code=exc.status_code) from exc
284+
except Exception as exc:
285+
raise ProviderError(
286+
"free-code",
287+
f"Connection to {self._base_url} failed: {exc}. "
288+
"Is 'claude serve' running?",
289+
) from exc

packages/core/src/repowise/core/providers/llm/registry.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- openai → OpenAIProvider
1010
- ollama → OllamaProvider
1111
- litellm → LiteLLMProvider
12+
- free-code → FreeCodeProvider (local Claude Code proxy)
1213
- mock → MockProvider (testing only)
1314
1415
Custom provider registration:
@@ -39,6 +40,7 @@
3940
"gemini": ("repowise.core.providers.llm.gemini", "GeminiProvider"),
4041
"ollama": ("repowise.core.providers.llm.ollama", "OllamaProvider"),
4142
"litellm": ("repowise.core.providers.llm.litellm", "LiteLLMProvider"),
43+
"free-code": ("repowise.core.providers.llm.free_code", "FreeCodeProvider"),
4244
"mock": ("repowise.core.providers.llm.mock", "MockProvider"),
4345
}
4446

@@ -135,6 +137,7 @@ def get_provider(
135137
"gemini": "google-genai",
136138
"ollama": "openai", # ollama uses the openai package
137139
"litellm": "litellm",
140+
"free-code": "anthropic", # free-code uses the anthropic package
138141
}
139142
package = _missing.get(name, name)
140143
raise ImportError(

pyproject.toml

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
# repowise — unified PyPI package
33
# ---------------------------------------------------------------------------
44
[build-system]
5-
requires = ["setuptools>=75"]
6-
build-backend = "setuptools.build_meta"
5+
requires = ["hatchling"]
6+
build-backend = "hatchling.build"
77

88
[project]
99
name = "repowise"
@@ -109,41 +109,24 @@ Issues = "https://github.com/RaghavChamadiya/repowise/issues"
109109
Documentation = "https://github.com/RaghavChamadiya/repowise/blob/main/docs/USER_GUIDE.md"
110110

111111
# ---------------------------------------------------------------------------
112-
# setuptools — explicit package-dir mapping across three src/ directories
112+
# hatchling — standard src/ layout with symlinks into per-package source trees
113+
#
114+
# src/repowise/{core,cli,server} are symlinks into packages/*/src/repowise/*.
115+
# This gives hatchling a single source root so editable installs work correctly
116+
# (one .pth entry pointing at src/ covers all three sub-namespaces).
113117
# ---------------------------------------------------------------------------
114-
[tool.setuptools.package-dir]
115-
"repowise.core" = "packages/core/src/repowise/core"
116-
"repowise.cli" = "packages/cli/src/repowise/cli"
117-
"repowise.server" = "packages/server/src/repowise/server"
118+
[tool.hatch.build.targets.wheel]
119+
packages = ["src/repowise"]
118120

119-
[tool.setuptools]
120-
packages = [
121-
# core
122-
"repowise.core",
123-
"repowise.core.analysis",
124-
"repowise.core.generation",
125-
"repowise.core.generation.editor_files",
126-
"repowise.core.ingestion",
127-
"repowise.core.ingestion.parsers",
128-
"repowise.core.pipeline",
129-
"repowise.core.persistence",
130-
"repowise.core.providers",
131-
"repowise.core.providers.llm",
132-
"repowise.core.providers.embedding",
133-
# cli
134-
"repowise.cli",
135-
"repowise.cli.commands",
136-
# server
137-
"repowise.server",
138-
"repowise.server.routers",
139-
"repowise.server.mcp_server",
140-
"repowise.server.services",
121+
[tool.hatch.build.targets.sdist]
122+
include = [
123+
"src/",
124+
"packages/*/src/**",
125+
"pyproject.toml",
126+
"README.md",
127+
"LICENSE",
141128
]
142129

143-
[tool.setuptools.package-data]
144-
"repowise.core.ingestion" = ["queries/*.scm"]
145-
"repowise.core.generation" = ["templates/*.j2"]
146-
147130
# ---------------------------------------------------------------------------
148131
# uv workspace (kept for local development)
149132
# ---------------------------------------------------------------------------

src/repowise/cli

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../packages/cli/src/repowise/cli

src/repowise/core

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../packages/core/src/repowise/core

src/repowise/server

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../packages/server/src/repowise/server

0 commit comments

Comments
 (0)