Mirrowel commited on
Commit
b7df2fe
·
1 Parent(s): 7f1d2c1

feat(filter): ✨ add full glob pattern support for model filtering

Browse files

This commit upgrades the model filtering system to support complete glob/fnmatch pattern syntax, replacing the previous limited wildcard implementation.

- Migrate from custom prefix-only wildcard matching to full fnmatch support
- Add support for suffix wildcards (*-preview), contains wildcards (*-preview*), single character matching (gpt-?), and character sets (gpt-[45]*)
- Implement cross-platform mouse wheel scrolling with normalized delta calculation for Windows, macOS, and Linux
- Update help documentation with comprehensive pattern examples
- Refactor duplicate model filtering logic in both GUI (FilterEngine) and client (RotatingClient) to use unified fnmatch approach
- Add traceback printing for better debugging of .env save errors
- Fix clipboard copy logic to properly include models without explicit status
- Improve code formatting with consistent line breaks and spacing
- Add pyinstaller to requirements and remove tkinter from build exclusions
- Bump rotator_library version to 1.05 and remove unused dependencies from pyproject.toml

The enhanced pattern matching provides users with significantly more flexibility when defining filter rules, supporting industry-standard glob syntax familiar from shell wildcards.

requirements.txt CHANGED
@@ -22,3 +22,6 @@ rich
22
 
23
  # GUI for model filter configuration
24
  customtkinter
 
 
 
 
22
 
23
  # GUI for model filter configuration
24
  customtkinter
25
+
26
+ # For building the executable
27
+ pyinstaller
src/proxy_app/build.py CHANGED
@@ -3,6 +3,7 @@ import sys
3
  import platform
4
  import subprocess
5
 
 
6
  def get_providers():
7
  """
8
  Scans the 'src/rotator_library/providers' directory to find all provider modules.
@@ -24,6 +25,7 @@ def get_providers():
24
  hidden_imports.append(f"--hidden-import={module_name}")
25
  return hidden_imports
26
 
 
27
  def main():
28
  """
29
  Constructs and runs the PyInstaller command to build the executable.
@@ -47,22 +49,27 @@ def main():
47
  "--collect-data",
48
  "litellm",
49
  # Optimization: Exclude unused heavy modules
50
- "--exclude-module=tkinter",
51
  "--exclude-module=matplotlib",
52
  "--exclude-module=IPython",
53
  "--exclude-module=jupyter",
54
  "--exclude-module=notebook",
55
  "--exclude-module=PIL.ImageTk",
56
  # Optimization: Enable UPX compression (if available)
57
- "--upx-dir=upx" if platform.system() != "Darwin" else "--noupx", # macOS has issues with UPX
 
 
58
  # Optimization: Strip debug symbols (smaller binary)
59
- "--strip" if platform.system() != "Windows" else "--console", # Windows gets clean console
 
 
60
  ]
61
 
62
  # Add hidden imports for providers
63
  provider_imports = get_providers()
64
  if not provider_imports:
65
- print("Warning: No providers found. The build might not include any LLM providers.")
 
 
66
  command.extend(provider_imports)
67
 
68
  # Add the main script
@@ -80,5 +87,6 @@ def main():
80
  except FileNotFoundError:
81
  print("Error: PyInstaller is not installed or not in the system's PATH.")
82
 
 
83
  if __name__ == "__main__":
84
  main()
 
3
  import platform
4
  import subprocess
5
 
6
+
7
  def get_providers():
8
  """
9
  Scans the 'src/rotator_library/providers' directory to find all provider modules.
 
25
  hidden_imports.append(f"--hidden-import={module_name}")
26
  return hidden_imports
27
 
28
+
29
  def main():
30
  """
31
  Constructs and runs the PyInstaller command to build the executable.
 
49
  "--collect-data",
50
  "litellm",
51
  # Optimization: Exclude unused heavy modules
 
52
  "--exclude-module=matplotlib",
53
  "--exclude-module=IPython",
54
  "--exclude-module=jupyter",
55
  "--exclude-module=notebook",
56
  "--exclude-module=PIL.ImageTk",
57
  # Optimization: Enable UPX compression (if available)
58
+ "--upx-dir=upx"
59
+ if platform.system() != "Darwin"
60
+ else "--noupx", # macOS has issues with UPX
61
  # Optimization: Strip debug symbols (smaller binary)
