diffron 0.1.3__tar.gz → 0.1.4__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.4}/CHANGELOG.md +12 -0
- {diffron-0.1.3/diffron.egg-info → diffron-0.1.4}/PKG-INFO +1 -1
- diffron-0.1.4/diffron/__main__.py +8 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/cli.py +86 -86
- diffron-0.1.4/diffron/lemonade.py +246 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/models.py +9 -4
- {diffron-0.1.3 → diffron-0.1.4/diffron.egg-info}/PKG-INFO +1 -1
- {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/SOURCES.txt +4 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/entry_points.txt +1 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/top_level.txt +1 -0
- diffron-0.1.4/docs/LEMONADE_PORT_VERIFICATION_FIX.md +105 -0
- diffron-0.1.4/docs/LEMONADE_UNRESPONSIVE_FIX.md +100 -0
- diffron-0.1.4/docs/MODEL_NAME_CASE_FIX.md +88 -0
- {diffron-0.1.3 → diffron-0.1.4}/pyproject.toml +2 -1
- {diffron-0.1.3 → diffron-0.1.4}/requirements.txt +1 -0
- {diffron-0.1.3 → diffron-0.1.4}/setup.py +2 -1
- diffron-0.1.3/diffron/lemonade.py +0 -192
- {diffron-0.1.3 → diffron-0.1.4}/LICENSE +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/MANIFEST.in +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/README.md +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/__init__.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/__init__.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/cli.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/client.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/client.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/commit_gen.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/commit_gen.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/git_hooks.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/git_hooks.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/aipr.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg-global.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/lemonade.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/models.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/pr_gen.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/pr_gen.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/py.typed +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/utils.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron/utils.pyi +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/dependency_links.txt +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/requires.txt +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/docs/HOOKS.md +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/docs/SETUP.md +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/docs/USAGE.md +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/hooks/aipr.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/hooks/prepare-commit-msg-global.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/hooks/prepare-commit-msg.py +0 -0
- {diffron-0.1.3 → diffron-0.1.4}/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,246 @@
|
|
|
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
|
+
# Reinitialize client with new port
|
|
233
|
+
self._init_client()
|
|
234
|
+
except Exception:
|
|
235
|
+
# Silently continue on reload failure
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def _init_client(self) -> None:
|
|
239
|
+
"""Reinitialize the OpenAI client with current configuration."""
|
|
240
|
+
self.client = OpenAI(
|
|
241
|
+
base_url=f"{self.base_url}/api/v1",
|
|
242
|
+
api_key=self.api_key
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def __repr__(self) -> str:
|
|
246
|
+
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:
|
|
@@ -7,6 +7,7 @@ requirements.txt
|
|
|
7
7
|
setup.py
|
|
8
8
|
diffron/__init__.py
|
|
9
9
|
diffron/__init__.pyi
|
|
10
|
+
diffron/__main__.py
|
|
10
11
|
diffron/cli.py
|
|
11
12
|
diffron/cli.pyi
|
|
12
13
|
diffron/client.py
|
|
@@ -35,6 +36,9 @@ diffron/hooks/prepare-commit-msg
|
|
|
35
36
|
diffron/hooks/prepare-commit-msg-global.py
|
|
36
37
|
diffron/hooks/prepare-commit-msg.py
|
|
37
38
|
docs/HOOKS.md
|
|
39
|
+
docs/LEMONADE_PORT_VERIFICATION_FIX.md
|
|
40
|
+
docs/LEMONADE_UNRESPONSIVE_FIX.md
|
|
41
|
+
docs/MODEL_NAME_CASE_FIX.md
|
|
38
42
|
docs/SETUP.md
|
|
39
43
|
docs/USAGE.md
|
|
40
44
|
hooks/aipr.py
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Lemonade Port Verification Fix
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Diffron incorrectly connected to **port 8000** (running Axo Biomarker Analyzer) instead of **port 8020** (running Lemonade), causing:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Error code: 404 - {'detail': 'Not Found'}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### Root Cause
|
|
12
|
+
|
|
13
|
+
The `detect_lemonade_port()` function in `diffron/lemonade.py` only checked if a **port was open** (`is_port_open`), not whether it was actually a **Lemonade server**.
|
|
14
|
+
|
|
15
|
+
When port 8000 responded (Axo Biomarker web app), Diffron mistakenly identified it as Lemonade and sent API requests to the wrong service.
|
|
16
|
+
|
|
17
|
+
| Port | Service | Response |
|
|
18
|
+
|------|---------|----------|
|
|
19
|
+
| 8000 | Axo Biomarker Analyzer | HTML web page |
|
|
20
|
+
| 8020 | **Lemonade** (correct) | `{"data": [...]}` API |
|
|
21
|
+
|
|
22
|
+
## Solution
|
|
23
|
+
|
|
24
|
+
### Use lemonade-python-sdk's `verify_lemonade_server`
|
|
25
|
+
|
|
26
|
+
The **lemonade-python-sdk** provides `verify_lemonade_server(port, host)` which:
|
|
27
|
+
|
|
28
|
+
1. Queries the native Lemonade API endpoint `/api/v1/models`
|
|
29
|
+
2. Validates the response format (`{"data": [...]}` or list)
|
|
30
|
+
3. Distinguishes Lemonade from other services (Ollama, web apps, etc.)
|
|
31
|
+
|
|
32
|
+
### Changes
|
|
33
|
+
|
|
34
|
+
**1. Added `lemonade-sdk` dependency**
|
|
35
|
+
|
|
36
|
+
Updated `requirements.txt`:
|
|
37
|
+
```txt
|
|
38
|
+
lemonade-sdk>=0.1.0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**2. Updated `detect_lemonade_port()`**
|
|
42
|
+
|
|
43
|
+
`diffron/lemonade.py` now:
|
|
44
|
+
|
|
45
|
+
- Imports `verify_lemonade_server` and `find_available_lemonade_port` from `lemonade_sdk.port_scanner`
|
|
46
|
+
- **Verifies** each open port is actually Lemonade before returning it
|
|
47
|
+
- Falls back to legacy `is_port_open` behavior if SDK is not installed
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# Import from lemonade-python-sdk for proper Lemonade verification
|
|
51
|
+
try:
|
|
52
|
+
from lemonade_sdk.port_scanner import verify_lemonade_server, find_available_lemonade_port
|
|
53
|
+
HAS_LEMONADE_SDK = True
|
|
54
|
+
except ImportError:
|
|
55
|
+
HAS_LEMONADE_SDK = False
|
|
56
|
+
|
|
57
|
+
def detect_lemonade_port(...) -> Optional[int]:
|
|
58
|
+
# ... existing env var checks ...
|
|
59
|
+
|
|
60
|
+
# Try SDK's find_available_lemonade_port first
|
|
61
|
+
if HAS_LEMONADE_SDK:
|
|
62
|
+
found = find_available_lemonade_port(hostname, scan_ports)
|
|
63
|
+
if found:
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
# Fallback: manual scan with SDK verification
|
|
67
|
+
for port in ports_to_scan:
|
|
68
|
+
if is_port_open(hostname, port):
|
|
69
|
+
if HAS_LEMONADE_SDK:
|
|
70
|
+
if verify_lemonade_server(port, hostname): # ← Key fix!
|
|
71
|
+
return port
|
|
72
|
+
else:
|
|
73
|
+
return port # Legacy: no verification
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**3. Added graceful fallback**
|
|
77
|
+
|
|
78
|
+
If `lemonade-sdk` is not installed, Diffron falls back to the original `is_port_open` behavior (first open port wins), maintaining backward compatibility.
|
|
79
|
+
|
|
80
|
+
## Testing
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Before fix: returned 8000 (Axo Biomarker)
|
|
84
|
+
# After fix: returns 8020 (Lemonade)
|
|
85
|
+
python -c "from diffron.lemonade import detect_lemonade_port; print(detect_lemonade_port())"
|
|
86
|
+
# Output: 8020
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Impact
|
|
90
|
+
|
|
91
|
+
- **Fixes 404 errors** when other services run on ports 8000/8080
|
|
92
|
+
- **No breaking changes** - existing setups continue to work
|
|
93
|
+
- **Better diagnostics** - clear separation between Lemonade and non-Lemonade services
|
|
94
|
+
|
|
95
|
+
## Files Changed
|
|
96
|
+
|
|
97
|
+
| File | Change |
|
|
98
|
+
|------|--------|
|
|
99
|
+
| `diffron/lemonade.py` | Use `verify_lemonade_server` for port detection |
|
|
100
|
+
| `requirements.txt` | Add `lemonade-sdk>=0.1.0` dependency |
|
|
101
|
+
|
|
102
|
+
## Version
|
|
103
|
+
|
|
104
|
+
- **Fixed in**: 0.1.3
|
|
105
|
+
- **Date**: 2026-04-13
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Lemonade Model Unresponsive Fix
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Das Lemonade-Modell wurde manchmal unresponsive, was zu einem Fehler in Diffron führte:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Diffron error: 'NoneType' object is not subscriptable
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Dies trat auf, wenn die Antwort des Modells `None` war oder `response.choices` leer war, was beim Zugriff auf `response.choices[0].message.content` zu einem Fehler führte.
|
|
12
|
+
|
|
13
|
+
## Lösung
|
|
14
|
+
|
|
15
|
+
In [`diffron/lemonade.py`](../diffron/lemonade.py:170) wurde eine Retry-Logik mit automatischem Entladen und Neuladen des Modells implementiert.
|
|
16
|
+
|
|
17
|
+
### Änderungen in `LemonadeClient.chat_completion()`
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
max_retries = 3
|
|
21
|
+
for attempt in range(1, max_retries + 1):
|
|
22
|
+
try:
|
|
23
|
+
response = self.client.chat.completions.create(...)
|
|
24
|
+
# Handle None response (model unresponsive)
|
|
25
|
+
if response is None or not response.choices:
|
|
26
|
+
if attempt < max_retries:
|
|
27
|
+
self._reload_model()
|
|
28
|
+
continue
|
|
29
|
+
raise RuntimeError("Lemonade model returned no response")
|
|
30
|
+
return response.choices[0].message.content
|
|
31
|
+
except Exception as e:
|
|
32
|
+
if attempt < max_retries:
|
|
33
|
+
self._reload_model()
|
|
34
|
+
continue
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
f"Lemonade model failed after {max_retries} attempts: {e}"
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Neue Methoden
|
|
41
|
+
|
|
42
|
+
#### `_reload_model()`
|
|
43
|
+
|
|
44
|
+
Entlädt und lädt das Lemonade-Modell neu, wenn es unresponsive ist:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
def _reload_model(self) -> None:
|
|
48
|
+
"""Unload and reload the Lemonade model when unresponsive."""
|
|
49
|
+
try:
|
|
50
|
+
if HAS_LEMONADE_SDK:
|
|
51
|
+
new_port = find_available_lemonade_port(
|
|
52
|
+
hostname=self.host or DEFAULT_HOST,
|
|
53
|
+
ports=DEFAULT_PORTS
|
|
54
|
+
)
|
|
55
|
+
if new_port and new_port != self._port:
|
|
56
|
+
self._port = new_port
|
|
57
|
+
self._init_client()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### `_init_client()`
|
|
63
|
+
|
|
64
|
+
Initialisiert den OpenAI-Client neu mit der aktuellen Konfiguration:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
def _init_client(self) -> None:
|
|
68
|
+
"""Reinitialize the OpenAI client with current configuration."""
|
|
69
|
+
self.client = OpenAI(
|
|
70
|
+
base_url=f"{self.base_url}/api/v1",
|
|
71
|
+
api_key=self.api_key
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Ablauf bei Unresponsiveness
|
|
76
|
+
|
|
77
|
+
1. **Erster Versuch**: Modell antwortet nicht → `_reload_model()` aufgerufen
|
|
78
|
+
2. **Zweiter Versuch**: Modell antwortet immer noch nicht → `_reload_model()` erneut aufgerufen
|
|
79
|
+
3. **Dritter Versuch**: Erfolgt mit neu geladenem Modell
|
|
80
|
+
4. **Falls alle Versuche fehlschlagen**: `RuntimeError` mit detaillierter Fehlermeldung
|
|
81
|
+
|
|
82
|
+
## Voraussetzungen
|
|
83
|
+
|
|
84
|
+
Die automatische Wiederherstellung erfordert das `lemonade-python-sdk`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
try:
|
|
88
|
+
from lemonade_sdk.port_scanner import verify_lemonade_server
|
|
89
|
+
from lemonade_sdk.port_scanner import find_available_lemonade_port
|
|
90
|
+
HAS_LEMONADE_SDK = True
|
|
91
|
+
except ImportError:
|
|
92
|
+
HAS_LEMONADE_SDK = False
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Ohne das SDK wird weiterhin versucht, einen offenen Port zu finden, aber die automatische Wiederherstellung ist eingeschränkt.
|
|
96
|
+
|
|
97
|
+
## Siehe auch
|
|
98
|
+
|
|
99
|
+
- [LEMONADE_PORT_VERIFICATION_FIX.md](LEMONADE_PORT_VERIFICATION_FIX.md) - Port-Erkennungsfix
|
|
100
|
+
- [MODEL_NAME_CASE_FIX.md](MODEL_NAME_CASE_FIX.md) - Modellnamen-Fallfix
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Model Name Case Sensitivity Fix
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Diffron returned a **404 Not Found** error from the Lemonade server when using the `Qwen3.5-0.8B-GGUF` model.
|
|
6
|
+
|
|
7
|
+
### Root Cause
|
|
8
|
+
|
|
9
|
+
The Lemonade server is **case-sensitive** when matching model names. Diffron's curated model configuration used the wrong casing:
|
|
10
|
+
|
|
11
|
+
| Location | Model Name (Before) | Model Name (Expected) |
|
|
12
|
+
|----------|---------------------|------------------------|
|
|
13
|
+
| `diffron/models.py` | `qwen3.5-0.8b-gguf` | `Qwen3.5-0.8B-GGUF` |
|
|
14
|
+
|
|
15
|
+
This caused the OpenAI SDK to send a request with the wrong model identifier, resulting in:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Error code: 404 - {'detail': 'Not Found'}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Solution
|
|
22
|
+
|
|
23
|
+
### 1. Corrected Model Name
|
|
24
|
+
|
|
25
|
+
Updated the canonical model name in `diffron/models.py`:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# Before
|
|
29
|
+
ModelConfig(name="qwen3.5-0.8b-gguf", ...)
|
|
30
|
+
|
|
31
|
+
# After
|
|
32
|
+
ModelConfig(name="Qwen3.5-0.8B-GGUF", ...)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. Case-Insensitive Model Lookup
|
|
36
|
+
|
|
37
|
+
Enhanced `get_model_config()` to accept case-insensitive input while always returning the **canonical name**:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
def get_model_config(model_name: str) -> Optional[ModelConfig]:
|
|
41
|
+
# Exact match first
|
|
42
|
+
for model in AVAILABLE_MODELS:
|
|
43
|
+
if model.name == model_name:
|
|
44
|
+
return model
|
|
45
|
+
# Case-insensitive fallback
|
|
46
|
+
for model in AVAILABLE_MODEOLS:
|
|
47
|
+
if model.name.lower() == model_name.lower():
|
|
48
|
+
return model
|
|
49
|
+
return None
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This allows users to type `qwen3.5-0.8b-gguf`, `QWEN3.5-0.8B-GGUF`, etc., but Diffron always sends the correct name to the server.
|
|
53
|
+
|
|
54
|
+
### 3. CLI Canonical Name Enforcement
|
|
55
|
+
|
|
56
|
+
The `setup_model_cli()` now stores the **canonical name** from the config, not the user's raw input:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# Always uses config.name (canonical) instead of args.model (user input)
|
|
60
|
+
os.environ["DIFFRON_MODEL"] = config.name
|
|
61
|
+
subprocess.run(["setx", "DIFFRON_MODEL", config.name], check=True)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# All of these now work correctly:
|
|
68
|
+
diffron-setup-model --model Qwen3.5-0.8B-GGUF # exact
|
|
69
|
+
diffron-setup-model --model qwen3.5-0.8b-gguf # lowercase (fallback)
|
|
70
|
+
diffron-setup-model --model QWEN3.5-0.8B-GGUF # uppercase (fallback)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Impact
|
|
74
|
+
|
|
75
|
+
- **No breaking changes** - existing `DIFFRON_MODEL` values with wrong casing are now resolved correctly
|
|
76
|
+
- **Improved UX** - users don't need to memorize exact casing
|
|
77
|
+
- **Server compatibility** - correct model name is always sent to Lemonade
|
|
78
|
+
|
|
79
|
+
## Files Changed
|
|
80
|
+
|
|
81
|
+
| File | Change |
|
|
82
|
+
|------|--------|
|
|
83
|
+
| `diffron/models.py` | Fixed `Qwen3.5-0.8B-GGUF` name, added case-insensitive lookup |
|
|
84
|
+
|
|
85
|
+
## Version
|
|
86
|
+
|
|
87
|
+
- **Fixed in**: 0.1.3
|
|
88
|
+
- **Date**: 2026-04-13
|
|
@@ -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.4"
|
|
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
|