Mirrowel commited on
Commit
846ba25
·
1 Parent(s): 92211ea

feat(ui): ✨ add GUI for visual model filter configuration

Browse files

Introduces a comprehensive CustomTkinter-based GUI application for managing model ignore/whitelist rules per provider, accessible from the settings tool.

- Created model_filter_gui.py with full-featured visual editor (2600+ lines)
- Implemented dual synchronized model lists showing unfiltered and filtered states
- Added color-coded rule chips with visual association to affected models
- Real-time pattern preview as users type filter rules
- Interactive click/right-click functionality for model-rule relationships
- Context menus for quick actions (add to ignore/whitelist, copy names)
- Comprehensive help documentation with keyboard shortcuts
- Unsaved changes detection with save/discard/cancel workflow
- Background prefetching of models for all providers to improve responsiveness
- Integration with settings tool as menu option #6

The GUI provides pattern matching with exact match, prefix wildcard (*), and match-all support. Whitelist rules take priority over ignore rules. All changes are persisted to .env file using IGNORE_MODELS_* and WHITELIST_MODELS_* variables.

requirements.txt CHANGED
@@ -19,3 +19,6 @@ aiohttp
19
  colorlog
20
 
21
  rich
 
 
 
 
19
  colorlog
20
 
21
  rich
