diff --git a/apps/docs/docs/workshop/homarr-provider.mdx b/apps/docs/docs/workshop/homarr-provider.mdx index 7b770575f..3aa14a8d7 100644 --- a/apps/docs/docs/workshop/homarr-provider.mdx +++ b/apps/docs/docs/workshop/homarr-provider.mdx @@ -28,6 +28,8 @@ changing the user-facing provider. Each user signs in with GitHub and receives a - Each user starts with **50 request units per UTC day**. - Each model request uses one unit. Agent tool loops can make multiple model requests. +- Once Workshop forwards a request, it uses one unit even if the upstream later fails. This prevents failed requests + from bypassing the shared cost and abuse limits. - The allowance resets at 00:00 UTC. The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the diff --git a/apps/workshop/homarr_provider.go b/apps/workshop/homarr_provider.go index c606c99ff..e53955cf2 100644 --- a/apps/workshop/homarr_provider.go +++ b/apps/workshop/homarr_provider.go @@ -47,6 +47,8 @@ var ( errProviderResponseTooLarge = errors.New("provider response is too large") errInvalidTools = errors.New("invalid request tools") errUnsupportedTool = errors.New("unsupported request tool") + errTooManyImages = errors.New("too many images or image too large") + errInputTooLarge = errors.New("request exceeds model input limit") ) var clientControlledRoutingFields = []string{ @@ -224,7 +226,11 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON.")) } if err := validateProviderInput(payload); err != nil { - return event.JSON(http.StatusRequestEntityTooLarge, openAIError(err.Error())) + message := "The request exceeds the Homarr model input limit." + if errors.Is(err, errTooManyImages) { + message = "The request contains too many images or an image that is too large." + } + return event.JSON(http.StatusRequestEntityTooLarge, openAIError(message)) } if err := sanitizeProviderPayload(payload, provider.modelID); err != nil { message := "The request contains an unsupported tool." @@ -238,6 +244,15 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared.")) } + upstreamRequest, err := http.NewRequestWithContext( + event.Request.Context(), + http.MethodPost, + provider.baseURL+"/chat/completions", + bytes.NewReader(upstreamBody), + ) + if err != nil { + return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared.")) + } snapshot, err := provider.startRequest(event.App, event.Auth) if err != nil { var quotaErr *quotaExceededError @@ -249,15 +264,6 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { } return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started.")) } - upstreamRequest, err := http.NewRequestWithContext( - event.Request.Context(), - http.MethodPost, - provider.baseURL+"/chat/completions", - bytes.NewReader(upstreamBody), - ) - if err != nil { - return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared.")) - } upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey) upstreamRequest.Header.Set("Content-Type", "application/json") upstreamRequest.Header.Set("Accept", "text/event-stream, application/json") @@ -376,13 +382,13 @@ func validateProviderInput(payload map[string]any) error { if strings.HasPrefix(typed, "data:image/") { imageCount++ if imageCount > maxChatImages || len(typed) > maxChatImageDataBytes { - return errors.New("The request contains too many images or an image that is too large.") + return errTooManyImages } return nil } textBytes += len(typed) if textBytes > maxChatTextBytes { - return errors.New("The request exceeds the Homarr model input limit.") + return errInputTooLarge } case []any: for _, item := range typed { @@ -394,7 +400,7 @@ func validateProviderInput(payload map[string]any) error { for key, item := range typed { textBytes += len(key) if textBytes > maxChatTextBytes { - return errors.New("The request exceeds the Homarr model input limit.") + return errInputTooLarge } if err := walk(item); err != nil { return err diff --git a/apps/workshop/homarr_provider_test.go b/apps/workshop/homarr_provider_test.go index 50d1d5f45..8acb205f6 100644 --- a/apps/workshop/homarr_provider_test.go +++ b/apps/workshop/homarr_provider_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "strings" "testing" ) @@ -125,8 +126,18 @@ func TestValidateProviderInput(t *testing.T) { t.Fatalf("expected a supported Homarr image request, got %v", err) } payload["messages"] = []any{map[string]any{"role": "user", "content": strings.Repeat("x", maxChatTextBytes+1)}} - if err := validateProviderInput(payload); err == nil { - t.Fatal("expected oversized text input to be rejected") + if err := validateProviderInput(payload); !errors.Is(err, errInputTooLarge) { + t.Fatalf("expected oversized text input to be rejected, got %v", err) + } + payload["messages"] = []any{map[string]any{ + "role": "user", + "content": []any{map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", maxChatImageDataBytes)}, + }}, + }} + if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) { + t.Fatalf("expected oversized image input to be rejected, got %v", err) } }