diffron 0.1.6__tar.gz → 0.1.8__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.6 → diffron-0.1.8}/CHANGELOG.md +6 -5
  2. {diffron-0.1.6/diffron.egg-info → diffron-0.1.8}/PKG-INFO +2 -2
  3. {diffron-0.1.6 → diffron-0.1.8}/README.md +1 -1
  4. {diffron-0.1.6 → diffron-0.1.8}/diffron/__init__.py +1 -1
  5. diffron-0.1.8/diffron/lemonade.py +241 -0
  6. {diffron-0.1.6 → diffron-0.1.8}/diffron/lemonade.pyi +5 -0
  7. {diffron-0.1.6 → diffron-0.1.8/diffron.egg-info}/PKG-INFO +2 -2
  8. {diffron-0.1.6 → diffron-0.1.8}/diffron.egg-info/SOURCES.txt +3 -0
  9. {diffron-0.1.6 → diffron-0.1.8}/diffron.egg-info/top_level.txt +2 -0
  10. diffron-0.1.8/docs/LEMONADE_PORT_VERIFICATION_FIX.md +105 -0
  11. diffron-0.1.8/docs/LEMONADE_UNRESPONSIVE_FIX.md +97 -0
  12. diffron-0.1.8/docs/MODEL_NAME_CASE_FIX.md +88 -0
  13. {diffron-0.1.6 → diffron-0.1.8}/pyproject.toml +1 -1
  14. {diffron-0.1.6 → diffron-0.1.8}/setup.py +1 -1
  15. diffron-0.1.6/diffron/lemonade.py +0 -249
  16. {diffron-0.1.6 → diffron-0.1.8}/LICENSE +0 -0
  17. {diffron-0.1.6 → diffron-0.1.8}/MANIFEST.in +0 -0
  18. {diffron-0.1.6 → diffron-0.1.8}/diffron/__init__.pyi +0 -0
  19. {diffron-0.1.6 → diffron-0.1.8}/diffron/__main__.py +0 -0
  20. {diffron-0.1.6 → diffron-0.1.8}/diffron/cli.py +0 -0
  21. {diffron-0.1.6 → diffron-0.1.8}/diffron/cli.pyi +0 -0
  22. {diffron-0.1.6 → diffron-0.1.8}/diffron/client.py +0 -0
  23. {diffron-0.1.6 → diffron-0.1.8}/diffron/client.pyi +0 -0
  24. {diffron-0.1.6 → diffron-0.1.8}/diffron/commit_gen.py +0 -0
  25. {diffron-0.1.6 → diffron-0.1.8}/diffron/commit_gen.pyi +0 -0
  26. {diffron-0.1.6 → diffron-0.1.8}/diffron/git_hooks.py +0 -0
  27. {diffron-0.1.6 → diffron-0.1.8}/diffron/git_hooks.pyi +0 -0
  28. {diffron-0.1.6 → diffron-0.1.8}/diffron/hooks/aipr.py +0 -0
  29. {diffron-0.1.6 → diffron-0.1.8}/diffron/hooks/prepare-commit-msg +0 -0
  30. {diffron-0.1.6 → diffron-0.1.8}/diffron/hooks/prepare-commit-msg-global.py +0 -0
  31. {diffron-0.1.6 → diffron-0.1.8}/diffron/hooks/prepare-commit-msg.py +0 -0
  32. {diffron-0.1.6 → diffron-0.1.8}/diffron/models.py +0 -0
  33. {diffron-0.1.6 → diffron-0.1.8}/diffron/models.pyi +0 -0
  34. {diffron-0.1.6 → diffron-0.1.8}/diffron/pr_gen.py +0 -0
  35. {diffron-0.1.6 → diffron-0.1.8}/diffron/pr_gen.pyi +0 -0
  36. {diffron-0.1.6 → diffron-0.1.8}/diffron/py.typed +0 -0
  37. {diffron-0.1.6 → diffron-0.1.8}/diffron/utils.py +0 -0
  38. {diffron-0.1.6 → diffron-0.1.8}/diffron/utils.pyi +0 -0
  39. {diffron-0.1.6 → diffron-0.1.8}/diffron.egg-info/dependency_links.txt +0 -0
  40. {diffron-0.1.6 → diffron-0.1.8}/diffron.egg-info/entry_points.txt +0 -0
  41. {diffron-0.1.6 → diffron-0.1.8}/diffron.egg-info/requires.txt +0 -0
  42. {diffron-0.1.6 → diffron-0.1.8}/docs/HOOKS.md +0 -0
  43. {diffron-0.1.6 → diffron-0.1.8}/docs/SETUP.md +0 -0
  44. {diffron-0.1.6 → diffron-0.1.8}/docs/USAGE.md +0 -0
  45. {diffron-0.1.6 → diffron-0.1.8}/hooks/aipr.py +0 -0
  46. {diffron-0.1.6 → diffron-0.1.8}/hooks/prepare-commit-msg-global.py +0 -0
  47. {diffron-0.1.6 → diffron-0.1.8}/hooks/prepare-commit-msg.py +0 -0
  48. {diffron-0.1.6 → diffron-0.1.8}/requirements.txt +0 -0
  49. {diffron-0.1.6 → diffron-0.1.8}/setup.cfg +0 -0