22
+
23
+ # GUI for model filter configuration
24
+ customtkinter
src/proxy_app/model_filter_gui.py ADDED
@@ -0,0 +1,2601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Filter GUI - Visual editor for model ignore/whitelist rules.
3
+
4
+ A CustomTkinter application that provides a friendly interface for managing
5
+ which models are available per provider through ignore lists and whitelists.
6
+
7
+ Features:
8
+ - Two synchronized model lists showing all fetched models and their filtered status
9
+ - Color-coded rules with visual association to affected models
10
+ - Real-time filtering preview as you type patterns
11
+ - Click interactions to highlight rule-model relationships
12
+ - Right-click context menus for quick actions
13
+ - Comprehensive help documentation
14
+ """
15
+
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
25
+ from dotenv import load_dotenv, set_key, unset_key
26
+
27
+
28
+ # ════════════════════════════════════════════════════════════════════════════════
29
+ # CONSTANTS & CONFIGURATION
30
+ # ════════════════════════════════════════════════════════════════════════════════
31
+
32
+ # Window settings
33
+ WINDOW_TITLE = "Model Filter Configuration"
34
+ WINDOW_DEFAULT_SIZE = "1000x750"
35
+ WINDOW_MIN_WIDTH = 850
36
+ WINDOW_MIN_HEIGHT = 600
37
+
38
+ # Color scheme (dark mode)
39
+ BG_PRIMARY = "#1a1a2e" # Main background
40
+ BG_SECONDARY = "#16213e" # Card/panel background
41
+ BG_TERTIARY = "#0f0f1a" # Input fields, lists
42
+ BG_HOVER = "#1f2b47" # Hover state
43
+ BORDER_COLOR = "#2a2a4a" # Subtle borders
44
+ TEXT_PRIMARY = "#e8e8e8" # Main text
45
+ TEXT_SECONDARY = "#a0a0a0" # Muted text
46
+ TEXT_MUTED = "#666680" # Very muted text
47
+ ACCENT_BLUE = "#4a9eff" # Primary accent
48
+ ACCENT_GREEN = "#2ecc71" # Success/normal
49
+ ACCENT_RED = "#e74c3c" # Danger/ignore
50
+ ACCENT_YELLOW = "#f1c40f" # Warning
51
+
52
+ # Status colors
53
+ NORMAL_COLOR = "#2ecc71" # Green - models not affected by any rule
54
+ HIGHLIGHT_BG = "#2a3a5a" # Background for highlighted items
55
+
56
+ # Ignore rules - warm color progression (reds/oranges)
57
+ IGNORE_COLORS = [
58
+ "#e74c3c", # Bright red
59
+ "#c0392b", # Dark red
60
+ "#e67e22", # Orange
61
+ "#d35400", # Dark orange
62
+ "#f39c12", # Gold
63
+ "#e91e63", # Pink
64
+ "#ff5722", # Deep orange
65
+ "#f44336", # Material red
66
+ "#ff6b6b", # Coral
67
+ "#ff8a65", # Light deep orange
68
+ ]
69
+
70
+ # Whitelist rules - cool color progression (blues/teals)
71
+ WHITELIST_COLORS = [
72
+ "#3498db", # Blue
73
+ "#2980b9", # Dark blue
74
+ "#1abc9c", # Teal
75
+ "#16a085", # Dark teal
76
+ "#9b59b6", # Purple
77
+ "#8e44ad", # Dark purple
78
+ "#00bcd4", # Cyan
79
+ "#2196f3", # Material blue
80
+ "#64b5f6", # Light blue
81
+ "#4dd0e1", # Light cyan
82
+ ]
83
+
84
+ # Font configuration
85
+ FONT_FAMILY = "Segoe UI"
86
+ FONT_SIZE_SMALL = 11
87
+ FONT_SIZE_NORMAL = 12
88
+ FONT_SIZE_LARGE = 14
89
+ FONT_SIZE_TITLE = 16
90
+ FONT_SIZE_HEADER = 20
91
+
92
+
93
+ # ════════════════════════════════════════════════════════════════════════════════
94
+ # DATA CLASSES
95
+ # ════════════════════════════════════════════════════════════════════════════════
96
+
97
+
98
+ @dataclass
99
+ class FilterRule:
100
+ """Represents a single filter rule (ignore or whitelist pattern)."""
101
+
102
+ pattern: str
103
+ color: str
104
+ rule_type: str # 'ignore' or 'whitelist'
105
+ affected_count: int = 0
106
+ affected_models: List[str] = field(default_factory=list)
107
+
108
+ def __hash__(self):
109
+ return hash((self.pattern, self.rule_type))
110
+
111
+ def __eq__(self, other):
112
+ if not isinstance(other, FilterRule):
113
+ return False
114
+ return self.pattern == other.pattern and self.rule_type == other.rule_type
115
+
116
+
117
+ @dataclass
118
+ class ModelStatus:
119
+ """Status information for a single model."""
120
+
121
+ model_id: str
122
+ status: str # 'normal', 'ignored', 'whitelisted'
123
+ color: str
124
+ affecting_rule: Optional[FilterRule] = None
125
+
126
+ @property
127
+ def display_name(self) -> str:
128
+ """Get the model name without provider prefix for display."""
129
+ if "/" in self.model_id:
130
+ return self.model_id.split("/", 1)[1]
131
+ return self.model_id
132
+
133
+ @property
134
+ def provider(self) -> str:
135
+ """Extract provider from model ID."""
136
+ if "/" in self.model_id:
137
+ return self.model_id.split("/")[0]
138
+ return ""
139
+
140
+
141
+ # ════════════════════════════════════════════════════════════════════════════════
142
+ # FILTER ENGINE
143
+ # ════════════════════════════════════════════════════════════════════════════════
144
+
145
+
146
+ class FilterEngine:
147
+ """
148
+ Core filtering logic with rule management.
149
+
150
+ Handles pattern matching, rule storage, and status calculation.
151
+ Tracks changes for save/discard functionality.
152
+ """
153
+
154
+ def __init__(self):
155
+ self.ignore_rules: List[FilterRule] = []
156
+ self.whitelist_rules: List[FilterRule] = []
157
+ self._ignore_color_index = 0
158
+ self._whitelist_color_index = 0
159
+ self._original_ignore_patterns: Set[str] = set()
160
+ self._original_whitelist_patterns: Set[str] = set()
161
+ self._current_provider: Optional[str] = None
162
+
163
+ def reset(self):
164
+ """Clear all rules and reset state."""
165
+ self.ignore_rules.clear()
166
+ self.whitelist_rules.clear()
167
+ self._ignore_color_index = 0
168
+ self._whitelist_color_index = 0
169
+ self._original_ignore_patterns.clear()
170
+ self._original_whitelist_patterns.clear()
171
+
172
+ def _get_next_ignore_color(self) -> str:
173
+ """Get next color for ignore rules (cycles through palette)."""
174
+ color = IGNORE_COLORS[self._ignore_color_index % len(IGNORE_COLORS)]
175
+ self._ignore_color_index += 1
176
+ return color
177
+
178
+ def _get_next_whitelist_color(self) -> str:
179
+ """Get next color for whitelist rules (cycles through palette)."""
180
+ color = WHITELIST_COLORS[self._whitelist_color_index % len(WHITELIST_COLORS)]
181
+ self._whitelist_color_index += 1
182
+ return color
183
+
184
+ def add_ignore_rule(self, pattern: str) -> Optional[FilterRule]:
185
+ """Add a new ignore rule. Returns the rule if added, None if duplicate."""
186
+ pattern = pattern.strip()
187
+ if not pattern:
188
+ return None
189
+
190
+ # Check for duplicates
191
+ for rule in self.ignore_rules:
192
+ if rule.pattern == pattern:
193
+ return None
194
+
195
+ rule = FilterRule(
196
+ pattern=pattern, color=self._get_next_ignore_color(), rule_type="ignore"
197
+ )
198
+ self.ignore_rules.append(rule)
199
+ return rule
200
+
201
+ def add_whitelist_rule(self, pattern: str) -> Optional[FilterRule]:
202
+ """Add a new whitelist rule. Returns the rule if added, None if duplicate."""
203
+ pattern = pattern.strip()
204
+ if not pattern:
205
+ return None
206
+
207
+ # Check for duplicates
208
+ for rule in self.whitelist_rules:
209
+ if rule.pattern == pattern:
210
+ return None
211
+
212
+ rule = FilterRule(
213
+ pattern=pattern,
214
+ color=self._get_next_whitelist_color(),
215
+ rule_type="whitelist",
216
+ )
217
+ self.whitelist_rules.append(rule)
218
+ return rule
219
+
220
+ def remove_ignore_rule(self, pattern: str) -> bool:
221
+ """Remove an ignore rule by pattern. Returns True if removed."""
222
+ for i, rule in enumerate(self.ignore_rules):
223
+ if rule.pattern == pattern:
224
+ self.ignore_rules.pop(i)
225
+ return True
226
+ return False
227
+
228
+ def remove_whitelist_rule(self, pattern: str) -> bool:
229
+ """Remove a whitelist rule by pattern. Returns True if removed."""
230
+ for i, rule in enumerate(self.whitelist_rules):
231
+ if rule.pattern == pattern:
232
+ self.whitelist_rules.pop(i)
233
+ return True
234
+ return False
235
+
236
+ def _pattern_matches(self, model_id: str, pattern: str) -> bool:
237
+ """
238
+ Check if a pattern matches a model ID.
239
+
240
+ Supports:
241
+ - Exact match: "gpt-4" matches only "gpt-4"
242
+ - Prefix wildcard: "gpt-4*" matches "gpt-4", "gpt-4-turbo", etc.
243
+ - Match all: "*" matches everything
244
+ """
245
+ # Extract model name without provider prefix
246
+ if "/" in model_id:
247
+ provider_model_name = model_id.split("/", 1)[1]
248
+ else:
249
+ provider_model_name = model_id
250
+
251
+ if pattern == "*":
252
+ return True
253
+ elif pattern.endswith("*"):
254
+ prefix = pattern[:-1]
255
+ return provider_model_name.startswith(prefix) or model_id.startswith(prefix)
256
+ else:
257
+ # Exact match against full ID or provider model name
258
+ return model_id == pattern or provider_model_name == pattern
259
+
260
+ def get_model_status(self, model_id: str) -> ModelStatus:
261
+ """
262
+ Determine the status of a model based on current rules.
263
+
264
+ Priority: Whitelist > Ignore > Normal
265
+ """
266
+ # Check whitelist first (takes priority)
267
+ for rule in self.whitelist_rules:
268
+ if self._pattern_matches(model_id, rule.pattern):
269
+ return ModelStatus(
270
+ model_id=model_id,
271
+ status="whitelisted",
272
+ color=rule.color,
273
+ affecting_rule=rule,
274
+ )
275
+
276
+ # Then check ignore
277
+ for rule in self.ignore_rules:
278
+ if self._pattern_matches(model_id, rule.pattern):
279
+ return ModelStatus(
280
+ model_id=model_id,
281
+ status="ignored",
282
+ color=rule.color,
283
+ affecting_rule=rule,
284
+ )
285
+
286
+ # Default: normal
287
+ return ModelStatus(
288
+ model_id=model_id, status="normal", color=NORMAL_COLOR, affecting_rule=None
289
+ )
290
+
291
+ def get_all_statuses(self, models: List[str]) -> List[ModelStatus]:
292
+ """Get status for all models."""
293
+ return [self.get_model_status(m) for m in models]
294
+
295
+ def update_affected_counts(self, models: List[str]):
296
+ """Update the affected_count and affected_models for all rules."""
297
+ # Reset counts
298
+ for rule in self.ignore_rules + self.whitelist_rules:
299
+ rule.affected_count = 0
300
+ rule.affected_models = []
301
+
302
+ # Count affected models
303
+ for model_id in models:
304
+ status = self.get_model_status(model_id)
305
+ if status.affecting_rule:
306
+ status.affecting_rule.affected_count += 1
307
+ status.affecting_rule.affected_models.append(model_id)
308
+
309
+ def get_available_count(self, models: List[str]) -> Tuple[int, int]:
310
+ """Returns (available_count, total_count)."""
311
+ available = 0
312
+ for model_id in models:
313
+ status = self.get_model_status(model_id)
314
+ if status.status != "ignored":
315
+ available += 1
316
+ return available, len(models)
317
+
318
+ def preview_pattern(
319
+ self, pattern: str, rule_type: str, models: List[str]
320
+ ) -> List[str]:
321
+ """
322
+ Preview which models would be affected by a pattern without adding it.
323
+ Returns list of affected model IDs.
324
+ """
325
+ affected = []
326
+ pattern = pattern.strip()
327
+ if not pattern:
328
+ return affected
329
+
330
+ for model_id in models:
331
+ if self._pattern_matches(model_id, pattern):
332
+ affected.append(model_id)
333
+
334
+ return affected
335
+
336
+ def load_from_env(self, provider: str):
337
+ """Load ignore/whitelist rules for a provider from environment."""
338
+ self.reset()
339
+ self._current_provider = provider
340
+ load_dotenv(override=True)
341
+
342
+ # Load ignore list
343
+ ignore_key = f"IGNORE_MODELS_{provider.upper()}"
344
+ ignore_value = os.getenv(ignore_key, "")
345
+ if ignore_value:
346
+ patterns = [p.strip() for p in ignore_value.split(",") if p.strip()]
347
+ for pattern in patterns:
348
+ self.add_ignore_rule(pattern)
349
+ self._original_ignore_patterns = set(patterns)
350
+
351
+ # Load whitelist
352
+ whitelist_key = f"WHITELIST_MODELS_{provider.upper()}"
353
+ whitelist_value = os.getenv(whitelist_key, "")
354
+ if whitelist_value:
355
+ patterns = [p.strip() for p in whitelist_value.split(",") if p.strip()]
356
+ for pattern in patterns:
357
+ self.add_whitelist_rule(pattern)
358
+ self._original_whitelist_patterns = set(patterns)
359
+
360
+ def save_to_env(self, provider: str) -> bool:
361
+ """
362
+ Save current rules to .env file.
363
+ Returns True if successful.
364
+ """
365
+ env_path = Path.cwd() / ".env"
366
+
367
+ try:
368
+ ignore_key = f"IGNORE_MODELS_{provider.upper()}"
369
+ whitelist_key = f"WHITELIST_MODELS_{provider.upper()}"
370
+
371
+ # Save ignore patterns
372
+ ignore_patterns = [rule.pattern for rule in self.ignore_rules]
373
+ if ignore_patterns:
374
+ set_key(str(env_path), ignore_key, ",".join(ignore_patterns))
375
+ else:
376
+ # Remove the key if no patterns
377
+ unset_key(str(env_path), ignore_key)
378
+
379
+ # Save whitelist patterns
380
+ whitelist_patterns = [rule.pattern for rule in self.whitelist_rules]
381
+ if whitelist_patterns:
382
+ set_key(str(env_path), whitelist_key, ",".join(whitelist_patterns))
383
+ else:
384
+ unset_key(str(env_path), whitelist_key)
385
+
386
+ # Update original state
387
+ self._original_ignore_patterns = set(ignore_patterns)
388
+ self._original_whitelist_patterns = set(whitelist_patterns)
389
+
390
+ return True
391
+ except Exception as e:
392
+ print(f"Error saving to .env: {e}")
393
+ return False
394
+
395
+ def has_unsaved_changes(self) -> bool:
396
+ """Check if current rules differ from saved state."""
397
+ current_ignore = set(rule.pattern for rule in self.ignore_rules)
398
+ current_whitelist = set(rule.pattern for rule in self.whitelist_rules)
399
+
400
+ return (
401
+ current_ignore != self._original_ignore_patterns
402
+ or current_whitelist != self._original_whitelist_patterns
403
+ )
404
+
405
+ def discard_changes(self):
406
+ """Reload rules from environment, discarding unsaved changes."""
407
+ if self._current_provider:
408
+ self.load_from_env(self._current_provider)
409
+
410
+
411
+ # ═══��════════════════════════════════════════════════════════════════════════════
412
+ # MODEL FETCHER
413
+ # ════════════════════════════════════════════════════════════════════════════════
414
+
415
+ # Global cache for fetched models (persists across provider switches)
416
+ _model_cache: Dict[str, List[str]] = {}
417
+
418
+
419
+ class ModelFetcher:
420
+ """
421
+ Handles async model fetching from providers.
422
+
423
+ Runs fetching in a background thread to avoid blocking the GUI.
424
+ Includes caching to avoid refetching on every provider switch.
425
+ """
426
+
427
+ @staticmethod
428
+ def get_cached_models(provider: str) -> Optional[List[str]]:
429
+ """Get cached models for a provider, if available."""
430
+ return _model_cache.get(provider)
431
+
432
+ @staticmethod
433
+ def clear_cache(provider: Optional[str] = None):
434
+ """Clear model cache. If provider specified, only clear that provider."""
435
+ if provider:
436
+ _model_cache.pop(provider, None)
437
+ else:
438
+ _model_cache.clear()
439
+
440
+ @staticmethod
441
+ def get_available_providers() -> List[str]:
442
+ """Get list of providers that have credentials configured."""
443
+ providers = set()
444
+ load_dotenv(override=True)
445
+
446
+ # Scan environment for API keys (handles numbered keys like GEMINI_API_KEY_1)
447
+ for key in os.environ:
448
+ if "_API_KEY" in key and "PROXY_API_KEY" not in key:
449
+ # Extract provider: NVIDIA_NIM_API_KEY_1 -> nvidia_nim
450
+ provider = key.split("_API_KEY")[0].lower()
451
+ providers.add(provider)
452
+
453
+ # Check for OAuth providers
454
+ oauth_dir = Path("oauth_creds")
455
+ if oauth_dir.exists():
456
+ for file in oauth_dir.glob("*_oauth_*.json"):
457
+ provider = file.name.split("_oauth_")[0]
458
+ providers.add(provider)
459
+
460
+ return sorted(list(providers))
461
+
462
+ @staticmethod
463
+ def _find_credential(provider: str) -> Optional[str]:
464
+ """Find a credential for a provider (handles numbered keys)."""
465
+ load_dotenv(override=True)
466
+ provider_upper = provider.upper()
467
+
468
+ # Try exact match first (e.g., GEMINI_API_KEY)
469
+ exact_key = f"{provider_upper}_API_KEY"
470
+ if os.getenv(exact_key):
471
+ return os.getenv(exact_key)
472
+
473
+ # Look for numbered keys (e.g., GEMINI_API_KEY_1, NVIDIA_NIM_API_KEY_1)
474
+ for key, value in os.environ.items():
475
+ if key.startswith(f"{provider_upper}_API_KEY") and value:
476
+ return value
477
+
478
+ # Check for OAuth credentials
479
+ oauth_dir = Path("oauth_creds")
480
+ if oauth_dir.exists():
481
+ oauth_files = list(oauth_dir.glob(f"{provider}_oauth_*.json"))
482
+ if oauth_files:
483
+ return str(oauth_files[0])
484
+
485
+ return None
486
+
487
+ @staticmethod
488
+ async def _fetch_models_async(provider: str) -> Tuple[List[str], Optional[str]]:
489
+ """
490
+ Async implementation of model fetching.
491
+ Returns: (models_list, error_message_or_none)
492
+ """
493
+ try:
494
+ import httpx
495
+ from rotator_library.providers import PROVIDER_PLUGINS
496
+
497
+ # Get credential
498
+ credential = ModelFetcher._find_credential(provider)
499
+ if not credential:
500
+ return [], f"No credentials found for '{provider}'"
501
+
502
+ # Get provider class
503
+ provider_class = PROVIDER_PLUGINS.get(provider.lower())
504
+ if not provider_class:
505
+ return [], f"Unknown provider: '{provider}'"
506
+
507
+ # Fetch models
508
+ async with httpx.AsyncClient(timeout=30.0) as client:
509
+ instance = provider_class()
510
+ models = await instance.get_models(credential, client)
511
+ return models, None
512
+
513
+ except ImportError as e:
514
+ return [], f"Import error: {e}"
515
+ except Exception as e:
516
+ return [], f"Failed to fetch: {str(e)}"
517
+
518
+ @staticmethod
519
+ def fetch_models(
520
+ provider: str,
521
+ on_success: Callable[[List[str]], None],
522
+ on_error: Callable[[str], None],
523
+ on_start: Optional[Callable[[], None]] = None,
524
+ force_refresh: bool = False,
525
+ ):
526
+ """
527
+ Fetch models in a background thread.
528
+
529
+ Args:
530
+ provider: Provider name (e.g., 'openai', 'gemini')
531
+ on_success: Callback with list of model IDs
532
+ on_error: Callback with error message
533
+ on_start: Optional callback when fetching starts
534
+ force_refresh: If True, bypass cache and fetch fresh
535
+ """
536
+ # Check cache first (unless force refresh)
537
+ if not force_refresh:
538
+ cached = ModelFetcher.get_cached_models(provider)
539
+ if cached is not None:
540
+ on_success(cached)
541
+ return
542
+
543
+ def run_fetch():
544
+ if on_start:
545
+ on_start()
546
+
547
+ try:
548
+ # Run async fetch in new event loop
549
+ loop = asyncio.new_event_loop()
550
+ asyncio.set_event_loop(loop)
551
+ try:
552
+ models, error = loop.run_until_complete(
553
+ ModelFetcher._fetch_models_async(provider)
554
+ )
555
+ # Clean up any pending tasks to avoid warnings
556
+ pending = asyncio.all_tasks(loop)
557
+ for task in pending:
558
+ task.cancel()
559
+ if pending:
560
+ loop.run_until_complete(
561
+ asyncio.gather(*pending, return_exceptions=True)
562
+ )
563
+ finally:
564
+ loop.run_until_complete(loop.shutdown_asyncgens())
565
+ loop.close()
566
+
567
+ if error:
568
+ on_error(error)
569
+ else:
570
+ # Cache the results
571
+ _model_cache[provider] = models
572
+ on_success(models)
573
+
574
+ except Exception as e:
575
+ on_error(str(e))
576
+
577
+ thread = threading.Thread(target=run_fetch, daemon=True)
578
+ thread.start()
579
+
580
+
581
+ # ════════════════════════════════════════════════════════════════════════════════
582
+ # HELP WINDOW
583
+ # ════════════════════════════════════════════════════════════════════════════════
584
+
585
+
586
+ class HelpWindow(ctk.CTkToplevel):
587
+ """
588
+ Modal help popup with comprehensive filtering documentation.
589
+ """
590
+
591
+ def __init__(self, parent):
592
+ super().__init__(parent)
593
+
594
+ self.title("Help - Model Filtering")
595
+ self.geometry("700x600")
596
+ self.minsize(600, 500)
597
+
598
+ # Make modal
599
+ self.transient(parent)
600
+ self.grab_set()
601
+
602
+ # Configure appearance
603
+ self.configure(fg_color=BG_PRIMARY)
604
+
605
+ # Build content
606
+ self._create_content()
607
+
608
+ # Center on parent
609
+ self.update_idletasks()
610
+ x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
611
+ y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
612
+ self.geometry(f"+{x}+{y}")
613
+
614
+ # Focus
615
+ self.focus_force()
616
+
617
+ # Bind escape to close
618
+ self.bind("<Escape>", lambda e: self.destroy())
619
+
620
+ def _create_content(self):
621
+ """Build the help content."""
622
+ # Main scrollable frame
623
+ main_frame = ctk.CTkScrollableFrame(
624
+ self,
625
+ fg_color=BG_PRIMARY,
626
+ scrollbar_fg_color=BG_SECONDARY,
627
+ scrollbar_button_color=BORDER_COLOR,
628
+ )
629
+ main_frame.pack(fill="both", expand=True, padx=20, pady=20)
630
+
631
+ # Title
632
+ title = ctk.CTkLabel(
633
+ main_frame,
634
+ text="📖 Model Filtering Guide",
635
+ font=(FONT_FAMILY, FONT_SIZE_HEADER, "bold"),
636
+ text_color=TEXT_PRIMARY,
637
+ )
638
+ title.pack(anchor="w", pady=(0, 20))
639
+
640
+ # Sections
641
+ sections = [
642
+ (
643
+ "🎯 Overview",
644
+ """
645
+ Model filtering allows you to control which models are available through your proxy for each provider.
646
+
647
+ • Use the IGNORE list to block specific models
648
+ • Use the WHITELIST to ensure specific models are always available
649
+ • Whitelist ALWAYS takes priority over Ignore""",
650
+ ),
651
+ (
652
+ "⚖️ Filtering Priority",
653
+ """
654
+ When a model is checked, the following order is used:
655
+
656
+ 1. WHITELIST CHECK
657
+ If the model matches any whitelist pattern → AVAILABLE
658
+ (Whitelist overrides everything else)
659
+
660
+ 2. IGNORE CHECK
661
+ If the model matches any ignore pattern → BLOCKED
662
+
663
+ 3. DEFAULT
664
+ If no patterns match → AVAILABLE""",
665
+ ),
666
+ (
667
+ "✏️ Pattern Syntax",
668
+ """
669
+ Three types of patterns are supported:
670
+
671
+ EXACT MATCH
672
+ Pattern: gpt-4
673
+ Matches: only "gpt-4", nothing else
674
+
675
+ PREFIX WILDCARD
676
+ Pattern: gpt-4*
677
+ Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc.
678
+
679
+ MATCH ALL
680
+ Pattern: *
681
+ Matches: every model for this provider""",
682
+ ),
683
+ (
684
+ "💡 Common Patterns",
685
+ """
686
+ BLOCK ALL, ALLOW SPECIFIC:
687
+ Ignore: *
688
+ Whitelist: gpt-4o, gpt-4o-mini
689
+ Result: Only gpt-4o and gpt-4o-mini available
690
+
691
+ BLOCK PREVIEW MODELS:
692
+ Ignore: *-preview, *-preview*
693
+ Result: All preview variants blocked
694
+
695
+ BLOCK SPECIFIC SERIES:
696
+ Ignore: o1*, dall-e*
697
+ Result: All o1 and DALL-E models blocked
698
+
699
+ ALLOW ONLY LATEST:
700
+ Ignore: *
701
+ Whitelist: *-latest
702
+ Result: Only models ending in "-latest" available""",
703
+ ),
704
+ (
705
+ "🖱️ Interface Guide",
706
+ """
707
+ PROVIDER DROPDOWN
708
+ Select which provider to configure
709
+
710
+ MODEL LISTS
711
+ • Left list: All fetched models (unfiltered)
712
+ • Right list: Same models with colored status
713
+ • Green = Available (normal)
714
+ • Red/Orange tones = Blocked (ignored)
715
+ • Blue/Teal tones = Whitelisted
716
+
717
+ SEARCH BOX
718
+ Filter both lists to find specific models quickly
719
+
720
+ CLICKING MODELS
721
+ • Left-click: Highlight the rule affecting this model
722
+ • Right-click: Context menu with quick actions
723
+
724
+ CLICKING RULES
725
+ • Highlights all models affected by that rule
726
+ • Shows which models will be blocked/allowed
727
+
728
+ RULE INPUT
729
+ • Enter patterns separated by commas
730
+ • Press Add or Enter to create rules
731
+ • Preview updates in real-time as you type
732
+
733
+ DELETE RULES
734
+ • Click the × button on any rule to remove it""",
735
+ ),
736
+ (
737
+ "⌨️ Keyboard Shortcuts",
738
+ """
739
+ Ctrl+S Save changes
740
+ Ctrl+R Refresh models from provider
741
+ Ctrl+F Focus search box
742
+ F1 Open this help window
743
+ Escape Clear search / Close dialogs""",
744
+ ),
745
+ (
746
+ "💾 Saving Changes",
747
+ """
748
+ Changes are saved to your .env file in this format:
749
+
750
+ IGNORE_MODELS_OPENAI=pattern1,pattern2*
751
+ WHITELIST_MODELS_OPENAI=specific-model
752
+
753
+ Click "Save" to persist changes, or "Discard" to revert.
754
+ Closing the window with unsaved changes will prompt you.""",
755
+ ),
756
+ ]
757
+
758
+ for title_text, content in sections:
759
+ self._add_section(main_frame, title_text, content)
760
+
761
+ # Close button
762
+ close_btn = ctk.CTkButton(
763
+ main_frame,
764
+ text="Got it!",
765
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"),
766
+ fg_color=ACCENT_BLUE,
767
+ hover_color="#3a8aee",
768
+ height=40,
769
+ width=120,
770
+ command=self.destroy,
771
+ )
772
+ close_btn.pack(pady=20)
773
+
774
+ def _add_section(self, parent, title: str, content: str):
775
+ """Add a help section."""
776
+ # Section title
777
+ title_label = ctk.CTkLabel(
778
+ parent,
779
+ text=title,
780
+ font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"),
781
+ text_color=ACCENT_BLUE,
782
+ )
783
+ title_label.pack(anchor="w", pady=(15, 5))
784
+
785
+ # Separator
786
+ sep = ctk.CTkFrame(parent, height=1, fg_color=BORDER_COLOR)
787
+ sep.pack(fill="x", pady=(0, 10))
788
+
789
+ # Content
790
+ content_label = ctk.CTkLabel(
791
+ parent,
792
+ text=content.strip(),
793
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
794
+ text_color=TEXT_SECONDARY,
795
+ justify="left",
796
+ anchor="w",
797
+ )
798
+ content_label.pack(anchor="w", fill="x")
799
+
800
+
801
+ # ════════════════════════════════════════════════════════════════════════════════
802
+ # CUSTOM DIALOG
803
+ # ════════════════════════════════════════════════════════════════════════════════
804
+
805
+
806
+ class UnsavedChangesDialog(ctk.CTkToplevel):
807
+ """Modal dialog for unsaved changes confirmation."""
808
+
809
+ def __init__(self, parent):
810
+ super().__init__(parent)
811
+
812
+ self.result: Optional[str] = None # 'save', 'discard', 'cancel'
813
+
814
+ self.title("Unsaved Changes")
815
+ self.geometry("400x180")
816
+ self.resizable(False, False)
817
+
818
+ # Make modal
819
+ self.transient(parent)
820
+ self.grab_set()
821
+
822
+ # Configure appearance
823
+ self.configure(fg_color=BG_PRIMARY)
824
+
825
+ # Build content
826
+ self._create_content()
827
+
828
+ # Center on parent
829
+ self.update_idletasks()
830
+ x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
831
+ y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
832
+ self.geometry(f"+{x}+{y}")
833
+
834
+ # Focus
835
+ self.focus_force()
836
+
837
+ # Bind escape to cancel
838
+ self.bind("<Escape>", lambda e: self._on_cancel())
839
+
840
+ # Handle window close
841
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
842
+
843
+ def _create_content(self):
844
+ """Build dialog content."""
845
+ # Icon and message
846
+ msg_frame = ctk.CTkFrame(self, fg_color="transparent")
847
+ msg_frame.pack(fill="x", padx=30, pady=(25, 15))
848
+
849
+ icon = ctk.CTkLabel(
850
+ msg_frame, text="⚠️", font=(FONT_FAMILY, 32), text_color=ACCENT_YELLOW
851
+ )
852
+ icon.pack(side="left", padx=(0, 15))
853
+
854
+ text_frame = ctk.CTkFrame(msg_frame, fg_color="transparent")
855
+ text_frame.pack(side="left", fill="x", expand=True)
856
+
857
+ title = ctk.CTkLabel(
858
+ text_frame,
859
+ text="Unsaved Changes",
860
+ font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"),
861
+ text_color=TEXT_PRIMARY,
862
+ anchor="w",
863
+ )
864
+ title.pack(anchor="w")
865
+
866
+ subtitle = ctk.CTkLabel(
867
+ text_frame,
868
+ text="You have unsaved filter changes.\nWhat would you like to do?",
869
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
870
+ text_color=TEXT_SECONDARY,
871
+ anchor="w",
872
+ justify="left",
873
+ )
874
+ subtitle.pack(anchor="w")
875
+
876
+ # Buttons
877
+ btn_frame = ctk.CTkFrame(self, fg_color="transparent")
878
+ btn_frame.pack(fill="x", padx=30, pady=(10, 25))
879
+
880
+ cancel_btn = ctk.CTkButton(
881
+ btn_frame,
882
+ text="Cancel",
883
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
884
+ fg_color=BG_SECONDARY,
885
+ hover_color=BG_HOVER,
886
+ border_width=1,
887
+ border_color=BORDER_COLOR,
888
+ width=100,
889
+ command=self._on_cancel,
890
+ )
891
+ cancel_btn.pack(side="right", padx=(10, 0))
892
+
893
+ discard_btn = ctk.CTkButton(
894
+ btn_frame,
895
+ text="Discard",
896
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
897
+ fg_color=ACCENT_RED,
898
+ hover_color="#c0392b",
899
+ width=100,
900
+ command=self._on_discard,
901
+ )
902
+ discard_btn.pack(side="right", padx=(10, 0))
903
+
904
+ save_btn = ctk.CTkButton(
905
+ btn_frame,
906
+ text="Save",
907
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
908
+ fg_color=ACCENT_GREEN,
909
+ hover_color="#27ae60",
910
+ width=100,
911
+ command=self._on_save,
912
+ )
913
+ save_btn.pack(side="right")
914
+
915
+ def _on_save(self):
916
+ self.result = "save"
917
+ self.destroy()
918
+
919
+ def _on_discard(self):
920
+ self.result = "discard"
921
+ self.destroy()
922
+
923
+ def _on_cancel(self):
924
+ self.result = "cancel"
925
+ self.destroy()
926
+
927
+ def show(self) -> Optional[str]:
928
+ """Show dialog and return result."""
929
+ self.wait_window()
930
+ return self.result
931
+
932
+
933
+ # ════════════════════════════════════════════════════════════════════════════════
934
+ # TOOLTIP
935
+ # ════════════════════════════════════════════════════════════════════════════════
936
+
937
+
938
+ class ToolTip:
939
+ """Simple tooltip implementation for CustomTkinter widgets."""
940
+
941
+ def __init__(self, widget, text: str, delay: int = 500):
942
+ self.widget = widget
943
+ self.text = text
944
+ self.delay = delay
945
+ self.tooltip_window = None
946
+ self.after_id = None
947
+
948
+ widget.bind("<Enter>", self._schedule_show)
949
+ widget.bind("<Leave>", self._hide)
950
+ widget.bind("<Button>", self._hide)
951
+
952
+ def _schedule_show(self, event=None):
953
+ self._hide()
954
+ self.after_id = self.widget.after(self.delay, self._show)
955
+
956
+ def _show(self):
957
+ if self.tooltip_window:
958
+ return
959
+
960
+ x = self.widget.winfo_rootx() + 20
961
+ y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
962
+
963
+ self.tooltip_window = tw = ctk.CTkToplevel(self.widget)
964
+ tw.wm_overrideredirect(True)
965
+ tw.wm_geometry(f"+{x}+{y}")
966
+ tw.configure(fg_color=BG_SECONDARY)
967
+
968
+ # Add border effect
969
+ frame = ctk.CTkFrame(
970
+ tw,
971
+ fg_color=BG_SECONDARY,
972
+ border_width=1,
973
+ border_color=BORDER_COLOR,
974
+ corner_radius=6,
975
+ )
976
+ frame.pack(fill="both", expand=True)
977
+
978
+ label = ctk.CTkLabel(
979
+ frame,
980
+ text=self.text,
981
+ font=(FONT_FAMILY, FONT_SIZE_SMALL),
982
+ text_color=TEXT_SECONDARY,
983
+ padx=10,
984
+ pady=5,
985
+ )
986
+ label.pack()
987
+
988
+ # Ensure tooltip is on top
989
+ tw.lift()
990
+
991
+ def _hide(self, event=None):
992
+ if self.after_id:
993
+ self.widget.after_cancel(self.after_id)
994
+ self.after_id = None
995
+ if self.tooltip_window:
996
+ self.tooltip_window.destroy()
997
+ self.tooltip_window = None
998
+
999
+ def update_text(self, text: str):
1000
+ """Update tooltip text."""
1001
+ self.text = text
1002
+
1003
+
1004
+ # ════════════════════════════════════════════════════════════════════════════════
1005
+ # RULE CHIP COMPONENT
1006
+ # ════════════════════════════════════════════════════════════════════════════════
1007
+
1008
+
1009
+ class RuleChip(ctk.CTkFrame):
1010
+ """
1011
+ Individual rule display showing pattern, affected count, and delete button.
1012
+
1013
+ The pattern text is colored with the rule's assigned color.
1014
+ """
1015
+
1016
+ def __init__(
1017
+ self,
1018
+ master,
1019
+ rule: FilterRule,
1020
+ on_delete: Callable[[str], None],
1021
+ on_click: Callable[[FilterRule], None],
1022
+ ):
1023
+ super().__init__(
1024
+ master,
1025
+ fg_color=BG_TERTIARY,
1026
+ corner_radius=6,
1027
+ border_width=1,
1028
+ border_color=BORDER_COLOR,
1029
+ )
1030
+
1031
+ self.rule = rule
1032
+ self.on_delete = on_delete
1033
+ self.on_click = on_click
1034
+ self._is_highlighted = False
1035
+
1036
+ self._create_content()
1037
+
1038
+ # Click binding
1039
+ self.bind("<Button-1>", self._handle_click)
1040
+
1041
+ def _create_content(self):
1042
+ """Build chip content."""
1043
+ # Container for horizontal layout
1044
+ content = ctk.CTkFrame(self, fg_color="transparent")
1045
+ content.pack(fill="x", padx=8, pady=6)
1046
+
1047
+ # Pattern text (colored)
1048
+ self.pattern_label = ctk.CTkLabel(
1049
+ content,
1050
+ text=self.rule.pattern,
1051
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1052
+ text_color=self.rule.color,
1053
+ anchor="w",
1054
+ )
1055
+ self.pattern_label.pack(side="left", fill="x", expand=True)
1056
+ self.pattern_label.bind("<Button-1>", self._handle_click)
1057
+
1058
+ # Affected count
1059
+ self.count_label = ctk.CTkLabel(
1060
+ content,
1061
+ text=f"({self.rule.affected_count})",
1062
+ font=(FONT_FAMILY, FONT_SIZE_SMALL),
1063
+ text_color=TEXT_MUTED,
1064
+ width=35,
1065
+ )
1066
+ self.count_label.pack(side="left", padx=(5, 5))
1067
+ self.count_label.bind("<Button-1>", self._handle_click)
1068
+
1069
+ # Delete button
1070
+ delete_btn = ctk.CTkButton(
1071
+ content,
1072
+ text="×",
1073
+ font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"),
1074
+ fg_color="transparent",
1075
+ hover_color=ACCENT_RED,
1076
+ text_color=TEXT_MUTED,
1077
+ width=24,
1078
+ height=24,
1079
+ corner_radius=4,
1080
+ command=self._handle_delete,
1081
+ )
1082
+ delete_btn.pack(side="right")
1083
+
1084
+ # Tooltip showing affected models
1085
+ self._update_tooltip()
1086
+
1087
+ def _handle_click(self, event=None):
1088
+ """Handle click on rule chip."""
1089
+ self.on_click(self.rule)
1090
+
1091
+ def _handle_delete(self):
1092
+ """Handle delete button click."""
1093
+ self.on_delete(self.rule.pattern)
1094
+
1095
+ def update_count(self, count: int, affected_models: List[str]):
1096
+ """Update the affected count and tooltip."""
1097
+ self.rule.affected_count = count
1098
+ self.rule.affected_models = affected_models
1099
+ self.count_label.configure(text=f"({count})")
1100
+ self._update_tooltip()
1101
+
1102
+ def _update_tooltip(self):
1103
+ """Update tooltip with affected models."""
1104
+ if self.rule.affected_models:
1105
+ if len(self.rule.affected_models) <= 5:
1106
+ models_text = "\n".join(self.rule.affected_models)
1107
+ else:
1108
+ models_text = "\n".join(self.rule.affected_models[:5])
1109
+ models_text += f"\n... and {len(self.rule.affected_models) - 5} more"
1110
+ ToolTip(self, f"Matches:\n{models_text}")
1111
+ else:
1112
+ ToolTip(self, "No models match this pattern")
1113
+
1114
+ def set_highlighted(self, highlighted: bool):
1115
+ """Set highlighted state."""
1116
+ self._is_highlighted = highlighted
1117
+ if highlighted:
1118
+ self.configure(border_color=self.rule.color, border_width=2)
1119
+ else:
1120
+ self.configure(border_color=BORDER_COLOR, border_width=1)
1121
+
1122
+
1123
+ # ════════════════════════════════════════════════════════════════════════════════
1124
+ # RULE PANEL COMPONENT
1125
+ # ════════════════════════════════════════════════════════════════════════════════
1126
+
1127
+
1128
+ class RulePanel(ctk.CTkFrame):
1129
+ """
1130
+ Panel containing rule chips, input field, and add button.
1131
+
1132
+ Handles adding and removing rules, with callbacks for changes.
1133
+ """
1134
+
1135
+ def __init__(
1136
+ self,
1137
+ master,
1138
+ title: str,
1139
+ rule_type: str, # 'ignore' or 'whitelist'
1140
+ on_rules_changed: Callable[[], None],
1141
+ on_rule_clicked: Callable[[FilterRule], None],
1142
+ on_input_changed: Callable[[str, str], None], # (text, rule_type)
1143
+ ):
1144
+ super().__init__(master, fg_color=BG_SECONDARY, corner_radius=8)
1145
+
1146
+ self.title = title
1147
+ self.rule_type = rule_type
1148
+ self.on_rules_changed = on_rules_changed
1149
+ self.on_rule_clicked = on_rule_clicked
1150
+ self.on_input_changed = on_input_changed
1151
+ self.rule_chips: Dict[str, RuleChip] = {}
1152
+
1153
+ self._create_content()
1154
+
1155
+ def _create_content(self):
1156
+ """Build panel content."""
1157
+ # Title
1158
+ title_label = ctk.CTkLabel(
1159
+ self,
1160
+ text=self.title,
1161
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"),
1162
+ text_color=TEXT_PRIMARY,
1163
+ )
1164
+ title_label.pack(anchor="w", padx=12, pady=(12, 8))
1165
+
1166
+ # Rules container (scrollable)
1167
+ self.rules_frame = ctk.CTkScrollableFrame(
1168
+ self,
1169
+ fg_color="transparent",
1170
+ height=120,
1171
+ scrollbar_fg_color=BG_TERTIARY,
1172
+ scrollbar_button_color=BORDER_COLOR,
1173
+ )
1174
+ self.rules_frame.pack(fill="both", expand=True, padx=8, pady=(0, 8))
1175
+
1176
+ # Empty state label
1177
+ self.empty_label = ctk.CTkLabel(
1178
+ self.rules_frame,
1179
+ text="No rules configured\nAdd patterns below",
1180
+ font=(FONT_FAMILY, FONT_SIZE_SMALL),
1181
+ text_color=TEXT_MUTED,
1182
+ justify="center",
1183
+ )
1184
+ self.empty_label.pack(expand=True, pady=20)
1185
+
1186
+ # Input frame
1187
+ input_frame = ctk.CTkFrame(self, fg_color="transparent")
1188
+ input_frame.pack(fill="x", padx=8, pady=(0, 8))
1189
+
1190
+ # Pattern input
1191
+ self.input_entry = ctk.CTkEntry(
1192
+ input_frame,
1193
+ placeholder_text="pattern1, pattern2*, ...",
1194
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1195
+ fg_color=BG_TERTIARY,
1196
+ border_color=BORDER_COLOR,
1197
+ text_color=TEXT_PRIMARY,
1198
+ placeholder_text_color=TEXT_MUTED,
1199
+ height=36,
1200
+ )
1201
+ self.input_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
1202
+ self.input_entry.bind("<Return>", self._on_add_clicked)
1203
+ self.input_entry.bind("<KeyRelease>", self._on_input_key)
1204
+
1205
+ # Add button
1206
+ add_btn = ctk.CTkButton(
1207
+ input_frame,
1208
+ text="+ Add",
1209
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1210
+ fg_color=ACCENT_BLUE,
1211
+ hover_color="#3a8aee",
1212
+ width=70,
1213
+ height=36,
1214
+ command=self._on_add_clicked,
1215
+ )
1216
+ add_btn.pack(side="right")
1217
+
1218
+ def _on_input_key(self, event=None):
1219
+ """Handle key release in input field - for real-time preview."""
1220
+ text = self.input_entry.get().strip()
1221
+ self.on_input_changed(text, self.rule_type)
1222
+
1223
+ def _on_add_clicked(self, event=None):
1224
+ """Handle add button click."""
1225
+ text = self.input_entry.get().strip()
1226
+ if text:
1227
+ # Parse comma-separated patterns
1228
+ patterns = [p.strip() for p in text.split(",") if p.strip()]
1229
+ if patterns:
1230
+ self.input_entry.delete(0, "end")
1231
+ for pattern in patterns:
1232
+ self._emit_add_pattern(pattern)
1233
+
1234
+ def _emit_add_pattern(self, pattern: str):
1235
+ """Emit request to add a pattern (handled by parent)."""
1236
+ # This will be connected to the main window's add method
1237
+ if hasattr(self, "_add_pattern_callback"):
1238
+ self._add_pattern_callback(pattern)
1239
+
1240
+ def set_add_callback(self, callback: Callable[[str], None]):
1241
+ """Set the callback for adding patterns."""
1242
+ self._add_pattern_callback = callback
1243
+
1244
+ def add_rule_chip(self, rule: FilterRule):
1245
+ """Add a rule chip to the panel."""
1246
+ if rule.pattern in self.rule_chips:
1247
+ return
1248
+
1249
+ # Hide empty label
1250
+ self.empty_label.pack_forget()
1251
+
1252
+ chip = RuleChip(
1253
+ self.rules_frame,
1254
+ rule,
1255
+ on_delete=self._on_rule_delete,
1256
+ on_click=self.on_rule_clicked,
1257
+ )
1258
+ chip.pack(fill="x", pady=2)
1259
+ self.rule_chips[rule.pattern] = chip
1260
+
1261
+ def remove_rule_chip(self, pattern: str):
1262
+ """Remove a rule chip from the panel."""
1263
+ if pattern in self.rule_chips:
1264
+ self.rule_chips[pattern].destroy()
1265
+ del self.rule_chips[pattern]
1266
+
1267
+ # Show empty label if no rules
1268
+ if not self.rule_chips:
1269
+ self.empty_label.pack(expand=True, pady=20)
1270
+
1271
+ def _on_rule_delete(self, pattern: str):
1272
+ """Handle rule deletion."""
1273
+ if hasattr(self, "_delete_pattern_callback"):
1274
+ self._delete_pattern_callback(pattern)
1275
+
1276
+ def set_delete_callback(self, callback: Callable[[str], None]):
1277
+ """Set the callback for deleting patterns."""
1278
+ self._delete_pattern_callback = callback
1279
+
1280
+ def update_rule_counts(self, rules: List[FilterRule], models: List[str]):
1281
+ """Update affected counts for all rule chips."""
1282
+ for rule in rules:
1283
+ if rule.pattern in self.rule_chips:
1284
+ self.rule_chips[rule.pattern].update_count(
1285
+ rule.affected_count, rule.affected_models
1286
+ )
1287
+
1288
+ def highlight_rule(self, pattern: str):
1289
+ """Highlight a specific rule chip."""
1290
+ for p, chip in self.rule_chips.items():
1291
+ chip.set_highlighted(p == pattern)
1292
+
1293
+ def clear_highlights(self):
1294
+ """Clear all rule highlights."""
1295
+ for chip in self.rule_chips.values():
1296
+ chip.set_highlighted(False)
1297
+
1298
+ def clear_all(self):
1299
+ """Remove all rule chips."""
1300
+ for chip in list(self.rule_chips.values()):
1301
+ chip.destroy()
1302
+ self.rule_chips.clear()
1303
+ self.empty_label.pack(expand=True, pady=20)
1304
+
1305
+ def get_input_text(self) -> str:
1306
+ """Get current input text."""
1307
+ return self.input_entry.get().strip()
1308
+
1309
+ def clear_input(self):
1310
+ """Clear the input field."""
1311
+ self.input_entry.delete(0, "end")
1312
+
1313
+
1314
+ # ════════════════════════════════════════════════════════════════════════════════
1315
+ # MODEL LIST ITEM
1316
+ # ════════════════════════════════════════════════════════════════════════════════
1317
+
1318
+
1319
+ class ModelListItem(ctk.CTkFrame):
1320
+ """
1321
+ Single model row in the list.
1322
+
1323
+ Shows model name with appropriate coloring based on status.
1324
+ """
1325
+
1326
+ def __init__(
1327
+ self,
1328
+ master,
1329
+ status: ModelStatus,
1330
+ show_status_indicator: bool = False,
1331
+ on_click: Optional[Callable[[str], None]] = None,
1332
+ on_right_click: Optional[Callable[[str, any], None]] = None,
1333
+ ):
1334
+ super().__init__(master, fg_color="transparent", height=28)
1335
+
1336
+ self.status = status
1337
+ self.on_click = on_click
1338
+ self.on_right_click = on_right_click
1339
+ self._is_highlighted = False
1340
+ self._show_status_indicator = show_status_indicator
1341
+
1342
+ self._create_content()
1343
+
1344
+ def _create_content(self):
1345
+ """Build item content."""
1346
+ self.pack_propagate(False)
1347
+
1348
+ # Container
1349
+ self.container = ctk.CTkFrame(self, fg_color="transparent")
1350
+ self.container.pack(fill="both", expand=True, padx=4, pady=1)
1351
+
1352
+ # Status indicator (for filtered list)
1353
+ if self._show_status_indicator:
1354
+ indicator_text = {"normal": "●", "ignored": "✗", "whitelisted": "★"}.get(
1355
+ self.status.status, "●"
1356
+ )
1357
+
1358
+ self.indicator = ctk.CTkLabel(
1359
+ self.container,
1360
+ text=indicator_text,
1361
+ font=(FONT_FAMILY, FONT_SIZE_SMALL),
1362
+ text_color=self.status.color,
1363
+ width=18,
1364
+ )
1365
+ self.indicator.pack(side="left")
1366
+ self.indicator.bind("<Button-1>", self._handle_click)
1367
+ self.indicator.bind("<Button-3>", self._handle_right_click)
1368
+
1369
+ # Model name
1370
+ self.name_label = ctk.CTkLabel(
1371
+ self.container,
1372
+ text=self.status.display_name,
1373
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1374
+ text_color=self.status.color
1375
+ if self._show_status_indicator
1376
+ else TEXT_PRIMARY,
1377
+ anchor="w",
1378
+ )
1379
+ self.name_label.pack(side="left", fill="x", expand=True)
1380
+ self.name_label.bind("<Button-1>", self._handle_click)
1381
+ self.name_label.bind("<Button-3>", self._handle_right_click)
1382
+
1383
+ # Bindings for the frame itself
1384
+ self.bind("<Button-1>", self._handle_click)
1385
+ self.bind("<Button-3>", self._handle_right_click)
1386
+ self.container.bind("<Button-1>", self._handle_click)
1387
+ self.container.bind("<Button-3>", self._handle_right_click)
1388
+
1389
+ # Hover effect
1390
+ self.bind("<Enter>", self._on_enter)
1391
+ self.bind("<Leave>", self._on_leave)
1392
+ self.container.bind("<Enter>", self._on_enter)
1393
+ self.container.bind("<Leave>", self._on_leave)
1394
+
1395
+ def _handle_click(self, event=None):
1396
+ """Handle left click."""
1397
+ if self.on_click:
1398
+ self.on_click(self.status.model_id)
1399
+
1400
+ def _handle_right_click(self, event):
1401
+ """Handle right click."""
1402
+ if self.on_right_click:
1403
+ self.on_right_click(self.status.model_id, event)
1404
+
1405
+ def _on_enter(self, event=None):
1406
+ """Mouse enter - show hover state."""
1407
+ if not self._is_highlighted:
1408
+ self.container.configure(fg_color=BG_HOVER)
1409
+
1410
+ def _on_leave(self, event=None):
1411
+ """Mouse leave - hide hover state."""
1412
+ if not self._is_highlighted:
1413
+ self.container.configure(fg_color="transparent")
1414
+
1415
+ def update_status(self, status: ModelStatus):
1416
+ """Update the model's status and appearance."""
1417
+ self.status = status
1418
+
1419
+ if self._show_status_indicator:
1420
+ indicator_text = {"normal": "●", "ignored": "✗", "whitelisted": "★"}.get(
1421
+ status.status, "●"
1422
+ )
1423
+ self.indicator.configure(text=indicator_text, text_color=status.color)
1424
+ self.name_label.configure(text_color=status.color)
1425
+ else:
1426
+ self.name_label.configure(text_color=TEXT_PRIMARY)
1427
+
1428
+ def set_highlighted(self, highlighted: bool):
1429
+ """Set highlighted state."""
1430
+ self._is_highlighted = highlighted
1431
+ if highlighted:
1432
+ self.container.configure(fg_color=HIGHLIGHT_BG)
1433
+ else:
1434
+ self.container.configure(fg_color="transparent")
1435
+
1436
+ def matches_search(self, query: str) -> bool:
1437
+ """Check if this item matches a search query."""
1438
+ if not query:
1439
+ return True
1440
+ return query.lower() in self.status.model_id.lower()
1441
+
1442
+
1443
+ # ════════════════════════════════════════════════════════════════════════════════
1444
+ # SYNCHRONIZED MODEL LIST PANEL
1445
+ # ════════════════════════════════════════════════════════════════════════════════
1446
+
1447
+
1448
+ class SyncModelListPanel(ctk.CTkFrame):
1449
+ """
1450
+ Two synchronized scrollable model lists side by side.
1451
+
1452
+ Left list: All fetched models (plain display)
1453
+ Right list: Same models with colored status indicators
1454
+
1455
+ Both lists scroll together and filter together.
1456
+ """
1457
+
1458
+ def __init__(
1459
+ self,
1460
+ master,
1461
+ on_model_click: Callable[[str], None],
1462
+ on_model_right_click: Callable[[str, any], None],
1463
+ ):
1464
+ super().__init__(master, fg_color="transparent")
1465
+
1466
+ self.on_model_click = on_model_click
1467
+ self.on_model_right_click = on_model_right_click
1468
+
1469
+ self.models: List[str] = []
1470
+ self.statuses: Dict[str, ModelStatus] = {}
1471
+ self.left_items: Dict[str, ModelListItem] = {}
1472
+ self.right_items: Dict[str, ModelListItem] = {}
1473
+ self.search_query: str = ""
1474
+
1475
+ self._create_content()
1476
+
1477
+ def _create_content(self):
1478
+ """Build the dual list layout."""
1479
+ # Configure grid
1480
+ self.grid_columnconfigure(0, weight=1)
1481
+ self.grid_columnconfigure(1, weight=1)
1482
+ self.grid_rowconfigure(1, weight=1)
1483
+
1484
+ # Left header
1485
+ left_header = ctk.CTkLabel(
1486
+ self,
1487
+ text="All Fetched Models",
1488
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"),
1489
+ text_color=TEXT_PRIMARY,
1490
+ )
1491
+ left_header.grid(row=0, column=0, sticky="w", padx=8, pady=(0, 5))
1492
+
1493
+ self.left_count_label = ctk.CTkLabel(
1494
+ self, text="(0)", font=(FONT_FAMILY, FONT_SIZE_SMALL), text_color=TEXT_MUTED
1495
+ )
1496
+ self.left_count_label.grid(row=0, column=0, sticky="e", padx=8, pady=(0, 5))
1497
+
1498
+ # Right header
1499
+ right_header = ctk.CTkLabel(
1500
+ self,
1501
+ text="Filtered Status",
1502
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"),
1503
+ text_color=TEXT_PRIMARY,
1504
+ )
1505
+ right_header.grid(row=0, column=1, sticky="w", padx=8, pady=(0, 5))
1506
+
1507
+ self.right_count_label = ctk.CTkLabel(
1508
+ self, text="", font=(FONT_FAMILY, FONT_SIZE_SMALL), text_color=TEXT_MUTED
1509
+ )
1510
+ self.right_count_label.grid(row=0, column=1, sticky="e", padx=8, pady=(0, 5))
1511
+
1512
+ # Left list container
1513
+ left_frame = ctk.CTkFrame(self, fg_color=BG_TERTIARY, corner_radius=6)
1514
+ left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
1515
+
1516
+ self.left_canvas = ctk.CTkCanvas(
1517
+ left_frame,
1518
+ bg=self._apply_appearance_mode(BG_TERTIARY),
1519
+ highlightthickness=0,
1520
+ )
1521
+ self.left_scrollbar = ctk.CTkScrollbar(left_frame, command=self._sync_scroll)
1522
+ self.left_inner = ctk.CTkFrame(self.left_canvas, fg_color="transparent")
1523
+
1524
+ self.left_canvas.pack(side="left", fill="both", expand=True)
1525
+ self.left_scrollbar.pack(side="right", fill="y")
1526
+
1527
+ self.left_canvas_window = self.left_canvas.create_window(
1528
+ (0, 0), window=self.left_inner, anchor="nw"
1529
+ )
1530
+
1531
+ self.left_canvas.configure(yscrollcommand=self.left_scrollbar.set)
1532
+
1533
+ # Right list container
1534
+ right_frame = ctk.CTkFrame(self, fg_color=BG_TERTIARY, corner_radius=6)
1535
+ right_frame.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
1536
+
1537
+ self.right_canvas = ctk.CTkCanvas(
1538
+ right_frame,
1539
+ bg=self._apply_appearance_mode(BG_TERTIARY),
1540
+ highlightthickness=0,
1541
+ )
1542
+ self.right_scrollbar = ctk.CTkScrollbar(right_frame, command=self._sync_scroll)
1543
+ self.right_inner = ctk.CTkFrame(self.right_canvas, fg_color="transparent")
1544
+
1545
+ self.right_canvas.pack(side="left", fill="both", expand=True)
1546
+ self.right_scrollbar.pack(side="right", fill="y")
1547
+
1548
+ self.right_canvas_window = self.right_canvas.create_window(
1549
+ (0, 0), window=self.right_inner, anchor="nw"
1550
+ )
1551
+
1552
+ self.right_canvas.configure(yscrollcommand=self.right_scrollbar.set)
1553
+
1554
+ # Bind scroll events
1555
+ self.left_canvas.bind("<MouseWheel>", self._on_mousewheel)
1556
+ self.right_canvas.bind("<MouseWheel>", self._on_mousewheel)
1557
+ self.left_inner.bind("<MouseWheel>", self._on_mousewheel)
1558
+ self.right_inner.bind("<MouseWheel>", self._on_mousewheel)
1559
+
1560
+ # Bind resize
1561
+ self.left_inner.bind("<Configure>", self._on_inner_configure)
1562
+ self.left_canvas.bind("<Configure>", self._on_canvas_configure)
1563
+
1564
+ # Loading state
1565
+ self.loading_frame = ctk.CTkFrame(self, fg_color=BG_TERTIARY, corner_radius=6)
1566
+ self.loading_label = ctk.CTkLabel(
1567
+ self.loading_frame,
1568
+ text="Loading...",
1569
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1570
+ text_color=TEXT_MUTED,
1571
+ )
1572
+ self.loading_label.pack(expand=True)
1573
+
1574
+ # Error state
1575
+ self.error_frame = ctk.CTkFrame(self, fg_color=BG_TERTIARY, corner_radius=6)
1576
+ self.error_label = ctk.CTkLabel(
1577
+ self.error_frame,
1578
+ text="",
1579
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1580
+ text_color=ACCENT_RED,
1581
+ )
1582
+ self.error_label.pack(expand=True, pady=20)
1583
+
1584
+ self.retry_btn = ctk.CTkButton(
1585
+ self.error_frame,
1586
+ text="Retry",
1587
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1588
+ fg_color=ACCENT_BLUE,
1589
+ hover_color="#3a8aee",
1590
+ width=100,
1591
+ )
1592
+ self.retry_btn.pack()
1593
+
1594
+ def _apply_appearance_mode(self, color):
1595
+ """Apply appearance mode to color."""
1596
+ return color
1597
+
1598
+ def _sync_scroll(self, *args):
1599
+ """Synchronized scroll handler."""
1600
+ self.left_canvas.yview(*args)
1601
+ self.right_canvas.yview(*args)
1602
+
1603
+ def _on_mousewheel(self, event):
1604
+ """Handle mouse wheel scrolling."""
1605
+ delta = -1 * (event.delta // 120)
1606
+ self.left_canvas.yview_scroll(delta, "units")
1607
+ self.right_canvas.yview_scroll(delta, "units")
1608
+ return "break"
1609
+
1610
+ def _on_inner_configure(self, event=None):
1611
+ """Update scroll region when inner frame changes."""
1612
+ self.left_canvas.configure(scrollregion=self.left_canvas.bbox("all"))
1613
+ self.right_canvas.configure(scrollregion=self.right_canvas.bbox("all"))
1614
+
1615
+ def _on_canvas_configure(self, event=None):
1616
+ """Update inner frame width when canvas resizes."""
1617
+ width = self.left_canvas.winfo_width()
1618
+ self.left_canvas.itemconfig(self.left_canvas_window, width=width)
1619
+ self.right_canvas.itemconfig(self.right_canvas_window, width=width)
1620
+
1621
+ def show_loading(self, provider: str):
1622
+ """Show loading state."""
1623
+ self.loading_label.configure(text=f"Fetching models from {provider}...")
1624
+ self.loading_frame.grid(row=1, column=0, columnspan=2, sticky="nsew")
1625
+ self.error_frame.grid_forget()
1626
+
1627
+ def show_error(self, message: str, on_retry: Callable):
1628
+ """Show error state."""
1629
+ self.error_label.configure(text=f"❌ {message}")
1630
+ self.retry_btn.configure(command=on_retry)
1631
+ self.error_frame.grid(row=1, column=0, columnspan=2, sticky="nsew")
1632
+ self.loading_frame.grid_forget()
1633
+
1634
+ def hide_overlays(self):
1635
+ """Hide loading and error overlays."""
1636
+ self.loading_frame.grid_forget()
1637
+ self.error_frame.grid_forget()
1638
+
1639
+ def set_models(self, models: List[str], statuses: List[ModelStatus]):
1640
+ """Set the models to display."""
1641
+ self.models = models
1642
+ self.statuses = {s.model_id: s for s in statuses}
1643
+
1644
+ self._rebuild_lists()
1645
+ self._update_counts()
1646
+ self.hide_overlays()
1647
+
1648
+ def _rebuild_lists(self):
1649
+ """Rebuild both model lists."""
1650
+ # Clear existing items
1651
+ for item in self.left_items.values():
1652
+ item.destroy()
1653
+ for item in self.right_items.values():
1654
+ item.destroy()
1655
+ self.left_items.clear()
1656
+ self.right_items.clear()
1657
+
1658
+ # Create new items
1659
+ for model_id in self.models:
1660
+ status = self.statuses.get(
1661
+ model_id,
1662
+ ModelStatus(model_id=model_id, status="normal", color=NORMAL_COLOR),
1663
+ )
1664
+
1665
+ # Left item (plain)
1666
+ left_item = ModelListItem(
1667
+ self.left_inner,
1668
+ status,
1669
+ show_status_indicator=False,
1670
+ on_click=self.on_model_click,
1671
+ on_right_click=self.on_model_right_click,
1672
+ )
1673
+ left_item.pack(fill="x")
1674
+ self.left_items[model_id] = left_item
1675
+
1676
+ # Right item (with status colors)
1677
+ right_item = ModelListItem(
1678
+ self.right_inner,
1679
+ status,
1680
+ show_status_indicator=True,
1681
+ on_click=self.on_model_click,
1682
+ on_right_click=self.on_model_right_click,
1683
+ )
1684
+ right_item.pack(fill="x")
1685
+ self.right_items[model_id] = right_item
1686
+
1687
+ # Apply current search filter
1688
+ self._apply_search_filter()
1689
+
1690
+ def update_statuses(self, statuses: List[ModelStatus]):
1691
+ """Update status display for all models."""
1692
+ self.statuses = {s.model_id: s for s in statuses}
1693
+
1694
+ for model_id, status in self.statuses.items():
1695
+ if model_id in self.right_items:
1696
+ self.right_items[model_id].update_status(status)
1697
+
1698
+ self._update_counts()
1699
+
1700
+ def _update_counts(self):
1701
+ """Update the count labels."""
1702
+ visible_count = sum(
1703
+ 1
1704
+ for item in self.left_items.values()
1705
+ if item.winfo_viewable() or item.matches_search(self.search_query)
1706
+ )
1707
+ total = len(self.models)
1708
+
1709
+ # Count available (not ignored)
1710
+ available = sum(1 for s in self.statuses.values() if s.status != "ignored")
1711
+
1712
+ self.left_count_label.configure(text=f"({total})")
1713
+ self.right_count_label.configure(text=f"{available} available")
1714
+
1715
+ def filter_by_search(self, query: str):
1716
+ """Filter models by search query."""
1717
+ self.search_query = query
1718
+ self._apply_search_filter()
1719
+
1720
+ def _apply_search_filter(self):
1721
+ """Apply the current search filter to items."""
1722
+ for model_id in self.models:
1723
+ left_item = self.left_items.get(model_id)
1724
+ right_item = self.right_items.get(model_id)
1725
+
1726
+ if left_item and right_item:
1727
+ matches = left_item.matches_search(self.search_query)
1728
+ if matches:
1729
+ left_item.pack(fill="x")
1730
+ right_item.pack(fill="x")
1731
+ else:
1732
+ left_item.pack_forget()
1733
+ right_item.pack_forget()
1734
+
1735
+ def highlight_models_by_rule(self, rule: FilterRule):
1736
+ """Highlight all models affected by a rule."""
1737
+ self.clear_highlights()
1738
+
1739
+ first_match = None
1740
+ for model_id in rule.affected_models:
1741
+ if model_id in self.left_items:
1742
+ self.left_items[model_id].set_highlighted(True)
1743
+ if first_match is None:
1744
+ first_match = model_id
1745
+ if model_id in self.right_items:
1746
+ self.right_items[model_id].set_highlighted(True)
1747
+
1748
+ # Scroll to first match
1749
+ if first_match:
1750
+ self._scroll_to_model(first_match)
1751
+
1752
+ def highlight_model(self, model_id: str):
1753
+ """Highlight a specific model."""
1754
+ self.clear_highlights()
1755
+
1756
+ if model_id in self.left_items:
1757
+ self.left_items[model_id].set_highlighted(True)
1758
+ if model_id in self.right_items:
1759
+ self.right_items[model_id].set_highlighted(True)
1760
+
1761
+ def clear_highlights(self):
1762
+ """Clear all model highlights."""
1763
+ for item in self.left_items.values():
1764
+ item.set_highlighted(False)
1765
+ for item in self.right_items.values():
1766
+ item.set_highlighted(False)
1767
+
1768
+ def _scroll_to_model(self, model_id: str):
1769
+ """Scroll to make a model visible."""
1770
+ if model_id not in self.left_items:
1771
+ return
1772
+
1773
+ item = self.left_items[model_id]
1774
+
1775
+ # Calculate position
1776
+ self.update_idletasks()
1777
+ item_y = item.winfo_y()
1778
+ inner_height = self.left_inner.winfo_height()
1779
+ canvas_height = self.left_canvas.winfo_height()
1780
+
1781
+ if inner_height > canvas_height:
1782
+ # Calculate scroll fraction
1783
+ scroll_pos = item_y / inner_height
1784
+ scroll_pos = max(0, min(scroll_pos, 1))
1785
+
1786
+ self.left_canvas.yview_moveto(scroll_pos)
1787
+ self.right_canvas.yview_moveto(scroll_pos)
1788
+
1789
+ def scroll_to_affected(self, affected_models: List[str]):
1790
+ """Scroll to first affected model in the list."""
1791
+ for model_id in self.models:
1792
+ if model_id in affected_models:
1793
+ self._scroll_to_model(model_id)
1794
+ break
1795
+
1796
+ def get_model_at_position(self, model_id: str) -> Optional[ModelStatus]:
1797
+ """Get the status of a model."""
1798
+ return self.statuses.get(model_id)
1799
+
1800
+
1801
+ # ════════════════════════════════════════════════════════════════════════════════
1802
+ # MAIN APPLICATION WINDOW
1803
+ # ════════════════════════════════════════════════════════════════════════════════
1804
+
1805
+
1806
+ class ModelFilterGUI(ctk.CTk):
1807
+ """
1808
+ Main application window for model filter configuration.
1809
+
1810
+ Provides a visual interface for managing IGNORE_MODELS_* and WHITELIST_MODELS_*
1811
+ environment variables per provider.
1812
+ """
1813
+
1814
+ def __init__(self):
1815
+ super().__init__()
1816
+
1817
+ # Window configuration
1818
+ self.title(WINDOW_TITLE)
1819
+ self.geometry(WINDOW_DEFAULT_SIZE)
1820
+ self.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
1821
+ self.configure(fg_color=BG_PRIMARY)
1822
+
1823
+ # State
1824
+ self.current_provider: Optional[str] = None
1825
+ self.models: List[str] = []
1826
+ self.filter_engine = FilterEngine()
1827
+ self.available_providers: List[str] = []
1828
+ self._preview_pattern: str = ""
1829
+ self._preview_rule_type: str = ""
1830
+ self._update_scheduled: bool = False
1831
+ self._pending_providers_to_fetch: List[str] = []
1832
+ self._fetch_in_progress: bool = False
1833
+ self._preview_after_id: Optional[str] = None
1834
+
1835
+ # Build UI
1836
+ self._create_header()
1837
+ self._create_search_bar()
1838
+ self._create_model_lists()
1839
+ self._create_rule_panels()
1840
+ self._create_status_bar()
1841
+ self._create_action_buttons()
1842
+
1843
+ # Context menu
1844
+ self._create_context_menu()
1845
+
1846
+ # Load providers and start fetching all models
1847
+ self._load_providers()
1848
+
1849
+ # Bind keyboard shortcuts
1850
+ self._bind_shortcuts()
1851
+
1852
+ # Handle window close
1853
+ self.protocol("WM_DELETE_WINDOW", self._on_close)
1854
+
1855
+ # Focus and raise window after it's fully loaded
1856
+ self.after(100, self._activate_window)
1857
+
1858
+ def _activate_window(self):
1859
+ """Activate and focus the window."""
1860
+ self.lift()
1861
+ self.focus_force()
1862
+ self.attributes("-topmost", True)
1863
+ self.after(200, lambda: self.attributes("-topmost", False))
1864
+
1865
+ def _create_header(self):
1866
+ """Create the header with provider selector and buttons."""
1867
+ header = ctk.CTkFrame(self, fg_color="transparent")
1868
+ header.pack(fill="x", padx=20, pady=(15, 10))
1869
+
1870
+ # Title
1871
+ title = ctk.CTkLabel(
1872
+ header,
1873
+ text="🎯 Model Filter Configuration",
1874
+ font=(FONT_FAMILY, FONT_SIZE_HEADER, "bold"),
1875
+ text_color=TEXT_PRIMARY,
1876
+ )
1877
+ title.pack(side="left")
1878
+
1879
+ # Help button
1880
+ help_btn = ctk.CTkButton(
1881
+ header,
1882
+ text="?",
1883
+ font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"),
1884
+ fg_color=BG_SECONDARY,
1885
+ hover_color=BG_HOVER,
1886
+ border_width=1,
1887
+ border_color=BORDER_COLOR,
1888
+ width=36,
1889
+ height=36,
1890
+ corner_radius=18,
1891
+ command=self._show_help,
1892
+ )
1893
+ help_btn.pack(side="right", padx=(10, 0))
1894
+ ToolTip(help_btn, "Help (F1)")
1895
+
1896
+ # Refresh button
1897
+ refresh_btn = ctk.CTkButton(
1898
+ header,
1899
+ text="🔄 Refresh",
1900
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1901
+ fg_color=BG_SECONDARY,
1902
+ hover_color=BG_HOVER,
1903
+ border_width=1,
1904
+ border_color=BORDER_COLOR,
1905
+ width=100,
1906
+ height=36,
1907
+ command=self._refresh_models,
1908
+ )
1909
+ refresh_btn.pack(side="right", padx=(10, 0))
1910
+ ToolTip(refresh_btn, "Refresh models (Ctrl+R)")
1911
+
1912
+ # Provider selector
1913
+ provider_frame = ctk.CTkFrame(header, fg_color="transparent")
1914
+ provider_frame.pack(side="right")
1915
+
1916
+ provider_label = ctk.CTkLabel(
1917
+ provider_frame,
1918
+ text="Provider:",
1919
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1920
+ text_color=TEXT_SECONDARY,
1921
+ )
1922
+ provider_label.pack(side="left", padx=(0, 8))
1923
+
1924
+ self.provider_dropdown = ctk.CTkComboBox(
1925
+ provider_frame,
1926
+ values=["Loading..."],
1927
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1928
+ dropdown_font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1929
+ fg_color=BG_SECONDARY,
1930
+ border_color=BORDER_COLOR,
1931
+ button_color=BORDER_COLOR,
1932
+ button_hover_color=BG_HOVER,
1933
+ dropdown_fg_color=BG_SECONDARY,
1934
+ dropdown_hover_color=BG_HOVER,
1935
+ text_color=TEXT_PRIMARY,
1936
+ width=180,
1937
+ height=36,
1938
+ state="readonly",
1939
+ command=self._on_provider_changed,
1940
+ )
1941
+ self.provider_dropdown.pack(side="left")
1942
+
1943
+ def _create_search_bar(self):
1944
+ """Create the search bar."""
1945
+ search_frame = ctk.CTkFrame(self, fg_color="transparent")
1946
+ search_frame.pack(fill="x", padx=20, pady=(0, 10))
1947
+
1948
+ search_icon = ctk.CTkLabel(
1949
+ search_frame,
1950
+ text="🔍",
1951
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1952
+ text_color=TEXT_MUTED,
1953
+ )
1954
+ search_icon.pack(side="left", padx=(0, 8))
1955
+
1956
+ self.search_entry = ctk.CTkEntry(
1957
+ search_frame,
1958
+ placeholder_text="Search models...",
1959
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
1960
+ fg_color=BG_SECONDARY,
1961
+ border_color=BORDER_COLOR,
1962
+ text_color=TEXT_PRIMARY,
1963
+ placeholder_text_color=TEXT_MUTED,
1964
+ height=36,
1965
+ )
1966
+ self.search_entry.pack(side="left", fill="x", expand=True)
1967
+ self.search_entry.bind("<KeyRelease>", self._on_search_changed)
1968
+
1969
+ # Clear button
1970
+ clear_btn = ctk.CTkButton(
1971
+ search_frame,
1972
+ text="×",
1973
+ font=(FONT_FAMILY, FONT_SIZE_LARGE),
1974
+ fg_color="transparent",
1975
+ hover_color=BG_HOVER,
1976
+ text_color=TEXT_MUTED,
1977
+ width=36,
1978
+ height=36,
1979
+ command=self._clear_search,
1980
+ )
1981
+ clear_btn.pack(side="left")
1982
+
1983
+ def _create_model_lists(self):
1984
+ """Create the synchronized model list panel."""
1985
+ self.model_list_panel = SyncModelListPanel(
1986
+ self,
1987
+ on_model_click=self._on_model_clicked,
1988
+ on_model_right_click=self._on_model_right_clicked,
1989
+ )
1990
+ self.model_list_panel.pack(fill="both", expand=True, padx=20, pady=(0, 10))
1991
+
1992
+ def _create_rule_panels(self):
1993
+ """Create the ignore and whitelist rule panels."""
1994
+ rules_frame = ctk.CTkFrame(self, fg_color="transparent")
1995
+ rules_frame.pack(fill="x", padx=20, pady=(0, 10))
1996
+ rules_frame.grid_columnconfigure(0, weight=1)
1997
+ rules_frame.grid_columnconfigure(1, weight=1)
1998
+
1999
+ # Ignore panel
2000
+ self.ignore_panel = RulePanel(
2001
+ rules_frame,
2002
+ title="🚫 Ignore Rules",
2003
+ rule_type="ignore",
2004
+ on_rules_changed=self._on_rules_changed,
2005
+ on_rule_clicked=self._on_rule_clicked,
2006
+ on_input_changed=self._on_rule_input_changed,
2007
+ )
2008
+ self.ignore_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
2009
+ self.ignore_panel.set_add_callback(self._add_ignore_pattern)
2010
+ self.ignore_panel.set_delete_callback(self._remove_ignore_pattern)
2011
+
2012
+ # Whitelist panel
2013
+ self.whitelist_panel = RulePanel(
2014
+ rules_frame,
2015
+ title="✓ Whitelist Rules",
2016
+ rule_type="whitelist",
2017
+ on_rules_changed=self._on_rules_changed,
2018
+ on_rule_clicked=self._on_rule_clicked,
2019
+ on_input_changed=self._on_rule_input_changed,
2020
+ )
2021
+ self.whitelist_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0))
2022
+ self.whitelist_panel.set_add_callback(self._add_whitelist_pattern)
2023
+ self.whitelist_panel.set_delete_callback(self._remove_whitelist_pattern)
2024
+
2025
+ def _create_status_bar(self):
2026
+ """Create the status bar showing available count and action buttons."""
2027
+ # Combined status bar and action buttons in one row
2028
+ self.status_frame = ctk.CTkFrame(self, fg_color="transparent")
2029
+ self.status_frame.pack(fill="x", padx=20, pady=(5, 15))
2030
+
2031
+ # Status label (left side)
2032
+ self.status_label = ctk.CTkLabel(
2033
+ self.status_frame,
2034
+ text="Select a provider to begin",
2035
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
2036
+ text_color=TEXT_SECONDARY,
2037
+ )
2038
+ self.status_label.pack(side="left")
2039
+
2040
+ # Unsaved indicator (after status)
2041
+ self.unsaved_label = ctk.CTkLabel(
2042
+ self.status_frame,
2043
+ text="",
2044
+ font=(FONT_FAMILY, FONT_SIZE_SMALL),
2045
+ text_color=ACCENT_YELLOW,
2046
+ )
2047
+ self.unsaved_label.pack(side="left", padx=(15, 0))
2048
+
2049
+ # Buttons (right side)
2050
+ # Discard button
2051
+ discard_btn = ctk.CTkButton(
2052
+ self.status_frame,
2053
+ text="↩️ Discard",
2054
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL),
2055
+ fg_color=BG_SECONDARY,
2056
+ hover_color=BG_HOVER,
2057
+ border_width=1,
2058
+ border_color=BORDER_COLOR,
2059
+ width=110,
2060
+ height=36,
2061
+ command=self._discard_changes,
2062
+ )
2063
+ discard_btn.pack(side="right", padx=(10, 0))
2064
+
2065
+ # Save button
2066
+ save_btn = ctk.CTkButton(
2067
+ self.status_frame,
2068
+ text="💾 Save",
2069
+ font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"),
2070
+ fg_color=ACCENT_GREEN,
2071
+ hover_color="#27ae60",
2072
+ width=110,
2073
+ height=36,
2074
+ command=self._save_changes,
2075
+ )
2076
+ save_btn.pack(side="right")
2077
+ ToolTip(save_btn, "Save changes (Ctrl+S)")
2078
+
2079
+ def _create_action_buttons(self):
2080
+ """Action buttons are now part of status bar - this is a no-op for compatibility."""
2081
+ pass
2082
+
2083
+ def _create_context_menu(self):
2084
+ """Create the right-click context menu."""
2085
+ self.context_menu = Menu(self, tearoff=0, bg=BG_SECONDARY, fg=TEXT_PRIMARY)
2086
+ self.context_menu.add_command(
2087
+ label="➕ Add to Ignore List",
2088
+ command=lambda: self._add_model_to_list("ignore"),
2089
+ )
2090
+ self.context_menu.add_command(
2091
+ label="➕ Add to Whitelist",
2092
+ command=lambda: self._add_model_to_list("whitelist"),
2093
+ )
2094
+ self.context_menu.add_separator()
2095
+ self.context_menu.add_command(
2096
+ label="🔍 View Affecting Rule", command=self._view_affecting_rule
2097
+ )
2098
+ self.context_menu.add_command(
2099
+ label="📋 Copy Model Name", command=self._copy_model_name
2100
+ )
2101
+
2102
+ self._context_model_id: Optional[str] = None
2103
+
2104
+ def _bind_shortcuts(self):
2105
+ """Bind keyboard shortcuts."""
2106
+ self.bind("<Control-s>", lambda e: self._save_changes())
2107
+ self.bind("<Control-r>", lambda e: self._refresh_models())
2108
+ self.bind("<Control-f>", lambda e: self.search_entry.focus_set())
2109
+ self.bind("<F1>", lambda e: self._show_help())
2110
+ self.bind("<Escape>", self._on_escape)
2111
+
2112
+ def _on_escape(self, event=None):
2113
+ """Handle escape key."""
2114
+ # Clear search if has content
2115
+ if self.search_entry.get():
2116
+ self._clear_search()
2117
+ else:
2118
+ # Clear highlights
2119
+ self.model_list_panel.clear_highlights()
2120
+ self.ignore_panel.clear_highlights()
2121
+ self.whitelist_panel.clear_highlights()
2122
+
2123
+ # ─────────────────────────────────────────────────────────────────────────────
2124
+ # Provider Management
2125
+ # ─────────────────────────────────────────────────────────────────────────────
2126
+
2127
+ def _load_providers(self):
2128
+ """Load available providers and start fetching all models in background."""
2129
+ self.available_providers = ModelFetcher.get_available_providers()
2130
+
2131
+ if self.available_providers:
2132
+ self.provider_dropdown.configure(values=self.available_providers)
2133
+ self.provider_dropdown.set(self.available_providers[0])
2134
+
2135
+ # Start fetching all provider models in background
2136
+ self._pending_providers_to_fetch = list(self.available_providers)
2137
+ self.status_label.configure(text="Loading models for all providers...")
2138
+ self._fetch_next_provider()
2139
+
2140
+ # Load the first provider immediately
2141
+ self._on_provider_changed(self.available_providers[0])
2142
+ else:
2143
+ self.provider_dropdown.configure(values=["No providers found"])
2144
+ self.provider_dropdown.set("No providers found")
2145
+ self.status_label.configure(
2146
+ text="No providers with credentials found. Add API keys to .env first."
2147
+ )
2148
+
2149
+ def _fetch_next_provider(self):
2150
+ """Fetch models for the next provider in the queue (background prefetch)."""
2151
+ if not self._pending_providers_to_fetch or self._fetch_in_progress:
2152
+ return
2153
+
2154
+ self._fetch_in_progress = True
2155
+ provider = self._pending_providers_to_fetch.pop(0)
2156
+
2157
+ # Skip if already cached
2158
+ if ModelFetcher.get_cached_models(provider) is not None:
2159
+ self._fetch_in_progress = False
2160
+ self.after(10, self._fetch_next_provider)
2161
+ return
2162
+
2163
+ def on_done(models):
2164
+ self._fetch_in_progress = False
2165
+ # If this is the current provider, update display
2166
+ if provider == self.current_provider:
2167
+ self._on_models_loaded(models)
2168
+ # Continue with next provider
2169
+ self.after(100, self._fetch_next_provider)
2170
+
2171
+ def on_error(error):
2172
+ self._fetch_in_progress = False
2173
+ # Continue with next provider even on error
2174
+ self.after(100, self._fetch_next_provider)
2175
+
2176
+ ModelFetcher.fetch_models(
2177
+ provider,
2178
+ on_success=on_done,
2179
+ on_error=on_error,
2180
+ force_refresh=False,
2181
+ )
2182
+
2183
+ def _on_provider_changed(self, provider: str):
2184
+ """Handle provider selection change."""
2185
+ if provider == self.current_provider:
2186
+ return
2187
+
2188
+ # Check for unsaved changes
2189
+ if self.current_provider and self.filter_engine.has_unsaved_changes():
2190
+ result = self._show_unsaved_dialog()
2191
+ if result == "cancel":
2192
+ # Reset dropdown
2193
+ self.provider_dropdown.set(self.current_provider)
2194
+ return
2195
+ elif result == "save":
2196
+ self._save_changes()
2197
+
2198
+ self.current_provider = provider
2199
+ self.models = []
2200
+
2201
+ # Clear UI
2202
+ self.ignore_panel.clear_all()
2203
+ self.whitelist_panel.clear_all()
2204
+ self.model_list_panel.clear_highlights()
2205
+
2206
+ # Load rules for this provider
2207
+ self.filter_engine.load_from_env(provider)
2208
+ self._populate_rule_panels()
2209
+
2210
+ # Try to load from cache first
2211
+ cached_models = ModelFetcher.get_cached_models(provider)
2212
+ if cached_models is not None:
2213
+ self._on_models_loaded(cached_models)
2214
+ else:
2215
+ # Fetch models (will cache automatically)
2216
+ self._fetch_models()
2217
+
2218
+ def _fetch_models(self, force_refresh: bool = False):
2219
+ """Fetch models for current provider."""
2220
+ if not self.current_provider:
2221
+ return
2222
+
2223
+ self.model_list_panel.show_loading(self.current_provider)
2224
+ self.status_label.configure(
2225
+ text=f"Fetching models from {self.current_provider}..."
2226
+ )
2227
+
2228
+ ModelFetcher.fetch_models(
2229
+ self.current_provider,
2230
+ on_success=self._on_models_loaded,
2231
+ on_error=self._on_models_error,
2232
+ on_start=None,
2233
+ force_refresh=force_refresh,
2234
+ )
2235
+
2236
+ def _on_models_loaded(self, models: List[str]):
2237
+ """Handle successful model fetch."""
2238
+ self.models = sorted(models)
2239
+
2240
+ # Update filter engine counts
2241
+ self.filter_engine.update_affected_counts(self.models)
2242
+
2243
+ # Update UI (must be on main thread)
2244
+ self.after(0, self._update_model_display)
2245
+
2246
+ def _on_models_error(self, error: str):
2247
+ """Handle model fetch error."""
2248
+ self.after(
2249
+ 0,
2250
+ lambda: self.model_list_panel.show_error(
2251
+ error, on_retry=self._refresh_models
2252
+ ),
2253
+ )
2254
+ self.after(
2255
+ 0,
2256
+ lambda: self.status_label.configure(
2257
+ text=f"Failed to fetch models: {error}"
2258
+ ),
2259
+ )
2260
+
2261
+ def _update_model_display(self):
2262
+ """Update the model list display."""
2263
+ statuses = self.filter_engine.get_all_statuses(self.models)
2264
+ self.model_list_panel.set_models(self.models, statuses)
2265
+
2266
+ # Update rule counts
2267
+ self.ignore_panel.update_rule_counts(
2268
+ self.filter_engine.ignore_rules, self.models
2269
+ )
2270
+ self.whitelist_panel.update_rule_counts(
2271
+ self.filter_engine.whitelist_rules, self.models
2272
+ )
2273
+
2274
+ # Update status
2275
+ self._update_status()
2276
+
2277
+ def _refresh_models(self):
2278
+ """Refresh models from provider (force bypass cache)."""
2279
+ if self.current_provider:
2280
+ ModelFetcher.clear_cache(self.current_provider)
2281
+ self._fetch_models(force_refresh=True)
2282
+
2283
+ # ─────────────────────────────────────────────────────────────────────────────
2284
+ # Rule Management
2285
+ # ─────────────────────────────────────────────────────────────────────────────
2286
+
2287
+ def _populate_rule_panels(self):
2288
+ """Populate rule panels from filter engine."""
2289
+ for rule in self.filter_engine.ignore_rules:
2290
+ self.ignore_panel.add_rule_chip(rule)
2291
+
2292
+ for rule in self.filter_engine.whitelist_rules:
2293
+ self.whitelist_panel.add_rule_chip(rule)
2294
+
2295
+ def _add_ignore_pattern(self, pattern: str):
2296
+ """Add an ignore pattern."""
2297
+ rule = self.filter_engine.add_ignore_rule(pattern)
2298
+ if rule:
2299
+ self.ignore_panel.add_rule_chip(rule)
2300
+ self._on_rules_changed()
2301
+
2302
+ def _add_whitelist_pattern(self, pattern: str):
2303
+ """Add a whitelist pattern."""
2304
+ rule = self.filter_engine.add_whitelist_rule(pattern)
2305
+ if rule:
2306
+ self.whitelist_panel.add_rule_chip(rule)
2307
+ self._on_rules_changed()
2308
+
2309
+ def _remove_ignore_pattern(self, pattern: str):
2310
+ """Remove an ignore pattern."""
2311
+ self.filter_engine.remove_ignore_rule(pattern)
2312
+ self.ignore_panel.remove_rule_chip(pattern)
2313
+ self._on_rules_changed()
2314
+
2315
+ def _remove_whitelist_pattern(self, pattern: str):
2316
+ """Remove a whitelist pattern."""
2317
+ self.filter_engine.remove_whitelist_rule(pattern)
2318
+ self.whitelist_panel.remove_rule_chip(pattern)
2319
+ self._on_rules_changed()
2320
+
2321
+ def _on_rules_changed(self):
2322
+ """Handle any rule change - uses debouncing to reduce lag."""
2323
+ if self._update_scheduled:
2324
+ return
2325
+
2326
+ self._update_scheduled = True
2327
+ self.after(50, self._perform_rules_update)
2328
+
2329
+ def _perform_rules_update(self):
2330
+ """Actually perform the rules update (called via debounce)."""
2331
+ self._update_scheduled = False
2332
+
2333
+ # Update affected counts
2334
+ self.filter_engine.update_affected_counts(self.models)
2335
+
2336
+ # Update model statuses
2337
+ statuses = self.filter_engine.get_all_statuses(self.models)
2338
+ self.model_list_panel.update_statuses(statuses)
2339
+
2340
+ # Update rule counts
2341
+ self.ignore_panel.update_rule_counts(
2342
+ self.filter_engine.ignore_rules, self.models
2343
+ )
2344
+ self.whitelist_panel.update_rule_counts(
2345
+ self.filter_engine.whitelist_rules, self.models
2346
+ )
2347
+
2348
+ # Update status
2349
+ self._update_status()
2350
+
2351
+ def _on_rule_input_changed(self, text: str, rule_type: str):
2352
+ """Handle real-time input change for preview - debounced."""
2353
+ self._preview_pattern = text
2354
+ self._preview_rule_type = rule_type
2355
+
2356
+ # Cancel any pending preview update
2357
+ if hasattr(self, "_preview_after_id") and self._preview_after_id:
2358
+ self.after_cancel(self._preview_after_id)
2359
+
2360
+ # Debounce preview updates
2361
+ self._preview_after_id = self.after(
2362
+ 100, lambda: self._perform_preview_update(text, rule_type)
2363
+ )
2364
+
2365
+ def _perform_preview_update(self, text: str, rule_type: str):
2366
+ """Actually perform the preview update."""
2367
+ if not text or not self.models:
2368
+ self.model_list_panel.clear_highlights()
2369
+ return
2370
+
2371
+ # Parse comma-separated patterns
2372
+ patterns = [p.strip() for p in text.split(",") if p.strip()]
2373
+
2374
+ # Find all affected models
2375
+ affected = []
2376
+ for pattern in patterns:
2377
+ affected.extend(
2378
+ self.filter_engine.preview_pattern(pattern, rule_type, self.models)
2379
+ )
2380
+
2381
+ # Highlight affected models
2382
+ if affected:
2383
+ # Create temporary statuses for preview
2384
+ for model_id in affected:
2385
+ if model_id in self.model_list_panel.right_items:
2386
+ self.model_list_panel.right_items[model_id].set_highlighted(True)
2387
+
2388
+ # Scroll to first affected
2389
+ self.model_list_panel.scroll_to_affected(affected)
2390
+ else:
2391
+ self.model_list_panel.clear_highlights()
2392
+
2393
+ def _on_rule_clicked(self, rule: FilterRule):
2394
+ """Handle click on a rule chip."""
2395
+ # Highlight affected models
2396
+ self.model_list_panel.highlight_models_by_rule(rule)
2397
+
2398
+ # Highlight the clicked rule
2399
+ if rule.rule_type == "ignore":
2400
+ self.ignore_panel.highlight_rule(rule.pattern)
2401
+ self.whitelist_panel.clear_highlights()
2402
+ else:
2403
+ self.whitelist_panel.highlight_rule(rule.pattern)
2404
+ self.ignore_panel.clear_highlights()
2405
+
2406
+ # ─────────────────────────────────────────────────────────────────────────────
2407
+ # Model Interactions
2408
+ # ─────────────────────────────────────────────────────────────────────────────
2409
+
2410
+ def _on_model_clicked(self, model_id: str):
2411
+ """Handle left-click on a model."""
2412
+ status = self.model_list_panel.get_model_at_position(model_id)
2413
+
2414
+ if status and status.affecting_rule:
2415
+ # Highlight the affecting rule
2416
+ rule = status.affecting_rule
2417
+ if rule.rule_type == "ignore":
2418
+ self.ignore_panel.highlight_rule(rule.pattern)
2419
+ self.whitelist_panel.clear_highlights()
2420
+ else:
2421
+ self.whitelist_panel.highlight_rule(rule.pattern)
2422
+ self.ignore_panel.clear_highlights()
2423
+
2424
+ # Also highlight the model
2425
+ self.model_list_panel.highlight_model(model_id)
2426
+ else:
2427
+ # No affecting rule - just show highlight briefly
2428
+ self.model_list_panel.highlight_model(model_id)
2429
+ self.ignore_panel.clear_highlights()
2430
+ self.whitelist_panel.clear_highlights()
2431
+
2432
+ def _on_model_right_clicked(self, model_id: str, event):
2433
+ """Handle right-click on a model."""
2434
+ self._context_model_id = model_id
2435
+
2436
+ try:
2437
+ self.context_menu.tk_popup(event.x_root, event.y_root)
2438
+ finally:
2439
+ self.context_menu.grab_release()
2440
+
2441
+ def _add_model_to_list(self, list_type: str):
2442
+ """Add the context menu model to ignore or whitelist."""
2443
+ if not self._context_model_id:
2444
+ return
2445
+
2446
+ # Extract model name without provider prefix
2447
+ if "/" in self._context_model_id:
2448
+ pattern = self._context_model_id.split("/", 1)[1]
2449
+ else:
2450
+ pattern = self._context_model_id
2451
+
2452
+ if list_type == "ignore":
2453
+ self._add_ignore_pattern(pattern)
2454
+ else:
2455
+ self._add_whitelist_pattern(pattern)
2456
+
2457
+ def _view_affecting_rule(self):
2458
+ """View the rule affecting the context menu model."""
2459
+ if not self._context_model_id:
2460
+ return
2461
+
2462
+ self._on_model_clicked(self._context_model_id)
2463
+
2464
+ def _copy_model_name(self):
2465
+ """Copy the context menu model name to clipboard."""
2466
+ if self._context_model_id:
2467
+ self.clipboard_clear()
2468
+ self.clipboard_append(self._context_model_id)
2469
+
2470
+ # ─────────────────────────────────────────────────────────────────────────────
2471
+ # Search
2472
+ # ─────────────────────────────────────────────────────────────────────────────
2473
+
2474
+ def _on_search_changed(self, event=None):
2475
+ """Handle search input change."""
2476
+ query = self.search_entry.get()
2477
+ self.model_list_panel.filter_by_search(query)
2478
+
2479
+ def _clear_search(self):
2480
+ """Clear search field."""
2481
+ self.search_entry.delete(0, "end")
2482
+ self.model_list_panel.filter_by_search("")
2483
+
2484
+ # ─────────────────────────────────────────────────────────────────────────────
2485
+ # Status & UI Updates
2486
+ # ─────────────────────────────────────────────────────────────────────────────
2487
+
2488
+ def _update_status(self):
2489
+ """Update the status bar."""
2490
+ if not self.models:
2491
+ self.status_label.configure(text="No models loaded")
2492
+ return
2493
+
2494
+ available, total = self.filter_engine.get_available_count(self.models)
2495
+ ignored = total - available
2496
+
2497
+ if ignored > 0:
2498
+ text = f"✅ {available} of {total} models available ({ignored} ignored)"
2499
+ else:
2500
+ text = f"✅ All {total} models available"
2501
+
2502
+ self.status_label.configure(text=text)
2503
+
2504
+ # Update unsaved indicator
2505
+ if self.filter_engine.has_unsaved_changes():
2506
+ self.unsaved_label.configure(text="● Unsaved changes")
2507
+ else:
2508
+ self.unsaved_label.configure(text="")
2509
+
2510
+ # ─────────────────────────────────────────────────────────────────────────────
2511
+ # Dialogs
2512
+ # ─────────────────────────────────────────────────────────────────────────────
2513
+
2514
+ def _show_help(self):
2515
+ """Show help window."""
2516
+ HelpWindow(self)
2517
+
2518
+ def _show_unsaved_dialog(self) -> str:
2519
+ """Show unsaved changes dialog. Returns 'save', 'discard', or 'cancel'."""
2520
+ dialog = UnsavedChangesDialog(self)
2521
+ return dialog.show() or "cancel"
2522
+
2523
+ # ─────────────────────────────────────────────────────────────────────────────
2524
+ # Save / Discard
2525
+ # ─────────────────────────────────────────────────────────────────────────────
2526
+
2527
+ def _save_changes(self):
2528
+ """Save current rules to .env file."""
2529
+ if not self.current_provider:
2530
+ return
2531
+
2532
+ if self.filter_engine.save_to_env(self.current_provider):
2533
+ self.status_label.configure(text="✅ Changes saved successfully!")
2534
+ self.unsaved_label.configure(text="")
2535
+
2536
+ # Reset to show normal status after a moment
2537
+ self.after(2000, self._update_status)
2538
+ else:
2539
+ self.status_label.configure(text="❌ Failed to save changes")
2540
+
2541
+ def _discard_changes(self):
2542
+ """Discard unsaved changes."""
2543
+ if not self.current_provider:
2544
+ return
2545
+
2546
+ if not self.filter_engine.has_unsaved_changes():
2547
+ return
2548
+
2549
+ # Reload from env
2550
+ self.filter_engine.discard_changes()
2551
+
2552
+ # Rebuild rule panels
2553
+ self.ignore_panel.clear_all()
2554
+ self.whitelist_panel.clear_all()
2555
+ self._populate_rule_panels()
2556
+
2557
+ # Update display
2558
+ self._on_rules_changed()
2559
+
2560
+ self.status_label.configure(text="Changes discarded")
2561
+ self.after(2000, self._update_status)
2562
+
2563
+ # ─────────────────────────────────────────────────────────────────────────────
2564
+ # Window Close
2565
+ # ─────────────────────────────────────────────────────────────────────────────
2566
+
2567
+ def _on_close(self):
2568
+ """Handle window close."""
2569
+ if self.filter_engine.has_unsaved_changes():
2570
+ result = self._show_unsaved_dialog()
2571
+ if result == "cancel":
2572
+ return
2573
+ elif result == "save":
2574
+ self._save_changes()
2575
+
2576
+ self.destroy()
2577
+
2578
+
2579
+ # ════════════════════════════════════════════════════════════════════════════════
2580
+ # ENTRY POINT
2581
+ # ════════════════════════════════════════════════════════════════════════════════
2582
+
2583
+
2584
+ def run_model_filter_gui():
2585
+ """
2586
+ Launch the Model Filter GUI application.
2587
+
2588
+ This function configures CustomTkinter for dark mode and starts the
2589
+ main application loop. It blocks until the window is closed.
2590
+ """
2591
+ # Force dark mode
2592
+ ctk.set_appearance_mode("dark")
2593
+ ctk.set_default_color_theme("blue")
2594
+
2595
+ # Create and run app
2596
+ app = ModelFilterGUI()
2597
+ app.mainloop()
2598
+
2599
+
2600
+ if __name__ == "__main__":
2601
+ run_model_filter_gui()
src/proxy_app/settings_tool.py CHANGED
@@ -616,8 +616,9 @@ class SettingsTool:
616
  self.console.print(" 3. ⚡ Concurrency Limits")
