Skip to content

Commit b7df2fe

Browse files
committed
feat(filter): ✨ add full glob pattern support for model filtering
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.
1 parent 7f1d2c1 commit b7df2fe

File tree

5 files changed

+118
-65
lines changed

5 files changed

+118
-65
lines changed

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ rich
2222

2323
# GUI for model filter configuration
2424
customtkinter
25+
26+
# For building the executable
27+
pyinstaller

src/proxy_app/build.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import platform
44
import subprocess
55

6+
67
def get_providers():
78
"""
89
Scans the 'src/rotator_library/providers' directory to find all provider modules.
@@ -24,6 +25,7 @@ def get_providers():
2425
hidden_imports.append(f"--hidden-import={module_name}")
2526
return hidden_imports
2627

28+
2729
def main():
2830
"""
2931
Constructs and runs the PyInstaller command to build the executable.
@@ -47,22 +49,27 @@ def main():
4749
"--collect-data",
4850
"litellm",
4951
# Optimization: Exclude unused heavy modules
50-
"--exclude-module=tkinter",
5152
"--exclude-module=matplotlib",
5253
"--exclude-module=IPython",
5354
"--exclude-module=jupyter",
5455
"--exclude-module=notebook",
5556
"--exclude-module=PIL.ImageTk",
5657
# Optimization: Enable UPX compression (if available)
57-
"--upx-dir=upx" if platform.system() != "Darwin" else "--noupx", # macOS has issues with UPX
58+
"--upx-dir=upx"
59+
if platform.system() != "Darwin"
60+
else "--noupx", # macOS has issues with UPX
5861
# Optimization: Strip debug symbols (smaller binary)
59-
"--strip" if platform.system() != "Windows" else "--console", # Windows gets clean console
62+
"--strip"
63+
if platform.system() != "Windows"
64+
else "--console", # Windows gets clean console
6065
]
6166

6267
# Add hidden imports for providers
6368
provider_imports = get_providers()
6469
if not provider_imports:
65-
print("Warning: No providers found. The build might not include any LLM providers.")
70+
print(
71+
"Warning: No providers found. The build might not include any LLM providers."
72+
)
6673
command.extend(provider_imports)
6774

6875
# Add the main script
@@ -80,5 +87,6 @@ def main():
8087
except FileNotFoundError:
8188
print("Error: PyInstaller is not installed or not in the system's PATH.")
8289

90+
8391
if __name__ == "__main__":
8492
main()

src/proxy_app/model_filter_gui.py

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
import customtkinter as ctk
1717
from tkinter import Menu
1818
import asyncio
19+
import fnmatch
20+
import platform
1921
import threading
2022
import os
2123
import re
24+
import traceback
2225
from pathlib import Path
2326
from dataclasses import dataclass, field
2427
from typing import List, Dict, Tuple, Optional, Callable, Set
@@ -90,6 +93,34 @@
9093
FONT_SIZE_HEADER = 20
9194

9295

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+
93124
# ════════════════════════════════════════════════════════════════════════════════
94125
# DATA CLASSES
95126
# ════════════════════════════════════════════════════════════════════════════════
@@ -254,25 +285,26 @@ def _pattern_matches(self, model_id: str, pattern: str) -> bool:
254285
"""
255286
Check if a pattern matches a model ID.
256287
257-
Supports:
288+
Supports full glob/fnmatch syntax:
258289
- Exact match: "gpt-4" matches only "gpt-4"
259290
- 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"
260293
- 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*"
261296
"""
262297
# Extract model name without provider prefix
263298
if "/" in model_id:
264299
provider_model_name = model_id.split("/", 1)[1]
265300
else:
266301
provider_model_name = model_id
267302

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
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+
)
276308

