Files
llmgw/internal/cli/federation.go
Ilker ManapandClaude Opus 5.5 40e9fd2ab1 Federation: another llmgw cluster as a provider, its models as PEER/MODEL
ctl federation add/list/remove/sync keeps peers in the store (migration
0029); their /v1/models are offered here as external models named
PEER/MODEL and kept in step, a model still named by a fallback kept
withdrawn. Peers are checked at /ready, timed, and taken out of routing
while down. Requests to a peer carry x-llmgw-hop and are refused (508) when
they come back to a cluster they passed through; residency and no_store
keep requests off peers; X-Request-Id and, with --send-tags, attribution
tags go along. ctl model fallbacks sets overflow to a peer. Console view,
metrics, admin API routes and docs.

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

212 lines
7.7 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/federation"
)
func newFederationCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "federation",
Short: "Offer another llmgw cluster's models here, as PEER/MODEL",
Long: "A peer is another llmgw gateway, reached with a key it issued to this\n" +
"cluster. Its models join this catalog as PEER/MODEL and follow its own\n" +
"list; each is sent requests only while the peer's /ready answers, which\n" +
"is checked every federation.interval (10s) and timed. Name one as a\n" +
"local model's fallback to send overflow to the other site:\n" +
" llmgw ctl model fallbacks qwen-7b east/qwen-7b\n\n" +
"A project whose requests stay in the cluster (keys residency\n" +
"--in-cluster), or a policy that keeps nothing, is never sent to a peer.\n" +
"Requests carry x-llmgw-hop, so two clusters federating each other do\n" +
"not send one back and forth.",
}
c.AddCommand(newFederationAddCmd(client), newFederationListCmd(client, asJSON),
newFederationRemoveCmd(client), newFederationSyncCmd(client, asJSON))
return c
}
func newFederationAddCmd(client func() *ctlClient) *cobra.Command {
var req federation.AddRequest
c := &cobra.Command{
Use: "add NAME --url URL --key-file FILE",
Short: "Add another llmgw cluster as a peer, or change one",
Long: "The peer is checked first — its /ready, and its /v1/models with the\n" +
"key — and refused if either fails. The key file is read on the control\n" +
"plane (or env:NAME, vault:PATH#FIELD), at every check, so a rotated\n" +
"key is used without a change here; every control plane needs it.\n\n" +
"--send-tags makes each request sent to the peer carry x-llmgw-tags\n" +
"federated_from=<this cluster>,org=,team=,project=<ids>, so the peer's\n" +
"usage can say who here asked. Off unless given: the peer otherwise sees\n" +
"one key, this cluster's, and nothing about who is behind it.",
Example: " llmgw ctl federation add east --url https://llm.east.example --key-file /etc/llmgw/east.key\n" +
" llmgw ctl federation add east --url https://llm.east.example --key-file /etc/llmgw/east.key --send-tags",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req.Name = args[0]
body, _ := json.Marshal(req)
var st federation.Status
if err := client().post(cmd.Context(), "/api/v1/federation", body, &st); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s added: %d models, %s\n", st.Name, len(st.Models), healthText(st))
for _, m := range st.Models {
fmt.Fprintln(cmd.OutOrStdout(), " "+m.Name)
}
return nil
},
}
f := c.Flags()
f.StringVar(&req.URL, "url", "", "the peer gateway's client address, without /v1")
f.StringVar(&req.KeyFile, "key-file", "", "file on the control plane holding the key the peer issued to this cluster")
f.BoolVar(&req.SendTags, "send-tags", false, "tell the peer which cluster and project each request is from")
_ = c.MarkFlagRequired("url")
_ = c.MarkFlagRequired("key-file")
return c
}
func newFederationListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "Peers, whether each is up, how far away, and the models it offers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var v adminapi.FederationView
if err := client().get(cmd.Context(), "/api/v1/federation", &v); err != nil {
return err
}
if *asJSON {
return emitJSON(v)
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "this cluster: %s\n", v.Cluster)
if len(v.Peers) == 0 {
fmt.Fprintln(out, "no peers: llmgw ctl federation add NAME --url URL --key-file FILE")
return nil
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "PEER\tURL\tHEALTH\tLATENCY\tTAGS\tMODELS")
for _, p := range v.Peers {
tags := "off"
if p.SendTags {
tags = "on"
}
lat := "-"
if p.Latency > 0 {
lat = fmt.Sprintf("%.1fms", p.LatencyMs)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.URL, healthText(p), lat, tags, modelList(p))
}
return w.Flush()
},
}
}
func newFederationRemoveCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Short: "Remove a peer and its models",
Long: "Refused while a model here falls back to one of the peer's: change its\n" +
"fallbacks first (llmgw ctl model fallbacks).",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var out adminapi.Removed
if err := client().delete(cmd.Context(), "/api/v1/federation/"+url.PathEscape(args[0]), &out); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", out.Removed)
return nil
},
}
}
func newFederationSyncCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "sync NAME",
Short: "Check a peer now and take its current models, rather than at the next interval",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var st federation.Status
if err := client().post(cmd.Context(), "/api/v1/federation/"+url.PathEscape(args[0])+"/sync", nil, &st); err != nil {
return err
}
if *asJSON {
return emitJSON(st)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s, %s\n", st.Name, healthText(st), modelList(st))
return nil
},
}
}
// healthText is "up" or "down: why", with how long ago it was checked.
func healthText(st federation.Status) string {
ago := ""
if !st.Checked.IsZero() {
ago = fmt.Sprintf(" (%s ago)", time.Since(st.Checked).Round(time.Second))
}
if st.Up {
return "up" + ago
}
return "down" + ago + ": " + st.Error
}
// modelList is the peer's models by their names here, a withdrawn one
// marked as such.
func modelList(st federation.Status) string {
if len(st.Models) == 0 {
return "-"
}
names := make([]string, 0, len(st.Models))
for _, m := range st.Models {
n := m.Name
if m.Withdrawn {
n += " (withdrawn)"
}
names = append(names, n)
}
return strings.Join(names, ", ")
}
func newModelFallbacksCmd(client func() *ctlClient) *cobra.Command {
var clear bool
c := &cobra.Command{
Use: "fallbacks MODEL [FALLBACK...]",
Short: "Set the models a model falls back to when it has no capacity",
Long: "Replaces the model's fallbacks, tried in order when nothing serving it\n" +
"has room. A federation peer's model, PEER/MODEL, sends the overflow to\n" +
"the other cluster. A model added at runtime only; one declared in\n" +
"server.yaml has its fallbacks there. --clear takes them all away.",
Example: " llmgw ctl model fallbacks qwen-7b east/qwen-7b\n" +
" llmgw ctl model fallbacks qwen-7b qwen-3b east/qwen-7b\n" +
" llmgw ctl model fallbacks qwen-7b --clear",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && !clear {
return fmt.Errorf("name the fallbacks, or give --clear to take them away")
}
body, _ := json.Marshal(adminapi.FallbacksRequest{Fallbacks: args[1:]})
var v adminapi.FallbacksView
if err := client().put(cmd.Context(), "/api/v1/models/"+args[0]+"/fallbacks", body, &v); err != nil {
return err
}
if len(v.Fallbacks) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "%s has no fallbacks\n", v.Model)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "%s falls back to %s\n", v.Model, strings.Join(v.Fallbacks, ", then "))
return nil
},
}
c.Flags().BoolVar(&clear, "clear", false, "take every fallback away")
return c
}