62
+ "--strip"
63
+ if platform.system() != "Windows"
64
+ else "--console", # Windows gets clean console
65
  ]
66
 
67
  # Add hidden imports for providers
68
  provider_imports = get_providers()
69
  if not provider_imports:
70
+ print(
71
+ "Warning: No providers found. The build might not include any LLM providers."
72
+ )
73
  command.extend(provider_imports)
74
 
75
  # Add the main script
 
87
  except FileNotFoundError:
88
  print("Error: PyInstaller is not installed or not in the system's PATH.")
89
 
90
+
91
  if __name__ == "__main__":
92
  main()
src/proxy_app/model_filter_gui.py CHANGED
@@ -16,9 +16,12 @@ Features:
16
  import customtkinter as ctk
17
  from tkinter import Menu
18
  import asyncio
 
 
19
  import threading
20
  import os
21
  import re
 
22
  from pathlib import Path
23
  from dataclasses import dataclass, field
24
  from typing import List, Dict, Tuple, Optional, Callable, Set
@@ -90,6 +93,34 @@ FONT_SIZE_TITLE = 16
90
  FONT_SIZE_HEADER = 20
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  # ════════════════════════════════════════════════════════════════════════════════
94
  # DATA CLASSES
95
  # ════════════════════════════════════════════════════════════════════════════════
@@ -254,10 +285,14 @@ class FilterEngine:
254
  """
255
  Check if a pattern matches a model ID.
256
 
257
- Supports:
258
  - Exact match: "gpt-4" matches only "gpt-4"
259
  - Prefix wildcard: "gpt-4*" matches "gpt-4", "gpt-4-turbo", etc.
 
 
260
  - Match all: "*" matches everything
 
 
261
  """
262
  # Extract model name without provider prefix
263
  if "/" in model_id:
@@ -265,14 +300,11 @@ class FilterEngine:
265
  else:
266
  provider_model_name = model_id
267
 
268
- if pattern == "*":
269
- return True
270
- elif pattern.endswith("*"):
271
- prefix = pattern[:-1]
272
- return provider_model_name.startswith(prefix) or model_id.startswith(prefix)
273
- else:
274
- # Exact match against full ID or provider model name
275
- return model_id == pattern or provider_model_name == pattern
276
 
277
  def pattern_is_covered_by(self, new_pattern: str, existing_pattern: str) -> bool:
278
  """
@@ -491,6 +523,7 @@ class FilterEngine:
491
  return True
492
  except Exception as e:
493
  print(f"Error saving to .env: {e}")
 
494
  return False
495
 
496
  def has_unsaved_changes(self) -> bool:
@@ -801,7 +834,9 @@ class HelpWindow(ctk.CTkToplevel):
801
  def _on_mousewheel(self, event):
802
  """Handle mouse wheel with faster scrolling."""
803
  # CTkTextbox uses _textbox internally
804
- self.text_box._textbox.yview_scroll(-1 * (event.delta // 40), "units")
 
 
805
  return "break"
806
 
807
  def _insert_help_content(self):
@@ -838,7 +873,7 @@ class HelpWindow(ctk.CTkToplevel):
838
  ),
839
  (
840
  "✏️ Pattern Syntax",
841
- """Three types of patterns are supported:
842
 
843
  EXACT MATCH
844
  Pattern: gpt-4
@@ -847,10 +882,26 @@ EXACT MATCH
847
  PREFIX WILDCARD
848
  Pattern: gpt-4*
849
  Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc.
850
-
 
 
 
 
 
 
 
 
851
  MATCH ALL
852
  Pattern: *
