Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions docker-compose.cluster.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
services:
cli-proxy-api:
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
pull_policy: always
build:
context: .
dockerfile: Dockerfile
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
image: golang:1.26-bookworm
container_name: cli-proxy-api-cluster
working_dir: /CLIProxyAPI
environment:
HOME_JWT: ${HOME_JWT:-}
TZ: Asia/Shanghai
ports:
- "8317:8317"
volumes:
- .:/CLIProxyAPI
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- ./home:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
command: >
Expand All @@ -24,6 +21,10 @@ services:
exit 1
fi

exec ./CLIProxyAPI -home-jwt "$$HOME_JWT"
cd /CLIProxyAPI && exec go run -buildvcs=false ./cmd/server/ -home-jwt "$$HOME_JWT"
'
restart: unless-stopped
restart: unless-stopped

volumes:
go-mod-cache:
go-build-cache:
29 changes: 29 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
services:
cli-proxy-api-dev:
image: golang:1.26-bookworm
container_name: cli-proxy-api-dev
working_dir: /CLIProxyAPI
environment:
TZ: Asia/Shanghai
DEPLOY: ${DEPLOY:-}
ports:
- "8317:8317"
- "8085:8085"
- "1455:1455"
- "54545:54545"
- "51121:51121"
- "11451:11451"
volumes:
- .:/CLIProxyAPI
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure the default config bind has a file

In a fresh checkout, .gitignore excludes config.yaml and only config.example.yaml is tracked, so this default short-syntax bind mount points at a missing host file. Docker's bind-mount docs say --volume creates a missing source as a directory (https://docs.docker.com/engine/storage/bind-mounts/); then cmd/server loads /CLIProxyAPI/config.yaml with optional=false when DEPLOY is not cloud, so the dev container exits on EISDIR unless users manually create/copy the config first. Mount an existing example file or create config.yaml before binding it here.

Useful? React with 👍 / 👎.

- ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
command: >
bash -c "cd /CLIProxyAPI && go run -buildvcs=false ./cmd/server/"
restart: unless-stopped

volumes:
go-mod-cache:
go-build-cache:
23 changes: 12 additions & 11 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
services:
cli-proxy-api:
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
pull_policy: always
build:
context: .
dockerfile: Dockerfile
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
image: golang:1.26-bookworm
container_name: cli-proxy-api
# env_file:
# - .env
working_dir: /CLIProxyAPI
environment:
DEPLOY: ${DEPLOY:-}
TZ: Asia/Shanghai
ports:
- "8317:8317"
- "8085:8085"
Expand All @@ -22,7 +14,16 @@ services:
- "51121:51121"
- "11451:11451"
volumes:
- .:/CLIProxyAPI
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
- ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
- ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
command: >
bash -c "cd /CLIProxyAPI && go run -buildvcs=false ./cmd/server/"
restart: unless-stopped

volumes:
go-mod-cache:
go-build-cache:
111 changes: 111 additions & 0 deletions internal/util/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package util

import (
"net/url"
"sort"
"strings"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap suggestion distance work for long model IDs

When the request's unknown model is very long, this Levenshtein call runs once per available registry model and scales with len(modelName) * len(base) * #models. The OpenAI handlers read client bodies through ReadRequestBody/GetRawData without 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 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of SuggestClosestModels compares the requested modelName (which may contain a provider prefix, e.g., openai/gpt-4o-minii) with the candidate's base name (which has its provider prefix stripped via stripProviderPrefix(id)).

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 modelName as well before computing the edit distance.

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]
}
6 changes: 5 additions & 1 deletion sdk/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter image-only models out of chat suggestions

On non-image endpoints, this can suggest an image-only model when it is the closest registered ID, e.g. gpt-image-2x on chat/completions with Codex models registered will suggest gpt-image-2, but using that suggestion immediately hits the 503 guard above because allowImageModel is false. Since getRequestDetailsWithOptions already has the endpoint context, skip isOpenAIImageOnlyModel suggestions unless image models are allowed.

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
Expand Down
37 changes: 37 additions & 0 deletions sdk/api/handlers/handlers_request_details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,40 @@ func TestGetRequestDetails_ImageModelReturns503(t *testing.T) {
t.Fatalf("unexpected error message: %q", msg)
}
}

func TestGetRequestDetails_UnknownModelSuggestsClosest(t *testing.T) {
modelRegistry := registry.GetGlobalRegistry()
now := time.Now().Unix()

modelRegistry.RegisterClient("test-suggest-closest", "gemini", []*registry.ModelInfo{
{ID: "gemini-2.5-pro", Created: now + 30},
{ID: "gemini-2.5-flash", Created: now + 25},
})
t.Cleanup(func() { modelRegistry.UnregisterClient("test-suggest-closest") })

handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil))

// Near-miss of a registered model: the error should suggest the close match.
_, _, errMsg := handler.getRequestDetails("gemini-2.5-proo")
if errMsg == nil || errMsg.Error == nil {
t.Fatalf("expected unknown-provider error for gemini-2.5-proo, got: %v", errMsg)
}
msg := errMsg.Error.Error()
if !strings.Contains(msg, "did you mean gemini-2.5-pro") {
t.Fatalf("expected a closest-match suggestion in error, got %q", msg)
}

// Totally unknown model: no close match, so the error should point to /v1/models
// instead of offering a misleading suggestion.
_, _, errMsg2 := handler.getRequestDetails("totally-bogus-xyz-12345")
if errMsg2 == nil || errMsg2.Error == nil {
t.Fatalf("expected unknown-provider error for totally-bogus-xyz-12345, got: %v", errMsg2)
}
msg2 := errMsg2.Error.Error()
if !strings.Contains(msg2, "/v1/models") {
t.Fatalf("expected /v1/models hint in error, got %q", msg2)
}
if strings.Contains(msg2, "did you mean") {
t.Fatalf("did not expect a suggestion for a bogus model, got %q", msg2)
}
}
Loading