Files
llmgw/internal/mirror/client.go
Ilker ManapandClaude Opus 5.5 85025dfb03 Trained adapters published, private to their owners like the knowledge bases they come from
An adapter now has an owner (org, team, project, user or key): only callers
there see it, and to anyone else it is a model that does not exist, absent
from /v1/models too. A training job's owner defaults to the one collection
its pairs come from, must be able to read every collection they come from,
and pairs from a knowledge base are never made public. ctl train publish
keeps the GGUF or PEFT files on the mirror, which accepts uploads now, and
sets the owned adapter after the usual architecture check.

The knowledge bases' cache_salt follows the collections searched rather
than the organization, so two teams of one organization no longer share
cached passages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:30:50 +02:00

200 lines
6.3 KiB
Go

package mirror
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Client drives a mirror's management API from somewhere else.
//
// The mirror runs on its own machine with its own disk, so every operation the
// control plane wants — what is held, fetch this, how far along, remove that —
// is a request over the network rather than a function call. This is the half
// that makes the mirror something the control plane administers instead of
// something an operator has to SSH into.
type Client struct {
base string
hc *http.Client
}
// NewClient dials a mirror's management API, with mutual TLS when configured.
//
// NextProtos is cleared from what tlsconf builds, for the same reason the
// mirror's server clears it: that configuration is shaped for the agent stream,
// which needs HTTP/2 and pins "h2". A transport carrying a TLS config with a
// pinned protocol it is not itself set up to speak negotiates h2 and then talks
// HTTP/1.1 over it, which fails as a protocol error that mentions neither ALPN
// nor the mirror. Cleared here and ForceAttemptHTTP2 set, net/http negotiates
// whichever the two ends agree on and speaks it.
func NewClient(addr string, tc tlsconf.Config, timeout time.Duration) (*Client, error) {
u, err := url.Parse(addr)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("the mirror address %q is not a URL with a host", addr)
}
if timeout <= 0 {
timeout = 15 * time.Second
}
hc := &http.Client{Timeout: timeout}
if tc.Enabled() {
cfg, err := tc.ClientConfig(u.Hostname())
if err != nil {
return nil, fmt.Errorf("the mirror's TLS configuration: %w", err)
}
cfg.NextProtos = nil
hc.Transport = &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: true}
}
return &Client{base: strings.TrimSuffix(addr, "/"), hc: hc}, nil
}
// Addr is where this client points, for a page that has to say so.
func (c *Client) Addr() string { return c.base }
// APIError is a refusal the mirror explained.
//
// The status and the mirror's own sentence, both: a page showing "the mirror
// said 400" sends somebody to the wrong machine, while "the source URL returned
// 404" sends them to the right one. The status is kept alongside so a caller
// can still branch on it without parsing prose.
type APIError struct {
Status int
Msg string
}
func (e *APIError) Error() string {
if e.Msg == "" {
return fmt.Sprintf("the mirror answered %d", e.Status)
}
return e.Msg
}
// Health reports whether the mirror is up and can read its own directory.
func (c *Client) Health(ctx context.Context) error {
var out map[string]any
return c.do(ctx, http.MethodGet, "/v1/health", nil, &out)
}
// List is everything the mirror holds or is fetching.
func (c *Client) List(ctx context.Context) (ModelsResponse, error) {
var out ModelsResponse
err := c.do(ctx, http.MethodGet, "/v1/models", nil, &out)
return out, err
}
// Fetch asks the mirror to download a model.
//
// It returns as soon as the download is under way, not when it finishes —
// that is the mirror's contract and it is the caller's too. Poll Status.
func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResponse, error) {
var out FetchResponse
err := c.do(ctx, http.MethodPost, "/v1/models", req, &out)
return out, err
}
// Upload sends a file for the mirror to keep at model/version/file, with
// where it came from; it answers the URL nodes fetch it from.
func (c *Client) Upload(ctx context.Context, model, version, file string, body []byte, source string) (FetchResponse, error) {
var out FetchResponse
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.base+"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version)+"/"+url.PathEscape(file), bytes.NewReader(body))
if err != nil {
return out, err
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Llmgw-Source", source)
resp, err := c.hc.Do(req)
if err != nil {
return out, fmt.Errorf("reaching the mirror at %s: %w", c.base, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return out, err
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &e)
return out, &APIError{Status: resp.StatusCode, Msg: e.Error}
}
return out, json.Unmarshal(raw, &out)
}
// Status is where one model version stands.
func (c *Client) Status(ctx context.Context, model, version string) (FetchResponse, error) {
var out FetchResponse
err := c.do(ctx, http.MethodGet,
"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version), nil, &out)
return out, err
}
// Remove deletes a whole version and reports the files that went.
func (c *Client) Remove(ctx context.Context, model, version string) ([]string, error) {
var out struct {
Removed []string `json:"removed"`
}
err := c.do(ctx, http.MethodDelete,
"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version), nil, &out)
return out.Removed, err
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.hc.Do(req)
if err != nil {
// Wrapped with the address because the failure an operator sees on a
// page is otherwise "connection refused" with no clue which of the two
// machines refused it.
return fmt.Errorf("reaching the mirror at %s: %w", c.base, err)
}
defer resp.Body.Close()
// Capped: the mirror is trusted but it is still a separate process over a
// network, and a page that renders whatever it sends should not be able to
// be handed a gigabyte.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("reading the mirror's answer: %w", err)
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &e)
return &APIError{Status: resp.StatusCode, Msg: e.Error}
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("the mirror's answer was not the JSON expected: %w", err)
}
return nil
}