mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
The previous commit was made on a stale base and so reverted work that landed on master before it. This commit puts every file back to the state of its parent's parent. The permission enforcement of the previous commit stays. Assisted-by: Claude:claude-sonnet-5-5 go
224 lines
7.0 KiB
Go
224 lines
7.0 KiB
Go
// 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)
|
|
}
|