@@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
- ### Planned
13
- - Pre-commit hook for linting suggestions
14
- - Custom model selection per repository
15
- - Multi-language commit message support
16
- - Integration with GitHub CLI for auto PR creation
12
+ ### Added
13
+ **Robust Model Reload Fix** Properly unload and reload Lemonade model using SDK's native methods when unresponsive
14
+ - Fixed `'NoneType' object is not subscriptable` error by using `_LemonadeSDKClient` for server operations
15
+ - Uses `sdk_client.unload_model()` followed by `sdk_client.load_model(model_name=self.model)` for true server restart
16
+ - Added 0.5 second delay between unload and load for clean server state
17
+ - Renamed SDK import to `_LemonadeSDKClient` to avoid naming conflicts with diffron's own `LemonadeClient` class
17
18
 
18
19
  ---
19
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffron
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -454,4 +454,4 @@ pytest tests/
454
454
 
455
455
  ---
456
456
 
457
- *Version: 0.1.0 | Last updated: 2026-03-28*
457
+ *Version: 0.1.6 | Last updated: 2026-04-25*
@@ -411,4 +411,4 @@ pytest tests/
411
411
 
412
412
  ---
413
413
 
414
- *Version: 0.1.0 | Last updated: 2026-03-28*
414
+ *Version: 0.1.6 | Last updated: 2026-04-25*
@@ -17,7 +17,7 @@ from .models import (
17
17
  get_model_config,
18
18
  )
19
19
 
20
- __version__ = "0.1.3"
20
+ __version__ = "0.1.8"
21
21
  __author__ = "Diffron Contributors"
