13
13
import concurrent .futures
14
14
import dataclasses
15
15
import datetime
16
+ import difflib
16
17
import json
17
18
import os
18
19
import re
29
30
Dict ,
30
31
List ,
31
32
Optional ,
33
+ Sequence ,
32
34
Tuple ,
33
35
)
34
36
@@ -207,6 +209,31 @@ def _await_future_interruptible(
207
209
except concurrent .futures .TimeoutError :
208
210
continue
209
211
212
+ @staticmethod
213
+ def _menu (prompt : str , options : List [str ]) -> Optional [str ]:
214
+ """
215
+ Display a menu of options and return the chosen item, or None
216
+ if canceled.
217
+ """
218
+ while True :
219
+ print (
220
+ prompt ,
221
+ "0. Cancel" ,
222
+ * (
223
+ f"{ i } . { option } "
224
+ for i , option in enumerate (options , start = 1 )
225
+ ),
226
+ sep = "\n " ,
227
+ )
228
+ selection = input ("Enter your selection: " )
229
+ if not selection .isdigit ():
230
+ continue
231
+ choice = int (selection )
232
+ if choice == 0 :
233
+ return None
234
+ if 1 <= choice <= len (options ):
235
+ return options [choice - 1 ]
236
+
210
237
KNOWN_ROLES = tuple (MessageRole )
211
238
212
239
@classmethod
@@ -217,6 +244,83 @@ def _complete_role(cls, text: str) -> List[str]:
217
244
def _validate_role (cls , role : str ) -> bool :
218
245
return role in cls .KNOWN_ROLES
219
246
247
+ @classmethod
248
+ def _disambiguate (
249
+ cls , user_input : str , choices : Sequence [str ]
250
+ ) -> Optional [str ]:
251
+ DIFFLIB_CUTOFF = 0.5
252
+ MAX_MATCHES = 9
253
+
254
+ in_lower = user_input .lower ()
255
+ matches = difflib .get_close_matches (
256
+ user_input ,
257
+ choices ,
258
+ n = MAX_MATCHES ,
259
+ cutoff = DIFFLIB_CUTOFF ,
260
+ )
261
+
262
+ if len (user_input ) > 2 :
263
+ matches .extend (
264
+ [
265
+ c
266
+ for c in choices
267
+ if in_lower in c .lower () and c not in matches
268
+ ]
269
+ )
270
+
271
+ if not matches :
272
+ return None
273
+
274
+ ratio = {
275
+ c : difflib .SequenceMatcher (None , user_input , c ).ratio ()
276
+ for c in matches
277
+ }
278
+
279
+ def _has_non_digit_suffix (s : str ) -> int :
280
+ # 1 when the last hyphen/underscore-separated token is not
281
+ # purely digits, 0 otherwise.
282
+ last = re .split (r"[-_]" , s )[- 1 ]
283
+ return int (not last .isdigit ())
284
+
285
+ def _max_numeric_token (s : str ) -> int :
286
+ # Greatest integer appearing anywhere in the candidate, or ‑1
287
+ nums = re .findall (r"\d+" , s )
288
+ return max (map (int , nums )) if nums else - 1
289
+
290
+ matches = sorted (
291
+ matches ,
292
+ key = lambda c : (
293
+ # Literal match (prefer prefix)
294
+ (
295
+ 0
296
+ if c .lower ().startswith (in_lower )
297
+ else 1 if in_lower in c .lower () else 2
298
+ ),
299
+ # Suffix match (prefer non-digit)
300
+ # Heuristic: Prefer unversioned model aliases
301
+ - _has_non_digit_suffix (c ),
302
+ # Difflib match (best first)
303
+ - ratio [c ],
304
+ # Length match (shortest first)
305
+ # Heuristic: Prefer unversioned model aliases
306
+ len (c ),
307
+ # Numeric match (prefer larger numbers)
308
+ # Heuristic: Prefer later model versions
309
+ - _max_numeric_token (c ),
310
+ # Fallback: Lexicographic order
311
+ c ,
312
+ ),
313
+ )[:MAX_MATCHES ]
314
+
315
+ if len (matches ) == 1 :
316
+ c = matches [0 ]
317
+ match = c if cls ._confirm (f"Did you mean { c !r} ?" ) else None
318
+ else :
319
+ match = cls ._menu ("Did you mean one of these?" , matches )
320
+ if match is None :
321
+ print ("Cancelled" )
322
+ return match
323
+
220
324
def emptyline (self ):
221
325
"Disable Python cmd's repeat last command behaviour."
222
326
pass
@@ -740,6 +844,10 @@ def do_model(self, arg, _print_on_success=True):
740
844
print (f"Switched to model { self ._account .provider .model !r} " )
741
845
else :
742
846
print (f"{ arg } is currently unavailable" )
847
+ valid_models = self ._account .provider .valid_models or ()
848
+ match = self .__class__ ._disambiguate (arg , valid_models )
849
+ if match and match != arg :
850
+ self .do_model (match , _print_on_success = _print_on_success )
743
851
744
852
def do_set (self , arg ):
745
853
"""
0 commit comments