617
  self.console.print(" 4. 🔄 Rotation Modes")
618
  self.console.print(" 5. 🔬 Provider-Specific Settings")
619
- self.console.print(" 6. 💾 Save & Exit")
620
- self.console.print(" 7. 🚫 Exit Without Saving")
 
621
 
622
  self.console.print()
623
  self.console.print("━" * 70)
@@ -630,14 +631,10 @@ class SettingsTool:
630
  self.console.print("[dim]ℹ️ No pending changes[/dim]")
631
 
632
  self.console.print()
633
- self.console.print(
634
- "[dim]⚠️ Model filters not supported - edit .env for IGNORE_MODELS_* / WHITELIST_MODELS_*[/dim]"
635
- )
636
- self.console.print()
637
 
638
  choice = Prompt.ask(
639
  "Select option",
640
- choices=["1", "2", "3", "4", "5", "6", "7"],
641
  show_choices=False,
642
  )
643
 
@@ -652,8 +649,10 @@ class SettingsTool:
652
  elif choice == "5":
653
  self.manage_provider_settings()
654
  elif choice == "6":
655
- self.save_and_exit()
656
  elif choice == "7":
 
 
657
  self.exit_without_saving()
658
 
659
  def manage_custom_providers(self):
@@ -1104,6 +1103,28 @@ class SettingsTool:
1104
 
