Files
llmgw/terraform-provider-llmgw/internal/client/client.go
T
Ilker ManapandClaude Opus 5.5 15a209e652 Terraform: organizations, teams, projects, keys and memberships
llmgw_org, llmgw_team and llmgw_project rename and move in place;
llmgw_key holds the key in a sensitive secret and is made again when
revoked or expired outside Terraform; llmgw_membership puts a person in
the tree. Tried with OpenTofu against the fake admin API, drift and
import included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:42:07 +02:00

618 lines
20 KiB
Go

// Package client talks to llmgw's admin API over HTTP with the admin token.
//
// Its own types rather than the gateway's: the provider is a separate module
// so the gateway does not carry Terraform's dependencies, and the gateway's
// packages are internal to it. What keeps the two in step is the OpenAPI
// document the gateway generates, docs/admin-api.openapi.json, which a test
// here checks every endpoint and field this client uses against.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
// Endpoints are the admin API's endpoints this client calls, as the OpenAPI
// document names them. A request for any other is refused before it is
// sent, so this list is the whole of what the contract test has to check.
var Endpoints = []string{
"GET /api/v1/models",
"POST /api/v1/models",
"GET /api/v1/models/{name}",
"DELETE /api/v1/models/{name}",
"POST /api/v1/models/{name}/scale",
"POST /api/v1/models/{name}/class",
"POST /api/v1/models/{name}/owner",
"POST /api/v1/models/{name}/adapters",
"GET /api/v1/nodes",
"GET /api/v1/quotas",
"PUT /api/v1/quotas",
"DELETE /api/v1/quotas/{scope}",
"GET /api/v1/guard",
"POST /api/v1/guard/attachments",
"DELETE /api/v1/guard/attachments",
"GET /api/v1/orgs",
"POST /api/v1/orgs",
"PATCH /api/v1/orgs/{id}",
"DELETE /api/v1/orgs/{id}",
"POST /api/v1/teams",
"PATCH /api/v1/teams/{id}",
"DELETE /api/v1/teams/{id}",
"POST /api/v1/projects",
"PATCH /api/v1/projects/{id}",
"DELETE /api/v1/projects/{id}",
"POST /api/v1/keys",
"GET /api/v1/keys/{id}",
"DELETE /api/v1/keys/{id}",
"GET /api/v1/users/{id}",
"POST /api/v1/users/{id}/memberships",
"DELETE /api/v1/users/{id}/memberships",
}
// Client is a connection to one control plane's admin API.
type Client struct {
base *url.URL
token string
actor string
http *http.Client
}
// New builds a client for the admin listener at endpoint, such as
// http://127.0.0.1:9090. Actor is who changes are recorded as made by.
func New(endpoint, token, actor string) (*Client, error) {
u, err := url.Parse(strings.TrimRight(endpoint, "/"))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("endpoint %q is not an http or https address", endpoint)
}
if token == "" {
return nil, errors.New("no admin token: set token, token_file or LLMGW_ADMIN_TOKEN")
}
return &Client{base: u, token: token, actor: actor, http: &http.Client{Timeout: 2 * time.Minute}}, nil
}
// Error is the API refusing a request, with what it said.
type Error struct {
Method, Path string
Status int
Message string
}
func (e *Error) Error() string {
return fmt.Sprintf("%s %s: %d %s", e.Method, e.Path, e.Status, e.Message)
}
// IsNotFound reports whether err is the API saying there is no such thing.
func IsNotFound(err error) bool {
var e *Error
return errors.As(err, &e) && e.Status == http.StatusNotFound
}
// do sends one request to an endpoint named as in Endpoints, with args put
// in its path parameters in order.
func (c *Client) do(ctx context.Context, endpoint string, args []string, in, out any) error {
if !slices.Contains(Endpoints, endpoint) {
return fmt.Errorf("the client does not call %s: add it to Endpoints", endpoint)
}
method, tmpl, _ := strings.Cut(endpoint, " ")
p := tmpl
for _, a := range args {
i, j := strings.Index(p, "{"), strings.Index(p, "}")
if i < 0 || j < i {
return fmt.Errorf("%s takes fewer parameters than given", endpoint)
}
p = p[:i] + url.PathEscape(a) + p[j+1:]
}
if strings.Contains(p, "{") {
return fmt.Errorf("%s takes more parameters than given", endpoint)
}
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
plain, err := url.PathUnescape(p)
if err != nil {
return err
}
u := *c.base
u.Path = strings.TrimRight(u.Path, "/") + plain
// RawPath keeps an escaped slash in a name escaped: a model or a scope
// called Acme/ml is one segment, not two.
u.RawPath = strings.TrimRight(c.base.EscapedPath(), "/") + p
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
// Every change must say it is JSON, even one without a body: the admin
// listener refuses any other, as a page's form post cannot set it.
if method != http.MethodGet {
req.Header.Set("Content-Type", "application/json")
}
if c.actor != "" {
req.Header.Set("X-Llmgw-Actor", c.actor)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return err
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
msg := strings.TrimSpace(string(b))
if json.Unmarshal(b, &e) == nil && e.Error != "" {
msg = e.Error
}
return &Error{Method: method, Path: tmpl, Status: resp.StatusCode, Message: msg}
}
if out != nil {
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("%s %s: the answer is not what was expected: %w", method, tmpl, err)
}
}
return nil
}
// --- models ---
// Model is a model's definition, as the catalog holds it.
type Model struct {
Name string `json:"name,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Profiles []Profile `json:"profiles,omitempty"`
Scaling Scaling `json:"scaling,omitzero"`
Class string `json:"class,omitempty"`
Owner string `json:"owner,omitempty"`
External *External `json:"external,omitempty"`
Adapters []Adapter `json:"adapters,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
ContextLength int64 `json:"context_length,omitempty"`
MaxOutput int64 `json:"max_output,omitempty"`
Capabilities *Capabilities `json:"capabilities,omitempty"`
}
// Profile is how a model runs on one kind of node.
type Profile struct {
Name string `json:"name,omitempty"`
Engine string `json:"engine,omitempty"`
GPUClass string `json:"gpu_class,omitempty"`
TP int64 `json:"tp,omitempty"`
PP int64 `json:"pp,omitempty"`
GPUsPerNode int64 `json:"gpus_per_node,omitempty"`
Nodes int64 `json:"nodes,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
MinMemoryGB int64 `json:"min_memory_gb,omitempty"`
CPUCores int64 `json:"cpu_cores,omitempty"`
MinDiskGB int64 `json:"min_disk_gb,omitempty"`
NodeSelector map[string]string `json:"node_selector,omitempty"`
Weight float64 `json:"weight,omitempty"`
Priority int64 `json:"priority,omitempty"`
StartupTimeout time.Duration `json:"startup_timeout,omitempty"`
}
// Scaling is how many replicas a model runs.
type Scaling struct {
Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"`
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
}
// External is a model served by a provider outside the cluster.
type External struct {
URL string `json:"url"`
Model string `json:"model"`
APIKeyFile string `json:"api_key_file,omitempty"`
}
// Adapter is a LoRA adapter a model serves.
type Adapter struct {
Name string `json:"name"`
Source string `json:"source"`
Scale float64 `json:"scale,omitempty"`
}
// Capabilities are what a model can do beyond chat.
type Capabilities struct {
Tools bool `json:"tools,omitempty"`
Vision bool `json:"vision,omitempty"`
Embeddings bool `json:"embeddings,omitempty"`
}
// ModelView is a model as the listing shows it.
type ModelView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Source string `json:"source"`
Min int64 `json:"min_replicas"`
Max int64 `json:"max_replicas"`
Ready int64 `json:"ready_replicas"`
Total int64 `json:"total_replicas"`
Desired int64 `json:"desired_replicas"`
Parked bool `json:"parked,omitempty"`
Reason string `json:"reason,omitempty"`
Profiles []string `json:"profiles"`
Class string `json:"class"`
Owner string `json:"owner,omitempty"`
External string `json:"external,omitempty"`
}
// ScaleRequest sets a model's replicas.
type ScaleRequest struct {
Min int64 `json:"min"`
Max int64 `json:"max,omitempty"`
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
}
// ClassRequest sets a model's capacity class.
type ClassRequest struct {
Class string `json:"class"`
}
// OwnerRequest sets a model's owner.
type OwnerRequest struct {
Owner string `json:"owner"`
}
// OwnerView is a model's owner, as scope:ID.
type OwnerView struct {
Model string `json:"model"`
Owner string `json:"owner"`
}
// AdaptersRequest replaces a model's adapters.
type AdaptersRequest struct {
Adapters []Adapter `json:"adapters"`
}
// Models lists the catalog.
func (c *Client) Models(ctx context.Context) ([]ModelView, error) {
var out []ModelView
return out, c.do(ctx, "GET /api/v1/models", nil, nil, &out)
}
// AddModel adds a model to the catalog.
func (c *Client) AddModel(ctx context.Context, m Model) error {
return c.do(ctx, "POST /api/v1/models", nil, m, nil)
}
// Model reads one model's definition, as it is in force.
func (c *Client) Model(ctx context.Context, name string) (Model, error) {
var out Model
return out, c.do(ctx, "GET /api/v1/models/{name}", []string{name}, nil, &out)
}
// RemoveModel removes a model added at runtime.
func (c *Client) RemoveModel(ctx context.Context, name string) error {
return c.do(ctx, "DELETE /api/v1/models/{name}", []string{name}, nil, nil)
}
// Scale sets how many replicas a model runs.
func (c *Client) Scale(ctx context.Context, name string, s ScaleRequest) error {
return c.do(ctx, "POST /api/v1/models/{name}/scale", []string{name}, s, nil)
}
// SetClass sets a model's capacity class; empty goes back to its default.
func (c *Client) SetClass(ctx context.Context, name, class string) error {
return c.do(ctx, "POST /api/v1/models/{name}/class", []string{name}, ClassRequest{Class: class}, nil)
}
// SetOwner sets a model's owner, by path or ID; empty makes it shared. It
// answers with the owner as scope:ID.
func (c *Client) SetOwner(ctx context.Context, name, owner string) (string, error) {
var out OwnerView
err := c.do(ctx, "POST /api/v1/models/{name}/owner", []string{name}, OwnerRequest{Owner: owner}, &out)
return out.Owner, err
}
// SetAdapters replaces a model's LoRA adapters.
func (c *Client) SetAdapters(ctx context.Context, name string, adapters []Adapter) error {
if adapters == nil {
adapters = []Adapter{}
}
return c.do(ctx, "POST /api/v1/models/{name}/adapters", []string{name}, AdaptersRequest{Adapters: adapters}, nil)
}
// --- nodes ---
// NodeView is a node as the listing shows it.
type NodeView struct {
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
MgmtAddr string `json:"mgmt_addr,omitempty"`
State string `json:"state"`
Labels map[string]string `json:"labels,omitempty"`
GPUTotal int64 `json:"gpu_total"`
GPUFree int64 `json:"gpu_free"`
MemoryGB int64 `json:"memory_gb,omitempty"`
CPUCount int64 `json:"cpu_count,omitempty"`
Engines []string `json:"engines,omitempty"`
OS string `json:"os,omitempty"`
Classes []string `json:"gpu_classes,omitempty"`
Fabrics []string `json:"fabrics,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
Replicas int64 `json:"replicas"`
}
// Nodes lists the fleet.
func (c *Client) Nodes(ctx context.Context) ([]NodeView, error) {
var out []NodeView
return out, c.do(ctx, "GET /api/v1/nodes", nil, nil, &out)
}
// --- quotas ---
// Resources is an amount of the cluster; nil is not governed.
type Resources struct {
GPUs *int64 `json:"gpus,omitempty"`
CPUCores *int64 `json:"cpu_cores,omitempty"`
MemoryGB *int64 `json:"memory_gb,omitempty"`
}
// QuotaView is one scope's quota.
type QuotaView struct {
Scope string `json:"scope"`
Name string `json:"name"`
Guarantee Resources `json:"guarantee"`
Limit Resources `json:"limit"`
}
// QuotaRequest sets a scope's quota, the scope by path or ID.
type QuotaRequest struct {
Scope string `json:"scope"`
Guarantee Resources `json:"guarantee"`
Limit Resources `json:"limit"`
}
// QuotaSet is the scope a quota was set on, as scope:ID.
type QuotaSet struct {
Scope string `json:"scope"`
}
// Quotas lists every capacity quota.
func (c *Client) Quotas(ctx context.Context) ([]QuotaView, error) {
var out []QuotaView
return out, c.do(ctx, "GET /api/v1/quotas", nil, nil, &out)
}
// SetQuota sets a scope's quota whole, and answers with the scope as
// scope:ID.
func (c *Client) SetQuota(ctx context.Context, q QuotaRequest) (string, error) {
var out QuotaSet
err := c.do(ctx, "PUT /api/v1/quotas", nil, q, &out)
return out.Scope, err
}
// ClearQuota removes a scope's quota.
func (c *Client) ClearQuota(ctx context.Context, scope string) error {
return c.do(ctx, "DELETE /api/v1/quotas/{scope}", []string{scope}, nil, nil)
}
// --- guardrails ---
// Attachment is a policy attached to a scope. The API writes its fields by
// their Go names.
type Attachment struct {
Scope string `json:"Scope"`
ScopeID string `json:"ScopeID"`
Policy string `json:"Policy"`
}
// GuardView is the policy library and its attachments.
type GuardView struct {
Policies []string `json:"policies"`
Default []string `json:"default"`
Attachments []Attachment `json:"attachments"`
}
// GuardAttachmentRequest names a policy and where it applies.
type GuardAttachmentRequest struct {
Target string `json:"target"`
Policy string `json:"policy"`
}
// Guard reads the policies and where they are attached.
func (c *Client) Guard(ctx context.Context) (GuardView, error) {
var out GuardView
return out, c.do(ctx, "GET /api/v1/guard", nil, nil, &out)
}
// Attach attaches a policy, answering with where it went by ID.
func (c *Client) Attach(ctx context.Context, target, policy string) (Attachment, error) {
var out Attachment
err := c.do(ctx, "POST /api/v1/guard/attachments", nil, GuardAttachmentRequest{Target: target, Policy: policy}, &out)
return out, err
}
// Detach takes a policy off.
func (c *Client) Detach(ctx context.Context, target, policy string) error {
return c.do(ctx, "DELETE /api/v1/guard/attachments", nil, GuardAttachmentRequest{Target: target, Policy: policy}, nil)
}
// --- the tenancy tree, keys and memberships ---
// OrgView is an organization with its teams.
type OrgView struct {
ID string `json:"id"`
Name string `json:"name"`
Teams []TeamView `json:"teams"`
}
// TeamView is a team with its projects.
type TeamView struct {
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org"`
Projects []ProjectView `json:"projects"`
}
// ProjectView is a project.
type ProjectView struct {
ID string `json:"id"`
Name string `json:"name"`
Team string `json:"team"`
Path string `json:"path"`
}
// TenancyCreateRequest adds an organization, a team or a project.
type TenancyCreateRequest struct {
Name string `json:"name"`
In string `json:"in,omitempty"`
}
// TenancyChangeRequest renames or moves one.
type TenancyChangeRequest struct {
Name string `json:"name,omitempty"`
In string `json:"in,omitempty"`
}
// TenancyItem is one added or changed, by ID.
type TenancyItem struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
In string `json:"in,omitempty"`
}
// Levels of the tree, as the API's paths name them.
const (
LevelOrg = "orgs"
LevelTeam = "teams"
LevelProject = "projects"
)
// Tree reads every organization, team and project.
func (c *Client) Tree(ctx context.Context) ([]OrgView, error) {
var out []OrgView
return out, c.do(ctx, "GET /api/v1/orgs", nil, nil, &out)
}
// Create adds an organization, team or project.
func (c *Client) Create(ctx context.Context, level string, in TenancyCreateRequest) (TenancyItem, error) {
var out TenancyItem
return out, c.do(ctx, "POST /api/v1/"+level, nil, in, &out)
}
// Change renames an organization, team or project, or moves a team or a
// project.
func (c *Client) Change(ctx context.Context, level, id string, in TenancyChangeRequest) (TenancyItem, error) {
var out TenancyItem
return out, c.do(ctx, "PATCH /api/v1/"+level+"/{id}", []string{id}, in, &out)
}
// Remove removes an organization, team or project with nothing in it.
func (c *Client) Remove(ctx context.Context, level, id string) error {
return c.do(ctx, "DELETE /api/v1/"+level+"/{id}", []string{id}, nil, nil)
}
// KeyView is a key, never the key itself.
type KeyView struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
}
// KeyCreateRequest issues a key.
type KeyCreateRequest struct {
Name string `json:"name"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
}
// KeyCreated is a new key with the key itself, given this once.
type KeyCreated struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
Secret string `json:"secret"`
}
// CreateKey issues a key.
func (c *Client) CreateKey(ctx context.Context, in KeyCreateRequest) (KeyCreated, error) {
var out KeyCreated
return out, c.do(ctx, "POST /api/v1/keys", nil, in, &out)
}
// Key reads a key.
func (c *Client) Key(ctx context.Context, id string) (KeyView, error) {
var out KeyView
return out, c.do(ctx, "GET /api/v1/keys/{id}", []string{id}, nil, &out)
}
// RevokeKey stops a key working.
func (c *Client) RevokeKey(ctx context.Context, id string) error {
return c.do(ctx, "DELETE /api/v1/keys/{id}", []string{id}, nil, nil)
}
// UserView is a person with where they are a member.
type UserView struct {
ID string `json:"id"`
Email string `json:"email"`
Memberships []MembershipView `json:"memberships"`
}
// MembershipView is one place a person belongs, target as scope:ID.
type MembershipView struct {
Target string `json:"target"`
Role string `json:"role"`
}
// MembershipRequest names where a person goes or leaves.
type MembershipRequest struct {
Target string `json:"target"`
Role string `json:"role,omitempty"`
}
// User reads a person, by ID or email.
func (c *Client) User(ctx context.Context, id string) (UserView, error) {
var out UserView
return out, c.do(ctx, "GET /api/v1/users/{id}", []string{id}, nil, &out)
}
// Join puts a person in an organization, team or project, or changes their
// role there.
func (c *Client) Join(ctx context.Context, user string, in MembershipRequest) (UserView, error) {
var out UserView
return out, c.do(ctx, "POST /api/v1/users/{id}/memberships", []string{user}, in, &out)
}
// Leave takes a person out.
func (c *Client) Leave(ctx context.Context, user, target string) error {
return c.do(ctx, "DELETE /api/v1/users/{id}/memberships", []string{user}, MembershipRequest{Target: target}, nil)
}