22
22
  __all__ = [
23
23
  "DiffronClient",
@@ -0,0 +1,241 @@
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
+ from lemonade_sdk.client import LemonadeClient as _LemonadeSDKClient
21
+ HAS_LEMONADE_SDK = True
22
+ except ImportError:
23
+ HAS_LEMONADE_SDK = False
24
+
25
+ DEFAULT_HOST = "localhost"
26
+ DEFAULT_PORT = 8020 # Default Lemonade port
27
+ DEFAULT_PORTS = [8020, 8000, 8001, 8080, 8081, 5000, 5001]
28
+ LEMONADE_API_KEY = "lemonade" # Default API key for Lemonade
29
+ DEFAULT_MODEL = "qwen2.5-it-3b-FLM" # Default Lemonade model
30
+
31
+
32
+ def detect_lemonade_port(
33
+ host: Optional[str] = None,
34
+ ports: Optional[List[int]] = None
35
+ ) -> Optional[int]:
36
+ """Detect port for running Lemonade server.
37
+
38
+ Uses lemonade-python-sdk's verify_lemonade_server to distinguish
39
+ Lemonade from other services (e.g., Axo Biomarker on port 8000).
40
+
41
+ Args:
42
+ host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
43
+ ports: List of ports to scan. Defaults to common Lemonade ports.
44
+
45
+ Returns:
46
+ Port number if found, None otherwise.
47
+ """
48
+ # Check LEMONADE_SERVER_URL environment variable first (standard Lemonade env)
49
+ server_url = os.environ.get("LEMONADE_SERVER_URL")
50
+ if server_url:
51
+ parsed = urlparse(server_url)
52
+ port = parsed.port
53
+ if port and is_port_open(parsed.hostname or "localhost", port):
54
+ return port
55
+
56
+ # Use SDK for verification
57
+ h = host or os.environ.get("DIFFRON_LEMONADE_HOST") or DEFAULT_HOST
58
+ p = ports or DEFAULT_PORTS
59
+ for port in p:
60
+ if verify_lemonade_server(port, h):
61
+ return port
62
+
63
+
64
+ class LemonadeClient:
65
+ """Lemonade LLM API client with automatic port detection.
66
+
67
+ Wraps the OpenAI-compatible API of the Lemonade server,
68
+ handling connection setup and model inference.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ host: Optional[str] = None,
74
+ port: Optional[int] = None,
75
+ api_key: str = LEMONADE_API_KEY,
76
+ model: str = DEFAULT_MODEL,
77
+ ) -> None:
78
+ """Initialize Lemonade client.
79
+
80
+ Args:
81
+ host: Host of the Lemonade server. Defaults to env or localhost.
82
+ port: Port of the Lemonade server. Auto-detects if None.
83
+ api_key: API key for the server.
84
+ model: Model name to use for inference.
85
+ """
86
+ self.host = host or os.environ.get("DIFFRON_LEMONADE_HOST") or DEFAULT_HOST
87
+ self.api_key = api_key
88
+ self.model = model
89
+
90
+ # Auto-detect port if not provided
91
+ if port:
92
+ self._port = port
93
+ self.port = port
94
+ else:
95
+ detected = detect_lemonade_port(host=self.host)
96
+ if detected:
97
+ self._port = detected
98
+ self.port = detected
99
+ else:
100
+ # Fallback to default port
101
+ self._port = DEFAULT_PORT
102
+ self.port = DEFAULT_PORT
103
+
104
+ self.base_url = f"http://{self.host}:{self._port}"
105
+ self._init_client()
106
+
107
+ def _init_client(self) -> None:
108
+ """Reinitialize OpenAI client with current configuration."""
109
+ self.client = OpenAI(
110
+ base_url=f"{self.base_url}/api/v1",
111
+ api_key=self.api_key,
112
+ )
113
+
114
+ def chat_completion(
115
+ self,
116
+ messages: List[dict],
117
+ max_tokens: int = 100,
118
+ temperature: float = 0.2,
119
+ **kwargs
120
+ ) -> str:
121
+ """Generate chat completion.
122
+
123
+ Args:
124
+ messages: List of message dicts with 'role' and 'content'.
125
+ max_tokens: Maximum tokens to generate.
126
+ temperature: Sampling temperature.
127
+ **kwargs: Additional arguments to pass to API.
128
+
129
+ Returns:
130
+ Generated response content.
131
+
132
+ Raises:
133
+ RuntimeError: If model fails after retry attempts.
134
+ """
135
+ max_retries = 3
136
+ for attempt in range(1, max_retries + 1):
137
+ try:
138
+ response = self.client.chat.completions.create(
139
+ model=self.model,
140
+ messages=messages,
141
+ max_tokens=max_tokens,
142
+ temperature=temperature,
143
+ **kwargs,
144
+ )
145
+
146
+ # Handle None response (model unresponsive)
147
+ if response is None or not response.choices:
148
+ if attempt < max_retries:
149
+ self._reload_model()
150
+ continue
151
+ raise RuntimeError("Lemonade model returned no response")
152
+
153
+ return response.choices[0].message.content
154
+
155
+ except Exception as e:
156
+ if attempt < max_retries:
157
+ self._reload_model()
158
+ continue
159
+ raise RuntimeError(
160
+ f"Lemonade model failed after {max_retries} attempts: {e}"
161
+ )
162
+
163
+ def _reload_model(self) -> None:
164
+ """Unload and reload Lemonade model when unresponsive.
165
+
166
+ Uses lemonade-python-sdk's unload_model() and load_model() methods
167
+ to properly force a server-side model restart.
168
+ """
169
+ try:
170
+ # Use SDK's LemonadeClient for server operations
171
+ sdk_client = _LemonadeSDKClient(
172
+ base_url=f"http://{self.host}:{self._port}"
173
+ )
174
+
175
+ # Unload current model from server
176
+ sdk_client.unload_model()
177
+
178
+ # Small delay to allow server to complete unload
179
+ import time
180
+ time.sleep(0.5)
181
+
182
+ # Load the model back on server
183
+ sdk_client.load_model(model_name=self.model)
184
+
185
+ # Reinitialize our client (same port, model should be fresh)
186
+ self._init_client()
187
+
188
+ except Exception:
189
+ pass # Continue even if reload fails
190
+
191
+ def __repr__(self) -> str:
192
+ return f"LemonadeClient(base_url={self.base_url}, model={self.model})"
193
+ def is_lemonade_running(
194
+ host: Optional[str] = None,
195
+ port: Optional[int] = None,
196
+ ) -> bool:
197
+ """Check if Lemonade server is running.
198
+
199
+ Args:
200
+ host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
201
+ port: Port to check. Defaults to auto-detection.
202
+
203
+ Returns:
204
+ True if Lemonade server is running, False otherwise.
205
+ """
206
+ try:
207
+ detected_port = detect_lemonade_port(host=host)
208
+ if detected_port is not None:
209
+ return True
210
+ except Exception:
211
+ pass
212
+
213
+ # Fallback: check default port
214
+ try:
215
+ return is_port_open(DEFAULT_HOST, DEFAULT_PORT)
216
+ except Exception:
217
+ return False
218
+
219
+
220
+ def get_lemonade_url(
221
+ host: Optional[str] = None,
222
+ port: Optional[int] = None,
223
+ ) -> str:
224
+ """Get the Lemonade server URL.
225
+
226
+ Auto-detects port if not provided.
227
+
228
+ Args:
229
+ host: Host to use. Defaults to DIFFRON_LEMONADE_HOST or localhost.
230
+ port: Port to use. Auto-detects if None.
231
+
232
+ Returns:
233
+ The Lemonade server URL (e.g., http://localhost:8020).
234
+ """
235
+ h = host or os.environ.get("DIFFRON_LEMONADE_HOST") or DEFAULT_HOST
236
+
237
+ if port is None:
238
+ detected = detect_lemonade_port(host=h)
239
+ port = detected or DEFAULT_PORT
240
+
241
+ return f"http://{h}:{port}"
@@ -64,3 +64,8 @@ class LemonadeClient:
64
64
 
65
65
  def __repr__(self) -> str:
66
66
  ...
67
+ def get_lemonade_url(
68
+ host: Optional[str] = None,
69
+ port: Optional[int] = None,
70
+ ) -> str:
71
+ """Get the Lemonade server URL."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffron
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -454,4 +454,4 @@ pytest tests/
454
454
 
455
455
  ---
456
456
 
457
- *Version: 0.1.0 | Last updated: 2026-03-28*
457
+ *Version: 0.1.6 | Last updated: 2026-04-25*
@@ -36,6 +36,9 @@ diffron/hooks/prepare-commit-msg
36
36
  diffron/hooks/prepare-commit-msg-global.py
37
37
  diffron/hooks/prepare-commit-msg.py
38
38
  docs/HOOKS.md
39
+ docs/LEMONADE_PORT_VERIFICATION_FIX.md
40
+ docs/LEMONADE_UNRESPONSIVE_FIX.md
41
+ docs/MODEL_NAME_CASE_FIX.md
39
42
  docs/SETUP.md
40
43
  docs/USAGE.md
41
44
  hooks/aipr.py
@@ -1,4 +1,6 @@
1
+ build
1
2
  diffron
2
3
  dist
3
4
  docs
4
5
  hooks
6
+ internal
@@ -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,97 @@
1
+ # Lemonade Model Unresponsive Fix
2
+
3
+ ## Problem
4
+
5
+ Lemonade-Modell wurde manchmal unresponsive, was zu Fehlern in Diffron führte:
6
+
7
+ ```
8
+ Diffron error: 'NoneType' object is not subscriptable
9
+ ```
10
+
11
+ oder
12
+
13
+ ```
14
+ Lemonade model failed after 3 attempts: Lemonade model returned no response
15
+ ```
16
+
17
+ ## Lösung
18
+
19
+ In [`diffron/lemonade.py`](diffron/lemonade.py) wurde die Retry-Logik mit automatischem Entladen und Neuladen des Modells implementiert.
20
+
21
+ ### Änderungen in `LemonadeClient.chat_completion()`
22
+
23
+ ```python
24
+ max_retries = 3
25
+ for attempt in range(1, max_retries + 1):
26
+ try:
27
+ response = self.client.chat.completions.create(...)
28
+ # Handle None response (model unresponsive)
29
+ if response is None or not response.choices:
30
+ if attempt < max_retries:
31
+ self._reload_model()
32
+ continue
33
+ raise RuntimeError("Lemonade model returned no response")
34
+ return response.choices[0].message.content
35
+ except Exception as e:
36
+ if attempt < max_retries:
37
+ self._reload_model()
38
+ continue
39
+ raise RuntimeError(
40
+ f"Lemonade model failed after {max_retries} attempts: {e}"
41
+ )
42
+ ```
43
+
44
+ ### Neue Methode `_reload_model()`
45
+
46
+ Entlädt und lädt das Lemonade-Modell neu, wenn es unresponsive wird. Verwendet die `unload_model()` und `load_model()` Methoden des lemonade-python-sdk, um einen echten Server-seitigen Neustart zu erzwingen.
47
+
48
+ ```python
49
+ def _reload_model(self) -> None:
50
+ """Unload and reload Lemonade model when unresponsive.
51
+
52
+ Uses lemonade-python-sdk's unload_model() and load_model() methods
53
+ to properly force a server-side model restart.
54
+ """
55
+ try:
56
+ # Use SDK's LemonadeClient for server operations
57
+ sdk_client = _LemonadeSDKClient(
58
+ base_url=f"http://{self.host}:{self._port}"
59
+ )
60
+
61
+ # Unload current model from server
62
+ sdk_client.unload_model()
63
+
64
+ # Small delay to allow server to complete unload
65
+ import time
66
+ time.sleep(0.5)
67
+
68
+ # Load the model back on server
69
+ sdk_client.load_model(model_name=self.model)
70
+
71
+ # Reinitialize our client (same port, model should be fresh)
72
+ self._init_client()
73
+
74
+ except Exception:
75
+ pass # Continue even if reload fails
76
+ ```
77
+
78
+ ### Wichtige Änderungen
79
+
80
+ 1. **Import-Alias**: Der SDK-Import wurde zu `_LemonadeSDKClient` umbenanmt, um Namenskonflikte mit der eigenen `LemonadeClient` Klasse zu vermeiden.
81
+
82
+ 2. **Echter Server-Neustart**: Statt nur den Port zu wechseln (was nicht funktioniert hat), wird jetzt das Modell über die SDK-API entladen und neu geladen:
83
+ - `sdk_client.unload_model()` - Entlädt das aktuelle Modell vom Server
84
+ - `time.sleep(0.5)` - Kurze Wartezeit für sauberen unload
85
+ - `sdk_client.load_model(model_name=self.model)` - Lädt das Modell neu
86
+
87
+ 3. **Retry-Logik**: Bei 3 Versuchen wird jeweils versucht, das Modell neu zu laden, bevor ein Fehler geworfen wird.
88
+
89
+ ## Technische Details
90
+
91
+ - **Maximale Wiederholungen**: 3
92
+ - **Wartezeit zwischen Reloads**: 0.5 Sekunden
93
+ - **Fehlerbehandlung**: Bei jedem Reload-Versuch werden alle Exceptions abgefangen, damit der Prozess fortgesetzt werden kann
94
+
95
+ ## Version
96
+
97
+ Diese Änderung wurde in Version 0.1.5 veröffentlicht.
@@ -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.6"
7
+ version = "0.1.8"
8
8
  description = "Git commit message and PR description generator using Lemonade"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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.4",
13
+ version="0.1.5",
14
14
  author="Diffron Contributors",
15
15
  author_email="diffron@example.com",
16
16
  description="Git commit message and PR description generator using Lemonade",
@@ -1,249 +0,0 @@
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}')"
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