1105
  input("Press Enter to return...")
1106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1107
  def manage_provider_settings(self):
1108
  """Manage provider-specific settings (Antigravity, Gemini CLI)"""
1109
  while True:
 
616
  self.console.print(" 3. ⚡ Concurrency Limits")
617
  self.console.print(" 4. 🔄 Rotation Modes")
618
  self.console.print(" 5. 🔬 Provider-Specific Settings")
619
+ self.console.print(" 6. 🎯 Model Filters (Ignore/Whitelist)")
620
+ self.console.print(" 7. 💾 Save & Exit")
621
+ self.console.print(" 8. 🚫 Exit Without Saving")
622
 
623
  self.console.print()
624
  self.console.print("━" * 70)
 
631
  self.console.print("[dim]ℹ️ No pending changes[/dim]")
632
 
633
  self.console.print()
 
 
 
 
634
 
635
  choice = Prompt.ask(
636
  "Select option",
637
+ choices=["1", "2", "3", "4", "5", "6", "7", "8"],
638
  show_choices=False,
639
  )
640
 
 
649
  elif choice == "5":
650
  self.manage_provider_settings()
651
  elif choice == "6":
652
+ self.launch_model_filter_gui()
653
  elif choice == "7":
654
+ self.save_and_exit()
655
+ elif choice == "8":
656
  self.exit_without_saving()