853
- Matches: every model for this provider""",
 
 
 
 
 
 
 
 
854
  ),
855
  (
856
  "💡 Common Patterns",
@@ -1533,7 +1584,7 @@ class VirtualModelList:
1533
 
1534
  def _on_mousewheel(self, event):
1535
  """Handle mouse wheel scrolling."""
1536
- delta = -1 * (event.delta // 120)
1537
  self.canvas.yview_scroll(delta, "units")
1538
  self._render()
1539
  return "break"
@@ -1998,16 +2049,12 @@ class VirtualSyncModelLists(ctk.CTkFrame):
1998
  """Copy filtered/available model names to clipboard (comma-separated)."""
1999
  if not self.models:
2000
  return
2001
- # Get only models that are not ignored
2002
  available = [
2003
  self._get_model_display_name(m)
2004
  for m in self.models
2005
- if self.statuses.get(m) and self.statuses[m].status != "ignored"
2006
  ]
2007
- # Also include models with no status (default to available)
2008
- for m in self.models:
2009
- if m not in self.statuses:
2010
- available.append(self._get_model_display_name(m))
2011
  text = ", ".join(available)
2012
  self.clipboard_clear()
2013
  self.clipboard_append(text)
@@ -2182,7 +2229,7 @@ class VirtualRuleList:
2182
 
2183
  def _on_mousewheel(self, event):
2184
  """Handle mouse wheel scrolling."""
2185
- delta = -1 * (event.delta // 120)
2186
  self.canvas.yview_scroll(delta, "units")
2187
  self._render()
2188
  return "break"
 
16
  import customtkinter as ctk
17
  from tkinter import Menu
18
  import asyncio
19
+ import fnmatch
20
+ import platform
21
  import threading
22
  import os
23
  import re
24
+ import traceback
25
  from pathlib import Path
26
  from dataclasses import dataclass, field
27
  from typing import List, Dict, Tuple, Optional, Callable, Set
 
93
  FONT_SIZE_HEADER = 20
94
 
95
 
96
+ # ════════════════════════════════════════════════════════════════════════════════
97
+ # CROSS-PLATFORM UTILITIES
98
+ # ════════════════════════════════════════════════════════════════════════════════
99
+
100
+
101
+ def get_scroll_delta(event) -> int:
102
+ """
103
+ Calculate scroll delta in a cross-platform manner.
104
+
105
+ On Windows, event.delta is typically ±120 per notch.
106
+ On macOS, event.delta is typically ±1 per scroll event.
107
+ On Linux/X11, behavior varies but is usually similar to macOS.
108
+
109
+ Returns a normalized scroll direction value (typically ±1).
110
+ """
111
+ system = platform.system()
112
+ if system == "Darwin": # macOS
113
+ return -event.delta
114
+ elif system == "Linux":
115
+ # Linux with X11 typically uses ±1 like macOS
116
+ # but some configurations may use larger values
117
+ if abs(event.delta) >= 120:
118
+ return -1 * (event.delta // 120)
119
+ return -event.delta
120
+ else: # Windows
121
+ return -1 * (event.delta // 120)
122
+
123
+
124
  # ════════════════════════════════════════════════════════════════════════════════
125
  # DATA CLASSES
126
  # ════════════════════════════════════════════════════════════════════════════════
 
285
  """
286
  Check if a pattern matches a model ID.
287
 
288
+ Supports full glob/fnmatch syntax:
289
  - Exact match: "gpt-4" matches only "gpt-4"
290
  - Prefix wildcard: "gpt-4*" matches "gpt-4", "gpt-4-turbo", etc.
291
+ - Suffix wildcard: "*-preview" matches "gpt-4-preview", "o1-preview", etc.
292
+ - Contains wildcard: "*-preview*" matches anything containing "-preview"
293
  - Match all: "*" matches everything
294
+ - Single char wildcard: "gpt-?" matches "gpt-4", "gpt-5", etc.
295
+ - Character sets: "gpt-[45]*" matches "gpt-4*", "gpt-5*"
296
  """
297
  # Extract model name without provider prefix
298
  if "/" in model_id:
 
300
  else:
301
  provider_model_name = model_id
302
 
303
+ # Use fnmatch for full glob pattern support
304
+ # Match against both the provider model name and the full model ID
305
+ return fnmatch.fnmatch(provider_model_name, pattern) or fnmatch.fnmatch(
306
+ model_id, pattern
307
+ )
 
 
 
308
 
309
  def pattern_is_covered_by(self, new_pattern: str, existing_pattern: str) -> bool:
310
  """
 
523
  return True
524
  except Exception as e:
525
  print(f"Error saving to .env: {e}")
526
+ traceback.print_exc()
527
  return False
528
 
529
  def has_unsaved_changes(self) -> bool:
 
834
  def _on_mousewheel(self, event):
835
  """Handle mouse wheel with faster scrolling."""
836
  # CTkTextbox uses _textbox internally
837
+ # Use larger scroll amount (3 units) for faster scrolling in help window
838
+ delta = get_scroll_delta(event) * 3
839
+ self.text_box._textbox.yview_scroll(delta, "units")
840
  return "break"
