Skip to content

Commit 29add67

Browse files
committed
Implement model disambiguation
1 parent bb3259a commit 29add67

File tree

1 file changed

+108
-0
lines changed

1 file changed

+108
-0
lines changed

src/gptcmd/cli.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import concurrent.futures
1414
import dataclasses
1515
import datetime
16+
import difflib
1617
import json
1718
import os
1819
import re
@@ -29,6 +30,7 @@
2930
Dict,
3031
List,
3132
Optional,
33+
Sequence,
3234
Tuple,
3335
)
3436

@@ -207,6 +209,31 @@ def _await_future_interruptible(
207209
except concurrent.futures.TimeoutError:
208210
continue
209211

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+
210237
KNOWN_ROLES = tuple(MessageRole)
211238

212239
@classmethod
@@ -217,6 +244,83 @@ def _complete_role(cls, text: str) -> List[str]:
217244
def _validate_role(cls, role: str) -> bool:
218245
return role in cls.KNOWN_ROLES
219246

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+
220324
def emptyline(self):
221325
"Disable Python cmd's repeat last command behaviour."
222326
pass
@@ -740,6 +844,10 @@ def do_model(self, arg, _print_on_success=True):
740844
print(f"Switched to model {self._account.provider.model!r}")
741845
else:
742846
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)
743851

744852
def do_set(self, arg):
745853
"""

0 commit comments

Comments
 (0)