657
 
658
  def manage_custom_providers(self):
 
1103
 
1104
  input("Press Enter to return...")
1105
 
1106
+ def launch_model_filter_gui(self):
1107
+ """Launch the Model Filter GUI for managing ignore/whitelist rules"""
1108
+ clear_screen()
1109
+ self.console.print("\n[cyan]Launching Model Filter GUI...[/cyan]\n")
1110
+ self.console.print(
1111
+ "[dim]The GUI will open in a separate window. Close it to return here.[/dim]\n"
1112
+ )
1113
+
1114
+ try:
1115
+ from proxy_app.model_filter_gui import run_model_filter_gui
1116
+
1117
+ run_model_filter_gui() # Blocks until GUI closes
1118
+ except ImportError as e:
1119
+ self.console.print(f"\n[red]Failed to launch Model Filter GUI: {e}[/red]")
1120
+ self.console.print()
1121
+ self.console.print(
1122
+ "[yellow]Make sure 'customtkinter' is installed:[/yellow]"
1123
+ )
1124
+ self.console.print(" [cyan]pip install customtkinter[/cyan]")
1125
+ self.console.print()
1126
+ input("Press Enter to continue...")
1127
+
1128
  def manage_provider_settings(self):
1129
  """Manage provider-specific settings (Antigravity, Gemini CLI)"""
1130
  while True: