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.
Files changed (49) hide show
  1. {diffron-0.1.3 → diffron-0.1.4}/CHANGELOG.md +12 -0
  2. {diffron-0.1.3/diffron.egg-info → diffron-0.1.4}/PKG-INFO +1 -1
  3. diffron-0.1.4/diffron/__main__.py +8 -0
  4. {diffron-0.1.3 → diffron-0.1.4}/diffron/cli.py +86 -86
  5. diffron-0.1.4/diffron/lemonade.py +246 -0
  6. {diffron-0.1.3 → diffron-0.1.4}/diffron/models.py +9 -4
  7. {diffron-0.1.3 → diffron-0.1.4/diffron.egg-info}/PKG-INFO +1 -1
  8. {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/SOURCES.txt +4 -0
  9. {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/entry_points.txt +1 -0
  10. {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/top_level.txt +1 -0
  11. diffron-0.1.4/docs/LEMONADE_PORT_VERIFICATION_FIX.md +105 -0
  12. diffron-0.1.4/docs/LEMONADE_UNRESPONSIVE_FIX.md +100 -0
  13. diffron-0.1.4/docs/MODEL_NAME_CASE_FIX.md +88 -0
  14. {diffron-0.1.3 → diffron-0.1.4}/pyproject.toml +2 -1
  15. {diffron-0.1.3 → diffron-0.1.4}/requirements.txt +1 -0
  16. {diffron-0.1.3 → diffron-0.1.4}/setup.py +2 -1
  17. diffron-0.1.3/diffron/lemonade.py +0 -192
  18. {diffron-0.1.3 → diffron-0.1.4}/LICENSE +0 -0
  19. {diffron-0.1.3 → diffron-0.1.4}/MANIFEST.in +0 -0
  20. {diffron-0.1.3 → diffron-0.1.4}/README.md +0 -0
  21. {diffron-0.1.3 → diffron-0.1.4}/diffron/__init__.py +0 -0
  22. {diffron-0.1.3 → diffron-0.1.4}/diffron/__init__.pyi +0 -0
  23. {diffron-0.1.3 → diffron-0.1.4}/diffron/cli.pyi +0 -0
  24. {diffron-0.1.3 → diffron-0.1.4}/diffron/client.py +0 -0
  25. {diffron-0.1.3 → diffron-0.1.4}/diffron/client.pyi +0 -0
  26. {diffron-0.1.3 → diffron-0.1.4}/diffron/commit_gen.py +0 -0
  27. {diffron-0.1.3 → diffron-0.1.4}/diffron/commit_gen.pyi +0 -0
  28. {diffron-0.1.3 → diffron-0.1.4}/diffron/git_hooks.py +0 -0
  29. {diffron-0.1.3 → diffron-0.1.4}/diffron/git_hooks.pyi +0 -0
  30. {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/aipr.py +0 -0
  31. {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg +0 -0
  32. {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg-global.py +0 -0
  33. {diffron-0.1.3 → diffron-0.1.4}/diffron/hooks/prepare-commit-msg.py +0 -0
  34. {diffron-0.1.3 → diffron-0.1.4}/diffron/lemonade.pyi +0 -0
  35. {diffron-0.1.3 → diffron-0.1.4}/diffron/models.pyi +0 -0
  36. {diffron-0.1.3 → diffron-0.1.4}/diffron/pr_gen.py +0 -0
  37. {diffron-0.1.3 → diffron-0.1.4}/diffron/pr_gen.pyi +0 -0
  38. {diffron-0.1.3 → diffron-0.1.4}/diffron/py.typed +0 -0
  39. {diffron-0.1.3 → diffron-0.1.4}/diffron/utils.py +0 -0
  40. {diffron-0.1.3 → diffron-0.1.4}/diffron/utils.pyi +0 -0
  41. {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/dependency_links.txt +0 -0
  42. {diffron-0.1.3 → diffron-0.1.4}/diffron.egg-info/requires.txt +0 -0
  43. {diffron-0.1.3 → diffron-0.1.4}/docs/HOOKS.md +0 -0
  44. {diffron-0.1.3 → diffron-0.1.4}/docs/SETUP.md +0 -0
  45. {diffron-0.1.3 → diffron-0.1.4}/docs/USAGE.md +0 -0
  46. {diffron-0.1.3 → diffron-0.1.4}/hooks/aipr.py +0 -0
  47. {diffron-0.1.3 → diffron-0.1.4}/hooks/prepare-commit-msg-global.py +0 -0
  48. {diffron-0.1.3 → diffron-0.1.4}/hooks/prepare-commit-msg.py +0 -0
  49. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffron
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Git commit message and PR description generator using Lemonade
5
5
  Home-page: https://github.com/diffron/diffron
6
6
  Author: Diffron Contributors
@@ -0,0 +1,8 @@
1
+ """
2
+ Main entry point for running diffron as a module: python -m diffron
3
+ """
4
+
5
+ from diffron.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -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
- parser = argparse.ArgumentParser(
15
- description="Install Diffron Git hooks to a repository."
16
- )
17
- parser.add_argument(
18
- "--global",
19
- dest="global_install",
20
- action="store_true",
21
- help="Install hooks globally for all repositories.",
22
- )
23
- parser.add_argument(
24
- "--repo",
25
- "-r",
26
- type=str,
27
- default=".",
28
- help="Path to the git repository (default: current directory).",
29
- )
30
-
31
- args = parser.parse_args()
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
- parser = argparse.ArgumentParser(
60
- description="Remove Diffron Git hooks from a repository."
61
- )
62
- parser.add_argument(
63
- "--global",
64
- dest="global_install",
65
- action="store_true",
66
- help="Remove global hooks configuration.",
67
- )
68
- parser.add_argument(
69
- "--repo",
70
- "-r",
71
- type=str,
72
- default=".",
73
- help="Path to the git repository (default: current directory).",
74
- )
75
-
76
- args = parser.parse_args()
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
- parser = argparse.ArgumentParser(
105
- description="Generate GitHub PR title and description."
106
- )
107
- parser.add_argument(
108
- "--branch",
109
- "-b",
110
- type=str,
111
- default=None,
112
- help="Branch to analyze (default: current branch).",
113
- )
114
- parser.add_argument(
115
- "--base",
116
- type=str,
117
- default=None,
118
- help="Base branch to compare against (default: main/master).",
119
- )
120
- parser.add_argument(
121
- "--create",
122
- "-c",
123
- action="store_true",
124
- help="Automatically create PR on GitHub (requires gh CLI).",
125
- )
126
-
127
- args = parser.parse_args()
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
- parser = argparse.ArgumentParser(
175
- description="Check Diffron status (Lemonade connection, hooks installation)."
176
- )
177
- parser.add_argument(
178
- "--repo",
179
- "-r",
180
- type=str,
181
- default=".",
182
- help="Path to the git repository (default: current directory).",
183
- )
184
- parser.add_argument(
185
- "--verbose",
186
- "-v",
187
- action="store_true",
188
- help="Show detailed status information.",
189
- )
190
-
191
- args = parser.parse_args()
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="qwen3.5-0.8b-gguf",
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., 'qwen3.5-0.8b-gguf').
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
- # Set DIFFRON_MODEL environment variable permanently
167
+ # Use the canonical name from config (not user input)
163
168
  os.environ["DIFFRON_MODEL"] = config.name
164
169
 
165
170
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffron
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Git commit message and PR description generator using Lemonade
5
5
  Home-page: https://github.com/diffron/diffron
6
6
  Author: Diffron Contributors
@@ -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
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ diffron = diffron.cli:main
2
3
  diffron-install-hooks = diffron.cli:install_hooks_cli
3
4
  diffron-pr = diffron.cli:pr_description_cli
4
5
  diffron-setup-model = diffron.models:setup_model_cli
@@ -1,3 +1,4 @@
1
+ build
1
2
  diffron
2
3
  dist
3
4
  docs
@@ -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.3"
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"
@@ -1,2 +1,3 @@
1
1
  openai>=1.0.0
2
2
  psutil>=5.9.0
3
+ lemonade-sdk>=0.1.0
@@ -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.3",
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