277309
def pattern_is_covered_by(self, new_pattern: str, existing_pattern: str) -> bool:
278310
"""
@@ -491,6 +523,7 @@ def save_to_env(self, provider: str) -> bool:
491523
return True
492524
except Exception as e:
493525
print(f"Error saving to .env: {e}")
526+
traceback.print_exc()
494527
return False
495528

496529
def has_unsaved_changes(self) -> bool:
@@ -801,7 +834,9 @@ def _create_content(self):
801834
def _on_mousewheel(self, event):
802835
"""Handle mouse wheel with faster scrolling."""
803836
# CTkTextbox uses _textbox internally
804-
self.text_box._textbox.yview_scroll(-1 * (event.delta // 40), "units")
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")
805840
return "break"
806841

807842
def _insert_help_content(self):
@@ -838,7 +873,7 @@ def _insert_help_content(self):
838873
),
839874
(
840875
"✏️ Pattern Syntax",
841-
"""Three types of patterns are supported:
876+
"""Full glob/wildcard patterns are supported:
842877
843878
EXACT MATCH
844879
Pattern: gpt-4
@@ -847,10 +882,26 @@ def _insert_help_content(self):
847882
PREFIX WILDCARD
848883
Pattern: gpt-4*
849884
Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc.
850-
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+
851894
MATCH ALL
852895
Pattern: *
853-
Matches: every model for this provider""",
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.""",
854905
),
855906
(
856907
"💡 Common Patterns",
@@ -1533,7 +1584,7 @@ def _on_configure(self, event=None):
15331584

15341585
def _on_mousewheel(self, event):
15351586
"""Handle mouse wheel scrolling."""
1536-
delta = -1 * (event.delta // 120)
1587+
delta = get_scroll_delta(event)
15371588
self.canvas.yview_scroll(delta, "units")
15381589
self._render()
15391590
return "break"
@@ -1998,16 +2049,12 @@ def _copy_filtered_models(self):
19982049
"""Copy filtered/available model names to clipboard (comma-separated)."""
19992050
if not self.models:
20002051
return
2001-
# Get only models that are not ignored
2052+
# Get only models that are not ignored (models without status default to available)
20022053
available = [
20032054
self._get_model_display_name(m)
20042055
for m in self.models
2005-
if self.statuses.get(m) and self.statuses[m].status != "ignored"
2056+
if self.statuses.get(m) is None or self.statuses[m].status != "ignored"
20062057
]
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))
20112058
text = ", ".join(available)
20122059
self.clipboard_clear()
20132060
self.clipboard_append(text)
@@ -2182,7 +2229,7 @@ def _on_configure(self, event=None):
21822229

21832230
def _on_mousewheel(self, event):
21842231
"""Handle mouse wheel scrolling."""
2185-
delta = -1 * (event.delta // 120)
2232+
delta = get_scroll_delta(event)
21862233
self.canvas.yview_scroll(delta, "units")
21872234
self._render()
21882235
return "break"

src/rotator_library/client.py

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import fnmatch
23
import json
34
import re
45
import codecs
@@ -274,7 +275,14 @@ def __init__(
274275
def _is_model_ignored(self, provider: str, model_id: str) -> bool:
275276
"""
276277
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+
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
278286
"""
279287
model_provider = model_id.split("/")[0]
280288
if model_provider not in self.ignore_models:
@@ -291,52 +299,43 @@ def _is_model_ignored(self, provider: str, model_id: str) -> bool:
291299
provider_model_name = model_id
292300

293301
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
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
306307
return False
307308

308309
def _is_model_whitelisted(self, provider: str, model_id: str) -> bool:
309310
"""
310311
Checks if a model is explicitly whitelisted.
311-
Supports exact and partial matching for both full model IDs and model names.
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
312320
"""
313321
model_provider = model_id.split("/")[0]
314322
if model_provider not in self.whitelist_models:
315323
return False
316324

317325
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+
318333
for whitelisted_pattern in whitelist:
319-
if whitelisted_pattern == "*":
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):
320338
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
340339
return False
341340

342341
def _sanitize_litellm_log(self, log_data: dict) -> dict:

src/rotator_library/pyproject.toml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "rotator_library"
7-
version = "1.0"
7+
version = "1.05"
88
authors = [
99
{ name="Mirrowel", email="nuh@uh.com" },
1010
]
@@ -16,11 +16,7 @@ classifiers = [
1616
"License :: OSI Approved :: MIT License",
1717
"Operating System :: OS Independent",
1818
]
19-
dependencies = [
20-
"litellm",
21-
"filelock",
22-
"httpx"
23-
]
19+
dependencies = []
2420

2521
[project.urls]
2622
"Homepage" = "https://github.com/Mirrowel/LLM-API-Key-Proxy"

0 commit comments

Comments
 (0)