841
 
842
  def _insert_help_content(self):
 
873
  ),
874
  (
875
  "✏️ Pattern Syntax",
876
+ """Full glob/wildcard patterns are supported:
877
 
878
  EXACT MATCH
879
  Pattern: gpt-4
 
882
  PREFIX WILDCARD
883
  Pattern: gpt-4*
884
  Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc.
885
+
886
+ SUFFIX WILDCARD
887
+ Pattern: *-preview
888
+ Matches: "gpt-4-preview", "o1-preview", etc.
889
+
890
+ CONTAINS WILDCARD
891
+ Pattern: *-preview*
892
+ Matches: anything containing "-preview"
893
+
894
  MATCH ALL
895
  Pattern: *
896
+ Matches: every model for this provider
897
+
898
+ SINGLE CHARACTER
899
+ Pattern: gpt-?
900
+ Matches: "gpt-4", "gpt-5", etc. (any single char)
901
+
902
+ CHARACTER SET
903
+ Pattern: gpt-[45]*
904
+ Matches: "gpt-4", "gpt-4-turbo", "gpt-5", etc.""",
905
  ),
906
  (
907
  "💡 Common Patterns",
 
1584
 
1585
  def _on_mousewheel(self, event):
1586
  """Handle mouse wheel scrolling."""
1587
+ delta = get_scroll_delta(event)
1588
  self.canvas.yview_scroll(delta, "units")
1589
  self._render()
1590
  return "break"
 
2049
  """Copy filtered/available model names to clipboard (comma-separated)."""
2050
  if not self.models:
2051
  return
2052
+ # Get only models that are not ignored (models without status default to available)
2053
  available = [
2054
  self._get_model_display_name(m)
2055
  for m in self.models
2056
+ if self.statuses.get(m) is None or self.statuses[m].status != "ignored"
2057
  ]
 
 
 
 
2058
  text = ", ".join(available)
2059
  self.clipboard_clear()
2060
  self.clipboard_append(text)
 
2229
 
2230
  def _on_mousewheel(self, event):
2231
  """Handle mouse wheel scrolling."""
2232
+ delta = get_scroll_delta(event)
2233
  self.canvas.yview_scroll(delta, "units")
2234
  self._render()
2235
  return "break"
src/rotator_library/client.py CHANGED
@@ -1,4 +1,5 @@
1
  import asyncio
 
2
  import json
3
  import re
4
  import codecs
@@ -274,7 +275,14 @@ class RotatingClient:
274
  def _is_model_ignored(self, provider: str, model_id: str) -> bool:
275
  """
276
  Checks if a model should be ignored based on the ignore list.
277
- Supports exact and partial matching for both full model IDs and model names.
 
 
 
 
 
 
 
278
  """
279
  model_provider = model_id.split("/")[0]
280
  if model_provider not in self.ignore_models:
@@ -291,52 +299,43 @@ class RotatingClient:
291
  provider_model_name = model_id
292
 
293
  for ignored_pattern in ignore_list:
294
- if ignored_pattern.endswith("*"):
295
- match_pattern = ignored_pattern[:-1]
296
- # Match wildcard against the provider's model name
297
- if provider_model_name.startswith(match_pattern):
298
- return True
299
- else:
300
- # Exact match against the full proxy ID OR the provider's model name
301
- if (
302
- model_id == ignored_pattern
303
- or provider_model_name == ignored_pattern
304
- ):
305
- return True
306
  return False
307
 
308
  def _is_model_whitelisted(self, provider: str, model_id: str) -> bool:
309
  """
310
  Checks if a model is explicitly whitelisted.
311
- Supports exact and partial matching for both full model IDs and model names.
 
 
 
 
 
 
 
312
  """
313
  model_provider = model_id.split("/")[0]
314
  if model_provider not in self.whitelist_models:
315
  return False
316
 
317
  whitelist = self.whitelist_models[model_provider]
 
 
 
 
 
 
 
318
  for whitelisted_pattern in whitelist:
319
- if whitelisted_pattern == "*":
 
 
 
320
  return True
321
-
322
- try:
323
- # This is the model name as the provider sees it (e.g., "gpt-4" or "google/gemma-7b")
324
- provider_model_name = model_id.split("/", 1)[1]
325
- except IndexError:
326
- provider_model_name = model_id
327
-
328
- if whitelisted_pattern.endswith("*"):
329
- match_pattern = whitelisted_pattern[:-1]
330
- # Match wildcard against the provider's model name
331
- if provider_model_name.startswith(match_pattern):
332
- return True
333
- else:
334
- # Exact match against the full proxy ID OR the provider's model name
335
- if (
336
- model_id == whitelisted_pattern
337
- or provider_model_name == whitelisted_pattern
338
- ):
339
- return True
340
  return False
341
 
342
  def _sanitize_litellm_log(self, log_data: dict) -> dict:
 
1
  import asyncio
2
+ import fnmatch
3
  import json
4
  import re
5
  import codecs
 
275
  def _is_model_ignored(self, provider: str, model_id: str) -> bool:
276
  """
277
  Checks if a model should be ignored based on the ignore list.
278
+ Supports full glob/fnmatch patterns for both full model IDs and model names.
279
+
280
+ Pattern examples:
281
+ - "gpt-4" - exact match
282
+ - "gpt-4*" - prefix wildcard (matches gpt-4, gpt-4-turbo, etc.)
283
+ - "*-preview" - suffix wildcard (matches gpt-4-preview, o1-preview, etc.)
284
+ - "*-preview*" - contains wildcard (matches anything with -preview)
285
+ - "*" - match all
286
  """
287
  model_provider = model_id.split("/")[0]
288
  if model_provider not in self.ignore_models:
 
299
  provider_model_name = model_id
300
 
301
  for ignored_pattern in ignore_list:
302
+ # Use fnmatch for full glob pattern support
303
+ if fnmatch.fnmatch(provider_model_name, ignored_pattern) or fnmatch.fnmatch(
304
+ model_id, ignored_pattern
305
+ ):
306
+ return True
 
 
 
 
 
 
 
307
  return False
308
 
309
  def _is_model_whitelisted(self, provider: str, model_id: str) -> bool:
310
  """
311
  Checks if a model is explicitly whitelisted.
312
+ Supports full glob/fnmatch patterns for both full model IDs and model names.
313
+
314
+ Pattern examples:
315
+ - "gpt-4" - exact match
316
+ - "gpt-4*" - prefix wildcard (matches gpt-4, gpt-4-turbo, etc.)
317
+ - "*-preview" - suffix wildcard (matches gpt-4-preview, o1-preview, etc.)
318
+ - "*-preview*" - contains wildcard (matches anything with -preview)
319
+ - "*" - match all
320
  """
321
  model_provider = model_id.split("/")[0]
322
  if model_provider not in self.whitelist_models:
323
  return False
324
 
325
  whitelist = self.whitelist_models[model_provider]
326
+
327
+ try:
328
+ # This is the model name as the provider sees it (e.g., "gpt-4" or "google/gemma-7b")
329
+ provider_model_name = model_id.split("/", 1)[1]
330
+ except IndexError:
331
+ provider_model_name = model_id
332
+
333
  for whitelisted_pattern in whitelist:
334
+ # Use fnmatch for full glob pattern support
335
+ if fnmatch.fnmatch(
336
+ provider_model_name, whitelisted_pattern
337
+ ) or fnmatch.fnmatch(model_id, whitelisted_pattern):
338
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  return False
340
 
341
  def _sanitize_litellm_log(self, log_data: dict) -> dict:
src/rotator_library/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "rotator_library"
7
- version = "1.0"
8
  authors = [
9
  { name="Mirrowel", email="nuh@uh.com" },
10
  ]
@@ -16,11 +16,7 @@ classifiers = [
16
  "License :: OSI Approved :: MIT License",
17
  "Operating System :: OS Independent",
18
  ]
19
- dependencies = [
20
- "litellm",
21
- "filelock",
22
- "httpx"
23
- ]
24
 
25
  [project.urls]
26
  "Homepage" = "https://github.com/Mirrowel/LLM-API-Key-Proxy"
 
4
 
5
  [project]
6
  name = "rotator_library"
7
+ version = "1.05"
8
  authors = [
9
  { name="Mirrowel", email="nuh@uh.com" },
10
  ]
 
16
  "License :: OSI Approved :: MIT License",
17
  "Operating System :: OS Independent",
18
  ]
19
+ dependencies = []
 
 
 
 
20
 
21
  [project.urls]
22
  "Homepage" = "https://github.com/Mirrowel/LLM-API-Key-Proxy"