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:
Ettore Di Giacinto committed 2026-11-03 14:20:12 +00:00
1 parent b261f90c0b
commit e0da582988
34 files changed
+3056 -10

No files matched your search

+223
View File
@@ -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)
}