-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(handlers): suggest closest models and point to /v1/models on unknown-provider error #4054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ package util | |
|
|
||
| import ( | ||
| "net/url" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/router-for-me/CLIProxyAPI/v7/internal/config" | ||
|
|
@@ -286,3 +287,113 @@ func shouldMaskQueryParam(key string) bool { | |
| } | ||
| return false | ||
| } | ||
|
|
||
| // suggestMaxDistance is the maximum normalized Levenshtein distance (0..1) for a | ||
| // registered model ID to be offered as a "did you mean" suggestion. A wrong | ||
| // suggestion is worse than none, so this is kept strict. | ||
| const suggestMaxDistance = 0.34 | ||
|
|
||
| // SuggestClosestModels returns up to limit registered model IDs that are close | ||
| // to modelName, to help users recover from an "unknown provider" error caused by | ||
| // a typo, a wrong alias, or a stale thinking suffix. | ||
| // | ||
| // modelName should already be suffix-stripped by the caller (the base model | ||
| // name). Registered IDs are matched after stripping any "provider/" prefix. | ||
| // Only matches within a normalized Levenshtein distance are returned. | ||
| // | ||
| // This runs only on the error path and GetAvailableModels is cached, so there | ||
| // is no hot-path impact. | ||
| func SuggestClosestModels(modelName string, limit int) []string { | ||
| modelName = strings.ToLower(strings.TrimSpace(modelName)) | ||
| if modelName == "" || limit <= 0 { | ||
| return nil | ||
| } | ||
|
|
||
| type candidate struct { | ||
| id string | ||
| dist int | ||
| } | ||
| var cands []candidate | ||
| for _, m := range registry.GetGlobalRegistry().GetAvailableModels("") { | ||
| id, _ := m["id"].(string) | ||
| if id == "" { | ||
| continue | ||
| } | ||
| base := stripProviderPrefix(id) | ||
| d := levenshtein(modelName, strings.ToLower(base)) | ||
| maxLen := len(modelName) | ||
| if len(base) > maxLen { | ||
| maxLen = len(base) | ||
| } | ||
| if maxLen == 0 { | ||
| continue | ||
| } | ||
| if float64(d)/float64(maxLen) <= suggestMaxDistance { | ||
| cands = append(cands, candidate{id: id, dist: d}) | ||
| } | ||
| } | ||
|
Comment on lines
+306
to
+334
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of This mismatch causes the Levenshtein distance to be artificially high when a user specifies a provider prefix, preventing any close matches from being suggested. To fix this, we should strip the provider prefix from func SuggestClosestModels(modelName string, limit int) []string {
modelName = strings.ToLower(strings.TrimSpace(modelName))
if modelName == "" || limit <= 0 {
return nil
}
modelBase := stripProviderPrefix(modelName)
type candidate struct {
id string
dist int
}
var cands []candidate
for _, m := range registry.GetGlobalRegistry().GetAvailableModels("") {
id, _ := m["id"].(string)
if id == "" {
continue
}
base := stripProviderPrefix(id)
d := levenshtein(modelBase, strings.ToLower(base))
maxLen := len(modelBase)
if len(base) > maxLen {
maxLen = len(base)
}
if maxLen == 0 {
continue
}
if float64(d)/float64(maxLen) <= suggestMaxDistance {
cands = append(cands, candidate{id: id, dist: d})
}
} |
||
|
|
||
| sort.Slice(cands, func(i, j int) bool { | ||
| if cands[i].dist != cands[j].dist { | ||
| return cands[i].dist < cands[j].dist | ||
| } | ||
| return cands[i].id < cands[j].id | ||
| }) | ||
|
|
||
| out := make([]string, 0, limit) | ||
| for i := 0; i < limit && i < len(cands); i++ { | ||
| out = append(out, cands[i].id) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // stripProviderPrefix returns the trailing segment after the last "/", | ||
| // mirroring handlers.routeModelBaseName without creating a cross-package | ||
| // dependency (internal/util must not import sdk/api/handlers). | ||
| func stripProviderPrefix(model string) string { | ||
| model = strings.TrimSpace(model) | ||
| if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { | ||
| return strings.TrimSpace(model[idx+1:]) | ||
| } | ||
| return model | ||
| } | ||
|
|
||
| // levenshtein computes the edit distance between two strings using a standard | ||
| // two-row dynamic programming algorithm. Pure stdlib, no external dependency. | ||
| func levenshtein(a, b string) int { | ||
| ra, rb := []rune(a), []rune(b) | ||
| la, lb := len(ra), len(rb) | ||
| if la == 0 { | ||
| return lb | ||
| } | ||
| if lb == 0 { | ||
| return la | ||
| } | ||
|
|
||
| prev := make([]int, lb+1) | ||
| curr := make([]int, lb+1) | ||
| for j := 0; j <= lb; j++ { | ||
| prev[j] = j | ||
| } | ||
| for i := 1; i <= la; i++ { | ||
| curr[0] = i | ||
| for j := 1; j <= lb; j++ { | ||
| cost := 1 | ||
| if ra[i-1] == rb[j-1] { | ||
| cost = 0 | ||
| } | ||
| ins := curr[j-1] + 1 | ||
| del := prev[j] + 1 | ||
| sub := prev[j-1] + cost | ||
| curr[j] = ins | ||
| if del < curr[j] { | ||
| curr[j] = del | ||
| } | ||
| if sub < curr[j] { | ||
| curr[j] = sub | ||
| } | ||
| } | ||
| prev, curr = curr, prev | ||
| } | ||
| return prev[lb] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1495,7 +1495,11 @@ func (h *BaseAPIHandler) getRequestDetailsWithOptions(modelName string, allowIma | |
| } | ||
|
|
||
| if len(providers) == 0 { | ||
| return nil, "", &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("unknown provider for model %s", modelName)} | ||
| msg := fmt.Sprintf("unknown provider for model %s; check available models via GET /v1/models", modelName) | ||
| if sugg := util.SuggestClosestModels(baseModel, 3); len(sugg) > 0 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On non-image endpoints, this can suggest an image-only model when it is the closest registered ID, e.g. Useful? React with 👍 / 👎. |
||
| msg = fmt.Sprintf("unknown provider for model %s; did you mean %s? check available models via GET /v1/models", modelName, strings.Join(sugg, ", ")) | ||
| } | ||
| return nil, "", &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("%s", msg)} | ||
| } | ||
|
|
||
| // The thinking suffix is preserved in the model name itself, so no | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the request's unknown
modelis very long, this Levenshtein call runs once per available registry model and scales withlen(modelName) * len(base) * #models. The OpenAI handlers read client bodies throughReadRequestBody/GetRawDatawithout a request-size or model-field cap, so a client can send a huge bogus model string and tie up CPU before the 502 response; the old unknown-provider path returned immediately. Skip suggestions or cap the input length for oversized model names.Useful? React with 👍 / 👎.