local-llm-wrapper 26.4__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,31 @@
1
+ """
2
+ Standardized exception types for the LLM wrapper.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ #============================================
8
+
9
+
10
+ class LLMError(RuntimeError):
11
+ """
12
+ Base class for LLM wrapper errors.
13
+ """
14
+
15
+
16
+ class TransportUnavailableError(LLMError):
17
+ """
18
+ Raised when a transport cannot be used on this machine.
19
+ """
20
+
21
+
22
+ class ContextWindowError(LLMError):
23
+ """
24
+ Raised when the prompt exceeds a model context window.
25
+ """
26
+
27
+
28
+ class GuardrailRefusalError(LLMError):
29
+ """
30
+ Raised when a model refuses a prompt due to safety/guardrails.
31
+ """
@@ -0,0 +1,46 @@
1
+ """
2
+ Convenience facade for local_llm_wrapper.
3
+
4
+ External callers can use `import local_llm_wrapper.llm as llm` to access
5
+ the most common names from a single import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from local_llm_wrapper.errors import (
11
+ ContextWindowError,
12
+ GuardrailRefusalError,
13
+ LLMError,
14
+ TransportUnavailableError,
15
+ )
16
+ from local_llm_wrapper.llm_client import LLMClient
17
+ from local_llm_wrapper.llm_parsers import RenameResult, SortResult
18
+ from local_llm_wrapper.llm_utils import (
19
+ apple_models_available,
20
+ choose_model,
21
+ extract_xml_tag_content,
22
+ get_vram_size_in_gb,
23
+ sanitize_filename,
24
+ total_ram_bytes,
25
+ )
26
+ from local_llm_wrapper.transports.apple import AppleTransport
27
+ from local_llm_wrapper.transports.ollama import OllamaTransport
28
+
29
+ # Re-exports are intentional; __all__ suppresses pyflakes unused-import warnings.
30
+ __all__ = [
31
+ "AppleTransport",
32
+ "ContextWindowError",
33
+ "GuardrailRefusalError",
34
+ "LLMClient",
35
+ "LLMError",
36
+ "OllamaTransport",
37
+ "RenameResult",
38
+ "SortResult",
39
+ "TransportUnavailableError",
40
+ "apple_models_available",
41
+ "choose_model",
42
+ "extract_xml_tag_content",
43
+ "get_vram_size_in_gb",
44
+ "sanitize_filename",
45
+ "total_ram_bytes",
46
+ ]
@@ -0,0 +1,82 @@
1
+ """
2
+ Public client wrapper for the local LLM engine.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ # local repo modules
8
+ from local_llm_wrapper.llm_engine import LLMEngine
9
+ from local_llm_wrapper.llm_parsers import RenameResult, SortResult
10
+ from local_llm_wrapper.llm_prompts import SortItem
11
+ from local_llm_wrapper.transports.base import LLMTransport
12
+
13
+ #============================================
14
+
15
+
16
+ class LLMClient:
17
+ """
18
+ Public entry point for local LLM usage.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ transports: list[LLMTransport],
24
+ *,
25
+ context: str | None = None,
26
+ quiet: bool = False,
27
+ ) -> None:
28
+ self._engine = LLMEngine(
29
+ transports=transports,
30
+ context=context,
31
+ quiet=quiet,
32
+ )
33
+
34
+ #============================================
35
+ def generate(
36
+ self,
37
+ prompt: str | None = None,
38
+ *,
39
+ messages: list[dict[str, str]] | None = None,
40
+ purpose: str | None = None,
41
+ max_tokens: int = 1200,
42
+ ) -> str:
43
+ return self._engine.generate(
44
+ prompt,
45
+ messages=messages,
46
+ purpose=purpose,
47
+ max_tokens=max_tokens,
48
+ )
49
+
50
+ #============================================
51
+ def rename(self, current_name: str, metadata: dict) -> RenameResult:
52
+ return self._engine.rename(current_name, metadata)
53
+
54
+ #============================================
55
+ def sort(self, files: list[SortItem | dict]) -> SortResult:
56
+ items: list[SortItem] = []
57
+ for item in files:
58
+ if isinstance(item, SortItem):
59
+ items.append(item)
60
+ continue
61
+ if isinstance(item, dict):
62
+ required_keys = ("path", "name", "ext", "description")
63
+ for key in required_keys:
64
+ if key not in item:
65
+ raise ValueError(
66
+ "Sort items require path, name, ext, and description."
67
+ )
68
+ path = item["path"]
69
+ name = item["name"]
70
+ ext = item["ext"]
71
+ description = item["description"]
72
+ items.append(
73
+ SortItem(
74
+ path=path,
75
+ name=name,
76
+ ext=ext,
77
+ description=description,
78
+ )
79
+ )
80
+ continue
81
+ raise TypeError("Sort items must be SortItem or dict.")
82
+ return self._engine.sort(items)
@@ -0,0 +1,293 @@
1
+ """
2
+ Backend-agnostic LLM engine with fallback and strict parsing.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ # Standard Library
8
+ from dataclasses import dataclass
9
+ from typing import cast
10
+
11
+ # local repo modules
12
+ from local_llm_wrapper.errors import TransportUnavailableError
13
+ from local_llm_wrapper.llm_parsers import ParseError, KeepResult, RenameResult, SortResult, parse_keep_response, parse_rename_response, parse_sort_response
14
+ from local_llm_wrapper.llm_prompts import (
15
+ KeepRequest,
16
+ RenameRequest,
17
+ SortItem,
18
+ SortRequest,
19
+ RENAME_EXAMPLE_OUTPUT,
20
+ KEEP_EXAMPLE_OUTPUT,
21
+ SORT_EXAMPLE_OUTPUT,
22
+ build_format_fix_prompt,
23
+ build_keep_prompt,
24
+ build_rename_prompt,
25
+ build_rename_prompt_minimal,
26
+ build_sort_prompt,
27
+ )
28
+ from local_llm_wrapper.llm_utils import (
29
+ compute_stem_features,
30
+ _ensure_text_prompt,
31
+ _ensure_chat_messages,
32
+ format_chat_prompt,
33
+ _is_guardrail_error,
34
+ _is_context_window_error,
35
+ _print_llm,
36
+ log_parse_failure,
37
+ normalize_reason,
38
+ sanitize_filename,
39
+ )
40
+ from local_llm_wrapper.transports.base import LLMTransport
41
+
42
+ #============================================
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class LLMEngine:
47
+ transports: list[LLMTransport]
48
+ context: str | None = None
49
+ quiet: bool = False
50
+
51
+ #============================================
52
+ def generate(
53
+ self,
54
+ prompt: str | None = None,
55
+ *,
56
+ messages: list[dict[str, str]] | None = None,
57
+ purpose: str | None = None,
58
+ max_tokens: int = 1200,
59
+ ) -> str:
60
+ if prompt is None and messages is None:
61
+ raise ValueError("Prompt or messages are required.")
62
+ if prompt is not None and messages is not None:
63
+ raise ValueError("Provide prompt or messages, not both.")
64
+ text_prompt: str | None = None
65
+ chat_messages: list[dict[str, str]] | None = None
66
+ if messages is not None:
67
+ chat_messages = _ensure_chat_messages(messages)
68
+ else:
69
+ text_prompt = _ensure_text_prompt(prompt)
70
+ return self._generate_with_fallback(
71
+ text_prompt,
72
+ messages=chat_messages,
73
+ purpose=purpose or "general response",
74
+ max_tokens=max_tokens,
75
+ retry_prompt=None,
76
+ )
77
+
78
+ #============================================
79
+ def rename(self, current_name: str, metadata: dict) -> RenameResult:
80
+ req = RenameRequest(metadata=metadata, current_name=current_name, context=self.context)
81
+ prompt = build_rename_prompt(req)
82
+ raw = self._generate_with_fallback(
83
+ prompt,
84
+ messages=None,
85
+ purpose="filename based on content",
86
+ max_tokens=200,
87
+ retry_prompt=build_rename_prompt_minimal(req),
88
+ )
89
+ result = self._parse_with_retry(
90
+ lambda text: parse_rename_response(text),
91
+ prompt,
92
+ RENAME_EXAMPLE_OUTPUT,
93
+ raw,
94
+ purpose="filename based on content",
95
+ max_tokens=200,
96
+ )
97
+ result.new_name = sanitize_filename(result.new_name)
98
+ result.reason = normalize_reason(result.reason)
99
+ return result
100
+
101
+ #============================================
102
+ def stem_action(self, original_stem: str, suggested_name: str, extension: str | None = None) -> KeepResult:
103
+ features = compute_stem_features(original_stem, suggested_name)
104
+ req = KeepRequest(
105
+ original_stem=original_stem,
106
+ suggested_name=suggested_name,
107
+ extension=extension,
108
+ features=features,
109
+ )
110
+ prompt = build_keep_prompt(req)
111
+ raw = self._generate_with_fallback(
112
+ prompt,
113
+ messages=None,
114
+ purpose="how to handle the original filename stem",
115
+ max_tokens=120,
116
+ retry_prompt=None,
117
+ )
118
+ result = self._parse_with_retry(
119
+ lambda text: parse_keep_response(text, original_stem),
120
+ prompt,
121
+ KEEP_EXAMPLE_OUTPUT,
122
+ raw,
123
+ purpose="how to handle the original filename stem",
124
+ max_tokens=120,
125
+ )
126
+ result.reason = normalize_reason(result.reason)
127
+ return result
128
+
129
+ #============================================
130
+ def sort(self, files: list[SortItem]) -> SortResult:
131
+ if not files:
132
+ return SortResult(assignments={}, raw_text="")
133
+ assignments: dict[str, str] = {}
134
+ reasons: dict[str, str] = {}
135
+ last_raw = ""
136
+ for item in files:
137
+ req = SortRequest(files=[item], context=self.context)
138
+ prompt = build_sort_prompt(req)
139
+ raw = self._generate_with_fallback(
140
+ prompt,
141
+ messages=None,
142
+ purpose="category assignment",
143
+ max_tokens=120,
144
+ retry_prompt=None,
145
+ )
146
+ result = self._parse_with_retry(
147
+ lambda text: parse_sort_response(text, [item.path]),
148
+ prompt,
149
+ SORT_EXAMPLE_OUTPUT,
150
+ raw,
151
+ purpose="category assignment",
152
+ max_tokens=120,
153
+ )
154
+ assignments.update(result.assignments)
155
+ for path, reason in result.reasons.items():
156
+ reasons[path] = normalize_reason(reason)
157
+ last_raw = result.raw_text
158
+ return SortResult(assignments=assignments, reasons=reasons, raw_text=last_raw)
159
+
160
+ #============================================
161
+ def _generate_with_fallback(
162
+ self,
163
+ prompt: str | None,
164
+ *,
165
+ messages: list[dict[str, str]] | None,
166
+ purpose: str,
167
+ max_tokens: int,
168
+ retry_prompt: str | None,
169
+ ) -> str:
170
+ last_exc: Exception | None = None
171
+ for idx, transport in enumerate(self.transports):
172
+ try:
173
+ if not self.quiet:
174
+ _print_llm(f"asking {transport.name} for {purpose}")
175
+ return self._generate_on_transport(
176
+ transport,
177
+ prompt,
178
+ messages,
179
+ purpose,
180
+ max_tokens,
181
+ )
182
+ except Exception as exc:
183
+ last_exc = exc
184
+ if isinstance(exc, TransportUnavailableError):
185
+ continue
186
+ if _is_guardrail_error(exc) or _is_context_window_error(exc):
187
+ if retry_prompt and idx == 0:
188
+ try:
189
+ if not self.quiet:
190
+ _print_llm(
191
+ f"retrying {transport.name} with minimal prompt for {purpose}"
192
+ )
193
+ return self._generate_on_transport(
194
+ transport,
195
+ retry_prompt,
196
+ None,
197
+ purpose,
198
+ max_tokens,
199
+ )
200
+ except Exception as retry_exc:
201
+ last_exc = retry_exc
202
+ if _is_guardrail_error(retry_exc) or _is_context_window_error(retry_exc):
203
+ continue
204
+ raise
205
+ continue
206
+ raise
207
+ if last_exc:
208
+ raise last_exc
209
+ raise TransportUnavailableError("No LLM transports available.")
210
+
211
+ #============================================
212
+ def _parse_with_retry(
213
+ self,
214
+ parser,
215
+ original_prompt: str,
216
+ example_output: str,
217
+ raw_text: str,
218
+ *,
219
+ purpose: str,
220
+ max_tokens: int,
221
+ ):
222
+ try:
223
+ return parser(raw_text)
224
+ except ParseError as exc:
225
+ excerpt = " ".join(raw_text.split())[:160]
226
+ if not self.quiet:
227
+ print(f"[WHY] parse_error: {exc} (excerpt: {excerpt})")
228
+ log_parse_failure(
229
+ purpose=purpose,
230
+ error=exc,
231
+ raw_text=exc.raw_text or raw_text,
232
+ prompt=original_prompt,
233
+ stage="initial",
234
+ )
235
+ fix_prompt = build_format_fix_prompt(example_output)
236
+ last_parse: ParseError | None = None
237
+ last_transport: Exception | None = None
238
+ last_fixed: str | None = None
239
+ for transport in self.transports:
240
+ try:
241
+ if not self.quiet:
242
+ _print_llm(f"asking {transport.name} for {purpose} (format fix)")
243
+ fixed = self._generate_on_transport(
244
+ transport,
245
+ fix_prompt,
246
+ None,
247
+ f"{purpose} (format fix)",
248
+ max_tokens,
249
+ )
250
+ last_fixed = fixed
251
+ except Exception as transport_exc:
252
+ if _is_guardrail_error(transport_exc):
253
+ last_transport = transport_exc
254
+ continue
255
+ last_transport = transport_exc
256
+ continue
257
+ try:
258
+ return parser(fixed)
259
+ except ParseError as parse_exc:
260
+ last_parse = parse_exc
261
+ log_parse_failure(
262
+ purpose=purpose,
263
+ error=parse_exc,
264
+ raw_text=parse_exc.raw_text or fixed,
265
+ prompt=fix_prompt,
266
+ stage=f"format fix ({transport.name})",
267
+ )
268
+ continue
269
+ if last_parse:
270
+ text = last_fixed or raw_text
271
+ raise ParseError(str(last_parse), raw_text=text)
272
+ if last_transport:
273
+ raise last_transport
274
+ raise ParseError("Format-fix retry failed.")
275
+
276
+ #============================================
277
+ def _generate_on_transport(
278
+ self,
279
+ transport: LLMTransport,
280
+ prompt: str | None,
281
+ messages: list[dict[str, str]] | None,
282
+ purpose: str,
283
+ max_tokens: int,
284
+ ) -> str:
285
+ if messages is not None:
286
+ generate_chat = getattr(transport, "generate_chat", None)
287
+ if callable(generate_chat):
288
+ result = generate_chat(messages, purpose=purpose, max_tokens=max_tokens)
289
+ return cast(str, result)
290
+ prompt = format_chat_prompt(messages)
291
+ if prompt is None:
292
+ raise ValueError("Prompt or messages are required.")
293
+ return transport.generate(prompt, purpose=purpose, max_tokens=max_tokens)
@@ -0,0 +1,173 @@
1
+ """
2
+ Backend-agnostic response parsers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ # Standard Library
8
+ from dataclasses import dataclass, field
9
+ import html
10
+ import re
11
+
12
+ # local repo modules
13
+
14
+ #============================================
15
+
16
+
17
+ class ParseError(RuntimeError):
18
+ """
19
+ Raised when a model response does not match required tags.
20
+ """
21
+
22
+ def __init__(self, message: str, raw_text: str = "") -> None:
23
+ super().__init__(message)
24
+ self.raw_text = raw_text
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class RenameResult:
29
+ new_name: str
30
+ reason: str
31
+ raw_text: str
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class KeepResult:
36
+ stem_action: str
37
+ reason: str
38
+ raw_text: str
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class SortResult:
43
+ assignments: dict[str, str]
44
+ raw_text: str
45
+ reasons: dict[str, str] = field(default_factory=dict)
46
+
47
+
48
+ _CODE_FENCE_RE = re.compile(r"```[a-zA-Z0-9_+-]*\n(.*?)```", re.DOTALL)
49
+ _TAG_NAME_RE = re.compile(r"^[a-zA-Z0-9_:-]+$")
50
+
51
+
52
+ def _strip_code_fences(text: str) -> str:
53
+ if not text:
54
+ return ""
55
+ cleaned = text.strip()
56
+ if "```" not in cleaned:
57
+ return cleaned
58
+ def _unwrap(match: re.Match) -> str:
59
+ return match.group(1)
60
+ cleaned = _CODE_FENCE_RE.sub(_unwrap, cleaned)
61
+ return cleaned.strip()
62
+
63
+
64
+ def _coerce_response_body(text: str) -> str:
65
+ cleaned = _strip_code_fences(text).strip().strip('"').strip("'")
66
+ if "<" in cleaned:
67
+ unescaped = html.unescape(cleaned)
68
+ if unescaped:
69
+ cleaned = unescaped
70
+ return cleaned
71
+
72
+
73
+ def _find_tag_values(text: str, tag: str) -> list[str]:
74
+ pattern = re.compile(
75
+ rf"<{tag}\b[^>]*>(.*?)</{tag}>",
76
+ flags=re.IGNORECASE | re.DOTALL,
77
+ )
78
+ return [match.strip() for match in pattern.findall(text)]
79
+
80
+
81
+ def parse_tag_response(text: str, tag: str) -> str:
82
+ response_body = _coerce_response_body(text)
83
+ if not response_body:
84
+ raise ParseError("Missing required tags in response.", text)
85
+ if not isinstance(tag, str):
86
+ raise TypeError("Tag name must be a string.")
87
+ tag_name = tag.strip()
88
+ if not tag_name:
89
+ raise ValueError("Tag name must not be empty.")
90
+ if not _TAG_NAME_RE.match(tag_name):
91
+ raise ValueError("Tag name must use letters, numbers, underscores, dashes, or colons.")
92
+ values = _find_tag_values(response_body, tag_name)
93
+ if not values:
94
+ raise ParseError(f"Missing <{tag_name}> in response.", text)
95
+ if len(values) > 1:
96
+ raise ParseError(f"Duplicate <{tag_name}> tags in response.", text)
97
+ return values[0]
98
+
99
+
100
+ def parse_rename_response(text: str) -> RenameResult:
101
+ response_body = _coerce_response_body(text)
102
+ if not response_body:
103
+ raise ParseError("Missing required tags in rename response.", text)
104
+ new_names = _find_tag_values(response_body, "new_name")
105
+ if not new_names:
106
+ raise ParseError("Missing <new_name> in rename response.", text)
107
+ if len(new_names) > 1:
108
+ raise ParseError("Duplicate <new_name> tags in rename response.", text)
109
+ reasons = _find_tag_values(response_body, "reason")
110
+ if len(reasons) > 1:
111
+ raise ParseError("Duplicate <reason> tags in rename response.", text)
112
+ new_name = new_names[0]
113
+ reason = reasons[0] if reasons else ""
114
+ return RenameResult(new_name=new_name, reason=reason, raw_text=text)
115
+
116
+ def parse_keep_response(
117
+ text: str, original_stem: str
118
+ ) -> KeepResult:
119
+ response_body = _coerce_response_body(text)
120
+ if not response_body:
121
+ raise ParseError("Missing required tags in keep response.", text)
122
+ stem_actions = _find_tag_values(response_body, "stem_action")
123
+ if len(stem_actions) > 1:
124
+ raise ParseError("Duplicate <stem_action> tags in keep response.", text)
125
+ reason_values = _find_tag_values(response_body, "reason")
126
+ if not reason_values:
127
+ raise ParseError("Missing <reason> in keep response.", text)
128
+ if len(reason_values) > 1:
129
+ raise ParseError("Duplicate <reason> tags in keep response.", text)
130
+ reason = reason_values[0].strip()
131
+ if stem_actions:
132
+ stem_action = stem_actions[0].strip().lower()
133
+ else:
134
+ keep_values = _find_tag_values(response_body, "keep_original")
135
+ if not keep_values:
136
+ raise ParseError("Missing <stem_action> in keep response.", text)
137
+ if len(keep_values) > 1:
138
+ raise ParseError("Duplicate <keep_original> tags in keep response.", text)
139
+ keep_text = keep_values[0].strip().lower()
140
+ stem_action = (
141
+ "keep"
142
+ if (keep_text.startswith("t") or keep_text == "1" or keep_text == "yes")
143
+ else "drop"
144
+ )
145
+ reason = reason.replace('\\"', '"').replace("\\'", "'")
146
+ if stem_action not in {"drop", "keep", "normalize"}:
147
+ raise ParseError("Invalid <stem_action> value in keep response.", text)
148
+ if not reason:
149
+ raise ParseError("Missing <reason> in keep response.", text)
150
+ return KeepResult(stem_action=stem_action, reason=reason, raw_text=text)
151
+
152
+
153
+ def parse_sort_response(text: str, expected_paths: list[str]) -> SortResult:
154
+ response_body = _coerce_response_body(text)
155
+ if not response_body:
156
+ raise ParseError("Missing required tags in sort response.", text)
157
+ if len(expected_paths) != 1:
158
+ raise ParseError("Sort responses only support a single file.", text)
159
+ categories = _find_tag_values(response_body, "category")
160
+ if not categories:
161
+ raise ParseError("Missing <category> in sort response.", text)
162
+ if len(categories) > 1:
163
+ raise ParseError("Duplicate <category> tags in sort response.", text)
164
+ category = categories[0].strip()
165
+ reasons = _find_tag_values(response_body, "reason")
166
+ if len(reasons) > 1:
167
+ raise ParseError("Duplicate <reason> tags in sort response.", text)
168
+ reason = reasons[0].strip() if reasons else ""
169
+ return SortResult(
170
+ assignments={expected_paths[0]: category},
171
+ reasons={expected_paths[0]: reason} if reason else {},
172
+ raw_text=text,
173
+ )