diffron 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {diffron-0.1.3 → diffron-0.1.5}/CHANGELOG.md +12 -0
- {diffron-0.1.3/diffron.egg-info → diffron-0.1.5}/PKG-INFO +1 -1
- diffron-0.1.5/diffron/__main__.py +8 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/cli.py +86 -86
- diffron-0.1.5/diffron/lemonade.py +249 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/models.py +9 -4
- {diffron-0.1.3 → diffron-0.1.5/diffron.egg-info}/PKG-INFO +1 -1
- {diffron-0.1.3 → diffron-0.1.5}/diffron.egg-info/SOURCES.txt +1 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron.egg-info/entry_points.txt +1 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron.egg-info/top_level.txt +0 -1
- {diffron-0.1.3 → diffron-0.1.5}/pyproject.toml +2 -1
- {diffron-0.1.3 → diffron-0.1.5}/requirements.txt +1 -0
- {diffron-0.1.3 → diffron-0.1.5}/setup.py +2 -1
- diffron-0.1.3/diffron/lemonade.py +0 -192
- {diffron-0.1.3 → diffron-0.1.5}/LICENSE +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/MANIFEST.in +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/README.md +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/__init__.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/__init__.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/cli.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/client.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/client.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/commit_gen.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/commit_gen.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/git_hooks.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/git_hooks.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/hooks/aipr.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/hooks/prepare-commit-msg +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/hooks/prepare-commit-msg-global.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/hooks/prepare-commit-msg.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/lemonade.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/models.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/pr_gen.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/pr_gen.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/py.typed +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/utils.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron/utils.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron.egg-info/dependency_links.txt +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/diffron.egg-info/requires.txt +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/docs/HOOKS.md +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/docs/SETUP.md +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/docs/USAGE.md +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/hooks/aipr.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/hooks/prepare-commit-msg-global.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/hooks/prepare-commit-msg.py +0 -0
- {diffron-0.1.3 → diffron-0.1.5}/setup.cfg +0 -0
|
@@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
## [0.1.4] - 2026-04-24
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Lemonade Model Unresponsive Fix** — Added retry logic with automatic model unload/reload when Lemonade model becomes unresponsive
|
|
24
|
+
- Added `_reload_model()` method that uses lemonade-python-sdk to detect and reconnect to the Lemonade server
|
|
25
|
+
- Added `_init_client()` method to reinitialize the OpenAI client after model reload
|
|
26
|
+
- Handles `'NoneType' object is not subscriptable` error by checking for None responses before accessing `response.choices[0]`
|
|
27
|
+
- Retry mechanism attempts up to 3 times before raising a detailed RuntimeError
|
|
28
|
+
- Added `from urllib.parse import urlparse` import at module level
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
20
32
|
## [0.1.3] - 2026-04-08
|
|
21
33
|
|
|
22
34
|
### Added
|
|
@@ -9,26 +9,26 @@ import sys
|
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def install_hooks_cli():
|
|
12
|
+
def install_hooks_cli(args: Optional[argparse.Namespace] = None):
|
|
13
13
|
"""CLI entry point for installing Git hooks."""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
14
|
+
if args is None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description="Install Diffron Git hooks to a repository."
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--global",
|
|
20
|
+
dest="global_install",
|
|
21
|
+
action="store_true",
|
|
22
|
+
help="Install hooks globally for all repositories.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--repo",
|
|
26
|
+
"-r",
|
|
27
|
+
type=str,
|
|
28
|
+
default=".",
|
|
29
|
+
help="Path to the git repository (default: current directory).",
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args()
|
|
32
32
|
|
|
33
33
|
from .git_hooks import install_hooks
|
|
34
34
|
|
|
@@ -54,26 +54,26 @@ def install_hooks_cli():
|
|
|
54
54
|
sys.exit(1)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def uninstall_hooks_cli():
|
|
57
|
+
def uninstall_hooks_cli(args: Optional[argparse.Namespace] = None):
|
|
58
58
|
"""CLI entry point for uninstalling Git hooks."""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
if args is None:
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
description="Remove Diffron Git hooks from a repository."
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--global",
|
|
65
|
+
dest="global_install",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Remove global hooks configuration.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--repo",
|
|
71
|
+
"-r",
|
|
72
|
+
type=str,
|
|
73
|
+
default=".",
|
|
74
|
+
help="Path to the git repository (default: current directory).",
|
|
75
|
+
)
|
|
76
|
+
args = parser.parse_args()
|
|
77
77
|
|
|
78
78
|
from .git_hooks import uninstall_hooks
|
|
79
79
|
|
|
@@ -99,32 +99,32 @@ def uninstall_hooks_cli():
|
|
|
99
99
|
sys.exit(1)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
def pr_description_cli():
|
|
102
|
+
def pr_description_cli(args: Optional[argparse.Namespace] = None):
|
|
103
103
|
"""CLI entry point for generating PR descriptions."""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
104
|
+
if args is None:
|
|
105
|
+
parser = argparse.ArgumentParser(
|
|
106
|
+
description="Generate GitHub PR title and description."
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--branch",
|
|
110
|
+
"-b",
|
|
111
|
+
type=str,
|
|
112
|
+
default=None,
|
|
113
|
+
help="Branch to analyze (default: current branch).",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--base",
|
|
117
|
+
type=str,
|
|
118
|
+
default=None,
|
|
119
|
+
help="Base branch to compare against (default: main/master).",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--create",
|
|
123
|
+
"-c",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="Automatically create PR on GitHub (requires gh CLI).",
|
|
126
|
+
)
|
|
127
|
+
args = parser.parse_args()
|
|
128
128
|
|
|
129
129
|
from .pr_gen import generate_pr_description, create_github_pr
|
|
130
130
|
from .lemonade import is_lemonade_running
|
|
@@ -169,26 +169,26 @@ def pr_description_cli():
|
|
|
169
169
|
sys.exit(130)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
-
def status_cli():
|
|
172
|
+
def status_cli(args: Optional[argparse.Namespace] = None):
|
|
173
173
|
"""CLI entry point for checking Lemonade and hooks status."""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
174
|
+
if args is None:
|
|
175
|
+
parser = argparse.ArgumentParser(
|
|
176
|
+
description="Check Diffron status (Lemonade connection, hooks installation)."
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--repo",
|
|
180
|
+
"-r",
|
|
181
|
+
type=str,
|
|
182
|
+
default=".",
|
|
183
|
+
help="Path to the git repository (default: current directory).",
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--verbose",
|
|
187
|
+
"-v",
|
|
188
|
+
action="store_true",
|
|
189
|
+
help="Show detailed status information.",
|
|
190
|
+
)
|
|
191
|
+
args = parser.parse_args()
|
|
192
192
|
|
|
193
193
|
from .lemonade import detect_lemonade_port, is_lemonade_running
|
|
194
194
|
from .git_hooks import get_hooks_status, is_hooks_installed
|
|
@@ -331,13 +331,13 @@ def main():
|
|
|
331
331
|
args = parser.parse_args()
|
|
332
332
|
|
|
333
333
|
if args.command == "install-hooks":
|
|
334
|
-
install_hooks_cli()
|
|
334
|
+
install_hooks_cli(args)
|
|
335
335
|
elif args.command == "uninstall-hooks":
|
|
336
|
-
uninstall_hooks_cli()
|
|
336
|
+
uninstall_hooks_cli(args)
|
|
337
337
|
elif args.command == "pr":
|
|
338
|
-
pr_description_cli()
|
|
338
|
+
pr_description_cli(args)
|
|
339
339
|
elif args.command == "status":
|
|
340
|
-
status_cli()
|
|
340
|
+
status_cli(args)
|
|
341
341
|
else:
|
|
342
342
|
parser.print_help()
|
|
343
343
|
sys.exit(0)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Lemonade integration for Diffron.
|
|
2
|
+
|
|
3
|
+
Provides automatic port detection and API client for Lemonade LLM server.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import typing
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import openai
|
|
12
|
+
from openai import OpenAI
|
|
13
|
+
|
|
14
|
+
from .utils import is_port_open
|
|
15
|
+
|
|
16
|
+
# Import lemonade-python-sdk for verification
|
|
17
|
+
try:
|
|
18
|
+
from lemonade_sdk.port_scanner import verify_lemonade_server
|
|
19
|
+
from lemonade_sdk.port_scanner import find_available_lemonade_port
|
|
20
|
+
HAS_LEMONADE_SDK = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_LEMONADE_SDK = False
|
|
23
|
+
|
|
24
|
+
DEFAULT_HOST = "localhost"
|
|
25
|
+
DEFAULT_PORT = 8020 # Default Lemonade port
|
|
26
|
+
DEFAULT_PORTS = [8020, 8000, 8001, 8080, 8081, 5000, 5001]
|
|
27
|
+
LEMONADE_API_KEY = "lemonade" # Default API key for Lemonade
|
|
28
|
+
DEFAULT_MODEL = "qwen2.5-it-3b-FLM" # Default Lemonade model
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_lemonade_port(
|
|
32
|
+
host: Optional[str] = None,
|
|
33
|
+
ports: Optional[List[int]] = None
|
|
34
|
+
) -> Optional[int]:
|
|
35
|
+
"""Auto-detect port for running Lemonade server.
|
|
36
|
+
|
|
37
|
+
Uses lemonade-python-sdk's verify_lemonade_server to distinguish
|
|
38
|
+
Lemonade from other services (e.g., Axo Biomarker on port 8000).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
|
|
42
|
+
ports: List of ports to scan. Defaults to common Lemonade ports.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Port number if found, None otherwise.
|
|
46
|
+
"""
|
|
47
|
+
# Check LEMONADE_SERVER_URL environment variable first (standard Lemonade env)
|
|
48
|
+
server_url = os.environ.get("LEMONADE_SERVER_URL")
|
|
49
|
+
if server_url:
|
|
50
|
+
from urllib.parse import urlparse
|
|
51
|
+
parsed = urlparse(server_url)
|
|
52
|
+
port = parsed.port
|
|
53
|
+
hostname = parsed.hostname or DEFAULT_HOST
|
|
54
|
+
# Verify it's actually Lemonade
|
|
55
|
+
if HAS_LEMONADE_SDK and verify_lemonade_server(port, hostname):
|
|
56
|
+
return port
|
|
57
|
+
elif is_port_open(hostname, port):
|
|
58
|
+
return port
|
|
59
|
+
|
|
60
|
+
# Check DIFFRON_LEMONADE_PORT (Diffron-specific)
|
|
61
|
+
env_port = os.environ.get("DIFFRON_LEMONADE_PORT")
|
|
62
|
+
if env_port:
|
|
63
|
+
try:
|
|
64
|
+
port = int(env_port)
|
|
65
|
+
hostname = host or DEFAULT_HOST
|
|
66
|
+
if HAS_LEMONADE_SDK and verify_lemonade_server(port, hostname):
|
|
67
|
+
return port
|
|
68
|
+
elif is_port_open(hostname, port):
|
|
69
|
+
return port
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Use lemonade-python-sdk's find_available_lemonade_port
|
|
74
|
+
if HAS_LEMONADE_SDK:
|
|
75
|
+
hostname = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
76
|
+
scan_ports = ports or DEFAULT_PORTS
|
|
77
|
+
found = find_available_lemonade_port(hostname, scan_ports)
|
|
78
|
+
if found:
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
# Fallback: manual scan verification
|
|
82
|
+
hostname = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
83
|
+
ports_to_scan = ports or DEFAULT_PORTS
|
|
84
|
+
for port in ports_to_scan:
|
|
85
|
+
if is_port_open(hostname, port):
|
|
86
|
+
if HAS_LEMONADE_SDK and verify_lemonade_server(port, hostname):
|
|
87
|
+
return port
|
|
88
|
+
# Without SDK, return first open port (legacy behavior)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_lemonade_url() -> str:
|
|
93
|
+
"""Get Lemonade server URL from environment or default.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Lemonade server URL.
|
|
97
|
+
"""
|
|
98
|
+
# Check LEMONADE_SERVER_URL first (standard)
|
|
99
|
+
server_url = os.environ.get("LEMONADE_SERVER_URL")
|
|
100
|
+
if server_url:
|
|
101
|
+
return server_url.strip().rstrip("/")
|
|
102
|
+
|
|
103
|
+
# Build URL from host/port
|
|
104
|
+
host = os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
105
|
+
port = detect_lemonade_port(host)
|
|
106
|
+
if port:
|
|
107
|
+
return f"http://{host}:{port}"
|
|
108
|
+
|
|
109
|
+
# Fallback to default
|
|
110
|
+
return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_lemonade_running(
|
|
114
|
+
host: Optional[str] = None,
|
|
115
|
+
port: Optional[int] = None
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Check if Lemonade is running.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
|
|
121
|
+
port: Port to check. Auto-detects if not provided.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if Lemonade is running, False otherwise.
|
|
125
|
+
"""
|
|
126
|
+
if port is None:
|
|
127
|
+
port = detect_lemonade_port(host)
|
|
128
|
+
if port is None:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
host = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
132
|
+
return is_port_open(host, port)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class LemonadeClient:
|
|
136
|
+
"""Client for interacting with Lemonade LLM server.
|
|
137
|
+
|
|
138
|
+
Detects running Lemonade instance and provides a convenient
|
|
139
|
+
interface for chat completions.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
base_url: Optional[str] = None,
|
|
145
|
+
api_key: Optional[str] = None,
|
|
146
|
+
model: Optional[str] = None
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Initialize Lemonade client.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
base_url: Lemonade server URL. Auto-detects from
|
|
152
|
+
LEMONADE_SERVER_URL, defaults to http://localhost:8020.
|
|
153
|
+
api_key: API key. Defaults to "lemonade".
|
|
154
|
+
model: Model name. Defaults to qwen2.5-it-3b-FLM.
|
|
155
|
+
"""
|
|
156
|
+
# Get base URL from parameter or environment default
|
|
157
|
+
self.base_url = (base_url or get_lemonade_url()).rstrip("/")
|
|
158
|
+
# Parse host and port from base_url
|
|
159
|
+
parsed = urlparse(self.base_url)
|
|
160
|
+
self.host = parsed.hostname or DEFAULT_HOST
|
|
161
|
+
self.port = parsed.port or DEFAULT_PORT
|
|
162
|
+
self.api_key = api_key or LEMONADE_API_KEY
|
|
163
|
+
# Model: parameter > environment > default
|
|
164
|
+
self.model = model or os.environ.get("DIFFRON_MODEL") or DEFAULT_MODEL
|
|
165
|
+
self._port = self.port # Track current port for reload
|
|
166
|
+
self.client = OpenAI(
|
|
167
|
+
base_url=f"{self.base_url}/api/v1",
|
|
168
|
+
api_key=self.api_key
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def chat_completion(
|
|
172
|
+
self,
|
|
173
|
+
messages: List[dict],
|
|
174
|
+
max_tokens: int = 100,
|
|
175
|
+
temperature: float = 0.2,
|
|
176
|
+
**kwargs
|
|
177
|
+
) -> str:
|
|
178
|
+
"""Generate chat completion.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
messages: List of message dicts with 'role' and 'content'.
|
|
182
|
+
max_tokens: Maximum tokens to generate.
|
|
183
|
+
temperature: Sampling temperature.
|
|
184
|
+
**kwargs: Additional arguments to pass to the API.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Generated response content.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
RuntimeError: If model fails after retry attempts.
|
|
191
|
+
"""
|
|
192
|
+
max_retries = 3
|
|
193
|
+
for attempt in range(1, max_retries + 1):
|
|
194
|
+
try:
|
|
195
|
+
response = self.client.chat.completions.create(
|
|
196
|
+
model=self.model,
|
|
197
|
+
messages=messages,
|
|
198
|
+
max_tokens=max_tokens,
|
|
199
|
+
temperature=temperature,
|
|
200
|
+
**kwargs
|
|
201
|
+
)
|
|
202
|
+
# Handle None response (model unresponsive)
|
|
203
|
+
if response is None or not response.choices:
|
|
204
|
+
if attempt < max_retries:
|
|
205
|
+
self._reload_model()
|
|
206
|
+
continue
|
|
207
|
+
raise RuntimeError("Lemonade model returned no response")
|
|
208
|
+
return response.choices[0].message.content
|
|
209
|
+
except Exception as e:
|
|
210
|
+
if attempt < max_retries:
|
|
211
|
+
self._reload_model()
|
|
212
|
+
continue
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"Lemonade model failed after {max_retries} attempts: {e}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _reload_model(self) -> None:
|
|
218
|
+
"""Unload and reload the Lemonade model when unresponsive.
|
|
219
|
+
|
|
220
|
+
Uses lemonade-python-sdk to detect and reconnect to the
|
|
221
|
+
Lemonade server, which triggers a model reload.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
if HAS_LEMONADE_SDK:
|
|
225
|
+
# Find available port (triggers unload/reload via SDK)
|
|
226
|
+
new_port = find_available_lemonade_port(
|
|
227
|
+
hostname=self.host or DEFAULT_HOST,
|
|
228
|
+
ports=DEFAULT_PORTS
|
|
229
|
+
)
|
|
230
|
+
if new_port and new_port != self._port:
|
|
231
|
+
self._port = new_port
|
|
232
|
+
self.port = new_port # Update the port attribute
|
|
233
|
+
# Rebuild the base_url with the new port and the same host
|
|
234
|
+
self.base_url = f"http://{self.host}:{new_port}"
|
|
235
|
+
# Reinitialize client with new base_url
|
|
236
|
+
self._init_client()
|
|
237
|
+
except Exception:
|
|
238
|
+
# Silently continue on reload failure
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
def _init_client(self) -> None:
|
|
242
|
+
"""Reinitialize the OpenAI client with current configuration."""
|
|
243
|
+
self.client = OpenAI(
|
|
244
|
+
base_url=f"{self.base_url}/api/v1",
|
|
245
|
+
api_key=self.api_key
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def __repr__(self) -> str:
|
|
249
|
+
return f"LemonadeClient(base_url='{self.base_url}', model='{self.model}')"
|
|
@@ -42,7 +42,7 @@ AVAILABLE_MODELS: List[ModelConfig] = [
|
|
|
42
42
|
is_default=True,
|
|
43
43
|
),
|
|
44
44
|
ModelConfig(
|
|
45
|
-
name="
|
|
45
|
+
name="Qwen3.5-0.8B-GGUF",
|
|
46
46
|
description="Qwen 3.5 — lightweight and fast, low resource usage",
|
|
47
47
|
parameters="0.8B",
|
|
48
48
|
best_for="Quick commits, low-resource PCs, fast iteration",
|
|
@@ -95,14 +95,19 @@ def get_model_config(model_name: str) -> Optional[ModelConfig]:
|
|
|
95
95
|
Get configuration for a specific model by name.
|
|
96
96
|
|
|
97
97
|
Args:
|
|
98
|
-
model_name: Model name (e.g., '
|
|
98
|
+
model_name: Model name (e.g., 'Qwen3.5-0.8B-GGUF').
|
|
99
99
|
|
|
100
100
|
Returns:
|
|
101
101
|
ModelConfig if found, None otherwise.
|
|
102
102
|
"""
|
|
103
|
+
# Exact match first
|
|
103
104
|
for model in AVAILABLE_MODELS:
|
|
104
105
|
if model.name == model_name:
|
|
105
106
|
return model
|
|
107
|
+
# Case-insensitive fallback
|
|
108
|
+
for model in AVAILABLE_MODELS:
|
|
109
|
+
if model.name.lower() == model_name.lower():
|
|
110
|
+
return model
|
|
106
111
|
return None
|
|
107
112
|
|
|
108
113
|
|
|
@@ -151,7 +156,7 @@ def setup_model_cli():
|
|
|
151
156
|
sys.exit(0)
|
|
152
157
|
|
|
153
158
|
if args.model:
|
|
154
|
-
# Validate model name
|
|
159
|
+
# Validate model name (case-insensitive)
|
|
155
160
|
config = get_model_config(args.model)
|
|
156
161
|
if config is None:
|
|
157
162
|
available = ", ".join(m.name for m in AVAILABLE_MODELS)
|
|
@@ -159,7 +164,7 @@ def setup_model_cli():
|
|
|
159
164
|
print(f"Available models: {available}", file=sys.stderr)
|
|
160
165
|
sys.exit(1)
|
|
161
166
|
|
|
162
|
-
#
|
|
167
|
+
# Use the canonical name from config (not user input)
|
|
163
168
|
os.environ["DIFFRON_MODEL"] = config.name
|
|
164
169
|
|
|
165
170
|
try:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diffron"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Git commit message and PR description generator using Lemonade"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -44,6 +44,7 @@ dev = [
|
|
|
44
44
|
]
|
|
45
45
|
|
|
46
46
|
[project.scripts]
|
|
47
|
+
diffron = "diffron.cli:main"
|
|
47
48
|
diffron-install-hooks = "diffron.cli:install_hooks_cli"
|
|
48
49
|
diffron-uninstall-hooks = "diffron.cli:uninstall_hooks_cli"
|
|
49
50
|
diffron-pr = "diffron.cli:pr_description_cli"
|
|
@@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh:
|
|
|
10
10
|
|
|
11
11
|
setup(
|
|
12
12
|
name="diffron",
|
|
13
|
-
version="0.1.
|
|
13
|
+
version="0.1.4",
|
|
14
14
|
author="Diffron Contributors",
|
|
15
15
|
author_email="diffron@example.com",
|
|
16
16
|
description="Git commit message and PR description generator using Lemonade",
|
|
@@ -51,6 +51,7 @@ setup(
|
|
|
51
51
|
},
|
|
52
52
|
entry_points={
|
|
53
53
|
"console_scripts": [
|
|
54
|
+
"diffron=diffron.cli:main",
|
|
54
55
|
"diffron-install-hooks=diffron.cli:install_hooks_cli",
|
|
55
56
|
"diffron-uninstall-hooks=diffron.cli:uninstall_hooks_cli",
|
|
56
57
|
"diffron-pr=diffron.cli:pr_description_cli",
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Lemonade integration for Diffron.
|
|
3
|
-
|
|
4
|
-
Provides automatic port detection and API client for Lemonade LLM server.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import os
|
|
8
|
-
from typing import Optional, List
|
|
9
|
-
from openai import OpenAI
|
|
10
|
-
|
|
11
|
-
from .utils import scan_ports, is_port_open
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
DEFAULT_HOST = "localhost"
|
|
15
|
-
DEFAULT_PORT = 8020 # Default Lemonade port
|
|
16
|
-
DEFAULT_PORTS = [8020, 8000, 8001, 8080, 8081, 5000, 5001]
|
|
17
|
-
LEMONADE_API_KEY = "lemonade" # Default API key for Lemonade
|
|
18
|
-
DEFAULT_MODEL = "qwen2.5-it-3b-FLM" # Default Lemonade model
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def detect_lemonade_port(
|
|
22
|
-
host: Optional[str] = None,
|
|
23
|
-
ports: Optional[List[int]] = None
|
|
24
|
-
) -> Optional[int]:
|
|
25
|
-
"""
|
|
26
|
-
Auto-detect the port where Lemonade is running.
|
|
27
|
-
|
|
28
|
-
Checks LEMONADE_SERVER_URL environment variable first, then scans common ports.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
|
|
32
|
-
ports: List of ports to scan. Defaults to common Lemonade ports.
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
Port number if found, None otherwise.
|
|
36
|
-
"""
|
|
37
|
-
# Check LEMONADE_SERVER_URL environment variable first (standard Lemonade env)
|
|
38
|
-
server_url = os.environ.get("LEMONADE_SERVER_URL")
|
|
39
|
-
if server_url:
|
|
40
|
-
try:
|
|
41
|
-
# Parse URL like "http://localhost:8020"
|
|
42
|
-
from urllib.parse import urlparse
|
|
43
|
-
parsed = urlparse(server_url)
|
|
44
|
-
if parsed.port:
|
|
45
|
-
port = parsed.port
|
|
46
|
-
if is_port_open(parsed.hostname or DEFAULT_HOST, port):
|
|
47
|
-
return port
|
|
48
|
-
except Exception:
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
# Check DIFFRON_LEMONADE_PORT (Diffron-specific)
|
|
52
|
-
env_port = os.environ.get("DIFFRON_LEMONADE_PORT")
|
|
53
|
-
if env_port:
|
|
54
|
-
try:
|
|
55
|
-
port = int(env_port)
|
|
56
|
-
if is_port_open(host or DEFAULT_HOST, port):
|
|
57
|
-
return port
|
|
58
|
-
except ValueError:
|
|
59
|
-
pass
|
|
60
|
-
|
|
61
|
-
# Scan common ports
|
|
62
|
-
host = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
63
|
-
ports_to_scan = ports or DEFAULT_PORTS
|
|
64
|
-
|
|
65
|
-
open_ports = scan_ports(ports_to_scan, host)
|
|
66
|
-
|
|
67
|
-
if open_ports:
|
|
68
|
-
return open_ports[0] # Return first open port
|
|
69
|
-
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def get_lemonade_url() -> str:
|
|
74
|
-
"""
|
|
75
|
-
Get the Lemonade server URL from environment or default.
|
|
76
|
-
|
|
77
|
-
Returns:
|
|
78
|
-
Lemonade server URL.
|
|
79
|
-
"""
|
|
80
|
-
# Check LEMONADE_SERVER_URL first (standard)
|
|
81
|
-
server_url = os.environ.get("LEMONADE_SERVER_URL")
|
|
82
|
-
if server_url:
|
|
83
|
-
return server_url.strip().rstrip("/")
|
|
84
|
-
|
|
85
|
-
# Build URL from host/port
|
|
86
|
-
host = os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
87
|
-
port = detect_lemonade_port(host)
|
|
88
|
-
|
|
89
|
-
if port:
|
|
90
|
-
return f"http://{host}:{port}"
|
|
91
|
-
|
|
92
|
-
# Fallback to default
|
|
93
|
-
return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def is_lemonade_running(
|
|
97
|
-
host: Optional[str] = None,
|
|
98
|
-
port: Optional[int] = None
|
|
99
|
-
) -> bool:
|
|
100
|
-
"""
|
|
101
|
-
Check if Lemonade is running.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
|
|
105
|
-
port: Port to check. Auto-detects if not provided.
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
True if Lemonade is running, False otherwise.
|
|
109
|
-
"""
|
|
110
|
-
if port is None:
|
|
111
|
-
port = detect_lemonade_port(host)
|
|
112
|
-
if port is None:
|
|
113
|
-
return False
|
|
114
|
-
|
|
115
|
-
host = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
|
|
116
|
-
return is_port_open(host, port)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
class LemonadeClient:
|
|
120
|
-
"""
|
|
121
|
-
Client for interacting with Lemonade LLM server.
|
|
122
|
-
|
|
123
|
-
Automatically detects the running Lemonade instance and provides
|
|
124
|
-
a convenient interface for chat completions.
|
|
125
|
-
"""
|
|
126
|
-
|
|
127
|
-
def __init__(
|
|
128
|
-
self,
|
|
129
|
-
base_url: Optional[str] = None,
|
|
130
|
-
api_key: Optional[str] = None,
|
|
131
|
-
model: Optional[str] = None,
|
|
132
|
-
):
|
|
133
|
-
"""
|
|
134
|
-
Initialize the Lemonade client.
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
base_url: Lemonade server URL. Auto-detects from LEMONADE_SERVER_URL or defaults to http://localhost:8020.
|
|
138
|
-
api_key: API key. Defaults to "lemonade".
|
|
139
|
-
model: Model name to use. Defaults to qwen2.5-it-3b-FLM.
|
|
140
|
-
"""
|
|
141
|
-
# Get base URL from parameter, environment, or default
|
|
142
|
-
if base_url:
|
|
143
|
-
self.base_url = base_url.rstrip("/")
|
|
144
|
-
else:
|
|
145
|
-
self.base_url = get_lemonade_url()
|
|
146
|
-
|
|
147
|
-
# Parse host and port from base_url
|
|
148
|
-
from urllib.parse import urlparse
|
|
149
|
-
parsed = urlparse(self.base_url)
|
|
150
|
-
self.host = parsed.hostname or DEFAULT_HOST
|
|
151
|
-
self.port = parsed.port or DEFAULT_PORT
|
|
152
|
-
|
|
153
|
-
self.api_key = api_key or LEMONADE_API_KEY
|
|
154
|
-
|
|
155
|
-
# Model: parameter > environment > default
|
|
156
|
-
self.model = model or os.environ.get("DIFFRON_MODEL") or DEFAULT_MODEL
|
|
157
|
-
|
|
158
|
-
self.client = OpenAI(
|
|
159
|
-
base_url=f"{self.base_url}/api/v1",
|
|
160
|
-
api_key=self.api_key,
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
def chat_completion(
|
|
164
|
-
self,
|
|
165
|
-
messages: List[dict],
|
|
166
|
-
max_tokens: int = 100,
|
|
167
|
-
temperature: float = 0.2,
|
|
168
|
-
**kwargs
|
|
169
|
-
) -> str:
|
|
170
|
-
"""
|
|
171
|
-
Generate a chat completion.
|
|
172
|
-
|
|
173
|
-
Args:
|
|
174
|
-
messages: List of message dicts with 'role' and 'content'.
|
|
175
|
-
max_tokens: Maximum tokens to generate.
|
|
176
|
-
temperature: Sampling temperature.
|
|
177
|
-
**kwargs: Additional arguments to pass to the API.
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
Generated response content.
|
|
181
|
-
"""
|
|
182
|
-
response = self.client.chat.completions.create(
|
|
183
|
-
model=self.model,
|
|
184
|
-
messages=messages,
|
|
185
|
-
max_tokens=max_tokens,
|
|
186
|
-
temperature=temperature,
|
|
187
|
-
**kwargs,
|
|
188
|
-
)
|
|
189
|
-
return response.choices[0].message.content
|
|
190
|
-
|
|
191
|
-
def __repr__(self) -> str:
|
|
192
|
-
return f"LemonadeClient(base_url='{self.base_url}', model='{self.model}')"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|