mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(addons): credential slot, resource allowlist and rate-limit error
Add a manifest credentials declaration, host-enforced per-binding resource lists, Request.Credential and ResourceKey set by the host, and a typed ErrRateLimited that reaches the model as rate_limited. Assisted-by: Claude:claude-sonnet-5-5 [go]
This commit is contained in:
34 files changed
+3056
-10
No files matched your search
@@ -0,0 +1,223 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package ghapi is the part of the GitHub REST API that the GitHub add-on
|
||||
// reads. It speaks to api.github.com only: no address comes from a model, so
|
||||
// the base address is fixed in the code and only a test replaces it.
|
||||
//
|
||||
// The token is passed to each call by the caller and is never stored. It
|
||||
// leaves this package only in the Authorization header, and an error built
|
||||
// here never holds it.
|
||||
package ghapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/pkg/httpclient"
|
||||
)
|
||||
|
||||
// DefaultBase is the address of the API.
|
||||
const DefaultBase = "https://api.github.com"
|
||||
|
||||
// APIVersion is the version of the REST API the add-on asks for.
|
||||
const APIVersion = "2022-11-28"
|
||||
|
||||
// MaxBody bounds one answer read in memory. A larger answer is an error, not a
|
||||
// cut document.
|
||||
const MaxBody = 16 << 20
|
||||
|
||||
// defaultWait is how long a limit with no stated end lasts, in seconds. GitHub
|
||||
// asks for at least a minute after a secondary limit.
|
||||
const defaultWait = 60
|
||||
|
||||
// Errors of a call. None of them holds the token or the text of the answer.
|
||||
var (
|
||||
// ErrUnauthorized means GitHub refused the token (401).
|
||||
ErrUnauthorized = errors.New("github refused the token")
|
||||
// ErrForbidden means the token lacks a permission or the repository is out of its reach (403).
|
||||
ErrForbidden = errors.New("the token has no permission for this")
|
||||
// ErrNotFound means the thing does not exist, or the token cannot see it (404).
|
||||
ErrNotFound = errors.New("not found or the token cannot see it")
|
||||
// ErrInvalid means GitHub could not process the request (422 and other 4xx).
|
||||
ErrInvalid = errors.New("github refused the request")
|
||||
// ErrUnavailable means a failure on the way or on the GitHub side.
|
||||
ErrUnavailable = errors.New("github is not reachable now")
|
||||
// ErrTooLarge means an answer above MaxBody.
|
||||
ErrTooLarge = errors.New("the answer of github is too large")
|
||||
)
|
||||
|
||||
// Client calls the API. The zero value is not ready; use New.
|
||||
type Client struct {
|
||||
// Base is the address of the API without a trailing slash.
|
||||
Base string
|
||||
// HTTP refuses redirects (pkg/httpclient does by default).
|
||||
HTTP *http.Client
|
||||
// Now is the clock; a test fixes it.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a client of the real API.
|
||||
func New() *Client {
|
||||
return &Client{Base: DefaultBase, HTTP: httpclient.NewWithTimeout(30 * time.Second), Now: time.Now}
|
||||
}
|
||||
|
||||
// Response is the part of an answer a caller needs besides the body.
|
||||
type Response struct {
|
||||
Status int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// NextPage reports whether the Link header offers a next page.
|
||||
func (r Response) NextPage() bool {
|
||||
for _, part := range strings.Split(r.Header.Get("Link"), ",") {
|
||||
if strings.Contains(part, `rel="next"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Get calls a path of the API with the token. accept is the media type
|
||||
// asked for; empty means JSON. A non-2xx answer is an error from the list
|
||||
// above, or a *contract.ErrRateLimited.
|
||||
func (c *Client) Get(ctx context.Context, token, path string, query url.Values, accept string) (Response, error) {
|
||||
if token == "" {
|
||||
return Response{}, ErrUnauthorized
|
||||
}
|
||||
u := strings.TrimRight(c.Base, "/") + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return Response{}, ErrInvalid
|
||||
}
|
||||
if accept == "" {
|
||||
accept = "application/vnd.github+json"
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("X-GitHub-Api-Version", APIVersion)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("User-Agent", "LocalAI-github-addon")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Response{}, ctx.Err()
|
||||
}
|
||||
// The transport error names the URL, never a header, but it is not
|
||||
// worth keeping either: the class is what a caller can act on.
|
||||
return Response{}, ErrUnavailable
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBody+1))
|
||||
if err != nil {
|
||||
return Response{}, ErrUnavailable
|
||||
}
|
||||
if len(body) > MaxBody {
|
||||
return Response{}, ErrTooLarge
|
||||
}
|
||||
out := Response{Status: resp.StatusCode, Header: resp.Header, Body: body}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return out, nil
|
||||
}
|
||||
return out, c.failure(resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
|
||||
// GetJSON is Get with the JSON body decoded into v.
|
||||
func (c *Client) GetJSON(ctx context.Context, token, path string, query url.Values, v any) (Response, error) {
|
||||
r, err := c.Get(ctx, token, path, query, "")
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if err := json.Unmarshal(r.Body, v); err != nil {
|
||||
return r, ErrUnavailable
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// failure maps a non-2xx answer. It reads the rate limit headers: GitHub
|
||||
// answers 403 or 429 with x-ratelimit-remaining 0 and x-ratelimit-reset (epoch
|
||||
// seconds) for the primary limit, and retry-after (seconds) or a message for
|
||||
// the secondary limit.
|
||||
func (c *Client) failure(status int, h http.Header, body []byte) error {
|
||||
switch status {
|
||||
case http.StatusUnauthorized:
|
||||
return ErrUnauthorized
|
||||
case http.StatusForbidden, http.StatusTooManyRequests:
|
||||
if wait, limited := c.limitWait(status, h, body); limited {
|
||||
return &contract.ErrRateLimited{RetryAfter: wait}
|
||||
}
|
||||
return ErrForbidden
|
||||
case http.StatusNotFound, http.StatusGone:
|
||||
return ErrNotFound
|
||||
}
|
||||
if status >= 500 {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return ErrInvalid
|
||||
}
|
||||
|
||||
func (c *Client) limitWait(status int, h http.Header, body []byte) (int, bool) {
|
||||
if v := h.Get("Retry-After"); v != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||
return min(max(n, 1), contract.MaxRetryAfter), true
|
||||
}
|
||||
}
|
||||
if h.Get("X-RateLimit-Remaining") == "0" {
|
||||
if epoch, err := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64); err == nil {
|
||||
wait := int(epoch - c.now().Unix())
|
||||
return min(max(wait, 1), contract.MaxRetryAfter), true
|
||||
}
|
||||
return defaultWait, true
|
||||
}
|
||||
if status == http.StatusTooManyRequests {
|
||||
return defaultWait, true
|
||||
}
|
||||
// A secondary limit is a 403 whose message says so. The message is read
|
||||
// here and never returned.
|
||||
var m struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(body, &m) == nil {
|
||||
l := strings.ToLower(m.Message)
|
||||
if strings.Contains(l, "secondary rate limit") || strings.Contains(l, "abuse detection") || strings.Contains(l, "rate limit exceeded") {
|
||||
return defaultWait, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Describe is a fixed sentence for an error of this package, for a log line or
|
||||
// a refusal. It exists so no caller formats the error of a transport.
|
||||
func Describe(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
return ErrUnauthorized.Error()
|
||||
case errors.Is(err, ErrForbidden):
|
||||
return ErrForbidden.Error()
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return ErrNotFound.Error()
|
||||
case errors.Is(err, ErrInvalid):
|
||||
return ErrInvalid.Error()
|
||||
case errors.Is(err, ErrTooLarge):
|
||||
return ErrTooLarge.Error()
|
||||
}
|
||||
return fmt.Sprint(ErrUnavailable)
|
||||
}
|
||||
Reference in new issue
Block a user