diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 96b9ed6..b2f51c8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -39,6 +39,16 @@ jobs: - name: Build run: go build ./... + # The Terraform provider is a module of its own, which ./... above does + # not reach. Its tests check its client against docs/admin-api.openapi.json, + # so a change to the admin API that breaks the provider fails here. The + # acceptance tests (TF_ACC=1) are left out: they need Terraform itself. + - name: Test the Terraform provider + working-directory: terraform-provider-llmgw + run: | + go vet ./... + go test ./... + # The NVML backend needs cgo and cannot run without a driver, but it must # at least keep compiling: it is the only source of NVLink topology and a # break would otherwise surface on a GPU node, far from here. diff --git a/.gitignore b/.gitignore index 31cc842..d3819d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ *.db-shm .DS_Store coverage.out +# The provider built in place, and what Terraform leaves beside an example. +/terraform-provider-llmgw/terraform-provider-llmgw +/terraform-provider-llmgw/examples/.terraform* +/terraform-provider-llmgw/examples/terraform.tfstate* diff --git a/api/gen/llmgw/v1/agent.pb.go b/api/gen/llmgw/v1/agent.pb.go index d26030c..42bd922 100644 --- a/api/gen/llmgw/v1/agent.pb.go +++ b/api/gen/llmgw/v1/agent.pb.go @@ -90,7 +90,7 @@ func (x ShardStatus_State) Number() protoreflect.EnumNumber { // Deprecated: Use ShardStatus_State.Descriptor instead. func (ShardStatus_State) EnumDescriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{21, 0} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{22, 0} } // AgentMessage is anything a node tells the control plane. @@ -272,7 +272,13 @@ type ControlMessage struct { // *ControlMessage_FabricTest // *ControlMessage_Upgrade // *ControlMessage_ControlPlanes - Msg isControlMessage_Msg `protobuf_oneof:"msg"` + Msg isControlMessage_Msg `protobuf_oneof:"msg"` + // trace is the W3C trace context (traceparent, and tracestate when there + // is one) of the control plane's span that sent this command, so what the + // agent does for it — fetching weights, launching the engine, waiting for + // it to answer — lands in the same trace. Empty when the control plane is + // not tracing, and ignored by an agent that is not. + Trace map[string]string `protobuf:"bytes,12,rep,name=trace,proto3" json:"trace,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -413,6 +419,13 @@ func (x *ControlMessage) GetControlPlanes() *ControlPlanes { return nil } +func (x *ControlMessage) GetTrace() map[string]string { + if x != nil { + return x.Trace + } + return nil +} + type isControlMessage_Msg interface { isControlMessage_Msg() } @@ -1149,8 +1162,13 @@ type GPU struct { ClusterUuid string `protobuf:"bytes,19,opt,name=cluster_uuid,json=clusterUuid,proto3" json:"cluster_uuid,omitempty"` CliqueId string `protobuf:"bytes,20,opt,name=clique_id,json=cliqueId,proto3" json:"clique_id,omitempty"` FabricBandwidth string `protobuf:"bytes,21,opt,name=fabric_bandwidth,json=fabricBandwidth,proto3" json:"fabric_bandwidth,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // mig is set when this is a MIG instance rather than a whole card. index + // is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and + // memory the instance's share; utilization_percent stays zero, because no + // driver measures it per instance. + Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GPU) Reset() { @@ -1330,6 +1348,109 @@ func (x *GPU) GetFabricBandwidth() string { return "" } +func (x *GPU) GetMig() *MIGDevice { + if x != nil { + return x.Mig + } + return nil +} + +// MIGDevice is which card a MIG instance is carved from, and how. +type MIGDevice struct { + state protoimpl.MessageState `protogen:"open.v1"` + ParentIndex int32 `protobuf:"varint,1,opt,name=parent_index,json=parentIndex,proto3" json:"parent_index,omitempty"` + ParentUuid string `protobuf:"bytes,2,opt,name=parent_uuid,json=parentUuid,proto3" json:"parent_uuid,omitempty"` + // parent_memory_total_mb is the whole card's: the class is named from it. + ParentMemoryTotalMb int32 `protobuf:"varint,3,opt,name=parent_memory_total_mb,json=parentMemoryTotalMb,proto3" json:"parent_memory_total_mb,omitempty"` + // device is the instance's number on its card, as nvidia-smi -L lists it. + Device int32 `protobuf:"varint,4,opt,name=device,proto3" json:"device,omitempty"` + // profile is NVIDIA's name for the slice, like 3g.40gb. + Profile string `protobuf:"bytes,5,opt,name=profile,proto3" json:"profile,omitempty"` + GpuInstance int32 `protobuf:"varint,6,opt,name=gpu_instance,json=gpuInstance,proto3" json:"gpu_instance,omitempty"` + ComputeInstance int32 `protobuf:"varint,7,opt,name=compute_instance,json=computeInstance,proto3" json:"compute_instance,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MIGDevice) Reset() { + *x = MIGDevice{} + mi := &file_llmgw_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MIGDevice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MIGDevice) ProtoMessage() {} + +func (x *MIGDevice) ProtoReflect() protoreflect.Message { + mi := &file_llmgw_v1_agent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MIGDevice.ProtoReflect.Descriptor instead. +func (*MIGDevice) Descriptor() ([]byte, []int) { + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *MIGDevice) GetParentIndex() int32 { + if x != nil { + return x.ParentIndex + } + return 0 +} + +func (x *MIGDevice) GetParentUuid() string { + if x != nil { + return x.ParentUuid + } + return "" +} + +func (x *MIGDevice) GetParentMemoryTotalMb() int32 { + if x != nil { + return x.ParentMemoryTotalMb + } + return 0 +} + +func (x *MIGDevice) GetDevice() int32 { + if x != nil { + return x.Device + } + return 0 +} + +func (x *MIGDevice) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *MIGDevice) GetGpuInstance() int32 { + if x != nil { + return x.GpuInstance + } + return 0 +} + +func (x *MIGDevice) GetComputeInstance() int32 { + if x != nil { + return x.ComputeInstance + } + return 0 +} + type FabricAttachment struct { state protoimpl.MessageState `protogen:"open.v1"` // fabric is the operator-assigned name. It is not derived from the GID @@ -1344,7 +1465,7 @@ type FabricAttachment struct { func (x *FabricAttachment) Reset() { *x = FabricAttachment{} - mi := &file_llmgw_v1_agent_proto_msgTypes[8] + mi := &file_llmgw_v1_agent_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1356,7 +1477,7 @@ func (x *FabricAttachment) String() string { func (*FabricAttachment) ProtoMessage() {} func (x *FabricAttachment) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[8] + mi := &file_llmgw_v1_agent_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1369,7 +1490,7 @@ func (x *FabricAttachment) ProtoReflect() protoreflect.Message { // Deprecated: Use FabricAttachment.ProtoReflect.Descriptor instead. func (*FabricAttachment) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{8} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{9} } func (x *FabricAttachment) GetFabric() string { @@ -1411,7 +1532,7 @@ type LocalWeights struct { func (x *LocalWeights) Reset() { *x = LocalWeights{} - mi := &file_llmgw_v1_agent_proto_msgTypes[9] + mi := &file_llmgw_v1_agent_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1423,7 +1544,7 @@ func (x *LocalWeights) String() string { func (*LocalWeights) ProtoMessage() {} func (x *LocalWeights) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[9] + mi := &file_llmgw_v1_agent_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1436,7 +1557,7 @@ func (x *LocalWeights) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalWeights.ProtoReflect.Descriptor instead. func (*LocalWeights) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{9} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{10} } func (x *LocalWeights) GetPath() string { @@ -1492,7 +1613,7 @@ type NodeLoad struct { func (x *NodeLoad) Reset() { *x = NodeLoad{} - mi := &file_llmgw_v1_agent_proto_msgTypes[10] + mi := &file_llmgw_v1_agent_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1504,7 +1625,7 @@ func (x *NodeLoad) String() string { func (*NodeLoad) ProtoMessage() {} func (x *NodeLoad) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[10] + mi := &file_llmgw_v1_agent_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1517,7 +1638,7 @@ func (x *NodeLoad) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeLoad.ProtoReflect.Descriptor instead. func (*NodeLoad) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{10} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{11} } func (x *NodeLoad) GetCpuPercent() float64 { @@ -1650,7 +1771,7 @@ type GPUSensor struct { func (x *GPUSensor) Reset() { *x = GPUSensor{} - mi := &file_llmgw_v1_agent_proto_msgTypes[11] + mi := &file_llmgw_v1_agent_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1662,7 +1783,7 @@ func (x *GPUSensor) String() string { func (*GPUSensor) ProtoMessage() {} func (x *GPUSensor) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[11] + mi := &file_llmgw_v1_agent_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1675,7 +1796,7 @@ func (x *GPUSensor) ProtoReflect() protoreflect.Message { // Deprecated: Use GPUSensor.ProtoReflect.Descriptor instead. func (*GPUSensor) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{11} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{12} } func (x *GPUSensor) GetIndex() int32 { @@ -1889,7 +2010,7 @@ type ShardLoad struct { func (x *ShardLoad) Reset() { *x = ShardLoad{} - mi := &file_llmgw_v1_agent_proto_msgTypes[12] + mi := &file_llmgw_v1_agent_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1901,7 +2022,7 @@ func (x *ShardLoad) String() string { func (*ShardLoad) ProtoMessage() {} func (x *ShardLoad) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[12] + mi := &file_llmgw_v1_agent_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1914,7 +2035,7 @@ func (x *ShardLoad) ProtoReflect() protoreflect.Message { // Deprecated: Use ShardLoad.ProtoReflect.Descriptor instead. func (*ShardLoad) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{12} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{13} } func (x *ShardLoad) GetShardId() string { @@ -1982,7 +2103,7 @@ type IBPort struct { func (x *IBPort) Reset() { *x = IBPort{} - mi := &file_llmgw_v1_agent_proto_msgTypes[13] + mi := &file_llmgw_v1_agent_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1994,7 +2115,7 @@ func (x *IBPort) String() string { func (*IBPort) ProtoMessage() {} func (x *IBPort) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[13] + mi := &file_llmgw_v1_agent_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2007,7 +2128,7 @@ func (x *IBPort) ProtoReflect() protoreflect.Message { // Deprecated: Use IBPort.ProtoReflect.Descriptor instead. func (*IBPort) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{13} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{14} } func (x *IBPort) GetHca() string { @@ -2150,7 +2271,7 @@ type SubnetManager struct { func (x *SubnetManager) Reset() { *x = SubnetManager{} - mi := &file_llmgw_v1_agent_proto_msgTypes[14] + mi := &file_llmgw_v1_agent_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2162,7 +2283,7 @@ func (x *SubnetManager) String() string { func (*SubnetManager) ProtoMessage() {} func (x *SubnetManager) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[14] + mi := &file_llmgw_v1_agent_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2175,7 +2296,7 @@ func (x *SubnetManager) ProtoReflect() protoreflect.Message { // Deprecated: Use SubnetManager.ProtoReflect.Descriptor instead. func (*SubnetManager) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{14} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{15} } func (x *SubnetManager) GetGuid() string { @@ -2218,7 +2339,7 @@ type IMEX struct { func (x *IMEX) Reset() { *x = IMEX{} - mi := &file_llmgw_v1_agent_proto_msgTypes[15] + mi := &file_llmgw_v1_agent_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2230,7 +2351,7 @@ func (x *IMEX) String() string { func (*IMEX) ProtoMessage() {} func (x *IMEX) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[15] + mi := &file_llmgw_v1_agent_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2243,7 +2364,7 @@ func (x *IMEX) ProtoReflect() protoreflect.Message { // Deprecated: Use IMEX.ProtoReflect.Descriptor instead. func (*IMEX) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{15} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{16} } func (x *IMEX) GetState() string { @@ -2275,7 +2396,7 @@ type GPUFabricState struct { func (x *GPUFabricState) Reset() { *x = GPUFabricState{} - mi := &file_llmgw_v1_agent_proto_msgTypes[16] + mi := &file_llmgw_v1_agent_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2287,7 +2408,7 @@ func (x *GPUFabricState) String() string { func (*GPUFabricState) ProtoMessage() {} func (x *GPUFabricState) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[16] + mi := &file_llmgw_v1_agent_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2300,7 +2421,7 @@ func (x *GPUFabricState) ProtoReflect() protoreflect.Message { // Deprecated: Use GPUFabricState.ProtoReflect.Descriptor instead. func (*GPUFabricState) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{16} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{17} } func (x *GPUFabricState) GetIndex() int32 { @@ -2415,7 +2536,7 @@ type Heartbeat struct { func (x *Heartbeat) Reset() { *x = Heartbeat{} - mi := &file_llmgw_v1_agent_proto_msgTypes[17] + mi := &file_llmgw_v1_agent_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2427,7 +2548,7 @@ func (x *Heartbeat) String() string { func (*Heartbeat) ProtoMessage() {} func (x *Heartbeat) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[17] + mi := &file_llmgw_v1_agent_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2440,7 +2561,7 @@ func (x *Heartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. func (*Heartbeat) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{17} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{18} } func (x *Heartbeat) GetNodeId() string { @@ -2595,7 +2716,7 @@ type Provision struct { func (x *Provision) Reset() { *x = Provision{} - mi := &file_llmgw_v1_agent_proto_msgTypes[18] + mi := &file_llmgw_v1_agent_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2607,7 +2728,7 @@ func (x *Provision) String() string { func (*Provision) ProtoMessage() {} func (x *Provision) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[18] + mi := &file_llmgw_v1_agent_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2620,7 +2741,7 @@ func (x *Provision) ProtoReflect() protoreflect.Message { // Deprecated: Use Provision.ProtoReflect.Descriptor instead. func (*Provision) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{18} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{19} } func (x *Provision) GetEngines() []*EngineWant { @@ -2644,7 +2765,7 @@ type EngineWant struct { func (x *EngineWant) Reset() { *x = EngineWant{} - mi := &file_llmgw_v1_agent_proto_msgTypes[19] + mi := &file_llmgw_v1_agent_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2656,7 +2777,7 @@ func (x *EngineWant) String() string { func (*EngineWant) ProtoMessage() {} func (x *EngineWant) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[19] + mi := &file_llmgw_v1_agent_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2669,7 +2790,7 @@ func (x *EngineWant) ProtoReflect() protoreflect.Message { // Deprecated: Use EngineWant.ProtoReflect.Descriptor instead. func (*EngineWant) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{19} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{20} } func (x *EngineWant) GetEngine() string { @@ -2709,7 +2830,7 @@ type ProvisionStatus struct { func (x *ProvisionStatus) Reset() { *x = ProvisionStatus{} - mi := &file_llmgw_v1_agent_proto_msgTypes[20] + mi := &file_llmgw_v1_agent_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2721,7 +2842,7 @@ func (x *ProvisionStatus) String() string { func (*ProvisionStatus) ProtoMessage() {} func (x *ProvisionStatus) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[20] + mi := &file_llmgw_v1_agent_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2734,7 +2855,7 @@ func (x *ProvisionStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProvisionStatus.ProtoReflect.Descriptor instead. func (*ProvisionStatus) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{20} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{21} } func (x *ProvisionStatus) GetEngine() string { @@ -2798,7 +2919,7 @@ type ShardStatus struct { func (x *ShardStatus) Reset() { *x = ShardStatus{} - mi := &file_llmgw_v1_agent_proto_msgTypes[21] + mi := &file_llmgw_v1_agent_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2810,7 +2931,7 @@ func (x *ShardStatus) String() string { func (*ShardStatus) ProtoMessage() {} func (x *ShardStatus) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[21] + mi := &file_llmgw_v1_agent_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2823,7 +2944,7 @@ func (x *ShardStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ShardStatus.ProtoReflect.Descriptor instead. func (*ShardStatus) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{21} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{22} } func (x *ShardStatus) GetNodeId() string { @@ -2897,7 +3018,7 @@ type FetchProgress struct { func (x *FetchProgress) Reset() { *x = FetchProgress{} - mi := &file_llmgw_v1_agent_proto_msgTypes[22] + mi := &file_llmgw_v1_agent_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2909,7 +3030,7 @@ func (x *FetchProgress) String() string { func (*FetchProgress) ProtoMessage() {} func (x *FetchProgress) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[22] + mi := &file_llmgw_v1_agent_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2922,7 +3043,7 @@ func (x *FetchProgress) ProtoReflect() protoreflect.Message { // Deprecated: Use FetchProgress.ProtoReflect.Descriptor instead. func (*FetchProgress) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{22} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{23} } func (x *FetchProgress) GetSource() string { @@ -2985,7 +3106,7 @@ type StartShard struct { func (x *StartShard) Reset() { *x = StartShard{} - mi := &file_llmgw_v1_agent_proto_msgTypes[23] + mi := &file_llmgw_v1_agent_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2997,7 +3118,7 @@ func (x *StartShard) String() string { func (*StartShard) ProtoMessage() {} func (x *StartShard) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[23] + mi := &file_llmgw_v1_agent_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3010,7 +3131,7 @@ func (x *StartShard) ProtoReflect() protoreflect.Message { // Deprecated: Use StartShard.ProtoReflect.Descriptor instead. func (*StartShard) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{23} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{24} } func (x *StartShard) GetReplicaId() string { @@ -3154,7 +3275,7 @@ type EngineProfile struct { func (x *EngineProfile) Reset() { *x = EngineProfile{} - mi := &file_llmgw_v1_agent_proto_msgTypes[24] + mi := &file_llmgw_v1_agent_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3166,7 +3287,7 @@ func (x *EngineProfile) String() string { func (*EngineProfile) ProtoMessage() {} func (x *EngineProfile) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[24] + mi := &file_llmgw_v1_agent_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3179,7 +3300,7 @@ func (x *EngineProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use EngineProfile.ProtoReflect.Descriptor instead. func (*EngineProfile) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{24} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{25} } func (x *EngineProfile) GetName() string { @@ -3282,7 +3403,7 @@ type RemoveWeights struct { func (x *RemoveWeights) Reset() { *x = RemoveWeights{} - mi := &file_llmgw_v1_agent_proto_msgTypes[25] + mi := &file_llmgw_v1_agent_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3294,7 +3415,7 @@ func (x *RemoveWeights) String() string { func (*RemoveWeights) ProtoMessage() {} func (x *RemoveWeights) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[25] + mi := &file_llmgw_v1_agent_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3307,7 +3428,7 @@ func (x *RemoveWeights) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWeights.ProtoReflect.Descriptor instead. func (*RemoveWeights) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{25} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{26} } func (x *RemoveWeights) GetPaths() []string { @@ -3327,7 +3448,7 @@ type StopShard struct { func (x *StopShard) Reset() { *x = StopShard{} - mi := &file_llmgw_v1_agent_proto_msgTypes[26] + mi := &file_llmgw_v1_agent_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3339,7 +3460,7 @@ func (x *StopShard) String() string { func (*StopShard) ProtoMessage() {} func (x *StopShard) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[26] + mi := &file_llmgw_v1_agent_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3352,7 +3473,7 @@ func (x *StopShard) ProtoReflect() protoreflect.Message { // Deprecated: Use StopShard.ProtoReflect.Descriptor instead. func (*StopShard) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{26} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{27} } func (x *StopShard) GetShardId() string { @@ -3372,7 +3493,7 @@ type DrainShard struct { func (x *DrainShard) Reset() { *x = DrainShard{} - mi := &file_llmgw_v1_agent_proto_msgTypes[27] + mi := &file_llmgw_v1_agent_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3384,7 +3505,7 @@ func (x *DrainShard) String() string { func (*DrainShard) ProtoMessage() {} func (x *DrainShard) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[27] + mi := &file_llmgw_v1_agent_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3397,7 +3518,7 @@ func (x *DrainShard) ProtoReflect() protoreflect.Message { // Deprecated: Use DrainShard.ProtoReflect.Descriptor instead. func (*DrainShard) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{27} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{28} } func (x *DrainShard) GetShardId() string { @@ -3419,7 +3540,7 @@ type Ack struct { func (x *Ack) Reset() { *x = Ack{} - mi := &file_llmgw_v1_agent_proto_msgTypes[28] + mi := &file_llmgw_v1_agent_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3431,7 +3552,7 @@ func (x *Ack) String() string { func (*Ack) ProtoMessage() {} func (x *Ack) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[28] + mi := &file_llmgw_v1_agent_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3444,7 +3565,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message { // Deprecated: Use Ack.ProtoReflect.Descriptor instead. func (*Ack) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{28} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{29} } func (x *Ack) GetMessageId() string { @@ -3488,7 +3609,7 @@ type LogsRequest struct { func (x *LogsRequest) Reset() { *x = LogsRequest{} - mi := &file_llmgw_v1_agent_proto_msgTypes[29] + mi := &file_llmgw_v1_agent_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3500,7 +3621,7 @@ func (x *LogsRequest) String() string { func (*LogsRequest) ProtoMessage() {} func (x *LogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[29] + mi := &file_llmgw_v1_agent_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3513,7 +3634,7 @@ func (x *LogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogsRequest.ProtoReflect.Descriptor instead. func (*LogsRequest) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{29} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{30} } func (x *LogsRequest) GetRequestId() string { @@ -3553,7 +3674,7 @@ type LogsReply struct { func (x *LogsReply) Reset() { *x = LogsReply{} - mi := &file_llmgw_v1_agent_proto_msgTypes[30] + mi := &file_llmgw_v1_agent_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3565,7 +3686,7 @@ func (x *LogsReply) String() string { func (*LogsReply) ProtoMessage() {} func (x *LogsReply) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[30] + mi := &file_llmgw_v1_agent_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3578,7 +3699,7 @@ func (x *LogsReply) ProtoReflect() protoreflect.Message { // Deprecated: Use LogsReply.ProtoReflect.Descriptor instead. func (*LogsReply) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{30} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{31} } func (x *LogsReply) GetRequestId() string { @@ -3624,7 +3745,7 @@ type FabricScanRequest struct { func (x *FabricScanRequest) Reset() { *x = FabricScanRequest{} - mi := &file_llmgw_v1_agent_proto_msgTypes[31] + mi := &file_llmgw_v1_agent_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3636,7 +3757,7 @@ func (x *FabricScanRequest) String() string { func (*FabricScanRequest) ProtoMessage() {} func (x *FabricScanRequest) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[31] + mi := &file_llmgw_v1_agent_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3649,7 +3770,7 @@ func (x *FabricScanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FabricScanRequest.ProtoReflect.Descriptor instead. func (*FabricScanRequest) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{31} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{32} } func (x *FabricScanRequest) GetRequestId() string { @@ -3688,7 +3809,7 @@ type FabricScanReply struct { func (x *FabricScanReply) Reset() { *x = FabricScanReply{} - mi := &file_llmgw_v1_agent_proto_msgTypes[32] + mi := &file_llmgw_v1_agent_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3700,7 +3821,7 @@ func (x *FabricScanReply) String() string { func (*FabricScanReply) ProtoMessage() {} func (x *FabricScanReply) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[32] + mi := &file_llmgw_v1_agent_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3713,7 +3834,7 @@ func (x *FabricScanReply) ProtoReflect() protoreflect.Message { // Deprecated: Use FabricScanReply.ProtoReflect.Descriptor instead. func (*FabricScanReply) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{32} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{33} } func (x *FabricScanReply) GetRequestId() string { @@ -3769,7 +3890,7 @@ type FabricTestRequest struct { func (x *FabricTestRequest) Reset() { *x = FabricTestRequest{} - mi := &file_llmgw_v1_agent_proto_msgTypes[33] + mi := &file_llmgw_v1_agent_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3781,7 +3902,7 @@ func (x *FabricTestRequest) String() string { func (*FabricTestRequest) ProtoMessage() {} func (x *FabricTestRequest) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[33] + mi := &file_llmgw_v1_agent_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3794,7 +3915,7 @@ func (x *FabricTestRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FabricTestRequest.ProtoReflect.Descriptor instead. func (*FabricTestRequest) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{33} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{34} } func (x *FabricTestRequest) GetRequestId() string { @@ -3863,7 +3984,7 @@ type FabricTestReply struct { func (x *FabricTestReply) Reset() { *x = FabricTestReply{} - mi := &file_llmgw_v1_agent_proto_msgTypes[34] + mi := &file_llmgw_v1_agent_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3875,7 +3996,7 @@ func (x *FabricTestReply) String() string { func (*FabricTestReply) ProtoMessage() {} func (x *FabricTestReply) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[34] + mi := &file_llmgw_v1_agent_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3888,7 +4009,7 @@ func (x *FabricTestReply) ProtoReflect() protoreflect.Message { // Deprecated: Use FabricTestReply.ProtoReflect.Descriptor instead. func (*FabricTestReply) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{34} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{35} } func (x *FabricTestReply) GetRequestId() string { @@ -3948,7 +4069,7 @@ type UpgradeAgent struct { func (x *UpgradeAgent) Reset() { *x = UpgradeAgent{} - mi := &file_llmgw_v1_agent_proto_msgTypes[35] + mi := &file_llmgw_v1_agent_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3960,7 +4081,7 @@ func (x *UpgradeAgent) String() string { func (*UpgradeAgent) ProtoMessage() {} func (x *UpgradeAgent) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[35] + mi := &file_llmgw_v1_agent_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3973,7 +4094,7 @@ func (x *UpgradeAgent) ProtoReflect() protoreflect.Message { // Deprecated: Use UpgradeAgent.ProtoReflect.Descriptor instead. func (*UpgradeAgent) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{35} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{36} } func (x *UpgradeAgent) GetRequestId() string { @@ -4010,7 +4131,7 @@ type UpgradeReply struct { func (x *UpgradeReply) Reset() { *x = UpgradeReply{} - mi := &file_llmgw_v1_agent_proto_msgTypes[36] + mi := &file_llmgw_v1_agent_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4022,7 +4143,7 @@ func (x *UpgradeReply) String() string { func (*UpgradeReply) ProtoMessage() {} func (x *UpgradeReply) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[36] + mi := &file_llmgw_v1_agent_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4035,7 +4156,7 @@ func (x *UpgradeReply) ProtoReflect() protoreflect.Message { // Deprecated: Use UpgradeReply.ProtoReflect.Descriptor instead. func (*UpgradeReply) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{36} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{37} } func (x *UpgradeReply) GetRequestId() string { @@ -4071,7 +4192,7 @@ type LoraAdapter struct { func (x *LoraAdapter) Reset() { *x = LoraAdapter{} - mi := &file_llmgw_v1_agent_proto_msgTypes[37] + mi := &file_llmgw_v1_agent_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4083,7 +4204,7 @@ func (x *LoraAdapter) String() string { func (*LoraAdapter) ProtoMessage() {} func (x *LoraAdapter) ProtoReflect() protoreflect.Message { - mi := &file_llmgw_v1_agent_proto_msgTypes[37] + mi := &file_llmgw_v1_agent_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4096,7 +4217,7 @@ func (x *LoraAdapter) ProtoReflect() protoreflect.Message { // Deprecated: Use LoraAdapter.ProtoReflect.Descriptor instead. func (*LoraAdapter) Descriptor() ([]byte, []int) { - return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{37} + return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{38} } func (x *LoraAdapter) GetName() string { @@ -4128,7 +4249,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" + "\vfabric_test\x18\x06 \x01(\v2\x19.llmgw.v1.FabricTestReplyH\x00R\n" + "fabricTest\x122\n" + "\aupgrade\x18\a \x01(\v2\x16.llmgw.v1.UpgradeReplyH\x00R\aupgradeB\x05\n" + - "\x03msg\"\xdb\x04\n" + + "\x03msg\"\xd0\x05\n" + "\x0eControlMessage\x12,\n" + "\x05start\x18\x01 \x01(\v2\x14.llmgw.v1.StartShardH\x00R\x05start\x12)\n" + "\x04stop\x18\x02 \x01(\v2\x13.llmgw.v1.StopShardH\x00R\x04stop\x12,\n" + @@ -4143,7 +4264,12 @@ const file_llmgw_v1_agent_proto_rawDesc = "" + "fabricTest\x122\n" + "\aupgrade\x18\n" + " \x01(\v2\x16.llmgw.v1.UpgradeAgentH\x00R\aupgrade\x12@\n" + - "\x0econtrol_planes\x18\v \x01(\v2\x17.llmgw.v1.ControlPlanesH\x00R\rcontrolPlanesB\x05\n" + + "\x0econtrol_planes\x18\v \x01(\v2\x17.llmgw.v1.ControlPlanesH\x00R\rcontrolPlanes\x129\n" + + "\x05trace\x18\f \x03(\v2#.llmgw.v1.ControlMessage.TraceEntryR\x05trace\x1a8\n" + + "\n" + + "TraceEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x05\n" + "\x03msg\")\n" + "\rControlPlanes\x12\x18\n" + "\aservers\x18\x01 \x03(\tR\aservers\"\x80\a\n" + @@ -4210,7 +4336,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" + "\vleader_port\x18\x0e \x01(\x05R\n" + "leaderPort\x12\x1d\n" + "\n" + - "profile_fp\x18\x0f \x01(\tR\tprofileFp\"\xa3\x06\n" + + "profile_fp\x18\x0f \x01(\tR\tprofileFp\"\xca\x06\n" + "\x03GPU\x12\x14\n" + "\x05index\x18\x01 \x01(\x05R\x05index\x12\x12\n" + "\x04uuid\x18\x02 \x01(\tR\x04uuid\x12\x12\n" + @@ -4235,10 +4361,20 @@ const file_llmgw_v1_agent_proto_rawDesc = "" + "\rfabric_status\x18\x12 \x01(\tR\ffabricStatus\x12!\n" + "\fcluster_uuid\x18\x13 \x01(\tR\vclusterUuid\x12\x1b\n" + "\tclique_id\x18\x14 \x01(\tR\bcliqueId\x12)\n" + - "\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x1a;\n" + + "\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x12%\n" + + "\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x1a;\n" + "\rNicPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"[\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x02\n" + + "\tMIGDevice\x12!\n" + + "\fparent_index\x18\x01 \x01(\x05R\vparentIndex\x12\x1f\n" + + "\vparent_uuid\x18\x02 \x01(\tR\n" + + "parentUuid\x123\n" + + "\x16parent_memory_total_mb\x18\x03 \x01(\x05R\x13parentMemoryTotalMb\x12\x16\n" + + "\x06device\x18\x04 \x01(\x05R\x06device\x12\x18\n" + + "\aprofile\x18\x05 \x01(\tR\aprofile\x12!\n" + + "\fgpu_instance\x18\x06 \x01(\x05R\vgpuInstance\x12)\n" + + "\x10compute_instance\x18\a \x01(\x05R\x0fcomputeInstance\"[\n" + "\x10FabricAttachment\x12\x16\n" + "\x06fabric\x18\x01 \x01(\tR\x06fabric\x12\x12\n" + "\x04hcas\x18\x02 \x03(\tR\x04hcas\x12\x1b\n" + @@ -4532,7 +4668,7 @@ func file_llmgw_v1_agent_proto_rawDescGZIP() []byte { } var file_llmgw_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_llmgw_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 44) +var file_llmgw_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 46) var file_llmgw_v1_agent_proto_goTypes = []any{ (ShardStatus_State)(0), // 0: llmgw.v1.ShardStatus.State (*AgentMessage)(nil), // 1: llmgw.v1.AgentMessage @@ -4543,99 +4679,103 @@ var file_llmgw_v1_agent_proto_goTypes = []any{ (*Storage)(nil), // 6: llmgw.v1.Storage (*RunningShard)(nil), // 7: llmgw.v1.RunningShard (*GPU)(nil), // 8: llmgw.v1.GPU - (*FabricAttachment)(nil), // 9: llmgw.v1.FabricAttachment - (*LocalWeights)(nil), // 10: llmgw.v1.LocalWeights - (*NodeLoad)(nil), // 11: llmgw.v1.NodeLoad - (*GPUSensor)(nil), // 12: llmgw.v1.GPUSensor - (*ShardLoad)(nil), // 13: llmgw.v1.ShardLoad - (*IBPort)(nil), // 14: llmgw.v1.IBPort - (*SubnetManager)(nil), // 15: llmgw.v1.SubnetManager - (*IMEX)(nil), // 16: llmgw.v1.IMEX - (*GPUFabricState)(nil), // 17: llmgw.v1.GPUFabricState - (*Heartbeat)(nil), // 18: llmgw.v1.Heartbeat - (*Provision)(nil), // 19: llmgw.v1.Provision - (*EngineWant)(nil), // 20: llmgw.v1.EngineWant - (*ProvisionStatus)(nil), // 21: llmgw.v1.ProvisionStatus - (*ShardStatus)(nil), // 22: llmgw.v1.ShardStatus - (*FetchProgress)(nil), // 23: llmgw.v1.FetchProgress - (*StartShard)(nil), // 24: llmgw.v1.StartShard - (*EngineProfile)(nil), // 25: llmgw.v1.EngineProfile - (*RemoveWeights)(nil), // 26: llmgw.v1.RemoveWeights - (*StopShard)(nil), // 27: llmgw.v1.StopShard - (*DrainShard)(nil), // 28: llmgw.v1.DrainShard - (*Ack)(nil), // 29: llmgw.v1.Ack - (*LogsRequest)(nil), // 30: llmgw.v1.LogsRequest - (*LogsReply)(nil), // 31: llmgw.v1.LogsReply - (*FabricScanRequest)(nil), // 32: llmgw.v1.FabricScanRequest - (*FabricScanReply)(nil), // 33: llmgw.v1.FabricScanReply - (*FabricTestRequest)(nil), // 34: llmgw.v1.FabricTestRequest - (*FabricTestReply)(nil), // 35: llmgw.v1.FabricTestReply - (*UpgradeAgent)(nil), // 36: llmgw.v1.UpgradeAgent - (*UpgradeReply)(nil), // 37: llmgw.v1.UpgradeReply - (*LoraAdapter)(nil), // 38: llmgw.v1.LoraAdapter - nil, // 39: llmgw.v1.Register.LabelsEntry - nil, // 40: llmgw.v1.Register.EngineVersionsEntry - nil, // 41: llmgw.v1.GPU.NicPathsEntry - nil, // 42: llmgw.v1.IBPort.CountersEntry - nil, // 43: llmgw.v1.Heartbeat.EngineVersionsEntry - nil, // 44: llmgw.v1.EngineProfile.EnvEntry + (*MIGDevice)(nil), // 9: llmgw.v1.MIGDevice + (*FabricAttachment)(nil), // 10: llmgw.v1.FabricAttachment + (*LocalWeights)(nil), // 11: llmgw.v1.LocalWeights + (*NodeLoad)(nil), // 12: llmgw.v1.NodeLoad + (*GPUSensor)(nil), // 13: llmgw.v1.GPUSensor + (*ShardLoad)(nil), // 14: llmgw.v1.ShardLoad + (*IBPort)(nil), // 15: llmgw.v1.IBPort + (*SubnetManager)(nil), // 16: llmgw.v1.SubnetManager + (*IMEX)(nil), // 17: llmgw.v1.IMEX + (*GPUFabricState)(nil), // 18: llmgw.v1.GPUFabricState + (*Heartbeat)(nil), // 19: llmgw.v1.Heartbeat + (*Provision)(nil), // 20: llmgw.v1.Provision + (*EngineWant)(nil), // 21: llmgw.v1.EngineWant + (*ProvisionStatus)(nil), // 22: llmgw.v1.ProvisionStatus + (*ShardStatus)(nil), // 23: llmgw.v1.ShardStatus + (*FetchProgress)(nil), // 24: llmgw.v1.FetchProgress + (*StartShard)(nil), // 25: llmgw.v1.StartShard + (*EngineProfile)(nil), // 26: llmgw.v1.EngineProfile + (*RemoveWeights)(nil), // 27: llmgw.v1.RemoveWeights + (*StopShard)(nil), // 28: llmgw.v1.StopShard + (*DrainShard)(nil), // 29: llmgw.v1.DrainShard + (*Ack)(nil), // 30: llmgw.v1.Ack + (*LogsRequest)(nil), // 31: llmgw.v1.LogsRequest + (*LogsReply)(nil), // 32: llmgw.v1.LogsReply + (*FabricScanRequest)(nil), // 33: llmgw.v1.FabricScanRequest + (*FabricScanReply)(nil), // 34: llmgw.v1.FabricScanReply + (*FabricTestRequest)(nil), // 35: llmgw.v1.FabricTestRequest + (*FabricTestReply)(nil), // 36: llmgw.v1.FabricTestReply + (*UpgradeAgent)(nil), // 37: llmgw.v1.UpgradeAgent + (*UpgradeReply)(nil), // 38: llmgw.v1.UpgradeReply + (*LoraAdapter)(nil), // 39: llmgw.v1.LoraAdapter + nil, // 40: llmgw.v1.ControlMessage.TraceEntry + nil, // 41: llmgw.v1.Register.LabelsEntry + nil, // 42: llmgw.v1.Register.EngineVersionsEntry + nil, // 43: llmgw.v1.GPU.NicPathsEntry + nil, // 44: llmgw.v1.IBPort.CountersEntry + nil, // 45: llmgw.v1.Heartbeat.EngineVersionsEntry + nil, // 46: llmgw.v1.EngineProfile.EnvEntry } var file_llmgw_v1_agent_proto_depIdxs = []int32{ 4, // 0: llmgw.v1.AgentMessage.register:type_name -> llmgw.v1.Register - 18, // 1: llmgw.v1.AgentMessage.heartbeat:type_name -> llmgw.v1.Heartbeat - 22, // 2: llmgw.v1.AgentMessage.shard:type_name -> llmgw.v1.ShardStatus - 31, // 3: llmgw.v1.AgentMessage.logs:type_name -> llmgw.v1.LogsReply - 33, // 4: llmgw.v1.AgentMessage.fabric_scan:type_name -> llmgw.v1.FabricScanReply - 35, // 5: llmgw.v1.AgentMessage.fabric_test:type_name -> llmgw.v1.FabricTestReply - 37, // 6: llmgw.v1.AgentMessage.upgrade:type_name -> llmgw.v1.UpgradeReply - 24, // 7: llmgw.v1.ControlMessage.start:type_name -> llmgw.v1.StartShard - 27, // 8: llmgw.v1.ControlMessage.stop:type_name -> llmgw.v1.StopShard - 28, // 9: llmgw.v1.ControlMessage.drain:type_name -> llmgw.v1.DrainShard - 29, // 10: llmgw.v1.ControlMessage.ack:type_name -> llmgw.v1.Ack - 26, // 11: llmgw.v1.ControlMessage.remove_weights:type_name -> llmgw.v1.RemoveWeights - 19, // 12: llmgw.v1.ControlMessage.provision:type_name -> llmgw.v1.Provision - 30, // 13: llmgw.v1.ControlMessage.logs:type_name -> llmgw.v1.LogsRequest - 32, // 14: llmgw.v1.ControlMessage.fabric_scan:type_name -> llmgw.v1.FabricScanRequest - 34, // 15: llmgw.v1.ControlMessage.fabric_test:type_name -> llmgw.v1.FabricTestRequest - 36, // 16: llmgw.v1.ControlMessage.upgrade:type_name -> llmgw.v1.UpgradeAgent + 19, // 1: llmgw.v1.AgentMessage.heartbeat:type_name -> llmgw.v1.Heartbeat + 23, // 2: llmgw.v1.AgentMessage.shard:type_name -> llmgw.v1.ShardStatus + 32, // 3: llmgw.v1.AgentMessage.logs:type_name -> llmgw.v1.LogsReply + 34, // 4: llmgw.v1.AgentMessage.fabric_scan:type_name -> llmgw.v1.FabricScanReply + 36, // 5: llmgw.v1.AgentMessage.fabric_test:type_name -> llmgw.v1.FabricTestReply + 38, // 6: llmgw.v1.AgentMessage.upgrade:type_name -> llmgw.v1.UpgradeReply + 25, // 7: llmgw.v1.ControlMessage.start:type_name -> llmgw.v1.StartShard + 28, // 8: llmgw.v1.ControlMessage.stop:type_name -> llmgw.v1.StopShard + 29, // 9: llmgw.v1.ControlMessage.drain:type_name -> llmgw.v1.DrainShard + 30, // 10: llmgw.v1.ControlMessage.ack:type_name -> llmgw.v1.Ack + 27, // 11: llmgw.v1.ControlMessage.remove_weights:type_name -> llmgw.v1.RemoveWeights + 20, // 12: llmgw.v1.ControlMessage.provision:type_name -> llmgw.v1.Provision + 31, // 13: llmgw.v1.ControlMessage.logs:type_name -> llmgw.v1.LogsRequest + 33, // 14: llmgw.v1.ControlMessage.fabric_scan:type_name -> llmgw.v1.FabricScanRequest + 35, // 15: llmgw.v1.ControlMessage.fabric_test:type_name -> llmgw.v1.FabricTestRequest + 37, // 16: llmgw.v1.ControlMessage.upgrade:type_name -> llmgw.v1.UpgradeAgent 3, // 17: llmgw.v1.ControlMessage.control_planes:type_name -> llmgw.v1.ControlPlanes - 39, // 18: llmgw.v1.Register.labels:type_name -> llmgw.v1.Register.LabelsEntry - 8, // 19: llmgw.v1.Register.gpus:type_name -> llmgw.v1.GPU - 9, // 20: llmgw.v1.Register.fabrics:type_name -> llmgw.v1.FabricAttachment - 40, // 21: llmgw.v1.Register.engine_versions:type_name -> llmgw.v1.Register.EngineVersionsEntry - 7, // 22: llmgw.v1.Register.running_shards:type_name -> llmgw.v1.RunningShard - 10, // 23: llmgw.v1.Register.weights:type_name -> llmgw.v1.LocalWeights - 6, // 24: llmgw.v1.Register.storage:type_name -> llmgw.v1.Storage - 5, // 25: llmgw.v1.Register.host:type_name -> llmgw.v1.Host - 16, // 26: llmgw.v1.Register.imex:type_name -> llmgw.v1.IMEX - 0, // 27: llmgw.v1.RunningShard.state:type_name -> llmgw.v1.ShardStatus.State - 41, // 28: llmgw.v1.GPU.nic_paths:type_name -> llmgw.v1.GPU.NicPathsEntry - 13, // 29: llmgw.v1.NodeLoad.shards:type_name -> llmgw.v1.ShardLoad - 12, // 30: llmgw.v1.NodeLoad.gpus:type_name -> llmgw.v1.GPUSensor - 42, // 31: llmgw.v1.IBPort.counters:type_name -> llmgw.v1.IBPort.CountersEntry - 15, // 32: llmgw.v1.IBPort.subnet_managers:type_name -> llmgw.v1.SubnetManager - 6, // 33: llmgw.v1.Heartbeat.storage:type_name -> llmgw.v1.Storage - 10, // 34: llmgw.v1.Heartbeat.weights:type_name -> llmgw.v1.LocalWeights - 43, // 35: llmgw.v1.Heartbeat.engine_versions:type_name -> llmgw.v1.Heartbeat.EngineVersionsEntry - 21, // 36: llmgw.v1.Heartbeat.provision:type_name -> llmgw.v1.ProvisionStatus - 11, // 37: llmgw.v1.Heartbeat.load:type_name -> llmgw.v1.NodeLoad - 14, // 38: llmgw.v1.Heartbeat.ib_ports:type_name -> llmgw.v1.IBPort - 9, // 39: llmgw.v1.Heartbeat.fabrics:type_name -> llmgw.v1.FabricAttachment - 17, // 40: llmgw.v1.Heartbeat.gpu_fabric:type_name -> llmgw.v1.GPUFabricState - 16, // 41: llmgw.v1.Heartbeat.imex:type_name -> llmgw.v1.IMEX - 20, // 42: llmgw.v1.Provision.engines:type_name -> llmgw.v1.EngineWant - 0, // 43: llmgw.v1.ShardStatus.state:type_name -> llmgw.v1.ShardStatus.State - 23, // 44: llmgw.v1.ShardStatus.fetch:type_name -> llmgw.v1.FetchProgress - 25, // 45: llmgw.v1.StartShard.profile:type_name -> llmgw.v1.EngineProfile - 44, // 46: llmgw.v1.EngineProfile.env:type_name -> llmgw.v1.EngineProfile.EnvEntry - 38, // 47: llmgw.v1.EngineProfile.adapters:type_name -> llmgw.v1.LoraAdapter - 1, // 48: llmgw.v1.AgentService.Connect:input_type -> llmgw.v1.AgentMessage - 2, // 49: llmgw.v1.AgentService.Connect:output_type -> llmgw.v1.ControlMessage - 49, // [49:50] is the sub-list for method output_type - 48, // [48:49] is the sub-list for method input_type - 48, // [48:48] is the sub-list for extension type_name - 48, // [48:48] is the sub-list for extension extendee - 0, // [0:48] is the sub-list for field type_name + 40, // 18: llmgw.v1.ControlMessage.trace:type_name -> llmgw.v1.ControlMessage.TraceEntry + 41, // 19: llmgw.v1.Register.labels:type_name -> llmgw.v1.Register.LabelsEntry + 8, // 20: llmgw.v1.Register.gpus:type_name -> llmgw.v1.GPU + 10, // 21: llmgw.v1.Register.fabrics:type_name -> llmgw.v1.FabricAttachment + 42, // 22: llmgw.v1.Register.engine_versions:type_name -> llmgw.v1.Register.EngineVersionsEntry + 7, // 23: llmgw.v1.Register.running_shards:type_name -> llmgw.v1.RunningShard + 11, // 24: llmgw.v1.Register.weights:type_name -> llmgw.v1.LocalWeights + 6, // 25: llmgw.v1.Register.storage:type_name -> llmgw.v1.Storage + 5, // 26: llmgw.v1.Register.host:type_name -> llmgw.v1.Host + 17, // 27: llmgw.v1.Register.imex:type_name -> llmgw.v1.IMEX + 0, // 28: llmgw.v1.RunningShard.state:type_name -> llmgw.v1.ShardStatus.State + 43, // 29: llmgw.v1.GPU.nic_paths:type_name -> llmgw.v1.GPU.NicPathsEntry + 9, // 30: llmgw.v1.GPU.mig:type_name -> llmgw.v1.MIGDevice + 14, // 31: llmgw.v1.NodeLoad.shards:type_name -> llmgw.v1.ShardLoad + 13, // 32: llmgw.v1.NodeLoad.gpus:type_name -> llmgw.v1.GPUSensor + 44, // 33: llmgw.v1.IBPort.counters:type_name -> llmgw.v1.IBPort.CountersEntry + 16, // 34: llmgw.v1.IBPort.subnet_managers:type_name -> llmgw.v1.SubnetManager + 6, // 35: llmgw.v1.Heartbeat.storage:type_name -> llmgw.v1.Storage + 11, // 36: llmgw.v1.Heartbeat.weights:type_name -> llmgw.v1.LocalWeights + 45, // 37: llmgw.v1.Heartbeat.engine_versions:type_name -> llmgw.v1.Heartbeat.EngineVersionsEntry + 22, // 38: llmgw.v1.Heartbeat.provision:type_name -> llmgw.v1.ProvisionStatus + 12, // 39: llmgw.v1.Heartbeat.load:type_name -> llmgw.v1.NodeLoad + 15, // 40: llmgw.v1.Heartbeat.ib_ports:type_name -> llmgw.v1.IBPort + 10, // 41: llmgw.v1.Heartbeat.fabrics:type_name -> llmgw.v1.FabricAttachment + 18, // 42: llmgw.v1.Heartbeat.gpu_fabric:type_name -> llmgw.v1.GPUFabricState + 17, // 43: llmgw.v1.Heartbeat.imex:type_name -> llmgw.v1.IMEX + 21, // 44: llmgw.v1.Provision.engines:type_name -> llmgw.v1.EngineWant + 0, // 45: llmgw.v1.ShardStatus.state:type_name -> llmgw.v1.ShardStatus.State + 24, // 46: llmgw.v1.ShardStatus.fetch:type_name -> llmgw.v1.FetchProgress + 26, // 47: llmgw.v1.StartShard.profile:type_name -> llmgw.v1.EngineProfile + 46, // 48: llmgw.v1.EngineProfile.env:type_name -> llmgw.v1.EngineProfile.EnvEntry + 39, // 49: llmgw.v1.EngineProfile.adapters:type_name -> llmgw.v1.LoraAdapter + 1, // 50: llmgw.v1.AgentService.Connect:input_type -> llmgw.v1.AgentMessage + 2, // 51: llmgw.v1.AgentService.Connect:output_type -> llmgw.v1.ControlMessage + 51, // [51:52] is the sub-list for method output_type + 50, // [50:51] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_llmgw_v1_agent_proto_init() } @@ -4671,7 +4811,7 @@ func file_llmgw_v1_agent_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_llmgw_v1_agent_proto_rawDesc), len(file_llmgw_v1_agent_proto_rawDesc)), NumEnums: 1, - NumMessages: 44, + NumMessages: 46, NumExtensions: 0, NumServices: 1, }, diff --git a/api/proto/llmgw/v1/agent.proto b/api/proto/llmgw/v1/agent.proto index 98caf13..90447fb 100644 --- a/api/proto/llmgw/v1/agent.proto +++ b/api/proto/llmgw/v1/agent.proto @@ -45,6 +45,12 @@ message ControlMessage { UpgradeAgent upgrade = 10; ControlPlanes control_planes = 11; } + // trace is the W3C trace context (traceparent, and tracestate when there + // is one) of the control plane's span that sent this command, so what the + // agent does for it — fetching weights, launching the engine, waiting for + // it to answer — lands in the same trace. Empty when the control plane is + // not tracing, and ignored by an agent that is not. + map trace = 12; } // ControlPlanes is every control plane an agent may dial, sent by the leader @@ -252,6 +258,25 @@ message GPU { string cluster_uuid = 19; string clique_id = 20; string fabric_bandwidth = 21; + // mig is set when this is a MIG instance rather than a whole card. index + // is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and + // memory the instance's share; utilization_percent stays zero, because no + // driver measures it per instance. + MIGDevice mig = 22; +} + +// MIGDevice is which card a MIG instance is carved from, and how. +message MIGDevice { + int32 parent_index = 1; + string parent_uuid = 2; + // parent_memory_total_mb is the whole card's: the class is named from it. + int32 parent_memory_total_mb = 3; + // device is the instance's number on its card, as nvidia-smi -L lists it. + int32 device = 4; + // profile is NVIDIA's name for the slice, like 3g.40gb. + string profile = 5; + int32 gpu_instance = 6; + int32 compute_instance = 7; } message FabricAttachment { diff --git a/deploy/eval/arithmetic.yaml b/deploy/eval/arithmetic.yaml new file mode 100644 index 0000000..c1bf1ae --- /dev/null +++ b/deploy/eval/arithmetic.yaml @@ -0,0 +1,58 @@ +# Arithmetic a small instruction model gets right: sums, products and a +# little reading, each answer scored by the last number in it. A quantisation +# that has gone wrong, or a chat template that no longer matches the +# weights, shows up here as a falling score long before anyone notices it in +# conversation. +# +# llmgw ctl eval MODEL --suite arithmetic +# llmgw ctl model update MODEL --eval arithmetic --min-score 0.8 +# +# Copy it into control.eval_dir (/etc/llmgw/eval) to change it; a suite +# there of the same name replaces this one. +name: arithmetic +description: Sums, products and word problems, each scored by the number in its answer. +threshold: 0.7 +system: You are a careful calculator. Answer with the number only, without words or units. +max_tokens: 64 +cases: + - id: add-small + prompt: What is 17 + 25? + number: 42 + - id: add-carry + prompt: What is 389 + 476? + number: 865 + - id: subtract + prompt: What is 1000 - 357? + number: 643 + - id: subtract-negative + prompt: What is 12 - 30? + number: -18 + - id: multiply + prompt: What is 12 * 12? + number: 144 + - id: multiply-larger + prompt: What is 23 * 17? + number: 391 + weight: 2 + - id: divide + prompt: What is 144 / 6? + number: 24 + - id: decimal + prompt: What is 2.5 * 4? + number: 10 + - id: percent + prompt: What is 15% of 200? + number: 30 + - id: words-apples + prompt: Ana has 7 apples and buys 5 more, then gives 3 away. How many apples does she have? + number: 9 + weight: 2 + - id: words-time + prompt: A train leaves at 14:00 and the trip takes 150 minutes. How many hours does the trip take? + number: 2.5 + tolerance: 0.01 + weight: 2 + - id: order-of-operations + prompt: What is 2 + 3 * 4? + number: 14 + weight: 2 diff --git a/deploy/eval/json-format.yaml b/deploy/eval/json-format.yaml new file mode 100644 index 0000000..f23e5a4 --- /dev/null +++ b/deploy/eval/json-format.yaml @@ -0,0 +1,94 @@ +# Following a format: asked for JSON of a given shape, does the model give +# it — the thing an application parsing its answers depends on, and the +# first to go when a chat template drifts. Each answer is read as JSON (a +# code fence or a sentence around it is forgiven) and held to a schema. +# +# llmgw ctl eval MODEL --suite json-format +# +# Copy it into control.eval_dir (/etc/llmgw/eval) to change it; a suite +# there of the same name replaces this one. +name: json-format +description: Answers asked for as JSON of a given shape, held to a schema. +threshold: 0.7 +system: You answer with a single JSON value and nothing else. +max_tokens: 200 +cases: + - id: person + prompt: >- + Give a JSON object for a person named Ada Lovelace, born in 1815, with the + keys "name" (a string) and "born" (a number). + json_schema: + type: object + required: [name, born] + properties: + name: {type: string, pattern: "Ada"} + born: {type: integer, minimum: 1815, maximum: 1815} + + - id: list-of-colours + prompt: Give a JSON array of exactly three colour names, as strings. + json_schema: + type: array + minItems: 3 + maxItems: 3 + items: {type: string, minLength: 1} + + - id: boolean-answer + prompt: >- + Is water wet? Answer as a JSON object with one key, "answer", whose + value is true or false. + json_schema: + type: object + required: [answer] + properties: + answer: {type: boolean} + + - id: nested + prompt: >- + Give a JSON object describing a book: "title" (a string), "author" (an + object with "first" and "last", both strings) and "pages" (a whole number + above zero). Use any book. + json_schema: + type: object + required: [title, author, pages] + properties: + title: {type: string, minLength: 1} + author: + type: object + required: [first, last] + properties: + first: {type: string} + last: {type: string} + pages: {type: integer, exclusiveMinimum: 0} + weight: 2 + + - id: enum-choice + prompt: >- + Classify the sentence "I love this product!" as positive, negative or + neutral. Answer as a JSON object {"sentiment": "..."} using one of those + three words in lower case. + json_schema: + type: object + required: [sentiment] + properties: + sentiment: {enum: [positive]} + weight: 2 + + - id: numbers + prompt: 'Give a JSON object {"sum": N} where N is the sum of 4, 5 and 6.' + json_schema: + type: object + required: [sum] + properties: + sum: {const: 15} + + - id: no-extra-keys + prompt: >- + Give a JSON object with exactly two keys, "city" and "country", for + Paris. No other keys. + json_schema: + type: object + required: [city, country] + additionalProperties: false + properties: + city: {type: string} + country: {type: string} diff --git a/deploy/examples/server.yaml b/deploy/examples/server.yaml index 5f6edf4..8f6d9f6 100644 --- a/deploy/examples/server.yaml +++ b/deploy/examples/server.yaml @@ -159,6 +159,12 @@ router: # back to a client that did not ask for it. inject_usage: true max_body_bytes: 33554432 + # Memory for the answers of projects that reuse them (llmgw keys cache), + # all together; the least recently used go first. Nothing is reused until a + # project turns it on. + # cache: + # max_bytes: 134217728 # 128 MB + # max_entry_bytes: 1048576 # 1 MB timeouts: dial: 5s diff --git a/docs.go b/docs.go index 11e35bc..ff3b449 100644 --- a/docs.go +++ b/docs.go @@ -1,10 +1,103 @@ -// Package llmgw holds what the binary carries besides code: its manual. +// Package llmgw holds what the binary carries besides code: its manual, the +// description of its admin API, and the example quality suites. package llmgw -import "embed" +import ( + "embed" + "io/fs" + "sort" + "strings" +) // Docs is the documentation, read on any machine by `llmgw docs` and the // console's manual without a checkout or the internet. // //go:embed README.md CHANGELOG.md docs/*.md var Docs embed.FS + +// Doc is one page of the manual. +type Doc struct { + // Key is what `llmgw docs KEY` takes, and the page's address under /docs/ + // on the gateway. + Key string + // Path is the file in Docs. + Path string + // Title is the document's first heading. + Title string +} + +// Source is the document's markdown. +func (d Doc) Source() string { + b, err := fs.ReadFile(Docs, d.Path) + if err != nil { + return "# " + d.Path + "\n\nNot in this build." + } + return string(b) +} + +// docOrder is the documents in the order someone new reads them. Here rather +// than with either reader, because the console's manual and the gateway's +// docs portal both list them, and two lists drift. +var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding", + "clients", "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data", + "reference", "development"} + +// Manual is every document, in reading order: those docOrder names, then any +// other under docs/ alphabetically, so a new one is never missing, then the +// README and the changes. +func Manual() []Doc { + var out []Doc + seen := map[string]bool{} + add := func(key, path string) { + if seen[key] { + return + } + b, err := fs.ReadFile(Docs, path) + if err != nil { + return + } + seen[key] = true + out = append(out, Doc{Key: key, Path: path, Title: titleOf(string(b), path)}) + } + for _, k := range docOrder { + add(k, "docs/"+k+".md") + } + entries, _ := fs.ReadDir(Docs, "docs") + var rest []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".md") { + rest = append(rest, strings.TrimSuffix(e.Name(), ".md")) + } + } + sort.Strings(rest) + for _, k := range rest { + add(k, "docs/"+k+".md") + } + add("readme", "README.md") + add("changes", "CHANGELOG.md") + return out +} + +// titleOf is a document's first top-level heading, or its path when it has +// none. +func titleOf(src, path string) string { + for _, l := range strings.Split(src, "\n") { + if strings.HasPrefix(l, "# ") { + return strings.TrimSpace(strings.TrimPrefix(l, "# ")) + } + } + return path +} + +// AdminOpenAPI is the admin API described in OpenAPI 3.1, generated from its +// route table by `go generate` in internal/docgen and served at +// /api/v1/openapi.json, so a client can be pointed at the server it talks to. +// +//go:embed docs/admin-api.openapi.json +var AdminOpenAPI []byte + +// EvalSuites are the example quality suites under deploy/eval, which the +// control plane runs by name when its eval directory has none of the name. +// +//go:embed deploy/eval/*.yaml +var EvalSuites embed.FS diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..964969e --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,160 @@ +# The admin API + +Written for whoever automates a cluster rather than typing at it: how to reach +the control plane's management API, what it asks of a request, and where its +description is. + +`llmgw ctl` and the console are clients of this API and nothing more, so +anything they do can be done with `curl`, a generated client or the +[Terraform provider](../terraform-provider-llmgw/README.md). It adds and +removes models, scales them, cordons and upgrades nodes, sets capacity +quotas, attaches guardrail policies, and reads the fleet, usage and the audit +log. + +## Reaching it + +The API is under `/api/v1` on the admin listener, `admin_listen` in +`/etc/llmgw/server.yaml`: `127.0.0.1:9090` unless changed. It is on loopback +on purpose. The same listener serves pprof when it is turned on, and the +server refuses to start with it on any other address unless +`admin_remote: true` — the token would cross the network in clear. + +From another machine, tunnel to it rather than moving it: + +```sh +ssh -N -L 9090:127.0.0.1:9090 admin@control-plane & +curl -s -H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" http://127.0.0.1:9090/api/v1/summary +``` + +With `admin_remote: true` and `admin_listen: 10.0.0.5:9090`, call that address +directly — on a network you trust, or behind a TLS proxy that keeps the +`Host` header an address. + +## The token + +Every path under `/api` needs the admin token as a bearer token: + +``` +Authorization: Bearer 6f1c… +``` + +The server writes it the first time it starts, 0600, to +`/var/lib/llmgw/admin-token` under its systemd unit (`admin_token_file` +moves it; a server run by hand keeps it in `secrets/admin-token` beside its +configuration). `llmgw ctl` reads it from there, which is why it runs with +sudo, or from `LLMGW_ADMIN_TOKEN`. To give it to automation: + +```sh +sudo cat /var/lib/llmgw/admin-token +``` + +The token is the whole of the API's authorisation: whoever holds it can do +everything the API does. Keep it where you keep other root credentials. + +## What a request must look like + +Besides the token, the listener refuses three things before a handler sees +them, as defences against a browser being turned against it: + +- **A name in `Host`.** The listener answers to an address or `localhost`, + not a DNS name, so a page on some other site cannot reach it by rebinding + a name to 127.0.0.1. Call it as `http://127.0.0.1:9090`, not + `http://cp.example.com:9090`; behind a proxy, have the proxy send an + address. +- **A cross-site request.** One whose `Origin` is another host, or whose + `Sec-Fetch-Site` says another site sent it. +- **A change that is not JSON.** Every method but `GET` and `HEAD` must carry + `Content-Type: application/json`, even a `DELETE` or a `POST` with no body. + A form post from a page cannot set that header. + +A change is recorded in the audit log with who made it, as the request says +in `X-Llmgw-Actor` (letters, digits and `._-@`, at most 64; `unknown` when +absent). `llmgw ctl` sends the name of whoever ran it; automation should send +its own, `terraform` or `ci@deploy`, so `llmgw ctl audit` can tell them apart. + +A refusal is a 4xx or 5xx with `{"error": "…"}` saying why. 501 means this +control plane lacks what the endpoint needs — a store, a mirror, guard +policies — and says which. + +## Some calls + +```sh +H=(-H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" -H "Content-Type: application/json" -H "X-Llmgw-Actor: $USER") +API=http://127.0.0.1:9090/api/v1 + +# The fleet in one answer, and every model. +curl -s "${H[@]}" $API/summary +curl -s "${H[@]}" $API/models + +# Add a model, read back its definition with the defaults filled in, scale it. +curl -s "${H[@]}" -X POST $API/models -d '{ + "name": "qwen-small", + "source": "/srv/models/qwen2.5-1.5b-instruct-q4_k_m.gguf", + "profiles": [{"name": "cpu", "engine": "llamacpp", "min_memory_gb": 4, "cpu_cores": 4}], + "scaling": {"min": 1, "max": 2} +}' +curl -s "${H[@]}" $API/models/qwen-small +curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2, "idle_timeout": 1800000000000}' + +# A team's capacity quota. +curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}' + +# Remove the model; a DELETE carries the content type too. +curl -s "${H[@]}" -X DELETE $API/models/qwen-small +``` + +Durations in bodies are integer nanoseconds, as Go encodes them: half an hour +is `1800000000000`. + +## The description + +The API is described in OpenAPI 3.1: + +- served by every control plane at `GET /api/v1/openapi.json` (with the + token, like the rest), as built into that binary, so it describes the + server you are talking to; +- committed as [admin-api.openapi.json](admin-api.openapi.json) for reading + without a cluster. + +```sh +curl -s "${H[@]}" $API/openapi.json > llmgw-admin.json +``` + +It lists every endpoint, its path and query parameters, and the schema of +each request and answer, with the meaning of each field. Load it into any +OpenAPI tool — Swagger UI, Redoc, an editor — or generate a client from it: + +```sh +docker run --rm -v "$PWD:/w" openapitools/openapi-generator-cli generate \ + -i /w/llmgw-admin.json -g python -o /w/llmgw-admin-client +``` + +### How it is kept true + +The document is generated, not written. `internal/control/adminapi/routes.go` +holds a table of every endpoint: its method and path, a summary, its query +parameters, and Go values of the types it reads and answers with. The +generator in `internal/docgen/openapi` reads those types by reflection, the +way `encoding/json` encodes them, and takes each field's description from its +comment in the source. + +Two tests keep it from drifting. One registers the API on a recording mux and +fails when an endpoint is served and not in the table, or in the table and not +served. The other regenerates the document and fails when it differs from the +committed one. After changing an endpoint or a type it carries: + +```sh +go generate ./internal/docgen +``` + +## What it does not cover + +- **Organizations, teams, projects, people and API keys.** These live in the + store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the + control plane, or in the web console's `/admin` pages on the client port + behind a session. The admin API reads the tree only where a quota or a + policy names a part of it. +- **The client-facing API** (`/v1/chat/completions` and the rest), which is + OpenAI's and Anthropic's, on `listen`, with API keys. +- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard + pages and `/debug/pprof`, which are not under `/api`. diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json new file mode 100644 index 0000000..74c449b --- /dev/null +++ b/docs/admin-api.openapi.json @@ -0,0 +1,6722 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "llmgw admin API", + "version": "v1", + "description": "The control plane's management API: what `llmgw ctl` and the console use.\n\nIt is served on the admin listener (admin_listen, 127.0.0.1:9090 by default), which stays on loopback unless admin_remote is set: reach it from elsewhere through an SSH tunnel. Every path under /api needs the admin token as a bearer token.\n\nThe listener also refuses, before the token is looked at: a Host header that is a name rather than an address or localhost; a request another site's page sent; and any method but GET and HEAD whose Content-Type is not application/json, even one with no body.\n\nDurations in bodies are integer nanoseconds, as Go encodes them. Generated from the route table in internal/control/adminapi; see docs/admin-api.md." + }, + "servers": [ + { + "url": "http://127.0.0.1:9090", + "description": "The admin listener at its default address, admin_listen." + } + ], + "security": [ + { + "adminToken": [] + } + ], + "tags": [ + { + "name": "audit" + }, + { + "name": "canaries" + }, + { + "name": "capacity" + }, + { + "name": "eval" + }, + { + "name": "explain-placement" + }, + { + "name": "fabric-test" + }, + { + "name": "fabrics" + }, + { + "name": "guard" + }, + { + "name": "jobs" + }, + { + "name": "metrics" + }, + { + "name": "models" + }, + { + "name": "nodes" + }, + { + "name": "notify" + }, + { + "name": "nvlink" + }, + { + "name": "openapi" + }, + { + "name": "plan" + }, + { + "name": "quotas" + }, + { + "name": "replicas" + }, + { + "name": "requests" + }, + { + "name": "restart" + }, + { + "name": "summary" + }, + { + "name": "updates" + }, + { + "name": "usage" + }, + { + "name": "weights" + } + ], + "paths": { + "/api/v1/audit": { + "get": { + "operationId": "getAudit", + "summary": "Changes made, newest first", + "tags": [ + "audit" + ], + "parameters": [ + { + "name": "since", + "in": "query", + "description": "How far back: 90m, 24h, 7d; 7d when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The most events to answer with.", + "schema": { + "type": "integer" + } + }, + { + "name": "actor", + "in": "query", + "description": "Only changes by this actor.", + "schema": { + "type": "string" + } + }, + { + "name": "contains", + "in": "query", + "description": "Only events whose action or target contains this.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.AuditEvent" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/canaries": { + "get": { + "operationId": "getCanaries", + "summary": "Every canary, and how its two sides serve", + "tags": [ + "canaries" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.CanaryView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/capacity": { + "get": { + "operationId": "getCapacity", + "summary": "Every recorded measurement", + "tags": [ + "capacity" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/eval/runs": { + "get": { + "operationId": "getEvalRuns", + "summary": "Quality runs, the newest first", + "tags": [ + "eval" + ], + "parameters": [ + { + "name": "model", + "in": "query", + "description": "Only this model's runs.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many; a sensible number when absent.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/eval.Live" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/eval/runs/{id}": { + "get": { + "operationId": "getEvalRunsId", + "summary": "One quality run, with each case", + "tags": [ + "eval" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The quality run's ID, as starting it answered.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eval.Live" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/eval/suites": { + "get": { + "operationId": "getEvalSuites", + "summary": "The quality suites the control plane can run by name", + "tags": [ + "eval" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/eval.SuiteInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/explain-placement": { + "get": { + "operationId": "getExplainPlacement", + "summary": "Why a model could not be placed", + "tags": [ + "explain-placement" + ], + "parameters": [ + { + "name": "model", + "in": "query", + "required": true, + "description": "The model's name or an alias.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabric-test": { + "post": { + "operationId": "postFabricTest", + "summary": "Measure bandwidth between two nodes, rail by rail", + "tags": [ + "fabric-test" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FabricTestRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FabricTestView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabrics": { + "get": { + "operationId": "getFabrics", + "summary": "The physical fabrics and the names nodes give them", + "tags": [ + "fabrics" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/fabrics.Report" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabrics/{id}/topology": { + "get": { + "operationId": "getFabricsIdTopology", + "summary": "One fabric's map, with the cluster's adapters live on it", + "tags": [ + "fabrics" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The physical fabric's ID, as GET /api/v1/fabrics lists it.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopologyView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/guard": { + "get": { + "operationId": "getGuard", + "summary": "The guardrail policies and where they are attached", + "tags": [ + "guard" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/guard/attachments": { + "post": { + "operationId": "postGuardAttachments", + "summary": "Attach a guardrail policy", + "tags": [ + "guard" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardAttachmentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/guard.Attachment" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteGuardAttachments", + "summary": "Detach a guardrail policy", + "tags": [ + "guard" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardAttachmentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/guard.Attachment" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/jobs": { + "get": { + "operationId": "getJobs", + "summary": "Every job since the control plane started", + "tags": [ + "jobs" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/jobs/{id}": { + "get": { + "operationId": "getJobsId", + "summary": "One job", + "tags": [ + "jobs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job's ID, as starting it answered.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/metrics/range": { + "post": { + "operationId": "postMetricsRange", + "summary": "Several Prometheus range queries at once", + "description": "Read from the Prometheus monitoring.prometheus names; configured is false without one.", + "tags": [ + "metrics" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models": { + "get": { + "operationId": "getModels", + "summary": "Every model, with its replicas and state", + "tags": [ + "models" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "postModels", + "summary": "Add a model to the catalog", + "description": "Kept in the store across restarts. Refused when the name is taken, a model in configuration has it, or the definition would make the catalog invalid.", + "tags": [ + "models" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelAdded" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/serve": { + "post": { + "operationId": "postModelsServe", + "summary": "Fetch a registry repository and serve it", + "tags": [ + "models" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Request" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}": { + "get": { + "operationId": "getModelsName", + "summary": "One model's definition, as it is in force", + "description": "With the defaults the catalog filled in. By its name only, not an alias.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteModelsName", + "summary": "Remove a model added at runtime", + "description": "A model declared in configuration is refused: it would come back on the next restart.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Removed" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/adapters": { + "post": { + "operationId": "postModelsNameAdapters", + "summary": "Replace a model's LoRA adapters", + "description": "Each new file is read first, to check it is an adapter for the model's architecture.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdaptersRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdaptersRequest" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/canary": { + "post": { + "operationId": "postModelsNameCanary", + "summary": "Start a canary of a model's newer weights", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Canary" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "putModelsNameCanary", + "summary": "Change the share of requests a canary takes", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CanaryPercentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteModelsNameCanary", + "summary": "Take a canary out: its share first, then the candidate", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/capacity": { + "put": { + "operationId": "putModelsNameCapacity", + "summary": "Record what a replica was measured to serve", + "description": "Replaces an earlier measurement of the same model on the same profile.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bench.Capacity" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bench.Capacity" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/class": { + "post": { + "operationId": "postModelsNameClass", + "summary": "Set a model's capacity class", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClassRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClassView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/eval": { + "post": { + "operationId": "postModelsNameEval", + "summary": "Ask a model a quality suite", + "description": "Started at once and answered with the run, to be followed at /api/v1/eval/runs/{id}.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eval.Request" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eval.Live" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/eval-gate": { + "put": { + "operationId": "putModelsNameEvalGate", + "summary": "Hold a model's updates and promotions to a quality suite", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvalGateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvalGateView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteModelsNameEvalGate", + "summary": "Stop holding a model's updates to a suite", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvalGateView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/headroom": { + "get": { + "operationId": "getModelsNameHeadroom", + "summary": "How many more replicas of a model fit now, and where", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The most to try placing; 16 when absent or past 64.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeadroomView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/origin": { + "post": { + "operationId": "postModelsNameOrigin", + "summary": "Record the repository a model's weights came from", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OriginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OriginView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/owner": { + "post": { + "operationId": "postModelsNameOwner", + "summary": "Set the part of the tenancy tree a model's capacity counts against", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/promote": { + "post": { + "operationId": "postModelsNamePromote", + "summary": "Make a canary's weights the model's", + "description": "With a quality suite, or the model's standing gate, the canary is asked it first and kept below the score.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Change" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/resources": { + "post": { + "operationId": "postModelsNameResources", + "summary": "Set what a model's profiles reserve of a node", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/retry": { + "post": { + "operationId": "postModelsNameRetry", + "summary": "Try a parked model again", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetryView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/rollback": { + "post": { + "operationId": "postModelsNameRollback", + "summary": "Go back to the weights the last update replaced", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/scale": { + "post": { + "operationId": "postModelsNameScale", + "summary": "Set how many replicas a model runs", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScaleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScaleResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/target": { + "get": { + "operationId": "getModelsNameTarget", + "summary": "How many replicas a throughput needs, and whether they fit", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "name": "tokens_per_sec", + "in": "query", + "required": true, + "description": "The output tokens a second wanted of the model.", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/update": { + "post": { + "operationId": "postModelsNameUpdate", + "summary": "Move a model to its repository's newer weights", + "description": "With a quality suite, or the model's standing gate, the new weights are asked it first as a hidden canary.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Change" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes": { + "get": { + "operationId": "getNodes", + "summary": "Every node, with its cards, load and fabrics", + "tags": [ + "nodes" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}": { + "delete": { + "operationId": "deleteNodesId", + "summary": "Forget a node that has gone", + "description": "Only a node that is lost, or unreachable with nothing on it; its certificate is revoked.", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeRemoved" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/engines": { + "post": { + "operationId": "postNodesIdEngines", + "summary": "Choose an engine's build for a node", + "description": "The node installs the build chosen. Build is auto, a build such as cu129, or none.", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeEngineRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeEngineRequest" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/logs": { + "get": { + "operationId": "getNodesIdLogs", + "summary": "The last lines of a node's logs", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "How many lines; 300 when absent, at most 2000.", + "schema": { + "type": "integer" + } + }, + { + "name": "source", + "in": "query", + "description": "Which log: agent when absent, or an engine the node lists in sources.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/state": { + "post": { + "operationId": "postNodesIdState", + "summary": "Cordon, drain or ready a node", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeStateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeStateView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/upgrade": { + "post": { + "operationId": "postNodesIdUpgrade", + "summary": "Have a node run a release", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradeView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/notify/test": { + "post": { + "operationId": "postNotifyTest", + "summary": "Send a test notification to every webhook", + "tags": [ + "notify" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/notify.Result" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nvlink": { + "get": { + "operationId": "getNvlink", + "summary": "The multi-node NVLink domains and what keeps nodes out of them", + "tags": [ + "nvlink" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/nvlink.Report" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "getOpenapiJson", + "summary": "This document", + "description": "The admin API in OpenAPI 3.1, generated from the route table and built into the binary.", + "tags": [ + "openapi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/plan": { + "get": { + "operationId": "getPlan", + "summary": "How a registry repository would be served here", + "tags": [ + "plan" + ], + "parameters": [ + { + "name": "repo", + "in": "query", + "required": true, + "description": "The repository, owner/name.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "gguf or safetensors; gguf when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "context", + "in": "query", + "description": "The context length to plan for, in tokens.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Plan" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/quotas": { + "get": { + "operationId": "getQuotas", + "summary": "Every capacity quota, with what its scope holds now", + "tags": [ + "quotas" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuotaView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "putQuotas", + "summary": "Set a scope's capacity quota", + "description": "Replaces the scope's quota whole. The scope may be named by path or by ID; the answer names it by ID.", + "tags": [ + "quotas" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaSet" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/quotas/{scope}": { + "delete": { + "operationId": "deleteQuotasScope", + "summary": "Remove a scope's capacity quota", + "tags": [ + "quotas" + ], + "parameters": [ + { + "name": "scope", + "in": "path", + "required": true, + "description": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Removed" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/replicas": { + "get": { + "operationId": "getReplicas", + "summary": "Every replica, with its shards", + "tags": [ + "replicas" + ], + "parameters": [ + { + "name": "model", + "in": "query", + "description": "Only this model's replicas.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReplicaView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}": { + "get": { + "operationId": "getRequestsId", + "summary": "What one request went through", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}/body": { + "get": { + "operationId": "getRequestsIdBody", + "summary": "A kept request and its answer", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/store.RequestBody" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}/replay": { + "post": { + "operationId": "postRequestsIdReplay", + "summary": "Send a kept request again, to any model", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/restart": { + "post": { + "operationId": "postRestart", + "summary": "Restart replicas, every replica of some models, or all", + "tags": [ + "restart" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/summary": { + "get": { + "operationId": "getSummary", + "summary": "The whole fleet in one answer", + "tags": [ + "summary" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Summary" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/updates": { + "get": { + "operationId": "getUpdates", + "summary": "Newer weights the registry has for models", + "tags": [ + "updates" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/updates/check": { + "post": { + "operationId": "postUpdatesCheck", + "summary": "Ask the registry now, about one model or every one", + "tags": [ + "updates" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/usage": { + "get": { + "operationId": "getUsage", + "summary": "What each group of the tenancy tree spent", + "tags": [ + "usage" + ], + "parameters": [ + { + "name": "by", + "in": "query", + "description": "org, team or project; team when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "since", + "in": "query", + "description": "A duration back from now, like 24h; 24h when absent.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScopeUsageView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/usage/export": { + "get": { + "operationId": "getUsageExport", + "summary": "Usage totals by period and dimension, as CSV or JSON", + "tags": [ + "usage" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "description": "A date (2026-09-01) or an RFC 3339 time; the first of this month when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "description": "A date or an RFC 3339 time; now when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "period", + "in": "query", + "description": "hour, day or month; day when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "by", + "in": "query", + "description": "Dimensions, comma-separated: org, team, project, user, key, model.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "json for JSON; CSV otherwise.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.ExportRow" + } + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/weights/collect": { + "post": { + "operationId": "postWeightsCollect", + "summary": "Delete weights that no model uses", + "tags": [ + "weights" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectResult" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/weights/orphans": { + "get": { + "operationId": "getWeightsOrphans", + "summary": "Weights on nodes that no model uses", + "tags": [ + "weights" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrphanView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "securitySchemes": { + "adminToken": { + "type": "http", + "scheme": "bearer", + "description": "The admin token: the contents of /var/lib/llmgw/admin-token on the control plane (admin_token_file), readable by root and the server. Sent as Authorization: Bearer TOKEN." + } + }, + "parameters": { + "Actor": { + "name": "X-Llmgw-Actor", + "in": "header", + "required": false, + "description": "Who the change is recorded as made by in the audit log: letters, digits and ._-@, at most 64. unknown when absent.", + "schema": { + "type": "string" + } + } + }, + "responses": { + "Error": { + "description": "Refused: the error says why.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorView" + } + } + } + }, + "Unauthorized": { + "description": "The admin token is missing or wrong.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorView" + } + } + } + } + }, + "schemas": { + "AdaptersRequest": { + "type": "object", + "description": "AdaptersRequest replaces a model's LoRA adapters with these.", + "properties": { + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + } + } + } + }, + "CanaryPercentRequest": { + "type": "object", + "description": "CanaryPercentRequest changes the share of a model's requests its canary takes.", + "properties": { + "percent": { + "type": "integer" + } + } + }, + "CapacityView": { + "type": "object", + "description": "CapacityView is one stored measurement, with who recorded it.", + "properties": { + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "max_concurrency_at_slo": { + "type": "integer", + "description": "MaxConcurrencyAtSLO is the capacity: the highest concurrency that still met the target. Zero means even one request did not, which is a real result and not a missing one — it says this hardware cannot serve this model at this SLO at all." + }, + "throughput_tok_per_sec": { + "type": "number", + "description": "ThroughputTokPerSec and the latencies are taken at that level, not at the peak. Peak throughput arrives long after latency has become unusable, so reporting it would overstate what the hardware can serve." + }, + "ttft_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "tpot_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "suggested_target_waiting": { + "type": "integer", + "description": "SuggestedTargetWaiting and SuggestedKVCeiling are the queue depth and cache pressure seen while the SLO was still being met, which is what admission should treat as healthy for this model. Admission currently applies one global default to every model; these are the per-model numbers it should be using instead." + }, + "suggested_kv_ceiling": { + "type": "number" + }, + "slo": { + "$ref": "#/components/schemas/bench.SLO", + "description": "SLO is recorded because capacity is meaningless without it. The same replica has a different capacity against a 200ms first-token target than against a 2s one, and a number stored without the target it was measured against cannot be compared with anything." + }, + "target": { + "type": "string", + "description": "Target is the endpoint the load actually went to, kept so a profile measured against the gateway is never mistaken for one measured against a replica. Only the second says anything about the hardware." + }, + "measured_at": { + "type": "string", + "format": "date-time" + }, + "measured_by": { + "type": "string" + } + } + }, + "CheckRequest": { + "type": "object", + "description": "CheckRequest asks the registry about one model now, or every one.", + "properties": { + "model": { + "type": "string" + } + } + }, + "ClassGPUs": { + "type": "object", + "description": "ClassGPUs is how many of a node's devices are of one class, and how many of those are free or kept out of placement.", + "properties": { + "class": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "free": { + "type": "integer" + }, + "unhealthy": { + "type": "integer" + }, + "mig": { + "type": "boolean", + "description": "MIG marks a class of MIG instances, each of which takes a replica of its own and never joins a tensor-parallel group." + } + } + }, + "ClassRequest": { + "type": "object", + "description": "ClassRequest sets a model's capacity class: critical, standard or opportunistic, or empty to go back to what its scaling implies.", + "properties": { + "class": { + "type": "string" + } + } + }, + "ClassUse": { + "type": "object", + "description": "ClassUse is how much of one kind of card is in use.", + "properties": { + "class": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "used": { + "type": "integer" + }, + "unhealthy": { + "type": "integer", + "description": "Unhealthy GPUs are neither used nor available, and counting them as free is how a cluster looks like it has room it does not have." + } + } + }, + "ClassView": { + "type": "object", + "description": "ClassView is a model's capacity class now.", + "properties": { + "model": { + "type": "string" + }, + "class": { + "type": "string" + } + } + }, + "CollectRequest": { + "type": "object", + "description": "CollectRequest limits a sweep to one node. Empty means every node, which is the common case after removing a model from the catalog.", + "properties": { + "node": { + "type": "string" + } + } + }, + "CollectResult": { + "type": "object", + "description": "CollectResult is what was deleted on one node, or why nothing was.", + "properties": { + "node": { + "type": "string" + }, + "files": { + "type": "integer" + }, + "bytes": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string" + } + } + }, + "ErrorView": { + "type": "object", + "description": "ErrorView is every refusal: a 4xx or 5xx status with what went wrong.", + "properties": { + "error": { + "type": "string" + } + } + }, + "EvalGateRequest": { + "type": "object", + "description": "EvalGateRequest sets a model's standing quality suite; an empty suite takes it away.", + "properties": { + "suite": { + "type": "string" + }, + "min_score": { + "type": "number" + }, + "judge": { + "type": "string" + } + } + }, + "EvalGateView": { + "type": "object", + "description": "EvalGateView is a model's standing quality gate, as it is now; Eval is absent when it has none.", + "properties": { + "model": { + "type": "string" + }, + "eval": { + "$ref": "#/components/schemas/catalog.EvalGate" + } + } + }, + "ExplainView": { + "type": "object", + "description": "ExplainView is why a model could not be placed.", + "properties": { + "model": { + "type": "string" + }, + "placed": { + "type": "boolean", + "description": "Placed is true when the model has replicas, in which case there is nothing to explain." + }, + "rejections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RejectionView" + }, + "description": "Rejections is empty when placement was never attempted, which is itself the answer: the model is satisfied, or the reconciler has not run yet." + } + } + }, + "FabricTestRequest": { + "type": "object", + "description": "FabricTestRequest is a bandwidth test between two nodes.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "fabric": { + "type": "string", + "description": "Fabric is the declared fabric to test over; empty is the one the two nodes share." + }, + "seconds": { + "type": "integer", + "description": "Seconds each rail is measured for, from 1 to 30." + } + } + }, + "FabricTestView": { + "type": "object", + "description": "FabricTestView is a bandwidth test between two nodes.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "rails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RailView" + } + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "string" + } + } + }, + "FabricUse": { + "type": "object", + "description": "FabricUse is capacity within one fabric.", + "properties": { + "fabric": { + "type": "string" + }, + "nodes": { + "type": "integer" + }, + "nodes_with_weights": { + "type": "integer", + "description": "ModelsPresent is how many nodes here hold weights at all, and Models how many distinct models they hold between them.\n\nPer fabric because that is the unit at which the answer is actionable. Fabrics are not connected to one another, so weights in one are not weights in another: a model seeded into a single fabric leaves the rest of the cluster unable to serve it without first moving tens of gigabytes over the management network, and nothing said so." + }, + "models_present": { + "type": "integer" + }, + "largest_free_block": { + "type": "integer", + "description": "LargestFreeBlock is the most nodes on this fabric that are entirely free, which is the number that decides whether a multi-node replica can land." + } + } + }, + "FetchView": { + "type": "object", + "description": "FetchView is a download in flight.", + "properties": { + "source": { + "type": "string" + }, + "bytes_done": { + "type": "integer", + "format": "int64" + }, + "bytes_total": { + "type": "integer", + "format": "int64", + "description": "BytesTotal is 0 when the server did not say how large the file is." + }, + "percent": { + "type": "integer", + "description": "Percent is absent rather than zero when the total is unknown: an invented percentage that stops moving is worse than a byte count that keeps rising." + } + } + }, + "GPUView": { + "type": "object", + "description": "GPUView is one card as a node's page shows it.", + "properties": { + "index": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string", + "description": "Label is the device as people are shown it: \"3\" for a whole card, \"0/mig2\" for card 0's MIG instance 2." + }, + "class": { + "type": "string" + }, + "card": { + "type": "integer", + "description": "Card is the physical card's index: the device's own, or for a MIG instance its parent's, which is what the card's sensors are under." + }, + "mig_profile": { + "type": "string", + "description": "MIGProfile is the instance's profile, like 3g.40gb; empty for a whole card." + }, + "memory_total_mb": { + "type": "integer" + }, + "memory_used_mb": { + "type": "integer" + }, + "compute_capability": { + "type": "string", + "description": "ComputeCapability is what decides which engine builds run on the card: CUDA 13 builds need 7.5, so a V100 (7.0) runs only older ones." + }, + "numa": { + "type": "integer" + }, + "nvlink_peers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "nearest_nic": { + "type": "string", + "description": "NearestNIC is the adapter closest to the card, and how close." + }, + "nic_path": { + "type": "string" + }, + "fabric_state": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "bandwidth": { + "type": "string" + } + } + }, + "GuardAttachmentRequest": { + "type": "object", + "description": "GuardAttachmentRequest attaches a policy to a part of the tenancy tree, a key or a person, or with DELETE takes it off again.", + "properties": { + "target": { + "type": "string", + "description": "Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID after the colon), key:KEY-ID or user:USER-ID." + }, + "policy": { + "type": "string", + "description": "Policy is a policy's name in server.yaml's guard.policies." + } + } + }, + "GuardAttachmentView": { + "type": "object", + "description": "GuardAttachmentView is one attachment, with the name people know its scope by.", + "properties": { + "Scope": { + "type": "string" + }, + "ScopeID": { + "type": "string" + }, + "Policy": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "GuardView": { + "type": "object", + "description": "GuardView is the library and where its policies are attached.", + "properties": { + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GuardAttachmentView" + } + } + } + }, + "HeadroomView": { + "type": "object", + "description": "HeadroomView is how many more replicas of a model fit now, and where.", + "properties": { + "model": { + "type": "string" + }, + "current": { + "type": "integer", + "description": "Current is the replicas holding capacity: starting or ready." + }, + "more": { + "type": "integer", + "description": "More is how many more placement would find room for now." + }, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Nodes is where each of those would go." + }, + "limited": { + "type": "boolean", + "description": "Limited is set when More stopped at the limit asked rather than at the cluster's edge: there may be room for more." + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Blocked says why one more does not fit, a line per node considered." + }, + "tok_per_sec": { + "type": "number", + "description": "TokPerSec is what one more replica was measured to serve; 0 unmeasured." + }, + "by_preempting": { + "type": "integer", + "description": "ByPreempting is how many more would fit by stopping replicas this model's class may take; Preempts the models those belong to." + }, + "preempts": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "IBPortView": { + "type": "object", + "description": "IBPortView is one port as a page shows it.", + "properties": { + "hca": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "phys_state": { + "type": "string" + }, + "rate_gbps": { + "type": "integer" + }, + "link_layer": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "fabric": { + "type": "string", + "description": "Fabric is the declared fabric this port's adapter carries, if any." + }, + "physical": { + "type": "string", + "description": "Physical is the fabric the port is physically on: its subnet manager, or its RoCE network. SMError says why that is not known." + }, + "sm_error": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "description": "Errors are the error counters that are not zero, by name." + }, + "tx_bytes": { + "type": "integer", + "format": "int64" + }, + "rx_bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "IdleReplica": { + "type": "object", + "description": "IdleReplica is a replica holding hardware nothing is asking for.", + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "idle_seconds": { + "type": "integer", + "format": "int64" + }, + "gpus": { + "type": "integer" + } + } + }, + "Live": { + "type": "object", + "description": "Live is one of the cluster's adapters as its node last reported it.", + "properties": { + "node": { + "type": "string" + }, + "hca": { + "type": "string" + }, + "state": { + "type": "string" + }, + "phys_state": { + "type": "string" + }, + "gbps": { + "type": "integer" + }, + "tx_bps": { + "type": "number" + }, + "rx_bps": { + "type": "number" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + }, + "LoadView": { + "type": "object", + "description": "LoadView is a node's CPU and memory in use, at its last heartbeat.", + "properties": { + "cpu_percent": { + "type": "number" + }, + "cpus": { + "type": "integer" + }, + "load1": { + "type": "number" + }, + "load5": { + "type": "number" + }, + "load15": { + "type": "number" + }, + "memory_used_bytes": { + "type": "integer", + "format": "int64" + }, + "memory_total_bytes": { + "type": "integer", + "format": "int64" + }, + "memory_percent": { + "type": "number", + "description": "MemPercent is used over used plus available." + }, + "age_seconds": { + "type": "integer", + "format": "int64" + }, + "cpu_temp_c": { + "type": "number", + "description": "What the node's sensors say; zero is not measured." + }, + "cpu_power_w": { + "type": "number" + }, + "inlet_temp_c": { + "type": "number" + }, + "gpu_sensors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/inventory.GPUSensor" + } + } + } + }, + "LogsView": { + "type": "object", + "description": "LogsView is one of a node's logs, as the node sent it.", + "properties": { + "node": { + "type": "string" + }, + "source": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sources are every log the node can show: \"agent\", and one per engine running there." + }, + "error": { + "type": "string" + } + } + }, + "MetricsQuery": { + "type": "object", + "description": "MetricsQuery is one line set of a chart: a PromQL expression, and the label naming each line.", + "properties": { + "id": { + "type": "string" + }, + "expr": { + "type": "string" + }, + "by": { + "type": "string" + } + } + }, + "MetricsRequest": { + "type": "object", + "description": "MetricsRequest asks for several queries over one window at once.", + "properties": { + "window": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "step": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricsQuery" + } + } + } + }, + "MetricsResult": { + "type": "object", + "description": "MetricsResult is each query's lines, by id; Configured is false when no Prometheus is set, and Errors says which queries failed and why.", + "properties": { + "configured": { + "type": "boolean" + }, + "series": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/promq.Series" + } + } + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ModelAdded": { + "type": "object", + "description": "ModelAdded names the model added to the catalog.", + "properties": { + "added": { + "type": "string" + } + } + }, + "ModelTotals": { + "type": "object", + "description": "ModelTotals counts models by whether they have what they asked for.", + "properties": { + "total": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "short": { + "type": "integer" + } + } + }, + "ModelView": { + "type": "object", + "description": "ModelView is a model as `ctl models` shows it.", + "properties": { + "name": { + "type": "string" + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary", + "description": "Canary is a share of its requests sent to a candidate; CanaryOf is set on the candidate, naming the model it is a canary of." + }, + "canary_of": { + "type": "string" + }, + "eval": { + "$ref": "#/components/schemas/catalog.EvalGate", + "description": "Eval is the quality suite its new weights must pass, when it has one." + }, + "description": { + "type": "string", + "description": "Description is what the model is for, when somebody said." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string" + }, + "min_replicas": { + "type": "integer" + }, + "max_replicas": { + "type": "integer" + }, + "ready_replicas": { + "type": "integer" + }, + "total_replicas": { + "type": "integer" + }, + "desired_replicas": { + "type": "integer", + "description": "Desired is how many it should have now: Min, or for an on-demand model one while it is being asked for and none otherwise." + }, + "failures": { + "type": "integer", + "description": "Failures counts replicas that failed to start in a row." + }, + "parked": { + "type": "boolean", + "description": "Parked is set once Failures reached the limit; the model is not retried until its definition changes or `ctl retry` is run. Reason is the last error." + }, + "reason": { + "type": "string" + }, + "unplaceable": { + "type": "boolean", + "description": "Unplaceable is set when it is short and the last attempt found no node." + }, + "fallbacks": { + "type": "array", + "items": { + "type": "string" + } + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "memory_gb": { + "type": "integer", + "description": "MemoryGB and CPUCores are what one replica reserves, from its first profile; MeasuredMemoryGB is the most one of its replicas uses now, 0 when none is measured." + }, + "cpu_cores": { + "type": "integer" + }, + "measured_memory_gb": { + "type": "number" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Warnings are what makes its reservation wrong: nothing declared where placement needs a figure, or less declared than it uses." + }, + "class": { + "type": "string", + "description": "Class is its capacity class; Preempted the replicas it lost to a higher class; HeldFor the model it is giving room to now, if any." + }, + "owner": { + "type": "string" + }, + "external": { + "type": "string", + "description": "External is where a model served outside the cluster goes, provider host and its name for the model; empty for one served here." + }, + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + }, + "description": "Adapters are the LoRA adapters it serves." + }, + "preempted": { + "type": "integer" + }, + "held_for": { + "type": "string" + }, + "origin": { + "$ref": "#/components/schemas/OriginView", + "description": "Origin is the registry repository its weights came from, and at which commit; Update what that repository had when it was last asked." + }, + "update": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + }, + "NodeEngineRequest": { + "type": "object", + "description": "NodeEngineRequest chooses one engine's build for a node.", + "properties": { + "engine": { + "type": "string" + }, + "build": { + "type": "string", + "description": "Build is \"auto\", a build such as \"cu129\", or \"none\"." + } + } + }, + "NodeRemoved": { + "type": "object", + "description": "NodeRemoved names the node forgotten. Removed is the string \"true\".", + "properties": { + "id": { + "type": "string" + }, + "removed": { + "type": "string" + } + } + }, + "NodeStateRequest": { + "type": "object", + "description": "NodeStateRequest cordons, drains or readies a node: ready (or uncordon), cordoned (or cordon), draining (or drain).", + "properties": { + "state": { + "type": "string" + } + } + }, + "NodeStateView": { + "type": "object", + "description": "NodeStateView is a node's state after the change.", + "properties": { + "id": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "NodeTotals": { + "type": "object", + "description": "NodeTotals counts nodes by the states that need different responses.", + "properties": { + "total": { + "type": "integer" + }, + "ready": { + "type": "integer" + }, + "cordoned": { + "type": "integer" + }, + "draining": { + "type": "integer" + }, + "unreachable": { + "type": "integer" + } + } + }, + "NodeView": { + "type": "object", + "description": "NodeView is a node as `ctl nodes` shows it.", + "properties": { + "id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "mgmt_addr": { + "type": "string" + }, + "state": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "gpu_total": { + "type": "integer" + }, + "gpu_free": { + "type": "integer" + }, + "memory_gb": { + "type": "integer", + "description": "MemoryGB is the machine's RAM, 0 when the agent did not report it. It is what decides whether a CPU profile can be placed here, so it belongs where someone looks when a model will not schedule." + }, + "cpu_count": { + "type": "integer", + "description": "CPUCount is the machine's CPUs, 0 when the agent did not report them." + }, + "engines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Engines are the inference engines installed here, as the agent found them. Absent means the agent is older than the probe and said nothing, which is not the same as having none — placement treats it that way too, or a control plane upgrade would empty a fleet." + }, + "provision": { + "type": "array", + "items": { + "$ref": "#/components/schemas/inventory.EngineInstall" + }, + "description": "Provision is where installing each wanted engine stands. An engine is in Engines only once its installation here is ready." + }, + "engine_builds": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "EngineBuilds are the builds chosen for this node, by engine: \"auto\" (its hardware decides), a build (\"cu129\"), or \"none\". Only on a node with GPUs, the only kind with a choice to make." + }, + "os": { + "type": "string", + "description": "OS is the operating system and architecture, as the agent read them." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Warnings are facts about the machine that make it slow or unusable without making it unhealthy: a VM with no vector instructions runs every model, at a fifth of the speed." + }, + "models_on_disk": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Models are the weights this node can see. Absent means it was not asked to look, which is not the same as holding none." + }, + "disk_free_gb": { + "type": "integer", + "description": "DiskFreeGB and DiskTotalGB are the filesystems holding those weights, summed. Zero means the agent did not report any, which an older agent and a node with no model directories both do — so it reads as unknown rather than as a full disk.\n\nSummed because an operator asking \"can this node take another model\" wants one number; the per-filesystem detail is in the node's own record for anyone who needs it. Shared filesystems are left out of the sum: the space on a filer is not this node's to spend, and adding it would let a fleet of twenty nodes each believe it has the same free terabyte." + }, + "disk_total_gb": { + "type": "integer" + }, + "gpu_classes": { + "type": "array", + "items": { + "type": "string" + } + }, + "gpus_by_class": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClassGPUs" + }, + "description": "ClassGPUs counts the node's devices by class, in the order of Classes. A node with a card in MIG mode has several classes — each profile its instances are cut to, and any whole card beside them — and GPUTotal alone would add 3g.40gb instances to 80 GB cards." + }, + "fabrics": { + "type": "array", + "items": { + "type": "string" + } + }, + "unhealthy_gpus": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Unhealthy lists GPUs kept out of placement and why, which is usually the answer when a node looks idle but nothing will schedule onto it." + }, + "last_seen_ago": { + "type": "string" + }, + "agent_version": { + "type": "string" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Features are the requests the agent answers beyond the usual: \"fabric-test\". Absent is an agent too old to say." + }, + "replicas": { + "type": "integer" + }, + "load": { + "$ref": "#/components/schemas/LoadView", + "description": "Load is how busy the machine is, absent when its agent has not measured it lately — which is unknown, not idle." + }, + "ib_ports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IBPortView" + }, + "description": "IBPorts are the node's InfiniBand and RoCE ports at the last heartbeat." + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUView" + }, + "description": "GPUs are the cards, with how they are wired: NVLink peers, the adapter nearest each, and the NVLink domain each has joined." + }, + "nvlink_domain": { + "type": "string", + "description": "NVLinkDomain is the multi-node domain every card has joined, if any." + }, + "imex": { + "type": "string" + }, + "imex_channels": { + "type": "integer" + } + } + }, + "OriginRequest": { + "type": "object", + "description": "OriginRequest names the repository a model's weights came from.", + "properties": { + "repo": { + "type": "string" + } + } + }, + "OriginView": { + "type": "object", + "description": "OriginView is where a model's weights were published.", + "properties": { + "repo": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "previous": { + "type": "string", + "description": "Previous is the commit the last update replaced, which it can go back to." + } + } + }, + "OrphanView": { + "type": "object", + "description": "OrphanView is cached weights on one node that no catalog model accounts for.", + "properties": { + "node": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "bytes": { + "type": "integer", + "format": "int64", + "description": "Bytes is what deleting them would free, as far as the node reported. A file whose size was not reported counts as zero rather than being left out, so the list and the number always describe the same set." + } + } + }, + "OwnerRequest": { + "type": "object", + "description": "OwnerRequest sets the part of the tenancy tree a model's capacity counts against: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or their IDs. Empty makes the model shared.", + "properties": { + "owner": { + "type": "string" + } + } + }, + "OwnerView": { + "type": "object", + "description": "OwnerView is a model's owner now, as scope:ID.", + "properties": { + "model": { + "type": "string" + }, + "owner": { + "type": "string" + } + } + }, + "ParkedModel": { + "type": "object", + "description": "ParkedModel is a model no longer retried, and why.", + "properties": { + "model": { + "type": "string" + }, + "failures": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, + "QuotaRequest": { + "type": "object", + "description": "QuotaRequest sets a scope's quota. Scope may be written with an ID or a name: org:Acme, team:Acme/ml, project:Acme/ml/training.", + "properties": { + "scope": { + "type": "string" + }, + "guarantee": { + "$ref": "#/components/schemas/store.Resources" + }, + "limit": { + "$ref": "#/components/schemas/store.Resources" + } + } + }, + "QuotaSet": { + "type": "object", + "description": "QuotaSet is the scope a quota was set on, as org:ID, team:ID or project:ID whatever name it was asked by.", + "properties": { + "scope": { + "type": "string" + } + } + }, + "QuotaView": { + "type": "object", + "description": "QuotaView is one scope's quota with what it holds now.", + "properties": { + "scope": { + "type": "string", + "description": "Scope is team:ID; Name is how people know it, \"Acme / ml\"." + }, + "name": { + "type": "string" + }, + "guarantee": { + "$ref": "#/components/schemas/store.Resources" + }, + "limit": { + "$ref": "#/components/schemas/store.Resources" + }, + "holds": { + "$ref": "#/components/schemas/quota.Amount" + }, + "updated_by": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "RailView": { + "type": "object", + "description": "RailView is one rail's result.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "rate_gbps": { + "type": "integer" + }, + "gbps": { + "type": "number" + }, + "verdict": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "RejectionView": { + "type": "object", + "description": "RejectionView is one rejected candidate.", + "properties": { + "profile": { + "type": "string" + }, + "pool": { + "type": "string" + }, + "node": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "Removed": { + "type": "object", + "description": "Removed names what was taken away: a model, or a quota's scope.", + "properties": { + "removed": { + "type": "string" + } + } + }, + "ReplayRequest": { + "type": "object", + "description": "ReplayRequest names the model a kept request is sent to again.", + "properties": { + "model": { + "type": "string" + } + } + }, + "ReplayView": { + "type": "object", + "description": "ReplayView is a kept request sent again, beside what it got the first time.", + "properties": { + "original": { + "$ref": "#/components/schemas/store.RequestBody" + }, + "model": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "body": { + "description": "Any JSON value." + }, + "took_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "ReplicaTotal": { + "type": "object", + "description": "ReplicaTotal counts replicas by state.", + "properties": { + "ready": { + "type": "integer" + }, + "starting": { + "type": "integer" + }, + "draining": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + } + }, + "ReplicaView": { + "type": "object", + "description": "ReplicaView is a replica as `ctl replicas` shows it.", + "properties": { + "id": { + "type": "string" + }, + "engine_key": { + "type": "string", + "description": "EngineKey is the key this replica's engine demands, for `ctl bench` and anyone else holding the admin listener who needs to reach an engine directly. The client-facing pages never render it." + }, + "restart": { + "type": "string", + "description": "Restart is \"queued\" or \"replacing\" while an operator's restart of this replica is under way." + }, + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "stale": { + "type": "boolean", + "description": "Stale is true when the profile has been edited since this replica was placed, so it is running a definition the catalog no longer holds.\n\nReported rather than acted on. Moving a replica that is serving costs more than the imperfection, and the operator who made the edit is the one who should decide whether it is worth a restart." + }, + "state": { + "type": "string" + }, + "pool": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "age_seconds": { + "type": "integer", + "format": "int64" + }, + "idle_seconds": { + "type": "integer", + "format": "int64", + "description": "IdleSec is how long since a request was routed here. Negative means the router has not reported any use of this replica, which is not the same as idle — it is what a replica looks like before anything has asked for it, and after a router restart." + }, + "error": { + "type": "string" + }, + "shards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardView" + } + } + } + }, + "RequestView": { + "type": "object", + "description": "RequestView is what one request went through.", + "properties": { + "request_id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "key_id": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "served_model": { + "type": "string" + }, + "replica": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "latency_ms": { + "type": "integer", + "format": "int64" + }, + "queue_ms": { + "type": "integer", + "format": "int64" + }, + "ttft_ms": { + "type": "integer", + "format": "int64" + }, + "prompt_tokens": { + "type": "integer", + "format": "int64" + }, + "completion_tokens": { + "type": "integer", + "format": "int64" + }, + "cost_micros": { + "type": "integer", + "format": "int64" + }, + "guard_action": { + "type": "string", + "description": "What the guardrails did, by name." + }, + "guard_policy": { + "type": "string" + }, + "guard_category": { + "type": "string" + }, + "guard_ms": { + "type": "integer", + "format": "int64" + }, + "end_user": { + "type": "string", + "description": "What the client said about it: OpenAI's user field, and its tags." + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "cache_hit": { + "type": "boolean", + "description": "CacheHit is an answer reused from the project's answer cache." + } + } + }, + "ResourcesRequest": { + "type": "object", + "description": "ResourcesRequest sets what a model's profiles reserve of a node.", + "properties": { + "profile": { + "type": "string", + "description": "Profile is one profile's name; empty is every profile." + }, + "memory_gb": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer" + } + } + }, + "RestartRequest": { + "type": "object", + "description": "RestartRequest names what to restart: replicas by id, every replica of some models, or — All — every ready replica in the cluster.", + "properties": { + "replicas": { + "type": "array", + "items": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "all": { + "type": "boolean" + }, + "in_place": { + "type": "boolean", + "description": "InPlace stops each replica before starting its replacement, rather than starting the replacement beside it first." + } + } + }, + "RestartResult": { + "type": "object", + "description": "RestartResult lists the replicas queued.", + "properties": { + "queued": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Result": { + "type": "object", + "description": "Result is what a change that has no other answer says it did.", + "properties": { + "result": { + "type": "string" + } + } + }, + "RetryView": { + "type": "object", + "description": "RetryView says whether the model had been parked, given up on after failing to start, before it was retried.", + "properties": { + "was_parked": { + "type": "boolean" + } + } + }, + "ScaleRequest": { + "type": "object", + "description": "ScaleRequest is how many replicas a model should run.", + "properties": { + "min": { + "type": "integer" + }, + "max": { + "type": "integer", + "description": "Max omitted follows Min, which is what the catalog does for a model that declares only a minimum." + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + } + } + }, + "ScaleResponse": { + "type": "object", + "description": "ScaleResponse reports the scaling that is now in force.\n\nWhat is in force rather than what was asked for: the catalog fills in a max that was left out, and a caller that echoed its own request would print a number the cluster is not using.", + "properties": { + "model": { + "type": "string" + }, + "min": { + "type": "integer" + }, + "max": { + "type": "integer" + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "on_demand": { + "type": "boolean" + } + } + }, + "ScopeUsageView": { + "type": "object", + "description": "ScopeUsageView is one group's spend, with its quota when it has one.", + "properties": { + "scope": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "keys": { + "type": "integer", + "format": "int64" + }, + "requests": { + "type": "integer", + "format": "int64" + }, + "prompt_tokens": { + "type": "integer", + "format": "int64" + }, + "completion_tokens": { + "type": "integer", + "format": "int64" + }, + "cost_micros": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64" + }, + "last_at": { + "type": "string", + "format": "date-time" + }, + "budget_micros": { + "type": "integer", + "format": "int64", + "description": "Budget is what this level sets, absent when it sets none. A group bounded only by its parent has no budget of its own, and reporting the parent's against each child would read as though each had that much." + }, + "budget_period": { + "type": "string" + }, + "fraction": { + "type": "number", + "description": "Fraction is spend over budget, absent for the same reason." + } + } + }, + "ShardView": { + "type": "object", + "description": "ShardView is one shard of a replica.", + "properties": { + "id": { + "type": "string" + }, + "node": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "gpus": { + "type": "array", + "items": { + "type": "integer" + } + }, + "state": { + "type": "string" + }, + "error": { + "type": "string" + }, + "fetch": { + "$ref": "#/components/schemas/FetchView", + "description": "Fetch is the download this shard is waiting on, absent when it is not waiting on one. It is what tells a replica pulling a hundred gigabytes apart from one that is wedged — which look identical without it." + }, + "cpu_cores": { + "type": "number", + "description": "CPUCores and MemoryBytes are the engine's use of its node, from the node's last heartbeat; zero when not measured." + }, + "memory_bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "StoreMember": { + "type": "object", + "description": "StoreMember is one member of a replicated store, as its Patroni reports it.", + "properties": { + "name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "role": { + "type": "string" + }, + "state": { + "type": "string" + }, + "lag": { + "type": "integer", + "format": "int64", + "description": "Lag is bytes behind the leader; -1 unknown, 0 for the leader." + } + } + }, + "Summary": { + "type": "object", + "description": "Summary is the whole fleet in one answer.\n\nA per-node listing is the right tool for four nodes and the wrong one for two hundred: the questions that matter at that size — how full am I, what is broken, what is holding cards it is not using — are aggregates, and reading them off a long table is a chore that gets skipped. Everything here is derived from the same state the tables show, so it can never disagree with them; it is a different question asked of the same facts.", + "properties": { + "nodes": { + "$ref": "#/components/schemas/NodeTotals" + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClassUse" + } + }, + "fabrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FabricUse" + }, + "description": "Fabrics matter because a multi-node replica may never span two of them, so free capacity in the wrong fabric is not capacity for the model that needs it." + }, + "replicas": { + "$ref": "#/components/schemas/ReplicaTotal" + }, + "models": { + "$ref": "#/components/schemas/ModelTotals" + }, + "unplaceable": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Unplaceable names models the scheduler currently cannot satisfy. This is the line that turns \"something is wrong\" into a place to look." + }, + "parked": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParkedModel" + }, + "description": "Parked are models that failed every time they started and are no longer retried, with the last error." + }, + "idle_replicas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdleReplica" + }, + "description": "IdleReplicas are serving replicas nothing has asked for in a while, which on a fleet with more models than GPUs is the first place to find room." + }, + "store": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoreMember" + }, + "description": "Store is the members of the store's cluster when it is replicated, and StoreError why it could not be read; both empty for a single database." + }, + "store_error": { + "type": "string" + } + } + }, + "TargetView": { + "type": "object", + "description": "TargetView is how many replicas a model needs to serve a throughput, from what one replica was measured to serve, and whether the cluster has room.", + "properties": { + "model": { + "type": "string" + }, + "tok_per_sec": { + "type": "number", + "description": "TokPerSec is what was asked for; PerReplica what one replica was measured to serve at its SLO, on Profile." + }, + "per_replica_tok_per_sec": { + "type": "number" + }, + "profile": { + "type": "string" + }, + "needed": { + "type": "integer", + "description": "Needed replicas in all; Current hold capacity now; More is how many of Needed are not running, and Room how many more fit." + }, + "current": { + "type": "integer" + }, + "more": { + "type": "integer" + }, + "room": { + "type": "integer" + }, + "fits": { + "type": "boolean" + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + } + }, + "preempts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Preempts is whose replicas would be stopped to make the room, when free room alone is not enough; Room counts that room too." + } + } + }, + "TopologyView": { + "type": "object", + "description": "TopologyView is one fabric's map, with the cluster's own adapters on it live: their state, rate and traffic from the last heartbeat.", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "declared": { + "type": "array", + "items": { + "type": "string" + } + }, + "scanned_at": { + "type": "string", + "format": "date-time" + }, + "scanned_by": { + "type": "string" + }, + "took_ms": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string" + }, + "map": { + "$ref": "#/components/schemas/topology.View" + }, + "live": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Live" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Problem" + }, + "description": "Problems are the fabric's own, from the analysis of its identity and subnet managers." + }, + "sm": { + "$ref": "#/components/schemas/fabrics.SM" + }, + "sms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.SM" + } + } + } + }, + "UpgradeRequest": { + "type": "object", + "description": "UpgradeRequest names the release a node should run.", + "properties": { + "version": { + "type": "string" + } + } + }, + "UpgradeView": { + "type": "object", + "description": "UpgradeView is the release a node was told to run.", + "properties": { + "node": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "bench.Capacity": { + "type": "object", + "description": "Capacity is what one replica was measured to serve, kept so the measurement outlives the terminal it was printed in.\n\nThe conclusion of a run, not the run. The ramp itself — every concurrency level, its latencies, its errors — is worth reading once, while deciding whether the run was sound; it is not worth carrying around afterwards, and storing it would grow without bound as a model is re-benchmarked. What a later reader needs is the number and enough provenance to judge whether it is still true.", + "properties": { + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "max_concurrency_at_slo": { + "type": "integer", + "description": "MaxConcurrencyAtSLO is the capacity: the highest concurrency that still met the target. Zero means even one request did not, which is a real result and not a missing one — it says this hardware cannot serve this model at this SLO at all." + }, + "throughput_tok_per_sec": { + "type": "number", + "description": "ThroughputTokPerSec and the latencies are taken at that level, not at the peak. Peak throughput arrives long after latency has become unusable, so reporting it would overstate what the hardware can serve." + }, + "ttft_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "tpot_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "suggested_target_waiting": { + "type": "integer", + "description": "SuggestedTargetWaiting and SuggestedKVCeiling are the queue depth and cache pressure seen while the SLO was still being met, which is what admission should treat as healthy for this model. Admission currently applies one global default to every model; these are the per-model numbers it should be using instead." + }, + "suggested_kv_ceiling": { + "type": "number" + }, + "slo": { + "$ref": "#/components/schemas/bench.SLO", + "description": "SLO is recorded because capacity is meaningless without it. The same replica has a different capacity against a 200ms first-token target than against a 2s one, and a number stored without the target it was measured against cannot be compared with anything." + }, + "target": { + "type": "string", + "description": "Target is the endpoint the load actually went to, kept so a profile measured against the gateway is never mistaken for one measured against a replica. Only the second says anything about the hardware." + }, + "measured_at": { + "type": "string", + "format": "date-time" + } + } + }, + "bench.SLO": { + "type": "object", + "description": "SLO is the latency target capacity is measured against.", + "properties": { + "TTFTp99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "TPOTp99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + } + } + }, + "canary.Side": { + "type": "object", + "description": "Side is what one side of a canary served since it began.", + "properties": { + "requests": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64", + "description": "Errors are answers of 500 and up: the engine's failures, not the client's mistakes." + }, + "ttft_ms": { + "type": "number", + "description": "TTFT is over streamed requests, Latency over the rest, both means in milliseconds with how many they are over." + }, + "ttft_count": { + "type": "integer", + "format": "int64" + }, + "latency_ms": { + "type": "number" + }, + "latency_count": { + "type": "integer", + "format": "int64" + } + } + }, + "canary.Stat": { + "type": "object", + "description": "Stat compares the two sides of one model's canary.", + "properties": { + "canary": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "stable": { + "$ref": "#/components/schemas/canary.Side" + }, + "candidate": { + "$ref": "#/components/schemas/canary.Side" + } + } + }, + "catalog.Adapter": { + "type": "object", + "description": "Adapter is a LoRA adapter served on a model's replicas.", + "properties": { + "name": { + "type": "string", + "description": "Name is what clients ask for, like a model name." + }, + "source": { + "type": "string", + "description": "Source is the adapter file (GGUF for llama.cpp), fetched by each node as the model's weights are." + }, + "scale": { + "type": "number", + "description": "Scale is how strongly it is applied; zero takes 1." + } + } + }, + "catalog.Canary": { + "type": "object", + "description": "Canary is a share of a model's requests sent to a candidate, and when to give up on it.", + "properties": { + "model": { + "type": "string", + "description": "Model is the candidate: a catalog model, usually this one at newer weights, which clients cannot name themselves." + }, + "percent": { + "type": "integer", + "description": "Percent of requests go to it, 0 to 100." + }, + "min_requests": { + "type": "integer", + "description": "The candidate is taken out — rolled back — when, over at least MinRequests of its own, its error rate is MaxErrorIncrease above this model's (0.05 is five points), or its mean time to the first token is MaxTTFTRatio times this model's. Zero is 50, 0.05 and 1.5." + }, + "max_error_increase": { + "type": "number" + }, + "max_ttft_ratio": { + "type": "number" + } + } + }, + "catalog.Capabilities": { + "type": "object", + "description": "Capabilities describes what a client may ask a model for.\n\nDeclared by the operator rather than discovered, because there is no way to ask an engine what its model can do. That makes these claims, and a wrong claim is worse than none — which is why `ctl engine-check` verifies tool calling against a running engine rather than trusting this.\n\nThe reason it exists: the gateway forwards a tools array to any model, and a model that cannot use them answers in prose. Every OpenAI-compatible client then behaves as though the model is broken, and the only way to find out was to try it. That cost an afternoon.", + "properties": { + "tools": { + "type": "boolean" + }, + "vision": { + "type": "boolean" + }, + "embeddings": { + "type": "boolean" + } + } + }, + "catalog.EvalGate": { + "type": "object", + "description": "EvalGate is a quality suite a model's new weights must pass.", + "properties": { + "suite": { + "type": "string", + "description": "Suite is a suite's name in control.eval_dir, or one the binary carries (arithmetic, json-format)." + }, + "min_score": { + "type": "number", + "description": "MinScore is the score, 0 to 1, to pass at; zero is the suite's own threshold." + }, + "judge": { + "type": "string", + "description": "Judge grades the suite's rubric cases, instead of the judge it names." + } + } + }, + "catalog.External": { + "type": "object", + "description": "External is a provider outside the cluster that serves a model.", + "properties": { + "url": { + "type": "string", + "description": "URL is the provider's base address, without /v1: https://api.openai.com." + }, + "model": { + "type": "string", + "description": "Model is what the provider calls it; requests are rewritten to it." + }, + "api_key_file": { + "type": "string", + "description": "APIKeyFile holds the provider's key, on the control plane. A file rather than the key itself, so the key is in neither the catalog, the store nor the audit log; empty for a provider that asks for none." + } + } + }, + "catalog.GuardModel": { + "type": "object", + "description": "GuardModel is how a judge model's answers are read.", + "properties": { + "format": { + "type": "string", + "description": "Format is llamaguard3 (Meta's Llama Guard 3: \"safe\", or \"unsafe\" and the categories), shieldgemma (Google's ShieldGemma: Yes or No), or json ({\"safe\": false, \"categories\": [\"...\"]})." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories the model is asked about, for shieldgemma — each a policy sentence — and the names reported for llamaguard3's S1, S2, …." + } + } + }, + "catalog.Model": { + "type": "object", + "description": "Model is a catalog entry as clients see it.", + "properties": { + "name": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "description": "Description is what this model is for, in a line.\n\nWritten by whoever added it, because nobody else can say it. A registry offers a pipeline tag and a wall of README, and neither answers the question a person picking from a list is asking: which of these nine names should I send this request to.\n\nIt reaches clients through /v1/models, which is the only view most of them ever see. A gateway serving nine models by name alone makes that choice by guesswork." + }, + "source": { + "type": "string", + "description": "Source is the weights path or a HuggingFace id." + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Profile" + } + }, + "scaling": { + "$ref": "#/components/schemas/catalog.Scaling" + }, + "class": { + "type": "string", + "description": "Class is how much this model's capacity is worth against another's, when there is not enough for both: critical, standard or opportunistic. Empty is standard, or opportunistic for a model loaded on demand. See EffectiveClass." + }, + "owner": { + "type": "string", + "description": "Owner is the part of the tenancy tree whose capacity quota this model counts against, written org:ID, team:ID or project:ID. Empty is shared: held by the cluster, counted against nobody." + }, + "external": { + "$ref": "#/components/schemas/catalog.External", + "description": "External is a model served by someone else — an OpenAI-compatible API outside the cluster — rather than by replicas placed here. Such a model has no profiles and nothing is placed for it; the router sends its requests to the provider. Most useful named in another model's fallbacks: used only when the cluster has no capacity left. Its prompts leave the cluster, so which keys may name it is worth an allowlist." + }, + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + }, + "description": "Adapters are LoRA adapters served on this model's replicas, each asked for by its own name as though it were a model. Every replica loads every adapter, applying one only to a request that names it." + }, + "fallbacks": { + "type": "array", + "items": { + "type": "string" + } + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary", + "description": "Canary sends a share of this model's requests to another catalog model — the same model at new weights — to compare the two before moving to it (`llmgw ctl model canary`)." + }, + "eval": { + "$ref": "#/components/schemas/catalog.EvalGate", + "description": "Eval is a quality suite new weights must pass before they replace this model's: `llmgw ctl model update` and `model promote` run it against them first, and stop below its score." + }, + "routing_profile": { + "type": "string", + "description": "RoutingProfile selects how the router scores endpoints for this model." + }, + "slo": { + "$ref": "#/components/schemas/catalog.SLO" + }, + "context_length": { + "type": "integer", + "description": "ContextLength is how many tokens one request may use, prompt and completion together, and MaxOutput bounds the completion alone. Zero means unknown, which is what every model was until now.\n\nThe gateway knew neither, so an over-long prompt was not refused: it was admitted, given a replica, and failed at the engine, and a client sizing its prompts from /v1/models had nothing to read. Worse, the number is not the one an operator writes in the engine's arguments — llama.cpp divides --ctx-size across its parallel slots, so a pool of 8192 across four slots is 2048 a request, and nothing said so." + }, + "max_output": { + "type": "integer" + }, + "capabilities": { + "$ref": "#/components/schemas/catalog.Capabilities", + "description": "Capabilities is what this model can do beyond plain completion." + }, + "role": { + "type": "string", + "description": "Role \"guard\" is a model that judges the gateway's traffic for the guardrails rather than serving clients: hidden from /v1/models and the chat page, refused to any key that names it, critical by default, and always running. Guard says how its answers are read." + }, + "guard": { + "$ref": "#/components/schemas/catalog.GuardModel" + }, + "origin": { + "$ref": "#/components/schemas/catalog.Origin", + "description": "Origin is the registry repository the weights came from, at which commit, and each file's checksum there. Written when a model is served from the registry; for one that was not, worked out from the mirror's record of where it fetched the file. It is what lets the control plane say that the repository has published different weights since, and move the model to them." + } + } + }, + "catalog.Origin": { + "type": "object", + "description": "Origin is where a model's weights were published.", + "properties": { + "repo": { + "type": "string", + "description": "Repo is the repository, owner/name." + }, + "revision": { + "type": "string", + "description": "Revision is the commit the files were fetched at." + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.OriginFile" + }, + "description": "Files are the weights fetched from it, with what the registry published as each one's sha256. A new commit that leaves them alone — a README edited — is not an update." + }, + "previous": { + "$ref": "#/components/schemas/catalog.PreviousOrigin", + "description": "Previous is what the last update replaced, kept so it can be gone back to while its files are still on the mirror." + } + } + }, + "catalog.OriginFile": { + "type": "object", + "description": "OriginFile is one weights file of an origin.", + "properties": { + "path": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "catalog.PreviousOrigin": { + "type": "object", + "description": "PreviousOrigin is a model's source before an update, and where it came from.", + "properties": { + "source": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.OriginFile" + } + } + } + }, + "catalog.Profile": { + "type": "object", + "description": "Profile is how a model runs on one GPU class.", + "properties": { + "name": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "gpu_class": { + "type": "string" + }, + "tp": { + "type": "integer" + }, + "pp": { + "type": "integer" + }, + "gpus_per_node": { + "type": "integer", + "description": "GPUsPerNode defaults to TP for a single-node profile." + }, + "nodes": { + "type": "integer", + "description": "Nodes above 1 makes this a multi-node profile, which requires a fabric." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "min_memory_gb": { + "type": "integer", + "description": "MinMemoryGB is the host RAM this profile needs to run.\n\nThe number that matters is the model's working set: the weights, plus the KV cache for the whole context pool, plus the engine's own overhead. For a GPU profile that lives in the cards and this is small; for a CPU profile it is the whole thing, and getting it wrong means an OOM on a node that reported itself healthy.\n\nZero means unconstrained, which is every profile that existed before this. A node that does not report its memory is not excluded either — an older agent must not have its nodes quietly emptied by an upgrade of the control plane." + }, + "cpu_cores": { + "type": "integer", + "description": "CPUCores is the cores a replica of a profile with no GPU is given: reserved out of its node's CPUs by placement, as memory is, and what its engine computes on (llama.cpp's --threads).\n\nReserved because an engine takes every core it is told about, and two on one node that each think they have all of them spin against each other: on an 8-vCPU node, two 7B replicas fell from 10 tokens a second to under one each. Zero reserves nothing and leaves the thread count to the node, which is every profile from before this." + }, + "min_disk_gb": { + "type": "integer", + "description": "MinDiskGB is the free space a node needs before it can hold these weights.\n\nOnly checked when the node does not already have them: a node holding the file needs no room for it, and requiring the space twice would keep a model off the one node best placed to run it.\n\nDeclared rather than derived, because the size of a model that has never been fetched anywhere is not knowable from here — and a wrong guess fails late, halfway through a download, which is the failure this exists to prevent. Zero means unconstrained, and a node that reports no storage is not excluded: unknown is not full." + }, + "node_selector": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "NodeSelector restricts this profile to nodes carrying every one of these labels, with these values.\n\nA CPU profile otherwise fits everywhere, because there is no card to be free — which is right for a 0.5B model and wrong for a 14B one. Nothing in the inventory says how much memory a node has, so the operator says which nodes are meant for a model instead of the scheduler inferring it. Agents have reported labels since the beginning; this is what reads them." + }, + "weight": { + "type": "number", + "description": "Weight is the router's capacity weight. It should come from ctl bench rather than a guess (plan §6.2.1)." + }, + "priority": { + "type": "integer", + "description": "Priority orders profiles during placement; highest that fits wins." + }, + "startup_timeout": { + "type": "integer", + "format": "int64", + "description": "StartupTimeout bounds model loading. A 70B model across two nodes takes tens of minutes, so this is per profile rather than global.\n\nA duration, in nanoseconds." + } + } + }, + "catalog.SLO": { + "type": "object", + "description": "SLO is the latency target a model is expected to meet. `ctl bench` measures capacity against it (plan §6.2.1), and admission holds the lower bands back while the model's recent p99 is past it.", + "properties": { + "TTFTp99": { + "type": "integer", + "format": "int64", + "description": "TTFTp99 is the time to the first token 99 requests in 100 are within.\n\nA duration, in nanoseconds." + }, + "TPOTp99": { + "type": "integer", + "format": "int64", + "description": "TPOTp99 is the time per output token, after the first, 99 streamed requests in 100 are within.\n\nA duration, in nanoseconds." + } + } + }, + "catalog.ScaleWindow": { + "type": "object", + "description": "ScaleWindow is a part of the week with its own min and max.", + "properties": { + "days": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Days are mon … sun, or weekdays, weekends; empty is every day." + }, + "from": { + "type": "string", + "description": "From and To are HH:MM; a window whose To is before its From runs past midnight." + }, + "to": { + "type": "string" + }, + "min": { + "type": "integer" + }, + "max": { + "type": "integer", + "description": "Max, when set, is the window's ceiling for load scaling; zero keeps the model's own." + } + } + }, + "catalog.Scaling": { + "type": "object", + "description": "Scaling is how many replicas a model should have.", + "properties": { + "min": { + "type": "integer" + }, + "max": { + "type": "integer" + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "IdleTimeout is how long an on-demand model may go unasked-for before its replicas are stopped. Zero takes the cluster default.\n\nMinutes rather than seconds, always. Stopping a replica throws away the KV cache prefix routing spent its effort filling, and a model that flaps in and out loses that entirely — so the saving has to be worth more than the cache, which it only is over a real idle period.\n\nA duration, in nanoseconds." + }, + "schedule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.ScaleWindow" + }, + "description": "Schedule changes min and max by the time of day, in the control plane's control.timezone: more replicas kept through working hours, fewer at night, an on-demand model kept loaded while people are at work. The first window that holds now wins; outside them all, Min and Max apply." + }, + "prewarm": { + "type": "integer", + "format": "int64", + "description": "Prewarm starts a window this long before it opens, so its replicas have loaded by then: loading a model is minutes, and the people who arrive at nine are the reason for the window.\n\nA duration, in nanoseconds." + } + } + }, + "eval.Live": { + "type": "object", + "description": "Live is a run as it stands: one running, with how many of its cases are done, or one finished.", + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string", + "description": "Model is the model the run is about; Target the catalog model that answered, when that was another — its canary, or the new weights an update tried before moving to them." + }, + "target": { + "type": "string" + }, + "repo": { + "type": "string", + "description": "Repo and Revision are where the weights that answered came from, when that is known." + }, + "revision": { + "type": "string" + }, + "suite": { + "type": "string", + "description": "Suite is its name, SuiteHash the sha256 of its text." + }, + "suite_hash": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "took_ms": { + "type": "integer", + "format": "int64" + }, + "score": { + "type": "number", + "description": "Score is the cases' scores weighed, 0 to 1; Passed whether it reached Threshold." + }, + "threshold": { + "type": "number" + }, + "passed": { + "type": "boolean" + }, + "cases": { + "type": "integer", + "description": "Cases were asked; Below scored under half; Errors got no answer." + }, + "below": { + "type": "integer" + }, + "errors": { + "type": "integer" + }, + "judge": { + "type": "string" + }, + "by": { + "type": "string" + }, + "job": { + "type": "string", + "description": "Job is the update or promotion the run gated, if one did." + }, + "error": { + "type": "string", + "description": "Error is why the run could not be scored at all." + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.EvalCase" + } + }, + "state": { + "type": "string" + }, + "done": { + "type": "integer" + } + } + }, + "eval.Request": { + "type": "object", + "description": "Request is a suite to run against a model: one on the control plane, by its name, or one sent whole.", + "properties": { + "suite": { + "type": "string", + "description": "Suite is a suite's name in the eval directory, or carried in the binary (arithmetic, json-format)." + }, + "content": { + "type": "string", + "description": "Content is a suite's text instead, and File the name of the file it was read from, which names the suite unless it names itself." + }, + "file": { + "type": "string" + }, + "concurrency": { + "type": "integer", + "description": "Concurrency, Judge and MinScore are as Options has them." + }, + "judge": { + "type": "string" + }, + "min_score": { + "type": "number" + } + } + }, + "eval.SuiteInfo": { + "type": "object", + "description": "SuiteInfo is a suite the control plane can run by name.", + "properties": { + "name": { + "type": "string" + }, + "from": { + "type": "string", + "description": "From is the file it is read from, or \"built in\"." + }, + "description": { + "type": "string" + }, + "cases": { + "type": "integer" + }, + "threshold": { + "type": "number" + }, + "judge": { + "type": "boolean" + }, + "hash": { + "type": "string" + }, + "error": { + "type": "string", + "description": "Error is why the file cannot be run." + } + } + }, + "fabrics.Change": { + "type": "object", + "description": "Change is a fabric's master subnet manager changing, as its ports saw it.", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "ports": { + "type": "integer" + } + } + }, + "fabrics.Member": { + "type": "object", + "description": "Member is one node's adapters on a physical fabric.", + "properties": { + "node": { + "type": "string" + }, + "hcas": { + "type": "array", + "items": { + "type": "string" + } + }, + "declared": { + "type": "string", + "description": "Declared is the name this node gives these adapters, empty when it declares them in no fabric." + } + } + }, + "fabrics.Physical": { + "type": "object", + "description": "Physical is one fabric as the adapters find it.", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "Kind is infiniband or roce." + }, + "label": { + "type": "string", + "description": "Label is the key as a person reads it." + }, + "sm": { + "$ref": "#/components/schemas/fabrics.SM", + "description": "SM is the master subnet manager, on InfiniBand: the one most of its ports see." + }, + "sms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.SM" + }, + "description": "SMs is every subnet manager the fabric lists, master first; empty when no port could ask. Standbys counts those that could take over." + }, + "standbys": { + "type": "integer" + }, + "masters": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Masters is how many ports see each master, when they do not all agree." + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Change" + }, + "description": "Changes are the master changes seen in the last day, newest first." + }, + "declared": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Declared are the names nodes gave this fabric; one, when all is well." + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Member" + } + }, + "rate_gbps": { + "type": "integer" + }, + "gid_prefix": { + "type": "string" + } + } + }, + "fabrics.Problem": { + "type": "object", + "description": "Problem is one disagreement, in words.", + "properties": { + "level": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "fabrics.Report": { + "type": "object", + "description": "Report is the whole picture.", + "properties": { + "physical": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Physical" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Problem" + } + }, + "unverified": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Unverified are declared fabrics some of whose nodes cannot say which physical fabric they are on, with why." + } + } + }, + "fabrics.SM": { + "type": "object", + "description": "SM is a subnet manager as the ports see it.", + "properties": { + "guid": { + "type": "string" + }, + "lid": { + "type": "integer" + }, + "priority": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "guard.Attachment": { + "type": "object", + "description": "Attachment ties a policy to a part of the tenancy tree: scope is org, team, project, user or key, and ScopeID which one.", + "properties": { + "Scope": { + "type": "string" + }, + "ScopeID": { + "type": "string" + }, + "Policy": { + "type": "string" + } + } + }, + "inventory.EngineInstall": { + "type": "object", + "description": "EngineInstall is where one engine's installation stands on a node.", + "properties": { + "engine": { + "type": "string" + }, + "version": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "state": { + "type": "string", + "description": "State is \"installing\", \"ready\", \"failed\" or \"unsupported\"." + }, + "detail": { + "type": "string" + } + } + }, + "inventory.GPUSensor": { + "type": "object", + "description": "GPUSensor is one card's heat and power, and what is throttling it.", + "properties": { + "index": { + "type": "integer" + }, + "temp_c": { + "type": "number" + }, + "memory_temp_c": { + "type": "number" + }, + "power_w": { + "type": "number" + }, + "power_limit_w": { + "type": "number" + }, + "throttle": { + "type": "array", + "items": { + "type": "string" + } + }, + "util_percent": { + "type": "number", + "description": "How busy the card is, as of this sample; -1 is not reported. Zero MemoryTotalMB is an agent too old to send these, whose figures are the ones it read at registration." + }, + "mem_util_percent": { + "type": "number" + }, + "memory_used_mb": { + "type": "number" + }, + "memory_total_mb": { + "type": "number" + }, + "sm_clock_mhz": { + "type": "number" + }, + "ecc_uncorrected": { + "type": "number" + }, + "wear": { + "$ref": "#/components/schemas/inventory.GPUWear", + "description": "Wear is what wears the card out, read every minute; nil from an agent that does not read it." + } + } + }, + "inventory.GPUWear": { + "type": "object", + "description": "GPUWear is a card's wear and failures. Counts of -1 are not reported for it: a card before Ampere has no remapped rows, one after no retired pages.", + "properties": { + "pcie_replays": { + "type": "integer", + "format": "int64" + }, + "retired_pages_sbe": { + "type": "integer", + "format": "int64" + }, + "retired_pages_dbe": { + "type": "integer", + "format": "int64" + }, + "retire_pending": { + "type": "boolean" + }, + "remapped_rows_correctable": { + "type": "integer", + "format": "int64" + }, + "remapped_rows_uncorrectable": { + "type": "integer", + "format": "int64" + }, + "remap_pending": { + "type": "boolean" + }, + "remap_failure": { + "type": "boolean" + }, + "nvlink_crc_errors": { + "type": "integer", + "format": "int64" + }, + "nvlink_replay_errors": { + "type": "integer", + "format": "int64" + }, + "nvlink_recovery_errors": { + "type": "integer", + "format": "int64" + }, + "xid_errors": { + "type": "integer", + "format": "int64" + }, + "last_xid": { + "type": "integer" + }, + "last_xid_text": { + "type": "string" + }, + "last_xid_at": { + "type": "string", + "format": "date-time" + } + } + }, + "modelplan.Option": { + "type": "object", + "description": "Option is one way to run one artifact.", + "properties": { + "artifact": { + "type": "string" + }, + "format": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "cpu": { + "type": "boolean" + }, + "gpu_class": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "gpus_per_node": { + "type": "integer", + "description": "GPUsPerNode cards on each of Nodes nodes." + }, + "nodes": { + "type": "integer" + }, + "need_gb": { + "type": "number", + "description": "NeedGB is what one card holds, or for CPU the whole replica in RAM; HaveGB what a card, or the node, offers for it." + }, + "have_gb": { + "type": "number" + }, + "hosts": { + "type": "integer", + "description": "Hosts is how many nodes could run it at all, and FreeNow whether one (or enough, together) could right now." + }, + "free_now": { + "type": "boolean" + }, + "bits_per_weight": { + "type": "number", + "description": "BitsPerWeight is the artifact's precision, 0 when unknown." + } + } + }, + "modelserve.CanaryView": { + "type": "object", + "description": "CanaryView is one model's canary as it stands.", + "properties": { + "model": { + "type": "string" + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary" + }, + "serving": { + "$ref": "#/components/schemas/canary.Stat" + } + } + }, + "modelserve.Change": { + "type": "object", + "description": "Change is how an update or a promotion is held to quality.", + "properties": { + "eval": { + "$ref": "#/components/schemas/eval.Request", + "description": "Eval is a suite the new weights must pass first. Nil takes the model's own (eval: in its catalog entry), if it has one." + }, + "skip_eval": { + "type": "boolean", + "description": "SkipEval moves without the model's own suite; the job says so." + } + } + }, + "modelserve.Job": { + "type": "object", + "description": "Job is one model being served, and where it stands.", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "Kind is \"update\" for a model moved to new weights, empty for one served." + }, + "request": { + "$ref": "#/components/schemas/modelserve.Request" + }, + "state": { + "type": "string" + }, + "error": { + "type": "string" + }, + "model": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.Step" + } + }, + "started": { + "type": "string", + "format": "date-time" + }, + "reply": { + "type": "string", + "description": "Reply is what the model said to the smoke test, and TokensPerSecond how fast it said it." + }, + "tokens_per_second": { + "type": "number" + }, + "eval": { + "$ref": "#/components/schemas/store.EvalRun", + "description": "Eval is the quality suite the new weights were asked before they replaced the old, and how they scored, without each case." + } + } + }, + "modelserve.Plan": { + "type": "object", + "description": "Plan is every way a repository's files could run here, best first.", + "properties": { + "repo": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "format": { + "type": "string" + }, + "architecture": { + "type": "string" + }, + "license": { + "type": "string" + }, + "params": { + "type": "integer", + "format": "int64" + }, + "context": { + "type": "integer" + }, + "gated": { + "type": "boolean" + }, + "servable": { + "type": "boolean", + "description": "Servable is false when the nodes' engine cannot run the model at all, and Reason says why." + }, + "reason": { + "type": "string" + }, + "note": { + "type": "string", + "description": "Note is anything the plan could not know, said once." + }, + "projectors": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.PlannedArtifact" + } + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelplan.Option" + } + } + } + }, + "modelserve.PlannedArtifact": { + "type": "object", + "description": "PlannedArtifact is one file a repository offers, and the best way to run it.", + "properties": { + "name": { + "type": "string", + "description": "Name is the file, or the set's name; for original weights, \"original\"." + }, + "format": { + "type": "string", + "description": "\"gguf\" or \"safetensors\"" + }, + "bytes": { + "type": "integer", + "format": "int64" + }, + "shards": { + "type": "integer" + }, + "quant": { + "type": "string", + "description": "Quant is the quantisation read from the name, like Q4_K_M; \"bf16\" for original weights." + }, + "best": { + "$ref": "#/components/schemas/modelplan.Option" + } + } + }, + "modelserve.Request": { + "type": "object", + "description": "Request is what to serve.", + "properties": { + "repo": { + "type": "string", + "description": "Repo is the registry repository, owner/name." + }, + "quant": { + "type": "string", + "description": "Quant picks the file by its quantisation, Q4_K_M unless given." + }, + "file": { + "type": "string", + "description": "File picks it by name instead." + }, + "projector": { + "type": "string", + "description": "Projector is a vision model's mmproj file in the repository, fetched beside the weights and loaded with them; empty serves text alone." + }, + "format": { + "type": "string", + "description": "Format is \"gguf\", run by llama.cpp, or \"safetensors\": the original weights with their config and tokenizer, run by vLLM on GPUs of GPUClass, across TP of them." + }, + "cpu": { + "type": "boolean", + "description": "Where it runs: on the CPU, or on GPUsPerNode cards of GPUClass on each of Nodes nodes. Left empty, the plan decides: the best way the cluster can run the chosen file. TP is GPUsPerNode's older name." + }, + "gpu_class": { + "type": "string" + }, + "gpus_per_node": { + "type": "integer" + }, + "nodes": { + "type": "integer" + }, + "tp": { + "type": "integer" + }, + "fallbacks": { + "type": "boolean", + "description": "Fallbacks adds the next best ways, lower in priority, so a model whose first choice is busy runs on another rather than waiting." + }, + "name": { + "type": "string", + "description": "Name is the catalog name; the repository suggests one otherwise." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "replicas": { + "type": "integer", + "description": "Replicas to run; 0 makes it on demand, loaded when first asked for." + }, + "guard_format": { + "type": "string", + "description": "GuardFormat makes it a judge model for the guardrails (role: guard): llamaguard3, shieldgemma or json. Empty serves clients." + }, + "ctx_pool": { + "type": "integer", + "description": "CtxPool is llama.cpp's --ctx-size; ContextLength what one request may use. They default to 16384 and 4096, what four parallel slots divide." + }, + "context_length": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer", + "description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on." + } + } + }, + "modelserve.Step": { + "type": "object", + "description": "Step is one stage of a job.", + "properties": { + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "modelserve.UpdateInfo": { + "type": "object", + "description": "UpdateInfo is what the registry has for a model against what it runs.", + "properties": { + "model": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "current": { + "type": "string", + "description": "Current is the commit the model runs, Latest the repository's now." + }, + "latest": { + "type": "string" + }, + "changed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Changed are the model's files whose weights differ at Latest, and Missing those Latest no longer has at all." + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "bytes": { + "type": "integer", + "format": "int64", + "description": "Bytes is what an update downloads." + }, + "available": { + "type": "boolean", + "description": "Available is true when the model can be moved to Latest." + }, + "previous": { + "type": "string", + "description": "Previous is the commit an update replaced, which it can go back to." + }, + "checked_at": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": "string" + } + } + }, + "notify.Result": { + "type": "object", + "description": "Result is how one webhook answered a test.", + "properties": { + "url": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, + "nvlink.Domain": { + "type": "object", + "description": "Domain is one multi-node NVLink domain.", + "properties": { + "key": { + "type": "string" + }, + "cluster_uuid": { + "type": "string" + }, + "clique_id": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Member" + } + }, + "gpus": { + "type": "integer" + }, + "joined": { + "type": "integer" + }, + "usable_nodes": { + "type": "integer" + } + } + }, + "nvlink.GPU": { + "type": "object", + "description": "GPU is one card as its domain sees it.", + "properties": { + "index": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "bandwidth": { + "type": "string" + }, + "joined": { + "type": "boolean" + } + } + }, + "nvlink.Member": { + "type": "object", + "description": "Member is one node of a domain.", + "properties": { + "node": { + "type": "string" + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.GPU" + } + }, + "usable": { + "type": "boolean", + "description": "Usable is every card joined and IMEX up: what placement requires." + }, + "imex": { + "type": "string" + }, + "imex_channels": { + "type": "integer" + } + } + }, + "nvlink.Problem": { + "type": "object", + "description": "Problem is one thing wrong with a domain.", + "properties": { + "level": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "nvlink.Report": { + "type": "object", + "description": "Report is every domain and what is wrong with them.", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Domain" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Problem" + } + } + } + }, + "promq.Point": { + "type": "object", + "description": "Point is one sample.", + "properties": { + "At": { + "type": "string", + "format": "date-time" + }, + "Value": { + "type": "number" + } + } + }, + "promq.Series": { + "type": "object", + "description": "Series is one line: a name and its points, oldest first.", + "properties": { + "Name": { + "type": "string" + }, + "Points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/promq.Point" + } + } + } + }, + "quota.Amount": { + "type": "object", + "description": "Amount is how much of each resource something holds or needs.", + "properties": { + "GPUs": { + "type": "integer" + }, + "CPUCores": { + "type": "integer" + }, + "MemoryGB": { + "type": "integer" + } + } + }, + "store.AuditEvent": { + "type": "object", + "description": "AuditEvent is one change: who made it, through what, to what, and how it ended. Detail never holds a secret; the writer takes them out.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "actor": { + "type": "string" + }, + "source": { + "type": "string", + "description": "api, web" + }, + "action": { + "type": "string", + "description": "\"POST /api/v1/models\", \"POST /admin/users/{id}/suspend\"" + }, + "target": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "store.EvalCase": { + "type": "object", + "description": "EvalCase is one case of a run: how its answer scored, and why.", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "weight": { + "type": "number" + }, + "score": { + "type": "number" + }, + "passed": { + "type": "boolean" + }, + "answer": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "error": { + "type": "string" + }, + "took_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "store.EvalRun": { + "type": "object", + "description": "EvalRun is one quality suite run against a model, as `llmgw ctl eval` and the gate on an update keep it.", + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string", + "description": "Model is the model the run is about; Target the catalog model that answered, when that was another — its canary, or the new weights an update tried before moving to them." + }, + "target": { + "type": "string" + }, + "repo": { + "type": "string", + "description": "Repo and Revision are where the weights that answered came from, when that is known." + }, + "revision": { + "type": "string" + }, + "suite": { + "type": "string", + "description": "Suite is its name, SuiteHash the sha256 of its text." + }, + "suite_hash": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "took_ms": { + "type": "integer", + "format": "int64" + }, + "score": { + "type": "number", + "description": "Score is the cases' scores weighed, 0 to 1; Passed whether it reached Threshold." + }, + "threshold": { + "type": "number" + }, + "passed": { + "type": "boolean" + }, + "cases": { + "type": "integer", + "description": "Cases were asked; Below scored under half; Errors got no answer." + }, + "below": { + "type": "integer" + }, + "errors": { + "type": "integer" + }, + "judge": { + "type": "string" + }, + "by": { + "type": "string" + }, + "job": { + "type": "string", + "description": "Job is the update or promotion the run gated, if one did." + }, + "error": { + "type": "string", + "description": "Error is why the run could not be scored at all." + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.EvalCase" + } + } + } + }, + "store.ExportRow": { + "type": "object", + "description": "ExportRow is one line of a usage export: a period, the place, person, key and model it is about — those asked for, the rest empty — and what was used.", + "properties": { + "Period": { + "type": "string", + "format": "date-time" + }, + "Org": { + "type": "string" + }, + "Team": { + "type": "string" + }, + "Project": { + "type": "string" + }, + "User": { + "type": "string" + }, + "Key": { + "type": "string" + }, + "Model": { + "type": "string" + }, + "Requests": { + "type": "integer", + "format": "int64" + }, + "Errors": { + "type": "integer", + "format": "int64" + }, + "Limited": { + "type": "integer", + "format": "int64" + }, + "Prompt": { + "type": "integer", + "format": "int64" + }, + "Completion": { + "type": "integer", + "format": "int64" + }, + "CostMicros": { + "type": "integer", + "format": "int64" + } + } + }, + "store.RequestBody": { + "type": "object", + "description": "RequestBody is one kept request and its answer.", + "properties": { + "request_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "model": { + "type": "string" + }, + "served_model": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "request": { + "type": "string" + }, + "response": { + "type": "string", + "description": "Response is the answer's text as the client was sent it; Truncated says it was longer than what is kept." + }, + "truncated": { + "type": "boolean" + } + } + }, + "store.Resources": { + "type": "object", + "description": "Resources are amounts of the three things a replica holds of a cluster. Nil is not governed: nothing guaranteed, or nothing limited.", + "properties": { + "gpus": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer" + }, + "memory_gb": { + "type": "integer" + } + } + }, + "topology.HostView": { + "type": "object", + "description": "HostView is an adapter, and the node it is in when the cluster knows it.", + "properties": { + "guid": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "node": { + "type": "string" + }, + "hca": { + "type": "string" + }, + "rail": { + "type": "integer" + }, + "leaf": { + "type": "string" + } + } + }, + "topology.LinkView": { + "type": "object", + "description": "LinkView is a cable, with what it trained at and whether that is short of its kind.", + "properties": { + "from": { + "type": "string" + }, + "from_port": { + "type": "integer" + }, + "to": { + "type": "string" + }, + "to_port": { + "type": "integer" + }, + "rate": { + "type": "string" + }, + "gbps": { + "type": "integer" + }, + "host": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "topology.Rail": { + "type": "object", + "description": "Rail is the leaves one rail's adapters reach.", + "properties": { + "index": { + "type": "integer" + }, + "leaves": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "topology.SwitchView": { + "type": "object", + "description": "SwitchView is a switch and its place in the tiers.", + "properties": { + "guid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tier": { + "type": "integer", + "description": "1 leaf, 2 spine, 3 core" + }, + "ports": { + "type": "integer" + }, + "used": { + "type": "integer" + }, + "lid": { + "type": "integer" + }, + "hosts": { + "type": "integer", + "description": "Hosts counts adapters hanging off it." + } + } + }, + "topology.View": { + "type": "object", + "description": "View is a fabric's shape, ready to draw.", + "properties": { + "switches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.SwitchView" + } + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.HostView" + } + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.LinkView" + } + }, + "tiers": { + "type": "integer", + "description": "Tiers is how many switch levels: 1 for a single switch, 2 for leaf and spine, 3 with a core." + }, + "expected_host_gbps": { + "type": "integer", + "description": "Expected is the rate most links of each kind trained at; a link below it is degraded." + }, + "expected_switch_gbps": { + "type": "integer" + }, + "degraded": { + "type": "integer" + }, + "rails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.Rail" + }, + "description": "Rails is, per rail — an adapter's position on its node — the leaves it reaches, and Aligned whether each rail has its own." + }, + "rail_aligned": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } +} diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..59e3dc5 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,548 @@ +# Using llmgw from code + +Written for a developer whose program talks to a model through llmgw. There +is no llmgw SDK, and there will not be one: the gateway speaks OpenAI's API +and Anthropic's Messages API, so the official SDKs for both work as they are. +Only two things change — the base URL and the key. Everything llmgw adds on +top is an HTTP header, which every one of those SDKs lets you set. + +The examples use `https://llm.example.com` for the gateway; use the address +your operator gave you, or the one on the gateway's own front page. Every +example reads the key from `LLMGW_API_KEY`. `` is a model this +gateway serves: `GET /v1/models` lists the ones your key may use, and sending +the placeholder unchanged is answered with that list rather than with an +error. + +## Getting a key + +Keys are made on the gateway's **Account** page after signing in, or issued +by an operator. A key is shown once and never again — only its hash is kept — +so put it in a password manager straight away. `GET /me` with the key says +what it may do and what it has spent. + +## The base URL and the key + +| SDK | Base URL | Key | +|---|---|---| +| OpenAI (any language) | `https://llm.example.com/v1` | `Authorization: Bearer …`, as the SDK sends it | +| Anthropic (any language) | `https://llm.example.com` — the SDK adds `/v1/messages` | `x-api-key: …`, as the SDK sends it | + +Both SDKs also read their usual environment variables, so a program that +already uses them needs no change at all: + +``` +export OPENAI_BASE_URL=https://llm.example.com/v1 +export OPENAI_API_KEY=$LLMGW_API_KEY + +export ANTHROPIC_BASE_URL=https://llm.example.com +export ANTHROPIC_API_KEY=$LLMGW_API_KEY +``` + +## A first request + +### curl + +``` +curl https://llm.example.com/v1/chat/completions \ + -H "Authorization: Bearer $LLMGW_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model": "", + "messages": [{"role": "user", "content": "Say hello in Turkish."}]}' +``` + +### Python + +``` +pip install openai +``` + +```python +import os +from openai import OpenAI + +client = OpenAI(base_url="https://llm.example.com/v1", + api_key=os.environ["LLMGW_API_KEY"]) + +reply = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "Say hello in Turkish."}], +) +print(reply.choices[0].message.content) +``` + +### TypeScript + +``` +npm install openai +``` + +```typescript +import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: "https://llm.example.com/v1", + apiKey: process.env.LLMGW_API_KEY, +}); + +const reply = await client.chat.completions.create({ + model: "", + messages: [{ role: "user", content: "Say hello in Turkish." }], +}); +console.log(reply.choices[0].message.content); +``` + +### Go + +``` +go get github.com/openai/openai-go/v3 +``` + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +func main() { + client := openai.NewClient( + option.WithBaseURL("https://llm.example.com/v1/"), + option.WithAPIKey(os.Getenv("LLMGW_API_KEY")), + ) + reply, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{ + Model: "", + Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Say hello in Turkish.")}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(reply.Choices[0].Message.Content) +} +``` + +The import path carries the SDK's major version; use the one your module +already has. + +## Streaming + +Set `stream` and read the chunks. llmgw may send a last chunk that carries +only the token counts and no choices, so check for a choice before reading +one — the OpenAI API does the same when usage is asked for. + +```python +stream = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "Count to ten."}], + stream=True, +) +for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +print() +``` + +```typescript +const stream = await client.chat.completions.create({ + model: "", + messages: [{ role: "user", content: "Count to ten." }], + stream: true, +}); +for await (const chunk of stream) { + process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); +} +``` + +```go +stream := client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Model: "", + Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Count to ten.")}, +}) +for stream.Next() { + chunk := stream.Current() + if len(chunk.Choices) > 0 { + fmt.Print(chunk.Choices[0].Delta.Content) + } +} +if err := stream.Err(); err != nil { + log.Fatal(err) +} +``` + +With curl, add `"stream": true` to the body and `-N` so curl prints the +events as they come. + +## Tools + +Function calling is OpenAI's: describe the tools, run the ones the model +asks for, send the results back. Whether a model calls tools well is the +model's business, not the gateway's; ask your operator which of the served +models are set up for it. + +```python +import json + +tools = [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "The current weather in a city.", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, +}] + +messages = [{"role": "user", "content": "Is it raining in Ankara?"}] +reply = client.chat.completions.create(model="", messages=messages, tools=tools) +msg = reply.choices[0].message + +if msg.tool_calls: + messages.append(msg) + for call in msg.tool_calls: + args = json.loads(call.function.arguments) + result = {"city": args["city"], "raining": False} # your code here + messages.append({"role": "tool", "tool_call_id": call.id, + "content": json.dumps(result)}) + reply = client.chat.completions.create(model="", messages=messages, tools=tools) + +print(reply.choices[0].message.content) +``` + +## Embeddings + +```python +result = client.embeddings.create( + model="", + input=["llmgw routes requests", "to GPUs it can reach"], +) +vectors = [d.embedding for d in result.data] +``` + +```typescript +const result = await client.embeddings.create({ + model: "", + input: ["llmgw routes requests", "to GPUs it can reach"], +}); +const vectors = result.data.map((d) => d.embedding); +``` + +An embedding model is a model of its own; a chat model does not answer +`/v1/embeddings`. + +## The Responses API + +`/v1/responses` is served too, for clients that speak nothing else (Codex +among them). llmgw translates it to chat completions, so it works with every +model the gateway serves; what has no equivalent there — OpenAI's hosted +tools, reasoning summaries — is ignored rather than refused. + +```python +response = client.responses.create( + model="", + instructions="Answer in one sentence.", + input="Why is the sky blue?", +) +print(response.output_text) +``` + +Streamed, the events are the Responses API's own: + +```python +stream = client.responses.create(model="", input="Count to ten.", stream=True) +for event in stream: + if event.type == "response.output_text.delta": + print(event.delta, end="", flush=True) +``` + +## Batches + +For work that need not finish now: OpenAI's Files and Batch APIs. A batch runs +at the lowest priority, so it uses what interactive traffic leaves free, and +each line is counted against the key that submitted it. + +```python +import json + +with open("requests.jsonl", "w") as f: + for i, text in enumerate(["first document", "second document"]): + f.write(json.dumps({ + "custom_id": f"doc-{i}", + "method": "POST", + "url": "/v1/chat/completions", + "body": {"model": "", + "messages": [{"role": "user", "content": "Summarise: " + text}]}, + }) + "\n") + +batch_file = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch") +batch = client.batches.create( + input_file_id=batch_file.id, + endpoint="/v1/chat/completions", + completion_window="24h", +) + +# Later: +batch = client.batches.retrieve(batch.id) +if batch.status == "completed": + for line in client.files.content(batch.output_file_id).text.splitlines(): + result = json.loads(line) + print(result["custom_id"], result["response"]["body"]["choices"][0]["message"]["content"]) +``` + +## The Anthropic SDK + +`/v1/messages` is Anthropic's Messages API, translated to chat completions and +back, streamed or not, with tools and images. It serves the gateway's own +models — `model` is a model this gateway serves, not a Claude model. + +``` +pip install anthropic +``` + +```python +import os +import anthropic + +client = anthropic.Anthropic(base_url="https://llm.example.com", + api_key=os.environ["LLMGW_API_KEY"]) + +message = client.messages.create( + model="", + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello in Turkish."}], +) +print(message.content[0].text) + +with client.messages.stream( + model="", + max_tokens=1024, + messages=[{"role": "user", "content": "Count to ten."}], +) as stream: + for text in stream.text_stream: + print(text, end="", flush=True) +``` + +```typescript +import Anthropic from "@anthropic-ai/sdk"; + +const client = new Anthropic({ + baseURL: "https://llm.example.com", + apiKey: process.env.LLMGW_API_KEY, +}); + +const message = await client.messages.create({ + model: "", + max_tokens: 1024, + messages: [{ role: "user", content: "Say hello in Turkish." }], +}); +for (const block of message.content) { + if (block.type === "text") console.log(block.text); +} +``` + +```go +client := anthropic.NewClient( + option.WithBaseURL("https://llm.example.com/"), + option.WithAPIKey(os.Getenv("LLMGW_API_KEY")), +) +message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: "", + MaxTokens: 1024, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello in Turkish.")), + }, +}) +if err != nil { + log.Fatal(err) +} +for _, block := range message.Content { + if text, ok := block.AsAny().(anthropic.TextBlock); ok { + fmt.Println(text.Text) + } +} +``` + +(`github.com/anthropics/anthropic-sdk-go` and its `option` package.) + +With curl: + +``` +curl https://llm.example.com/v1/messages \ + -H "x-api-key: $LLMGW_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{"model": "", "max_tokens": 1024, + "messages": [{"role": "user", "content": "Say hello in Turkish."}]}' +``` + +## What llmgw adds + +Each is a header, so a request stays exactly what the SDK would send anywhere +else. Set one for every request on the client, or for one request: + +| | Every request | One request | +|---|---|---| +| Python (both SDKs) | `OpenAI(..., default_headers={...})` | `create(..., extra_headers={...})` | +| TypeScript (both SDKs) | `new OpenAI({ ..., defaultHeaders: {...} })` | `create({...}, { headers: {...} })` | +| Go (both SDKs) | `openai.NewClient(..., option.WithHeader(k, v))` | `New(ctx, params, option.WithHeader(k, v))` | +| curl | | `-H "k: v"` | + +### Stored conversations: `x-llmgw-thread` + +Send the same thread id with each request and only the new turn; llmgw puts +the stored conversation in front of it. The conversation continues on +whichever model serves the next turn, even a different one. It must be +turned on for your project by an operator (`llmgw keys threads`); a request +that names a thread on a gateway that keeps none is refused rather than +answered without its history. + +```python +import uuid + +thread = str(uuid.uuid4()) # anyone in your project who knows it reads it +for turn in ["My name is Deniz.", "What is my name?"]: + reply = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": turn}], + extra_headers={"x-llmgw-thread": thread}, + ) + print(reply.choices[0].message.content) +``` + +A thread belongs to the project, not to the key, so use an id nobody can +guess: at most 128 letters, digits and `- _ . :`. When the conversation no +longer fits the model, the oldest turns are left out and the reply carries +`x-llmgw-thread-trimmed` with how many. `DELETE /v1/threads/{id}` forgets one: + +``` +curl -X DELETE https://llm.example.com/v1/threads/$THREAD \ + -H "Authorization: Bearer $LLMGW_API_KEY" +``` + +### Sessions: `x-session-id` + +Requests with the same session id go to the same replica while it is +healthy, so a conversation that resends its history finds it already in that +replica's cache and answers sooner. Without the header, the body's `user` +field is used. Nothing is stored; it is only a routing hint. + +```python +client = OpenAI(base_url="https://llm.example.com/v1", api_key=os.environ["LLMGW_API_KEY"], + default_headers={"x-session-id": "chat-7f3a"}) +``` + +### Tags and metadata + +Requests can be labelled so the usage they record can be told apart — by +feature, by customer, by job. Send OpenAI's own `metadata`, string keys to +string values, or the same as the `x-llmgw-tags` header for a client that +cannot set `metadata`; the `user` field is recorded too. Which tags also +become metric labels is the operator's choice (`router.tag_labels`). + +```python +reply = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "Classify this ticket."}], + metadata={"feature": "triage", "customer": "acme"}, + user="user-1234", +) +``` + +``` +curl https://llm.example.com/v1/chat/completions \ + -H "Authorization: Bearer $LLMGW_API_KEY" \ + -H "x-llmgw-tags: feature=triage,customer=acme" \ + -H "Content-Type: application/json" \ + -d '{"model": "", "messages": [{"role": "user", "content": "Classify this ticket."}]}' +``` + +### Canaries: `x-llmgw-variant` + +While an operator tries a model's newer weights on a share of its traffic, a +session stays on one side. `x-llmgw-variant: canary` sends a request to the +canary deliberately — to try it before it is promoted, or to compare the two. + +```python +reply = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "hello"}], + extra_headers={"x-llmgw-variant": "canary"}, +) +``` + +### Memory notes: `/v1/memory` + +Notes an agent keeps for itself across conversations: a key, some text, and +optional tags, private to your project and to the agent named in +`x-llmgw-agent` (`default` when it is not sent). An operator turns them on for +a project (`llmgw keys memory`). They are plain REST, so any HTTP client +will do: + +``` +H="Authorization: Bearer $LLMGW_API_KEY" + +curl -X PUT https://llm.example.com/v1/memory/favourite-colour -H "$H" \ + -H "x-llmgw-agent: helper" -H "Content-Type: application/json" \ + -d '{"text": "The user likes dark green.", "tags": ["preference"]}' + +curl "https://llm.example.com/v1/memory?q=green" -H "$H" -H "x-llmgw-agent: helper" +curl "https://llm.example.com/v1/memory?tag=preference" -H "$H" -H "x-llmgw-agent: helper" +curl https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper" +curl -X DELETE https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper" +``` + +A key is up to 200 bytes, a note up to 16 KiB, with at most 16 tags of +lowercase letters, digits, `-` and `_`, and 1000 notes per agent. + +### MCP: `/mcp` + +The same notes as tools an agent can be given — `remember`, `recall` and +`forget` — from an MCP server at `https://llm.example.com/mcp` (the HTTP +transport, answered as plain JSON). Add it to your agent's MCP servers with +the key as a bearer token and, if you want notes kept apart, `x-llmgw-agent`. +llmgw does not put these tools into requests by itself: an agent that is not +given the server never sees its notes. + +``` +curl https://llm.example.com/mcp \ + -H "Authorization: Bearer $LLMGW_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' +``` + +### Headers llmgw sends back + +| Header | Says | +|---|---| +| `x-request-id` | The request's id in the gateway's logs and usage records — quote it when asking an operator about a request. | +| `x-llmgw-served-model` | The model that answered, when it was not the one asked for: a spent budget can move a request to a cheaper model. | +| `x-llmgw-queue-ms` | How long the request waited for room before it was sent. | +| `x-llmgw-thread-trimmed` | How many stored turns were left out to fit the model. | + +## When something is refused + +Errors come back in the shape of the API that was called — OpenAI's +`{"error": {"message": …, "type": …}}` or Anthropic's — so each SDK raises +its usual exception with the gateway's message in it. The ones worth knowing: + +| Status | Usually means | +|---|---| +| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). | +| 401 | No key, a mistyped one, or one that was revoked or has expired. | +| 403 | The key may not use that model. | +| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. | +| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. | + +## Priority + +With keys on, the key decides the request's priority band and routing +profile, set by an operator; the `x-llmgw-band` and `x-llmgw-profile` headers +are ignored, so a client cannot promote itself. On a gateway without keys +they are read from the request. diff --git a/docs/console.md b/docs/console.md index f891558..39c7eda 100644 --- a/docs/console.md +++ b/docs/console.md @@ -117,6 +117,16 @@ and **UPDATE** when the registry has new weights for it. On a row: canary is removed) and **Take the canary out…**. A canary that errs or answers more slowly than the model is taken out by itself. The MODEL column shows `(10% to canary)` and `(canary of …)`. +- **Ask it a quality suite…** — one of the suites the control plane has (in + `control.eval_dir`, or built in: `arithmetic`, `json-format`), asked + through the router; the dialog counts the answers as they come in, then + shows the score, each case and the model's last runs. Esc stops watching; + the run carries on and is kept. **Its quality runs** lists them, as + `llmgw ctl eval --history` does. +- **Hold its updates to a quality suite…** — the suite and score its new + weights must reach before an update or a promotion moves it to them + (`llmgw ctl model gate`); **none** takes it away. With one set, **Update + to …** and **Promote the canary…** say so. - **Why is it placed where it is** — `llmgw ctl explain-placement`. - **Try again**, for a model parked after failing every start. - **Remove** — its replicas stop and it leaves the catalog. A model from @@ -138,7 +148,10 @@ team or project is guaranteed and may at most hold, and what it holds now. **Mirror** is what the model mirror holds, one row for each version of each model — a file, or the set of files a model of several is: shards, a vision projector, a config and tokenizer — with how far its download has got and -which models use it. **A** downloads a URL into it; on a row, its files with +which models use it. **A** downloads a URL into it; **I** imports a model +from an Ollama on this machine into a mirror directory on this machine — the +mirror beside the control plane — and shows the model definition to add for +it (a mirror elsewhere: `llmgw mirror import-ollama` there); on a row, its files with their checksums and where each was fetched from, the source a model definition names for it, or remove it — with a warning when a model still uses it. @@ -195,7 +208,9 @@ row: add a team or project inside it, rename, move, limits for the level and for each of its members, its capacity quota, and for a project how long conversations and agent notes are kept, and whether its requests are (**Keep requests…**: for how long, and what share), and whether they stay in -the cluster (**Requests stay in the cluster…**: never an outside provider). **Longest key lifetime…** caps how +the cluster (**Requests stay in the cluster…**: never an outside provider), +and whether the same request is answered again from memory (**Cache +answers…**: for how long, and whether only reproducible answers or any). **Longest key lifetime…** caps how long a key made there may live, and is the expiry of one made without any (`llmgw keys ttl`; `auth.max_key_ttl` in server.yaml caps every key). **Remove** takes away only something empty. diff --git a/docs/data.md b/docs/data.md index 1f97567..f6ae66b 100644 --- a/docs/data.md +++ b/docs/data.md @@ -67,10 +67,20 @@ until deleted (`DELETE /v1/files/{id}`) or, for what a batch wrote, until them can read them, and a project under a `no_store` policy cannot make them. +**Reused answers** — only for a project that asked (`llmgw keys cache`): +each answer that may be reused, as the client got it, and a hash of the +request it answers, in the gateway process's memory — never in the store, +never on disk — for the project's TTL or until pushed out by newer ones +(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store` +policy. Forgetting a person does not reach into it; the answers age out by +the TTL. The store keeps only the setting, and a usage record's mark that it +was answered from the cache. + ## What is not stored Prompts. Completions. Anything the model was shown or produced, unless the project turned on conversations, notes or kept requests, or sent a batch. +(Reused answers are held in memory only, as above.) The chat page in llmgw's own web interface keeps the conversation in a JavaScript variable in the browser. Refresh it and the conversation is gone, diff --git a/docs/install.md b/docs/install.md index ebc79eb..4f9105f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -252,7 +252,8 @@ knowing before you edit it: `LLMGW_ADMIN_TOKEN` gives it from elsewhere. pprof, whose heap holds the API keys requests presented, is off unless `debug_pprof: true`, and then behind the token too. To see the dashboard from elsewhere use `/admin` on the - client port — do not move this listener. + client port — do not move this listener. The API itself is described in + [admin-api.md](admin-api.md). - `default_rpm`, `default_tpm` and `default_price` are what make limits and budgets mean anything. Without a price every request costs zero and a budget never binds; without the defaults, a project created without limits is @@ -383,6 +384,46 @@ llmgw mirror fetch --model qwen2.5-14b-instruct --version 2026-03 \ or from the admin page, or by copying files into the directory by hand — the mirror is a directory, and anything that puts files in it works. +Files already on the mirror machine go in with `import`, which copies them the +way a download is kept — under a temporary name until whole, the checksum +beside them for nodes to check against, a version never replaced — and checks +that a `.gguf` is one: + +``` +llmgw mirror import ./qwen2.5-7b-instruct-q4_k_m.gguf --model qwen2.5-7b-instruct \ + --version 2026-10 --sha256 +``` + +#### From Ollama + +Models you already run in Ollama are GGUFs llama.cpp serves as they are. +On the mirror machine, with Ollama's models readable there: + +``` +llmgw mirror import-ollama # lists what Ollama has +llmgw mirror import-ollama llama3.2:3b +``` + +It finds Ollama's models directory where Ollama keeps it — `OLLAMA_MODELS`, +`~/.ollama/models`, or `/usr/share/ollama/.ollama/models` for Ollama's Linux +service, which needs `sudo` to read — or takes `--ollama-dir`. It copies the +model's GGUF, and a vision model's projector beside it, each checked against +the sha256 Ollama named its file by; a file that does not match is not +mirrored, and nothing of the model is. The version is the tag and the start of +the digest (`llama3.2/3b-a80c4f17acd5/llama3.2-3b.gguf`), because a tag like +`latest` moves in Ollama and a mirror version must not; `--model` and +`--version` choose otherwise. It ends with a model definition to save and +`llmgw ctl model add -f`, with `--ctx-size` set to the `num_ctx` Ollama ran it +with. + +Ollama's prompt template is not carried: llama.cpp renders the chat template +inside the GGUF, which for any recent model is the one it was trained on. Its +system prompt and adapters are not carried either, and the import says so. +Models Ollama keeps as separate tensors rather than one GGUF, or as several +weight layers, are refused with a sentence saying so — mirror that model's GGUF +from its publisher instead. When the mirror runs beside the control plane, the +console's Mirror view does the same with **I**. + ## 4. Preparing a node **Do this before onboarding.** A node missing its driver or engine registers @@ -428,6 +469,71 @@ sudo llmgw doctor A `-nvml` build is still published beside the portable one; it reads NVLink from NVML directly, and needs a libc no older than the builder's. +### MIG, if you want to cut a card into pieces + +An A100, A30, H100, H200 or later card can be split into MIG (Multi-Instance +GPU) instances, each with its own memory, its own share of the card's compute +and its own UUID. llmgw places a model on an instance as it would on a card, +which makes MIG the way to run several small models on one large card without +them competing for it. + +**llmgw does not create the instances.** How a card is cut is your decision, +made once with `nvidia-smi` on the node; the agent reads what is there when +it starts: + +``` +sudo nvidia-smi -i 0 -mig 1 # MIG mode on card 0; may need a reset +sudo nvidia-smi mig -i 0 -lgip # the profiles this card offers +sudo nvidia-smi mig -i 0 -cgi 3g.40gb,3g.40gb -C # two instances, each with its compute instance +nvidia-smi -L # the instances, with their MIG- UUIDs +sudo systemctl restart llmgw-agent # so it registers the new devices +``` + +`-C` matters: a GPU instance with no compute instance inside it is not a +device yet. Turning MIG on takes effect at the card's next reset +(`nvidia-smi -i 0 -r` with nothing running on it, or a reboot), and instances +do not survive a reboot unless something recreates them — NVIDIA's +`mig-parted` or a systemd unit of your own. + +**Classes.** A card in MIG mode is reported as its instances instead of as +the whole card, and each instance's class is the card's class and the +profile: two `3g.40gb` instances of an A100 80GB are two devices of class +`a100-80g-mig-3g.40gb`, an H100's `1g.10gb+me` is `h100-80g-mig-1g.10gb-me`. +`llmgw ctl nodes` lists the classes, and a profile names one like any other: + +```yaml +profiles: + - name: mig + engine: vllm + gpu_class: a100-80g-mig-3g.40gb +``` + +**One instance a replica.** A process can open only one MIG device, and +instances have no NVLink or peer-to-peer access between them, so a replica on +MIG runs on exactly one instance: `tp: 1`, one GPU, one node. A profile on a +MIG class asking for more is refused when the model is added, with that +reason. A model too large for one instance wants a larger profile or a whole +card. `llmgw ctl model plan` and `ctl model serve` plan the same way: an +instance is offered for a model it holds, and never several together. + +**How they are numbered and shown.** Placement numbers card 0's instance 2 as +device 1002 (1000 + 100 × card + instance), and every view shows it as +`0/mig2`. The engine is given the instance by its UUID — +`CUDA_VISIBLE_DEVICES=MIG-…` — which works for vLLM and for llama.cpp's CUDA +build. llama.cpp's Vulkan build cannot see MIG instances at all. + +**What is measured.** Each instance's memory is its own; temperature, power +and wear are its card's, shown on each of its instances. Utilisation is not +reported for an instance, because no driver measures it per instance: the +console says `n/a on MIG`, and no `llmgw_gpu_utilization_percent` series is +published for it rather than a zero. + +`sudo llmgw doctor` lists each card's MIG mode and instances, and warns of a +card in MIG mode with none created (nothing can run on it: CUDA will not open +the whole card while MIG is on), of GPU instances with no compute instance, +and of a mode change waiting for a reset. Such a card is also kept out of +placement, with the reason, in `llmgw ctl nodes`. + ### InfiniBand, if you have it ``` @@ -458,6 +564,7 @@ Nothing to do: the agent installs them once the node joins, under | llama.cpp | every node | the release build for its processor and GPU — CUDA 13.3, CUDA 12.8 (x64), or Vulkan for GPUs and drivers the CUDA builds do not cover; the CPU build only where there is no NVIDIA GPU — checked against GitHub's checksum and seen to list the GPU | | vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB | | SGLang | only when you ask for it | the same way as vLLM | +| TensorRT-LLM | only nodes where you installed it | not installed by llmgw: see [TensorRT-LLM, which you bring](#tensorrt-llm-which-you-bring) | vLLM is resolved as PyPI stood a day after that vLLM was published, so every node gets the same dependencies whenever it is built, and installed with @@ -492,8 +599,8 @@ engines: A changed version reaches every node when it next connects. To use an engine you installed yourself on a node, name its path in that node's `agent.yaml` -(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`): the agent -then installs none of its own beside it. +(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`, +`engines.trtllm.python`): the agent then installs none of its own beside it. #### Choosing a node's vLLM build @@ -544,6 +651,82 @@ which drift between releases: llmgw ctl engine-check vllm --bin /opt/llmgw/engines/vllm/0.30.0-cu13/bin/vllm ``` +#### TensorRT-LLM, which you bring + +llmgw does not install TensorRT-LLM. It is a pip package tied to one CUDA, one +PyTorch and an MPI the machine has to provide, and NVIDIA's own advice is its +container; an installer guessing at all of that would fail on most machines it +met, a long way into a large download. Install it on the node yourself — a +virtual environment, or NVIDIA's TensorRT-LLM container — and name its +interpreter in that node's `agent.yaml`: + +```yaml +engines: + trtllm: + python: /opt/trtllm/venv/bin/python # python3 inside NVIDIA's container +``` + +The node then offers the engine `trtllm` whenever that interpreter has the +`tensorrt_llm` package; without the line it offers nothing, whatever is on +`PATH`. It needs **TensorRT-LLM 1.3** (1.3.0rc13 or later): 1.2 has no +`--served_model_name`, and publishes none of the queue figures the router +reads. Check a node's against the flags llmgw builds: + +``` +llmgw ctl engine-check trtllm --bin /opt/trtllm/venv/bin/python +``` + +A model for it names a Hugging Face checkpoint, as for vLLM — trtllm-serve +downloads a repository itself, or loads one from the mirror: + +```yaml +name: llama-3.1-70b-instruct +source: meta-llama/Llama-3.1-70B-Instruct +profiles: + - name: h100x4 + engine: trtllm + gpu_class: h100-80g + tp: 4 + args: ["--max_batch_size", "64", "--free_gpu_memory_fraction", "0.85"] +``` + +The agent starts `trtllm-serve serve --host … --port … --tp_size … +--served_model_name …`, through a few lines of Python of its own that do what +trtllm-serve has no flag for: + +- **the key.** trtllm-serve checks none; every path but `/health` is made to + demand the one the gateway gives the replica, so the port on the node is not + a way around the gateway. +- **the metrics.** Its options file turns on `return_perf_metrics` and + `enable_iter_perf_stats`, without which `/prometheus/metrics` does not exist + and its queue figures do not move. The router reads + `trtllm_num_requests_running`, `trtllm_num_requests_waiting` and + `trtllm_kv_cache_utilization` there — not `/metrics`, which on trtllm-serve + is JSON. They are set as the engine iterates, so an engine gone idle shows + its last batch until the next request. +- **no usage reports to NVIDIA**: `TRTLLM_NO_USAGE_STATS` and `DO_NOT_TRACK` + are set, after the profile's own `env`. + +What it does not do: + +- **span nodes.** A TensorRT-LLM replica larger than one machine is launched + under MPI or Slurm from one of them, which the agents, each starting its own + node's part, do not do. A profile with `nodes` above 1 is refused; use vLLM + or SGLang for one. +- **run before Ampere.** Compute capability 8.0 or newer, which is what current + TensorRT-LLM builds kernels for. +- **take an options file from a profile.** `--config` and + `--extra_llm_api_options` are llmgw's; change a setting with `--set + PATH=VALUE` instead (`--set kv_cache_config.enable_block_reuse=false`). Also + refused, like the other engines' equivalents: `--trust_remote_code`, + `--tokenizer`, `--middleware`, `--chat_template`, `--grpc` and + `--num_serve_frontends` (both would serve without the key), and the flags the + agent sets. + +This engine was written against TensorRT-LLM's documented command line, +server and metrics, and tested with stand-ins for them; it has not yet run on a +datacenter GPU. Run `engine-check` on the first node before placing a model on it. + ### Weights Nothing to do here if you built the mirror in step 3 — the agent fetches what a @@ -911,6 +1094,118 @@ llmgw ctl model canary qwen-7b --abort # or not - **Promote** is the update the canary was a trial of: the model's replicas are replaced one at a time, and `llmgw ctl model rollback` goes back. +### Quality suites, and holding new weights to one + +A canary says whether new weights serve as fast and as reliably; it does not +say whether their answers are as good. A republished quantisation or a chat +template that no longer matches can make a model faster and worse at once. A +quality suite is a handful of questions with known answers, asked of a model +and scored: + +``` +llmgw ctl eval --suites # what the control plane can run +llmgw ctl eval qwen-7b --suite arithmetic # by name, on the control plane +llmgw ctl eval qwen-7b --suite ./support.yaml --judge llama-70b --concurrency 8 +llmgw ctl eval --history qwen-7b # the runs kept +llmgw ctl eval --run eval-20261004-101500-1a2b3c4d # one, case by case +``` + +It prints each case's score and why, then the suite's: the cases' scores +weighed, 0 to 1, and whether it reached the suite's `threshold` (or +`--min-score`); it exits non-zero below it, so a script can stop on it. + +- **How it is asked:** on the control plane, through the router as the + gateway's own requests, the way a replay is — past admission, quotas, + accounting and the guardrails, so the score is the weights' and not a + tenant's limits'. It reaches the model exactly as a client's request + would, a canary or a model loading on demand included. Answers are asked + at temperature 0 unless a case says otherwise, and a reasoning model's + `` block is not part of its answer. +- **Where suites come from:** `--suite` is a file on the machine running + `ctl`, sent whole, or the name of one in the control plane's + `control.eval_dir` (`/etc/llmgw/eval`). Two are built in and run on small + models: `arithmetic` (sums and word problems, each scored by the number in + the answer) and `json-format` (answers asked for as JSON of a given shape); + their files are in `deploy/eval/`, to copy and change. A file in the + directory with the same name replaces the built-in one. +- **What is kept:** every run, in the store, with the model, the repository + and revision the answering weights came from (when known), the suite's name + and sha256, when, the score, and each case's answer (up to 2,000 + characters), score and reason. A run on a canary is filed under the model + it is a canary of. Runs are not pruned. + +A suite is YAML (or JSON lines: one case on each line, and optionally a line +without a prompt that holds the suite's own settings): + +```yaml +name: support # the file's name unless given +threshold: 0.8 # the score to pass at, 0 to 1; 0.8 unless given +judge: llama-70b # grades the cases scored by a rubric +system: You are a support agent for Acme. # every case's, unless it has one +max_tokens: 256 # every case's; 512 unless given +cases: + - id: refund-window + prompt: How many days do customers have to ask for a refund? + number: 30 # the last number in the answer, ±tolerance + tolerance: 0 + - prompt: Which plan includes SSO? + contains: [Enterprise] # every string must appear + ignore_case: true + - prompt: Reply with only the word OK. + exact: OK # the whole answer, white space around it aside + - prompt: What is today's ISO date format? + regex: '\d{4}-\d{2}-\d{2}' # matches somewhere in the answer + - prompt: Give the order as JSON with "id" and "total". + json_schema: # the answer, read as JSON, satisfies it + type: object + required: [id, total] + properties: { id: {type: string}, total: {type: number, minimum: 0} } + - messages: # a conversation instead of a prompt + - { role: user, content: My card was charged twice. } + judge: Apologises, and says the duplicate charge is refunded within 5 days. + weight: 3 # counts three times; 1 unless given +``` + +Each case is scored by exactly one of `exact`, `contains`, `regex`, `number`, +`json_schema` or `judge`. A JSON answer in a code fence or inside a sentence is +still read. The schema supports `type`, `properties`, `required`, +`additionalProperties`, `items`, `enum`, `const`, the numeric and length bounds, +`pattern`, `anyOf` and `allOf`; a suite using anything else is refused when it +is read rather than passed unchecked. A `judge` case asks another model on the +cluster (`judge_model:` on the case, else the suite's `judge:`, else `--judge`) +to grade the answer by the rubric, 0 to 1. A case passes at 0.5; a case that +got no answer scores 0 and says why. + +**Holding updates to a suite.** `model update` and `model promote` take +`--eval SUITE --min-score N` (and `--judge`); or a model carries one — set +with `ctl model gate`, or `eval: { suite: arithmetic, min_score: 0.8 }` in a +definition added with `ctl model add` — so every update and promotion of it +is held to it, from `ctl`, the console or anywhere: + +``` +llmgw ctl model gate qwen-7b --eval arithmetic --min-score 0.8 +llmgw ctl model update qwen-7b # asked the suite first +llmgw ctl model update qwen-7b --skip-eval # once, without it; the job says so +llmgw ctl model gate qwen-7b --off +``` + +- **Promote** asks the canary the suite before anything moves; below the + score the job fails with the score, and the canary stays, with its share, + until you take it out. +- **Update** runs the new weights first as `MODEL-canary` given none of the + model's requests (hidden from clients like any canary), asks it the suite, + then either promotes it — the model's replicas are replaced one at a time, + as for any update — or takes it out again, leaving the model on its old + weights. That needs room for one more replica while it runs; a model that + already has a canary is promoted or aborted first. Without a suite, an + update swaps the weights in as it always has. +- The job's `eval` step says the score (`llmgw ctl jobs JOB`), and the run is + kept with the job's id beside it. + +The console has it on a model's menu: **Ask it a quality suite…** (a suite the +control plane has, the score as it comes in), **Its quality runs**, and **Hold +its updates to a quality suite…**. + ### How many replicas, and how much each one carries Replica count is changed while the cluster runs, without editing the @@ -1090,6 +1385,46 @@ same event about the same thing is sent at most once an hour, so a budget that refuses a thousand requests is one message. `llmgw ctl notify test` sends a test event to each webhook and says how each answered. +### Tracing + +Metrics say that a model took four minutes to start; a trace says where they +went. Give the control plane and every agent the same OpenTelemetry collector, +over OTLP/HTTP — Jaeger, Tempo, Honeycomb, anything that takes OTLP: + +```yaml +# /etc/llmgw/server.yaml, and the same block in each node's agent.yaml +tracing: + endpoint: http://otel.internal:4318 + sample_ratio: 1 # of requests that arrive without a decision of their own +``` + +`OTEL_EXPORTER_OTLP_ENDPOINT` does the same when the file does not say. With +neither, nothing is traced and nothing is sent: every span is a no-op, and a +command to a node carries no trace context. + +What is traced: + +| Trace | Spans | +|---|---| +| An inference request (`llmgw-server`) | `inference /v1/…` with `admission`, `schedule` and an `upstream` per attempt beneath it; a caller's `traceparent` is continued and the engine is sent one. | +| A reconcile pass that did something | `reconcile`, and beneath it what it did: `scale up` with `place` (the profile, GPU class, pool, fabric and nodes chosen, or every candidate turned down and why) and `replica start`; `scale down`; `drain replica` and `stop replica`, each with its reason; `evict`, `preempt` with its victims; `reap replica` with the error and when it is tried again; `restart replica`, `restart refused`; `node unreachable`, `node lost`. A pass that found nothing to do is not traced. | +| A replica's start | `replica start` stays open from placement until the replica serves, fails or is stopped, with each shard's progress as an event on it and how long it took. A `send start` per shard beneath it is the command to the node, and the node's spans (`llmgw-agent`) continue the same trace: `start shard`, with `fetch weights` (local, cached, downloaded or fetched by another shard, and a `download` with its bytes), `launch engine` (and `wait for rendezvous` on a worker of a multi-node replica) and `wait for engine` (how often the engine was asked, and its last answer if it never came up). | +| A stop on a node | `stop shard` and `stop engine` in the trace of the control plane's `stop replica`, saying whether the engine had to be killed. | +| An adoption | `adopt`, when a node that registers reports engines already running, with how many nobody claimed. | +| A model job | `serve model` or `update model`, with a span per step — `registry`, `mirror`, `check`, `catalog`, `placement`, `smoke test`, `replicas` — and from the placement step a link to the replica's own start. | +| An engine install | `install engine` on the node, a trace of its own. | + +So "why did qwen take four minutes to start" is one trace: find `replica start` +with `llmgw.model` = qwen, and the node's spans beneath it say whether the +time went to the download, the launch or the engine loading. + +A node follows the control plane's decision: a start the control plane kept is +traced on the node whatever the agent's own `sample_ratio`, and one it sampled +out is not; the agent's ratio decides only for what arrives with no trace, as +from a control plane that is not tracing. The agent needs its own `tracing` +block either way — without one its spans go nowhere, and the control plane's +trace simply ends at `send start`. + ## PostgreSQL, when SQLite is no longer the right shape SQLite is the default and stays the right answer for most installations: one @@ -1208,6 +1543,68 @@ llmgw keys requests --project Acme/ml/train --off requests past their hours, or of a project turned off, are removed hourly. The console has it as **Keep requests…** on a project. +## Reusing answers + +A project that asks the same thing again and again — an evaluation run over +the same prompts, embeddings of documents that do not change, an agent that +retries — can have the same request answered from memory rather than by the +model: + +``` +llmgw keys cache --project Acme/ml/train --ttl 1h # reproducible answers +llmgw keys cache --project Acme/ml/eval --ttl 24h --any # every answer +llmgw keys cache --project Acme/ml/train # how it stands +llmgw keys cache --project Acme/ml/train --off +``` + +- **What is the same request:** the same project, the same endpoint, the same + model (by its catalog name, whichever alias was used) and the same body, + with `stream`, `stream_options`, `user`, `metadata`, `store` and + `service_tier` left out and the keys in any order. Keys of another project + never see the answer, whatever they send. +- **Which answers are reused:** a chat or completion asked at `temperature` + 0 or with a `seed`, and every embedding, rerank and score, which sample + nothing. A request sampled above temperature 0 with no seed asks for a new + answer, and is asked anew — unless the project says `--any`. Only a whole + 200 from the model that was asked for is kept: not an error, not a + fallback model's or a canary's answer, not one a guardrail changed or + stopped, not one longer than `router.cache.max_entry_bytes` (1 MB). +- **Streams:** an answer given whole is streamed to a client that asks for a + stream, as one chunk per choice with the usage chunk if it asked for that; + a streamed answer is kept put back together, and served whole to a client + that does not stream. Through `/v1/messages` and `/v1/responses` too. +- **The client decides too:** `Cache-Control: no-cache` (or `no-store`), or + `x-llmgw-cache: no`, is always asked anew. Every answer of a project that + reuses answers says `x-llmgw-cache: hit`, `miss` or `bypass` (not one that + can be reused), and a hit says its `Age` in seconds. +- **Policies still apply:** the limits are checked and the guardrails judge + the request before the cache is looked in, and the output policy judges a + reused answer as it would a new one. A request continuing a stored + conversation, or under a `no_store` policy, is never answered from the + cache nor kept in it. +- **What a hit costs:** nothing. It is recorded as a request — status 200, + the model, the key, the rate limit's count — with no tokens and no cost, + marked as a cache hit (`llmgw ctl request ` says so), so it spends no + budget and no token limit. The answer itself carries the usage the model + reported when it was first given. +- **Where it lives:** in each gateway process's memory, at most + `router.cache.max_bytes` (128 MB) of answers for all projects together, + those used longest ago dropped first; nothing is written to the store, and + a restart begins it again. With two control planes behind a balancer each + keeps its own. A change of setting is seen within 30 seconds; a shorter TTL + applies to what is already kept. + +```yaml +router: + cache: + max_bytes: 268435456 # 256 MB + max_entry_bytes: 2097152 # 2 MB +``` + +`llmgw_cache_requests_total{model,result}` counts hits, misses and +bypasses, `llmgw_cache_bytes` and `llmgw_cache_entries` what is held. The +console has it as **Cache answers…** on a project. + ## Batches Work that can wait — a night's classification, embeddings for a document diff --git a/docs/onboarding.md b/docs/onboarding.md index 6212dae..68ee207 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -309,6 +309,28 @@ shown as text. Other schemes are never linked — configuration is not always written by whoever runs the gateway, and a `javascript:` href on a page served to every visitor is a short path to a stored XSS. +## The docs portal + +The gateway also serves its manual at `/docs`: these pages, rendered, with +[clients.md](clients.md) — how to use the gateway from the official OpenAI +and Anthropic SDKs — first on its index. The landing page and the account +page link to it, so a user's questions about streaming, tools or the Anthropic +SDK have an answer that is not you. + +It is public, like the landing page. The client guide is for the person who +has no account yet, and the operator documents describe llmgw in general — +no address, model, node or key of this installation — which is what the +project's repository publishes anyway. A site that would rather not say even +that turns sign-in on: + +```yaml +landing: + docs_sign_in: true +``` + +A visitor is then sent to sign in first. On a gateway with API keys and no +accounts, the portal asks for a key instead, like the API. + ## What the user sees They open the link, press the button, and get the key once. The page shows them diff --git a/docs/reference.md b/docs/reference.md index 403c141..a4d9f0f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -47,6 +47,8 @@ rather than repeated. | `max_lines` | `int` | [Batch](#batch) | | `concurrency` | `int` | [Batch](#batch) | | `retain_days` | `int` | [Batch](#batch) | +| `max_bytes` | `int64` | [Cache](#cache) | +| `max_entry_bytes` | `int64` | [Cache](#cache) | | `model` | `string` | [Canary](#canary) | | `percent` | `int` | [Canary](#canary) | | `min_requests` | `int` | [Canary](#canary) | @@ -63,6 +65,7 @@ rather than repeated. | `port_range` | `[]int` | [Control](#control) | | `mgmt_interface` | `string` | [Control](#control) | | `timezone` | `string` | [Control](#control) | +| `eval_dir` | `string` | [Control](#control) | | `enabled` | `bool` | [Control](#control) | | `lease_ttl` | `time.Duration` | [Control](#control) | | `id` | `string` | [Endpoint](#endpoint) | @@ -77,6 +80,9 @@ rather than repeated. | `nodes` | `[]string` | [EngineOverride](#engineoverride) | | `labels` | `map[string]string` | [EngineOverride](#engineoverride) | | `engines` | `map[string]string` | [EngineOverride](#engineoverride) | +| `suite` | `string` | [EvalGate](#evalgate) | +| `min_score` | `float64` | [EvalGate](#evalgate) | +| `judge` | `string` | [EvalGate](#evalgate) | | `url` | `string` | [External](#external) | | `model` | `string` | [External](#external) | | `api_key_file` | `string` | [External](#external) | @@ -90,6 +96,7 @@ rather than repeated. | `contact` | `string` | [Landing](#landing) | | `notice` | `string` | [Landing](#landing) | | `language` | `string` | [Landing](#landing) | +| `docs_sign_in` | `bool` | [Landing](#landing) | | `bin` | `string` | [LlamaCppConfig](#llamacppconfig) | | `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) | | `threads` | `int` | [LlamaCppConfig](#llamacppconfig) | @@ -110,6 +117,7 @@ rather than repeated. | `adapters` | `[]Adapter` | [Model](#model) | | `fallbacks` | `[]string` | [Model](#model) | | `canary` | `*Canary` | [Model](#model) | +| `eval` | `*EvalGate` | [Model](#model) | | `routing_profile` | `string` | [Model](#model) | | `slo` | `SLO` | [Model](#model) | | `context_length` | `int` | [Model](#model) | @@ -205,6 +213,7 @@ rather than repeated. | `tag_labels` | `[]string` | [Router](#router) | | `max_body_bytes` | `int64` | [Router](#router) | | `batch` | `Batch` | [Router](#router) | +| `cache` | `Cache` | [Router](#router) | | `trusted_proxies` | `[]string` | [Router](#router) | | `timeouts` | `Timeouts` | [Router](#router) | | `scoring` | `Scoring` | [Router](#router) | @@ -247,7 +256,7 @@ rather than repeated. | `debug_pprof` | `bool` | [Server](#server) | | `agent_insecure` | `bool` | [Server](#server) | | `monitoring` | `Monitoring` | [Server](#server) | -| `tracing` | `Tracing` | [Server](#server) | +| `tracing` | `obs.Tracing` | [Server](#server) | | `mirror` | `Mirror` | [Server](#server) | | `engines` | `map[string]string` | [Server](#server) | | `engine_overrides` | `[]EngineOverride` | [Server](#server) | @@ -265,6 +274,7 @@ rather than repeated. | `models` | `[]catalog.Model` | [Server](#server) | | `endpoints` | `[]Endpoint` | [Server](#server) | | `bin` | `string` | [SimConfig](#simconfig) | +| `python` | `string` | [TRTLLMConfig](#trtllmconfig) | | `store` | `string` | [Threads](#threads) | | `retain_hours` | `int` | [Threads](#threads) | | `dial` | `time.Duration` | [Timeouts](#timeouts) | @@ -323,6 +333,7 @@ rather than repeated. | `discovery` | `discovery.Options` | [agent.Config](#agentconfig) | | `engines` | `engines.Config` | [agent.Config](#agentconfig) | | `supervisor` | `supervisor.Config` | [agent.Config](#agentconfig) | +| `tracing` | `obs.Tracing` | [agent.Config](#agentconfig) | | `name` | `string` | [config.Band](#configband) | | `priority` | `int` | [config.Band](#configband) | | `saturation_ceiling` | `float64` | [config.Band](#configband) | @@ -332,6 +343,7 @@ rather than repeated. | `vllm` | `VLLMConfig` | [engines.Config](#enginesconfig) | | `sglang` | `SGLangConfig` | [engines.Config](#enginesconfig) | | `llamacpp` | `LlamaCppConfig` | [engines.Config](#enginesconfig) | +| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) | | `sim` | `SimConfig` | [engines.Config](#enginesconfig) | | `default` | `[]string` | [guard.Config](#guardconfig) | | `policies` | `map[string]Policy` | [guard.Config](#guardconfig) | @@ -571,6 +583,22 @@ every interactive request. RetainDays is how long a batch's answer and error files are kept; 30 unless set. Uploaded files are kept until deleted. +## Cache + +### `max_bytes` + +`int64` + +MaxBytes bounds every project's answers together; 128 MB unless set. +Past it, the answers used longest ago go first. + +### `max_entry_bytes` + +`int64` + +MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB +unless set. + ## Canary ### `model` @@ -666,6 +694,15 @@ MgmtInterface is the ethernet interface NCCL bootstraps over. Timezone is where models' scaling schedules are read, an IANA name like Europe/Istanbul; empty is the control plane's own. +### `eval_dir` + +`string` + +EvalDir is where quality suites are read from by name, for `llmgw ctl +eval MODEL --suite NAME`, a model's eval gate and the console. The +suites the binary carries (arithmetic, json-format) are found when it +has none of the name; a suite here of the same name replaces one. + ### `enabled` `bool` @@ -759,6 +796,28 @@ Labels picks nodes carrying all of these. Engines are laid over the cluster's: a version, or "" for none. +## EvalGate + +### `suite` + +`string` + +Suite is a suite's name in control.eval_dir, or one the binary +carries (arithmetic, json-format). + +### `min_score` + +`float64` + +MinScore is the score, 0 to 1, to pass at; zero is the suite's own +threshold. + +### `judge` + +`string` + +Judge grades the suite's rubric cases, instead of the judge it names. + ## External ### `url` @@ -855,6 +914,17 @@ Language is what the web portal speaks to someone who has not chosen a language with the switcher at the foot of every page and whose browser asks for none it speaks: "en" or "tr". Empty is English. +### `docs_sign_in` + +`bool` + +DocsSignIn serves the docs portal at /docs only to someone signed in. +Off, the portal is public like this page: its client guide is for the +person who has no account yet, and the operator documents describe +llmgw in general — no address, model or key of this installation — +which the project's repository publishes anyway. On, a visitor is sent +to sign in, and without accounts the portal needs an API key. + ## LlamaCppConfig ### `bin` @@ -1033,6 +1103,16 @@ moving to it (`llmgw ctl model canary`). Takes a [Canary](#canary) block. +### `eval` + +`*EvalGate` + +Eval is a quality suite new weights must pass before they replace +this model's: `llmgw ctl model update` and `model promote` run it +against them first, and stop below its score. + +Takes a [EvalGate](#evalgate) block. + ### `routing_profile` `string` @@ -1701,6 +1781,16 @@ another. Needs the store. Takes a [Batch](#batch) block. +### `cache` + +`Cache` + +Cache bounds the answers kept for the projects that reuse them +(llmgw keys cache). Which projects do, and for how long, is theirs +to set; this is only how much memory it may take. + +Takes a [Cache](#cache) block. + ### `trusted_proxies` `[]string` @@ -2029,10 +2119,11 @@ Takes a [Monitoring](#monitoring) block. ### `tracing` -`Tracing` +`obs.Tracing` -Tracing sends a trace of each inference request to an OpenTelemetry -collector. Off unless an endpoint is set here or in +Tracing sends traces to an OpenTelemetry collector: each inference +request, and on a control plane each placement, replica start and +model job. Off unless an endpoint is set here or in OTEL_EXPORTER_OTLP_ENDPOINT. Takes a [Tracing](#tracing) block. @@ -2184,6 +2275,21 @@ against sim-engines possible without a control plane. Bin is the llmgw binary itself; the engine is one of its subcommands. Empty means whatever started this agent, which is right almost always. +## TRTLLMConfig + +### `python` + +`string` + +Python is the interpreter of the environment TensorRT-LLM is installed +in: /bin/python for a virtual environment, python3 in NVIDIA's +container. Naming it is what makes this node offer the engine; empty +offers nothing. + +The interpreter rather than trtllm-serve, because llmgw starts the +server through a few lines of its own that make it demand the +gateway's key, which trtllm-serve has no option for. + ## Threads ### `store` @@ -2282,8 +2388,10 @@ Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing. `float64` -SampleRatio is the share of requests traced, 0 to 1, when the caller -did not decide it with a sampled traceparent of its own. Zero takes 1. +SampleRatio is the share of traces kept, 0 to 1, of those nobody +upstream already decided. Zero takes 1. A request with a sampled +traceparent of its own is kept, and an agent keeps every start the +control plane kept, whatever their own ratio says. ## VLLMConfig @@ -2590,6 +2698,18 @@ Takes a [engines.Config](#enginesconfig) block. Takes a [supervisor.Config](#supervisorconfig) block. +### `tracing` + +`obs.Tracing` + +Tracing sends this node's part of each replica start — the weights +fetched, the engine launched, the wait for it to answer — to an +OpenTelemetry collector, inside the control plane's trace of that +start. Give it the collector server.yaml's tracing names. Off unless +an endpoint is set here or in OTEL_EXPORTER_OTLP_ENDPOINT. + +Takes a [Tracing](#tracing) block. + ## config.Band ### `name` @@ -2642,6 +2762,15 @@ Takes a [SGLangConfig](#sglangconfig) block. Takes a [LlamaCppConfig](#llamacppconfig) block. +### `trtllm` + +`TRTLLMConfig` + +TRTLLM is TensorRT-LLM, which the agent does not install: a node +offers it only when its interpreter is named here. + +Takes a [TRTLLMConfig](#trtllmconfig) block. + ### `sim` `SimConfig` diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4d6008b..b792358 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -23,6 +23,8 @@ import ( "sync" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/net/http2" llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1" @@ -33,6 +35,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights" "gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" "gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf" ) @@ -419,6 +422,17 @@ func int32Slice(v []int) []int32 { return out } +func int32Ints(v []int32) []int { + out := make([]int, len(v)) + for i, x := range v { + out[i] = int(x) + } + return out +} + +// tracerName names the spans the agent opens. +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/agent" + // scan refreshes the node's hardware inventory. func (a *Agent) scan(ctx context.Context) error { // Carried from the agent's own configuration rather than duplicated into @@ -786,15 +800,23 @@ func (a *Agent) removeWeights(paths []string) { } func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out chan<- *llmgwv1.AgentMessage) { + // The control plane's span that sent this, when it is tracing: what is + // done here for a start or a stop goes into the same trace. + tctx := obs.Extract(ctx, msg.GetTrace()) switch m := msg.Msg.(type) { case *llmgwv1.ControlMessage_Start: - go a.startShard(ctx, m.Start, out) + go a.startShard(tctx, m.Start, out) case *llmgwv1.ControlMessage_Stop: - go a.stopShard(ctx, m.Stop.GetShardId(), out) + go a.stopShard(tctx, m.Stop.GetShardId(), out) case *llmgwv1.ControlMessage_Drain: - if err := a.sup.Drain(m.Drain.GetShardId()); err != nil { + _, span := obs.Tracer(tracerName).Start(tctx, "drain shard", + trace.WithSpanKind(trace.SpanKindConsumer), + trace.WithAttributes(attribute.String("llmgw.shard", m.Drain.GetShardId()))) + err := a.sup.Drain(m.Drain.GetShardId()) + if err != nil { a.log.Warn("draining failed", "shard", m.Drain.GetShardId(), "err", err) } + obs.End(span, err) case *llmgwv1.ControlMessage_RemoveWeights: a.removeWeights(m.RemoveWeights.GetPaths()) case *llmgwv1.ControlMessage_Provision: @@ -927,6 +949,25 @@ func localAddr(ip netip.Addr) bool { func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<- *llmgwv1.AgentMessage) { shardID, replicaID := m.GetShardId(), m.GetReplicaId() + // This node's whole part of the start, from the command arriving to the + // engine answering, beneath the control plane's span that sent it. The + // steps — weights, launch, readiness — are its children. + ctx, span := obs.Tracer(tracerName).Start(ctx, "start shard", + trace.WithSpanKind(trace.SpanKindConsumer), + trace.WithAttributes( + attribute.String("llmgw.node", a.cfg.NodeID), + attribute.String("llmgw.replica", replicaID), + attribute.String("llmgw.shard", shardID), + attribute.Int("llmgw.rank", int(m.GetRank())), + attribute.String("llmgw.model", m.GetProfile().GetServedModelName()), + attribute.String("llmgw.profile", m.GetProfile().GetName()), + attribute.String("llmgw.engine", m.GetProfile().GetEngine()), + attribute.String("llmgw.gpu_class", m.GetProfile().GetGpuClass()), + attribute.IntSlice("llmgw.gpus", int32Ints(m.GetGpuIndices())), + )) + var failed error + defer func() { obs.End(span, failed) }() + a.mu.Lock() a.shardReplica[shardID] = replicaID a.mu.Unlock() @@ -939,6 +980,9 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan< } } report := func(st supervisor.State, errMsg string, tail []string) { + if st == supervisor.Failed { + failed = errors.New(errMsg) + } send(&llmgwv1.ShardStatus{ State: agentsvc.StateToWire(st), Error: errMsg, LastOutput: tail, }) @@ -1109,9 +1153,18 @@ func (a *Agent) stopShard(ctx context.Context, shardID string, out chan<- *llmgw delete(a.shardReplica, shardID) a.mu.Unlock() - if err := a.sup.Stop(ctx, shardID); err != nil { + ctx, span := obs.Tracer(tracerName).Start(ctx, "stop shard", + trace.WithSpanKind(trace.SpanKindConsumer), + trace.WithAttributes( + attribute.String("llmgw.node", a.cfg.NodeID), + attribute.String("llmgw.replica", replicaID), + attribute.String("llmgw.shard", shardID), + )) + err := a.sup.Stop(ctx, shardID) + if err != nil { a.log.Warn("stopping a shard failed", "shard", shardID, "err", err) } + obs.End(span, err) a.sup.Remove(shardID) msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{ @@ -1202,18 +1255,20 @@ const ( // // The interface NCCL bootstraps over is this node's, not the cluster's: the // control plane sends one name for every node, and a node whose management -// network is on ens3 rather than eth0 would have NCCL bind nothing. And on -// RoCE, which GID entry routes is a property of these adapters. +// network is on ens3 rather than eth0 would have NCCL bind nothing. On RoCE, +// which GID entry routes is a property of these adapters. And a MIG instance +// is opened by its UUID, which only this node knows. func (a *Agent) localizeSpec(spec *engines.ShardSpec) { if a.cfg.MgmtInterface != "" { spec.MgmtInterface = a.cfg.MgmtInterface } + a.mu.Lock() + hcas, gpus := a.inv.HCAs, a.inv.GPUs + a.mu.Unlock() + spec.GPUUUIDs = migUUIDs(gpus, spec.GPUIndices) if len(spec.HCAs) == 0 { return } - a.mu.Lock() - hcas := a.inv.HCAs - a.mu.Unlock() want := map[string]bool{} for _, h := range spec.HCAs { want[h] = true @@ -1232,6 +1287,27 @@ func (a *Agent) localizeSpec(spec *engines.ShardSpec) { } } +// migUUIDs names a shard's devices by UUID when one of them is a MIG +// instance, and is nil otherwise: whole cards keep being given by index, as +// they always were. A device this node does not have is left to fail where +// it always has, in the engine, rather than guessed at here. +func migUUIDs(gpus []discovery.GPU, indices []int) []string { + var out []string + mig := false + for _, i := range indices { + k := slices.IndexFunc(gpus, func(g discovery.GPU) bool { return g.Index == i }) + if k < 0 || gpus[k].UUID == "" { + return nil + } + mig = mig || gpus[k].IsMIG() + out = append(out, gpus[k].UUID) + } + if !mig { + return nil + } + return out +} + // ResolveManagement is the address and interface this node's agent would use, // found the way normalize finds them, for a tool that reports them. func ResolveManagement(c Config) (addr, iface string) { diff --git a/internal/agent/config.go b/internal/agent/config.go index e1593ee..3763640 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -14,6 +14,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" "gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf" ) @@ -94,6 +95,13 @@ type Config struct { Engines engines.Config `koanf:"engines"` Superv supervisor.Config `koanf:"supervisor"` + // Tracing sends this node's part of each replica start — the weights + // fetched, the engine launched, the wait for it to answer — to an + // OpenTelemetry collector, inside the control plane's trace of that + // start. Give it the collector server.yaml's tracing names. Off unless + // an endpoint is set here or in OTEL_EXPORTER_OTLP_ENDPOINT. + Tracing obs.Tracing `koanf:"tracing"` + Version string } diff --git a/internal/agent/discovery/gpu.go b/internal/agent/discovery/gpu.go index 36fa88f..6495d38 100644 --- a/internal/agent/discovery/gpu.go +++ b/internal/agent/discovery/gpu.go @@ -47,6 +47,11 @@ type GPU struct { Fabric *GPUFabric `json:"fabric,omitempty"` // Health carries anything that should keep the GPU out of placement. Health GPUHealth `json:"health"` + // MIG is set when this is a MIG instance rather than a whole card: its + // Index is then MIGIndex's, its UUID the instance's (MIG-…), and its + // memory the instance's share. Utilisation stays unreported, because no + // driver measures it per instance. + MIG *MIGDevice `json:"mig,omitempty"` } // GPUHealth records conditions that make a GPU unsafe to schedule onto. @@ -94,8 +99,30 @@ func (g GPU) Class() string { } // Round to the nearest GiB: vendors report 81559 MiB for an "80GB" card. - gb := int(math.Round(float64(g.MemoryTotalMB) / 1024)) - return fmt.Sprintf("%s-%dg", model, gb) + // A MIG instance is named from its card's memory and then its profile, + // so two instances are one class only when both the card and the slice + // are the same. + mem := g.MemoryTotalMB + if g.MIG != nil { + mem = g.MIG.ParentMemoryTotalMB + } + gb := int(math.Round(float64(mem) / 1024)) + class := fmt.Sprintf("%s-%dg", model, gb) + if g.MIG != nil { + return migClass(class, g.MIG.Profile) + } + return class +} + +// normalizeBusID makes bus ids comparable: NVML reports them uppercase from one +// call and lowercase from another, and sometimes with a shortened domain. +func normalizeBusID(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + // "0000:1b:00.0" and "00000000:1B:00.0" are the same device. + if i := strings.Index(s, ":"); i > 0 { + s = strings.TrimLeft(s[:i], "0") + s[i:] + } + return strings.TrimPrefix(s, ":") } // MarshalJSON adds the derived class, so a consumer of the JSON does not have diff --git a/internal/agent/discovery/gpu_nvml.go b/internal/agent/discovery/gpu_nvml.go index f88c934..aa52220 100644 --- a/internal/agent/discovery/gpu_nvml.go +++ b/internal/agent/discovery/gpu_nvml.go @@ -14,7 +14,6 @@ package discovery import ( "context" "fmt" - "strings" "github.com/NVIDIA/go-nvml/pkg/nvml" ) @@ -83,11 +82,18 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) { } assessHealth(&g) - gpus = append(gpus, g) + // A card in MIG mode is its instances, not itself: CUDA will not + // open the whole card while MIG is on. + devs, _ := migFromNVML(g, nvmlCard{dev}) + gpus = append(gpus, devs...) } - // A second pass, because a link's remote end may be a GPU enumerated later. + // A second pass, because a link's remote end may be a GPU enumerated + // later. MIG instances have no NVLink of their own to use. for i := range gpus { + if gpus[i].IsMIG() { + continue + } dev, ret := nvml.DeviceGetHandleByIndex(gpus[i].Index) if ret != nvml.SUCCESS { continue @@ -136,13 +142,73 @@ func busIDString(b []int8) string { return string(buf) } -// normalizeBusID makes bus ids comparable: NVML reports them uppercase from one -// call and lowercase from another, and sometimes with a shortened domain. -func normalizeBusID(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - // "0000:1b:00.0" and "00000000:1B:00.0" are the same device. - if i := strings.Index(s, ":"); i > 0 { - s = strings.TrimLeft(s[:i], "0") + s[i:] +// nvmlCard adapts an NVML device to what MIG discovery reads of it. +type nvmlCard struct{ dev nvml.Device } + +func (c nvmlCard) MIGMode() (current, pending, ok bool) { + cur, pend, ret := c.dev.GetMigMode() + if ret != nvml.SUCCESS { + // NOT_SUPPORTED on a card without MIG, which is most of them. + return false, false, false } - return strings.TrimPrefix(s, ":") + return cur == nvml.DEVICE_MIG_ENABLE, pend == nvml.DEVICE_MIG_ENABLE, true +} + +func (c nvmlCard) MaxMIGDevices() int { + n, ret := c.dev.GetMaxMigDeviceCount() + if ret != nvml.SUCCESS { + return 0 + } + return n +} + +func (c nvmlCard) MIGDevice(i int) (nvmlMIGInstance, bool) { + h, ret := c.dev.GetMigDeviceHandleByIndex(i) + if ret != nvml.SUCCESS { + // NOT_FOUND for an index with no instance: the count is the most a + // card can hold, not how many it has. + return nil, false + } + return nvmlMIG{h}, true +} + +// nvmlMIG adapts a MIG device handle. +type nvmlMIG struct{ dev nvml.Device } + +func (m nvmlMIG) UUID() string { + u, _ := m.dev.GetUUID() + return u +} + +func (m nvmlMIG) Name() string { + n, _ := m.dev.GetName() + return n +} + +func (m nvmlMIG) Memory() (totalMB, usedMB int, ok bool) { + mem, ret := m.dev.GetMemoryInfo() + if ret != nvml.SUCCESS { + return 0, 0, false + } + return int(mem.Total / (1 << 20)), int(mem.Used / (1 << 20)), true +} + +func (m nvmlMIG) Instances() (gpuInstance, computeInstance int) { + gi, ret := m.dev.GetGpuInstanceId() + if ret != nvml.SUCCESS { + gi = -1 + } + ci, ret := m.dev.GetComputeInstanceId() + if ret != nvml.SUCCESS { + ci = -1 + } + return gi, ci +} + +func (m nvmlMIG) Attributes() (slices, memoryMB int, ok bool) { + a, ret := m.dev.GetAttributes() + if ret != nvml.SUCCESS { + return 0, 0, false + } + return int(a.GpuInstanceSliceCount), int(a.MemorySizeMB), true } diff --git a/internal/agent/discovery/gpu_smi.go b/internal/agent/discovery/gpu_smi.go index b3d165b..44c9490 100644 --- a/internal/agent/discovery/gpu_smi.go +++ b/internal/agent/discovery/gpu_smi.go @@ -60,7 +60,33 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) { if err != nil { return nil, fmt.Errorf("running %s: %w", binary, err) } - return parseSMI(string(out)) + cards, err := parseSMI(string(out)) + if err != nil || len(cards) == 0 { + return cards, err + } + // The query lists whole cards, MIG mode or not. A card in MIG mode is + // replaced by its instances, which only -L names and only the full + // report sizes; asked only when some card could be in MIG mode, since + // the report is the slowest thing nvidia-smi does. + if !anyMIGCapable(cards) { + return cards, nil + } + listed, report := readMIG(ctx, binary) + return expandMIG(cards, listed, report), nil +} + +// anyMIGCapable reports whether any card could be in MIG mode: MIG began +// with compute capability 8.0 (the A100). The consumer cards of the same +// generations have none, but telling them apart by name is guesswork, and +// asking one costs only two more nvidia-smi runs at registration. +func anyMIGCapable(cards []GPU) bool { + for _, c := range cards { + major, _, _ := strings.Cut(c.ComputeCapability, ".") + if n, err := strconv.Atoi(major); err == nil && n >= 8 { + return true + } + } + return false } func parseSMI(out string) ([]GPU, error) { diff --git a/internal/agent/discovery/gpuprocs.go b/internal/agent/discovery/gpuprocs.go index 3c45510..4c1c9d1 100644 --- a/internal/agent/discovery/gpuprocs.go +++ b/internal/agent/discovery/gpuprocs.go @@ -5,6 +5,7 @@ import ( "encoding/xml" "fmt" "os/exec" + "slices" "strconv" "strings" ) @@ -34,9 +35,16 @@ func GPUProcesses(ctx context.Context, binary string) (map[int][]int, error) { return parseGPUProcesses(out) } -// GPUMemory is one card's memory as nvidia-smi reports it at a moment: what +// GPUMemory is one device's memory as nvidia-smi reports it at a moment: what // is allocated on it, and which processes hold how much of that. +// +// A device is what discovery reports: a whole card, or each MIG instance of a +// card in MIG mode, in the same order, so the readings line up with the +// inventory one for one. type GPUMemory struct { + // Index is the device's number as placement knows it: the card's + // nvidia-smi index, or MIGIndex's for an instance. + Index int UsedMB int Procs []GPUProcess } @@ -66,48 +74,62 @@ func GPUMemoryByProcess(ctx context.Context, binary string) ([]GPUMemory, error) return parseGPUMemory(out) } -// smiLog is the part of nvidia-smi's XML report this reads: each GPU, in -// index order, its memory and the processes on it. -type smiLog struct { - GPUs []struct { - Used string `xml:"fb_memory_usage>used"` - Processes []struct { - PID string `xml:"pid"` - Type string `xml:"type"` - UsedMB string `xml:"used_memory"` - } `xml:"processes>process_info"` - } `xml:"gpu"` -} - -// parseGPUProcesses reads the report. GPUs are listed in index order, which -// is nvidia-smi's own numbering — the one placement assigns by. +// parseGPUProcesses reads the report, by device as placement numbers them: +// nvidia-smi's index for a whole card, MIGIndex's for a MIG instance. func parseGPUProcesses(report []byte) (map[int][]int, error) { mem, err := parseGPUMemory(report) if err != nil { return nil, err } out := map[int][]int{} - for i, g := range mem { + for _, g := range mem { for _, p := range g.Procs { - out[i] = append(out[i], p.PID) + out[g.Index] = append(out[g.Index], p.PID) } } return out, nil } -// parseGPUMemory reads the report into each GPU's memory and its holders. +// parseGPUMemory reads the report into each device's memory and its holders. +// +// Cards are listed in index order, which is nvidia-smi's own numbering. A +// card in MIG mode is read as its instances, each with its own memory and +// the processes nvidia-smi attributes to its GPU and compute instance: a +// process on one instance is not on its neighbours, and the card's total +// would have charged every instance with all of them. func parseGPUMemory(report []byte) ([]GPUMemory, error) { - var log smiLog + var log migLog if err := xml.Unmarshal(report, &log); err != nil { return nil, fmt.Errorf("reading nvidia-smi's report: %w", err) } - out := make([]GPUMemory, len(log.GPUs)) + out := make([]GPUMemory, 0, len(log.GPUs)) for i, g := range log.GPUs { - out[i].UsedMB = mib(g.Used) - for _, p := range g.Processes { - if pid, err := strconv.Atoi(strings.TrimSpace(p.PID)); err == nil { - out[i].Procs = append(out[i].Procs, GPUProcess{PID: pid, UsedMB: mib(p.UsedMB)}) + procs := func(gi, ci int) []GPUProcess { + var ps []GPUProcess + for _, p := range g.Processes { + pid, err := strconv.Atoi(strings.TrimSpace(p.PID)) + if err != nil { + continue + } + if gi >= 0 && (atoiOr(p.GPUInstance, -1) != gi || atoiOr(p.ComputeInstance, -1) != ci) { + continue + } + ps = append(ps, GPUProcess{PID: pid, UsedMB: mib(p.UsedMB)}) } + return ps + } + if !enabled(g.Current) || len(g.Devices) == 0 { + out = append(out, GPUMemory{Index: i, UsedMB: mib(g.Used), Procs: procs(-1, -1)}) + continue + } + devs := slices.Clone(g.Devices) + slices.SortFunc(devs, func(a, b migLogDevice) int { return atoiOr(a.Index, -1) - atoiOr(b.Index, -1) }) + for _, d := range devs { + out = append(out, GPUMemory{ + Index: MIGIndex(i, atoiOr(d.Index, 0)), + UsedMB: mib(d.Used), + Procs: procs(atoiOr(d.GPUInstance, -1), atoiOr(d.ComputeInstance, -1)), + }) } } return out, nil diff --git a/internal/agent/discovery/gpuprocs_test.go b/internal/agent/discovery/gpuprocs_test.go index 6472fba..f3fe702 100644 --- a/internal/agent/discovery/gpuprocs_test.go +++ b/internal/agent/discovery/gpuprocs_test.go @@ -57,8 +57,8 @@ func TestGPUMemoryIsReadPerProcess(t *testing.T) { t.Fatal(err) } want := []GPUMemory{ - {UsedMB: 14514, Procs: []GPUProcess{{PID: 71315, UsedMB: 4932}, {PID: 4100, UsedMB: 9500}}}, - {}, + {Index: 0, UsedMB: 14514, Procs: []GPUProcess{{PID: 71315, UsedMB: 4932}, {PID: 4100, UsedMB: 9500}}}, + {Index: 1}, } if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) diff --git a/internal/agent/discovery/mig.go b/internal/agent/discovery/mig.go new file mode 100644 index 0000000..618b274 --- /dev/null +++ b/internal/agent/discovery/mig.go @@ -0,0 +1,560 @@ +package discovery + +// MIG — Multi-Instance GPU — carves one A100, H100 or later card into up to +// seven instances, each with its own memory, its own share of the +// multiprocessors and its own UUID. To CUDA an instance is a device of its +// own, and to placement it is one too: a card in MIG mode is reported as its +// instances instead of as the whole card, each a GPU of a class named from +// the card's and the instance's profile ("a100-80g-mig-3g.40gb"). +// +// llmgw does not create instances. Which split a card gets is an operator's +// decision, made with nvidia-smi mig; discovery reads what is there. +// +// Two facts about MIG shape everything below. A process is given one MIG +// device: CUDA enumerates a single instance however many are made visible. +// And instances have no NVLink or peer-to-peer access between them. A replica +// on MIG therefore runs on exactly one instance, never a tensor-parallel group +// of them, and the catalog refuses a profile that asks for more. + +import ( + "context" + "encoding/xml" + "fmt" + "math" + "os/exec" + "regexp" + "slices" + "strconv" + "strings" +) + +// MIGDevice is what a MIG instance carries beyond any GPU's fields: which +// card it is carved from, and how. +type MIGDevice struct { + // ParentIndex is the card's nvidia-smi index, which is what its sensors, + // topology and wear are reported by. + ParentIndex int `json:"parent_index"` + ParentUUID string `json:"parent_uuid,omitempty"` + // ParentMemoryTotalMB is the whole card's memory: the class is named from + // the card, so an instance of an 80 GB card is not mistaken for one of a + // 40 GB card that happens to have the same profile name. + ParentMemoryTotalMB int `json:"parent_memory_total_mb"` + // Device is the instance's number on its card, as nvidia-smi -L lists it. + Device int `json:"device"` + // Profile is NVIDIA's name for the slice, like "3g.40gb": three of the + // card's seven compute slices and forty gigabytes of its memory. + Profile string `json:"profile"` + // GPUInstance and ComputeInstance are the instance's ids in nvidia-smi + // mig -lgi and -lci, which is how nvidia-smi attributes processes to it. + GPUInstance int `json:"gpu_instance"` + ComputeInstance int `json:"compute_instance"` +} + +// migIndexBase starts the numbers MIG instances are given. +// +// Placement, the agent protocol and every running shard name a node's devices +// by an integer, which for a whole card is its nvidia-smi index. An instance +// has none of its own, so it is given 1000 + 100 × card + device: card 0's +// instance 2 is 1002, card 3's instance 0 is 1300. Readable, stable while the +// instances are, and never one a whole card could have. +const migIndexBase = 1000 + +// MIGIndex is the device number placement knows a MIG instance by. +func MIGIndex(card, device int) int { return migIndexBase + 100*card + device } + +// IsMIGIndex reports whether a device number names a MIG instance. +func IsMIGIndex(i int) bool { return i >= migIndexBase } + +// DeviceLabel is how a device number is shown to people: "3" for a whole +// card, "0/mig2" for card 0's MIG instance 2. +func DeviceLabel(i int) string { + if !IsMIGIndex(i) { + return strconv.Itoa(i) + } + i -= migIndexBase + return fmt.Sprintf("%d/mig%d", i/100, i%100) +} + +// DeviceLabels is DeviceLabel for each of a list. +func DeviceLabels(is []int) []string { + out := make([]string, len(is)) + for k, i := range is { + out[k] = DeviceLabel(i) + } + return out +} + +// IsMIG reports whether this GPU is a MIG instance rather than a whole card. +func (g GPU) IsMIG() bool { return g.MIG != nil } + +// CardIndex is the nvidia-smi index of the physical card this GPU is on: its +// own index for a whole card, its parent's for a MIG instance. +func (g GPU) CardIndex() int { + if g.MIG != nil { + return g.MIG.ParentIndex + } + return g.Index +} + +// migClassMarker joins a card's class to an instance's profile in a MIG +// class: "a100-80g" + "-mig-" + "3g.40gb". +const migClassMarker = "-mig-" + +// IsMIGClass reports whether a GPU class names MIG instances. +func IsMIGClass(class string) bool { return strings.Contains(class, migClassMarker) } + +// migProfileClean keeps a profile name to what a class may hold: "1g.10gb+me" +// becomes "1g.10gb-me". +var migProfileClean = regexp.MustCompile(`[^a-z0-9.]+`) + +// migClass is the class of a MIG instance: the card's class and the profile. +func migClass(parentClass, profile string) string { + p := migProfileClean.ReplaceAllString(strings.ToLower(strings.TrimSpace(profile)), "-") + return parentClass + migClassMarker + strings.Trim(p, "-") +} + +// MIGProfileMemoryGB reads the memory out of a MIG class or profile: +// 40 from "a100-80g-mig-3g.40gb" or "3g.40gb". Zero when there is none. +func MIGProfileMemoryGB(s string) int { + if i := strings.LastIndex(s, migClassMarker); i >= 0 { + s = s[i+len(migClassMarker):] + } + m := migProfileMemory.FindStringSubmatch(strings.ToLower(s)) + if m == nil { + return 0 + } + gb, _ := strconv.Atoi(m[1]) + return gb +} + +var migProfileMemory = regexp.MustCompile(`^\d+g\.(\d+)gb`) + +// --- nvidia-smi -L --- + +// listedMIG is one MIG instance as nvidia-smi -L lists it. +type listedMIG struct { + Device int + Profile string + UUID string +} + +var ( + // "GPU 0: NVIDIA A100-SXM4-80GB (UUID: GPU-5d5ba0d6-...)" + smiListGPU = regexp.MustCompile(`^GPU (\d+):`) + // " MIG 3g.40gb Device 0: (UUID: MIG-c6d4f1ef-...)". Drivers before + // R470 name the instance MIG-GPU-//, which CUDA of that + // era takes in the same place, so the UUID is read as whatever it is. + smiListMIG = regexp.MustCompile(`^\s+MIG\s+(\S+)\s+Device\s+(\d+):\s*\(UUID:\s*([^)\s]+)\)`) +) + +// parseSMIList reads nvidia-smi -L into each card's MIG instances, by the +// card's index, in device order. A card with none is absent. +func parseSMIList(out string) map[int][]listedMIG { + res := map[int][]listedMIG{} + card := -1 + for _, line := range strings.Split(out, "\n") { + if m := smiListGPU.FindStringSubmatch(line); m != nil { + card, _ = strconv.Atoi(m[1]) + continue + } + m := smiListMIG.FindStringSubmatch(line) + if m == nil || card < 0 { + continue + } + dev, _ := strconv.Atoi(m[2]) + res[card] = append(res[card], listedMIG{Device: dev, Profile: m[1], UUID: m[3]}) + } + for c := range res { + slices.SortFunc(res[c], func(a, b listedMIG) int { return a.Device - b.Device }) + } + return res +} + +// --- nvidia-smi -q -x --- + +// migLog is the part of nvidia-smi's XML report MIG is read from: each card's +// mode, and each instance's ids and memory. +type migLog struct { + GPUs []migLogGPU `xml:"gpu"` +} + +type migLogGPU struct { + ID string `xml:"id,attr"` + Current string `xml:"mig_mode>current_mig"` + Pending string `xml:"mig_mode>pending_mig"` + Devices []migLogDevice `xml:"mig_devices>mig_device"` + // Processes carry the GPU and compute instance they run in on a card + // in MIG mode, and "N/A" for both otherwise. + Processes []struct { + GPUInstance string `xml:"gpu_instance_id"` + ComputeInstance string `xml:"compute_instance_id"` + PID string `xml:"pid"` + UsedMB string `xml:"used_memory"` + } `xml:"processes>process_info"` + Used string `xml:"fb_memory_usage>used"` +} + +type migLogDevice struct { + Index string `xml:"index"` + GPUInstance string `xml:"gpu_instance_id"` + ComputeInstance string `xml:"compute_instance_id"` + Total string `xml:"fb_memory_usage>total"` + Used string `xml:"fb_memory_usage>used"` +} + +// migState is one card's MIG mode and instances from the report. +type migState struct { + BusID string + Enabled bool + PendingEnabled bool + Devices []migReported +} + +// migReported is one instance as the report describes it. +type migReported struct { + Device, GPUInstance, ComputeInstance int + TotalMB, UsedMB int +} + +// enabled reads nvidia-smi's mode words: "Enabled", "Disabled", "N/A". +func enabled(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "Enabled") } + +func atoiOr(s string, def int) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return def + } + return n +} + +// parseMIGReport reads each card's MIG state from the XML report, in the +// report's order, which is nvidia-smi's index order. +func parseMIGReport(report []byte) ([]migState, error) { + var log migLog + if err := xml.Unmarshal(report, &log); err != nil { + return nil, fmt.Errorf("reading nvidia-smi's report: %w", err) + } + out := make([]migState, len(log.GPUs)) + for i, g := range log.GPUs { + out[i] = migState{BusID: g.ID, Enabled: enabled(g.Current), PendingEnabled: enabled(g.Pending)} + for _, d := range g.Devices { + out[i].Devices = append(out[i].Devices, migReported{ + Device: atoiOr(d.Index, -1), GPUInstance: atoiOr(d.GPUInstance, -1), + ComputeInstance: atoiOr(d.ComputeInstance, -1), + TotalMB: mib(d.Total), UsedMB: mib(d.Used), + }) + } + slices.SortFunc(out[i].Devices, func(a, b migReported) int { return a.Device - b.Device }) + } + return out, nil +} + +// --- putting it together --- + +// migProblemNoInstances keeps a card in MIG mode with nothing carved from it +// out of placement: CUDA cannot use the whole card while MIG is on, and +// there is no instance to use instead. +const migProblemNoInstances = "MIG is enabled with no instances created: nothing can run on it " + + "until instances are created (nvidia-smi mig -cgi … -C) or MIG is turned off" + +// migProblemUnnamed is a card whose instances exist but could not be named: +// an engine is given an instance by its UUID, and without one it cannot be. +const migProblemUnnamed = "MIG is enabled and has instances, but nvidia-smi -L did not name them, " + + "so none can be given to an engine" + +// expandMIG replaces each card in MIG mode with its instances. +// +// listed is nvidia-smi -L's instances by card, which names them; report is +// the XML report's state per card, which gives their memory and ids. Either +// may be missing: -L alone still says which instances exist, and the report +// alone still says a card is in MIG mode, so a card it says has instances +// that -L did not name is kept as the whole card with the reason, rather +// than placed as a card CUDA will not open. +func expandMIG(cards []GPU, listed map[int][]listedMIG, report []migState) []GPU { + if len(listed) == 0 && !slices.ContainsFunc(report, func(s migState) bool { return s.Enabled }) { + return cards + } + byBus := map[string]migState{} + for _, s := range report { + byBus[normalizeBusID(s.BusID)] = s + } + out := make([]GPU, 0, len(cards)) + for pos, c := range cards { + st, ok := byBus[normalizeBusID(c.PCIBusID)] + if !ok && pos < len(report) { + st = report[pos] + } + devs := listed[c.Index] + if !st.Enabled && len(devs) == 0 { + out = append(out, c) + continue + } + if len(devs) == 0 { + p := migProblemNoInstances + if len(st.Devices) > 0 { + p = migProblemUnnamed + } + c.Health.Problems = append(c.Health.Problems, p) + out = append(out, c) + continue + } + for _, d := range devs { + var rep migReported + found := false + for _, r := range st.Devices { + if r.Device == d.Device { + rep, found = r, true + } + } + mig := migInstance(c, d.Device, d.Profile, d.UUID) + if found { + mig.MIG.GPUInstance, mig.MIG.ComputeInstance = rep.GPUInstance, rep.ComputeInstance + mig.MemoryTotalMB, mig.MemoryUsedMB = rep.TotalMB, rep.UsedMB + } + if mig.MemoryTotalMB == 0 { + // The profile says it to the gigabyte, which is close enough + // to place by when the report could not be read. + mig.MemoryTotalMB = MIGProfileMemoryGB(d.Profile) * 1024 + } + assessHealth(&mig) + out = append(out, mig) + } + } + return out +} + +// migInstance builds a MIG instance of card: everything about the card that +// is shared — driver, compute capability, NUMA node, bus, temperature and +// ECC errors — and nothing that is not. Not NVLink, which an instance cannot +// use; not utilisation, which no driver reports per instance; and not the +// card's memory or problems, which the instance's own replace. +func migInstance(card GPU, device int, profile, uuid string) GPU { + g := GPU{ + Index: MIGIndex(card.Index, device), UUID: uuid, + Name: strings.TrimSpace(card.Name + " MIG " + profile), + ComputeCapability: card.ComputeCapability, NUMANode: card.NUMANode, + PCIBusID: card.PCIBusID, DriverVersion: card.DriverVersion, CUDAVersion: card.CUDAVersion, + NICPaths: card.NICPaths, Fabric: card.Fabric, + Health: GPUHealth{ECCErrors: card.Health.ECCErrors, TemperatureC: card.Health.TemperatureC}, + MIG: &MIGDevice{ + ParentIndex: card.Index, ParentUUID: card.UUID, ParentMemoryTotalMB: card.MemoryTotalMB, + Device: device, Profile: profile, GPUInstance: -1, ComputeInstance: -1, + }, + } + return g +} + +// readMIG asks nvidia-smi for what expandMIG needs. A failure of either +// command leaves its half empty, which expandMIG is written to take. +func readMIG(ctx context.Context, binary string) (map[int][]listedMIG, []migState) { + var listed map[int][]listedMIG + if out, err := exec.CommandContext(ctx, binary, "-L").Output(); err == nil { + listed = parseSMIList(string(out)) + } + var report []migState + if out, err := exec.CommandContext(ctx, binary, "-q", "-x").Output(); err == nil { + report, _ = parseMIGReport(out) + } + return listed, report +} + +// --- NVML --- + +// nvmlMIGCard is the part of an NVML device MIG discovery reads, behind an +// interface so the logic is tested without a driver: gpu_nvml.go adapts +// nvml.Device to it. +type nvmlMIGCard interface { + // MIGMode is nvmlDeviceGetMigMode; ok is false on a card without MIG. + MIGMode() (current, pending, ok bool) + // MaxMIGDevices is nvmlDeviceGetMaxMigDeviceCount. + MaxMIGDevices() int + // MIGDevice is nvmlDeviceGetMigDeviceHandleByIndex; ok is false for an + // index with no instance behind it, which is how NVML says so. + MIGDevice(i int) (nvmlMIGInstance, bool) +} + +// nvmlMIGInstance is one MIG device handle. +type nvmlMIGInstance interface { + UUID() string + // Name is "NVIDIA A100-SXM4-80GB MIG 3g.40gb" on the drivers that name + // MIG devices, which is where the profile is read from. + Name() string + Memory() (totalMB, usedMB int, ok bool) + Instances() (gpuInstance, computeInstance int) + // Slices and MemoryMB are the instance's attributes, which name the + // profile on a driver whose device name does not. + Attributes() (slices, memoryMB int, ok bool) +} + +var nvmlMIGName = regexp.MustCompile(`MIG\s+(\S+)\s*$`) + +// migFromNVML is what one card is to placement under NVML: its instances if +// it is in MIG mode, itself if it is not. isMIG says which. +func migFromNVML(card GPU, dev nvmlMIGCard) (out []GPU, isMIG bool) { + current, _, ok := dev.MIGMode() + if !ok || !current { + return []GPU{card}, false + } + n := dev.MaxMIGDevices() + for i := range n { + h, ok := dev.MIGDevice(i) + if !ok { + continue + } + profile := "" + if m := nvmlMIGName.FindStringSubmatch(h.Name()); m != nil { + profile = m[1] + } else if n, mem, ok := h.Attributes(); ok { + // The convention NVIDIA's names follow: compute slices, then the + // memory in whole gigabytes. + profile = fmt.Sprintf("%dg.%dgb", n, int(math.Round(float64(mem)/1024))) + } + g := migInstance(card, i, profile, h.UUID()) + g.MIG.GPUInstance, g.MIG.ComputeInstance = h.Instances() + if total, used, ok := h.Memory(); ok { + g.MemoryTotalMB, g.MemoryUsedMB = total, used + } else { + g.MemoryTotalMB = MIGProfileMemoryGB(profile) * 1024 + } + assessHealth(&g) + out = append(out, g) + } + if len(out) == 0 { + card.Health.Problems = append(card.Health.Problems, migProblemNoInstances) + return []GPU{card}, true + } + return out, true +} + +// --- what the doctor reports --- + +// MIGCard is one card's MIG state, as `llmgw doctor` reports it. +type MIGCard struct { + Index int `json:"index"` + Name string `json:"name,omitempty"` + // Enabled is the mode in force; PendingEnabled the mode after the next + // reset of the card, which differs from it until the card is reset. + Enabled bool `json:"enabled"` + PendingEnabled bool `json:"pending_enabled"` + // Instances are the MIG devices an engine can be given, as nvidia-smi -L + // lists them. + Instances []MIGListed `json:"instances,omitempty"` + // GPUInstances and ComputeInstances are nvidia-smi mig -lgi and -lci: a + // GPU instance with no compute instance in it is not a device yet. + GPUInstances []MIGInstance `json:"gpu_instances,omitempty"` + ComputeInstances []MIGInstance `json:"compute_instances,omitempty"` +} + +// MIGListed is one instance an engine can be given. +type MIGListed struct { + Device int `json:"device"` + Profile string `json:"profile"` + UUID string `json:"uuid"` +} + +// MIGInstance is one row of nvidia-smi mig -lgi or -lci. +type MIGInstance struct { + GPU int `json:"gpu"` + Profile string `json:"profile"` + // ProfileID is NVIDIA's number for the profile. + ProfileID int `json:"profile_id"` + // GPUInstance is the GPU instance's id; ComputeInstance the compute + // instance's within it, -1 in a -lgi row. + GPUInstance int `json:"gpu_instance"` + ComputeInstance int `json:"compute_instance"` + Placement string `json:"placement,omitempty"` +} + +var ( + // -lgi: "| 0 MIG 3g.40gb 9 2 4:4 |" + migLGIRow = regexp.MustCompile(`^\|\s*(\d+)\s+MIG\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+:\d+)\s*\|`) + // -lci: "| 0 1 MIG 3g.40gb 2 0 0:3 |" + migLCIRow = regexp.MustCompile(`^\|\s*(\d+)\s+(\d+)\s+MIG\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+:\d+)\s*\|`) +) + +// ParseMIGInstances reads nvidia-smi mig -lgi or -lci, whichever it is +// given: the two tables differ by the GPU instance column -lci adds. "No GPU +// instances found" and the like are no rows, not an error. +func ParseMIGInstances(out string) []MIGInstance { + var res []MIGInstance + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if m := migLCIRow.FindStringSubmatch(line); m != nil { + res = append(res, MIGInstance{ + GPU: atoiOr(m[1], -1), GPUInstance: atoiOr(m[2], -1), Profile: m[3], + ProfileID: atoiOr(m[4], -1), ComputeInstance: atoiOr(m[5], -1), Placement: m[6], + }) + continue + } + if m := migLGIRow.FindStringSubmatch(line); m != nil { + res = append(res, MIGInstance{ + GPU: atoiOr(m[1], -1), Profile: m[2], ProfileID: atoiOr(m[3], -1), + GPUInstance: atoiOr(m[4], -1), ComputeInstance: -1, Placement: m[5], + }) + } + } + return res +} + +// DiscoverMIG reads every card's MIG state for the doctor: the mode from the +// XML report, the devices from -L, the instances from nvidia-smi mig. Only +// cards that support MIG are listed, and an error is returned only when the +// report itself cannot be read. +func DiscoverMIG(ctx context.Context, binary string) ([]MIGCard, error) { + out, err := exec.CommandContext(ctx, binary, "-q", "-x").Output() + if err != nil { + return nil, fmt.Errorf("running %s -q -x: %w", binary, err) + } + var list, lgi, lci string + if b, err := exec.CommandContext(ctx, binary, "-L").Output(); err == nil { + list = string(b) + } + // nvidia-smi mig exits non-zero when there is nothing to list, with the + // table or the reason on its output either way. + b, _ := exec.CommandContext(ctx, binary, "mig", "-lgi").CombinedOutput() + lgi = string(b) + b, _ = exec.CommandContext(ctx, binary, "mig", "-lci").CombinedOutput() + lci = string(b) + return buildMIGCards(out, list, lgi, lci) +} + +// buildMIGCards is DiscoverMIG from the commands' output. +func buildMIGCards(report []byte, list, lgi, lci string) ([]MIGCard, error) { + var log struct { + GPUs []struct { + Name string `xml:"product_name"` + Current string `xml:"mig_mode>current_mig"` + Pending string `xml:"mig_mode>pending_mig"` + } `xml:"gpu"` + } + if err := xml.Unmarshal(report, &log); err != nil { + return nil, fmt.Errorf("reading nvidia-smi's report: %w", err) + } + listed := parseSMIList(list) + gis, cis := ParseMIGInstances(lgi), ParseMIGInstances(lci) + var out []MIGCard + for i, g := range log.GPUs { + cur, pend := strings.TrimSpace(g.Current), strings.TrimSpace(g.Pending) + // A card without MIG says N/A, or nothing on a driver that predates + // it; neither is worth a line. + if !enabled(cur) && !enabled(pend) && !strings.EqualFold(cur, "Disabled") { + continue + } + c := MIGCard{Index: i, Name: strings.TrimSpace(g.Name), Enabled: enabled(cur), PendingEnabled: enabled(pend)} + for _, l := range listed[i] { + c.Instances = append(c.Instances, MIGListed(l)) + } + for _, gi := range gis { + if gi.GPU == i && gi.ComputeInstance < 0 { + c.GPUInstances = append(c.GPUInstances, gi) + } + } + for _, ci := range cis { + if ci.GPU == i && ci.ComputeInstance >= 0 { + c.ComputeInstances = append(c.ComputeInstances, ci) + } + } + out = append(out, c) + } + return out, nil +} diff --git a/internal/agent/discovery/mig_test.go b/internal/agent/discovery/mig_test.go new file mode 100644 index 0000000..1cef522 --- /dev/null +++ b/internal/agent/discovery/mig_test.go @@ -0,0 +1,418 @@ +package discovery + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" +) + +// Two A100 80GB cards: card 0 in MIG mode, cut into two 3g.40gb instances, +// card 1 whole. What nvidia-smi prints for that machine, by command. None of +// it is from real MIG hardware, which this was written without; the shapes +// follow NVIDIA's documentation and the output of drivers R470 to R550. +const ( + migQuery = `0, GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77, NVIDIA A100-SXM4-80GB, 81920, 0, 8.0, 00000000:07:00.0, 550.54.15, 31, 0, [N/A] +1, GPU-1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718, NVIDIA A100-SXM4-80GB, 81920, 0, 8.0, 00000000:0F:00.0, 550.54.15, 33, 0, 0 +` + migList = `GPU 0: NVIDIA A100-SXM4-80GB (UUID: GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77) + MIG 3g.40gb Device 0: (UUID: MIG-c6d4f1ef-42e4-5de3-91c7-45d71c87eb3f) + MIG 3g.40gb Device 1: (UUID: MIG-0f7c3a2e-9d1b-5a8e-b3c4-1e2f3a4b5c6d) +GPU 1: NVIDIA A100-SXM4-80GB (UUID: GPU-1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718) +` + migReport = ` + + 550.54.15 + + NVIDIA A100-SXM4-80GB + EnabledEnabled + + + 1 + 1 + 0 + 42 + 40192 MiB0 MiB21000 MiB19192 MiB + + + 0 + 2 + 0 + 40192 MiB0 MiB37 MiB40155 MiB + + + 81920 MiB21037 MiB + + + 1 + 0 + 4100Cvllm + 20900 MiB + + + + + NVIDIA A100-SXM4-80GB + DisabledDisabled + None + 81920 MiB300 MiB + + + N/A + N/A + 5200C250 MiB + + + +` + migLGI = `+-------------------------------------------------------+ +| GPU instances: | +| GPU Name Profile Instance Placement | +| ID ID Start:Size | +|=======================================================| +| 0 MIG 3g.40gb 9 1 4:4 | ++-------------------------------------------------------+ +| 0 MIG 3g.40gb 9 2 0:4 | ++-------------------------------------------------------+ +` + migLCI = `+--------------------------------------------------------------------+ +| Compute instances: | +| GPU GPU Name Profile Instance Placement | +| Instance ID ID Start:Size | +| ID | +|====================================================================| +| 0 1 MIG 3g.40gb 2 0 0:3 | ++--------------------------------------------------------------------+ +| 0 2 MIG 3g.40gb 2 0 0:3 | ++--------------------------------------------------------------------+ +` +) + +func migMachine(t *testing.T) []GPU { + t.Helper() + cards, err := parseSMI(migQuery) + if err != nil { + t.Fatal(err) + } + report, err := parseMIGReport([]byte(migReport)) + if err != nil { + t.Fatal(err) + } + return expandMIG(cards, parseSMIList(migList), report) +} + +func TestACardInMIGModeIsReportedAsItsInstancesInsteadOfItself(t *testing.T) { + gpus := migMachine(t) + var got []string + for _, g := range gpus { + got = append(got, DeviceLabel(g.Index)+" "+g.Class()) + } + want := []string{"0/mig0 a100-80g-mig-3g.40gb", "0/mig1 a100-80g-mig-3g.40gb", "1 a100-80g"} + if !slices.Equal(got, want) { + t.Fatalf("devices = %q, want %q", got, want) + } + + mig := gpus[1] + if mig.Index != 1001 || mig.UUID != "MIG-0f7c3a2e-9d1b-5a8e-b3c4-1e2f3a4b5c6d" { + t.Errorf("instance 1 is %d %s; an engine is given it by that UUID", mig.Index, mig.UUID) + } + if mig.MemoryTotalMB != 40192 || mig.MemoryUsedMB != 21000 { + t.Errorf("instance 1 memory %d/%d, want its own 21000/40192 rather than the card's", + mig.MemoryUsedMB, mig.MemoryTotalMB) + } + if m := mig.MIG; m == nil || m.ParentIndex != 0 || m.Device != 1 || m.Profile != "3g.40gb" || + m.GPUInstance != 1 || m.ComputeInstance != 0 || m.ParentUUID != "GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77" { + t.Errorf("instance 1's parentage: %+v", mig.MIG) + } + if mig.ComputeCapability != "8.0" || mig.DriverVersion != "550.54.15" || mig.Health.TemperatureC != 31 { + t.Errorf("what the card and its instances share was not carried: %+v", mig) + } + // Twenty gigabytes in use on an instance is a foreign tenant there, and + // only there: instance 0 next to it is free. + if mig.Health.OK() || !gpus[0].Health.OK() { + t.Errorf("foreign memory judged per instance: 0 %v, 1 %v", gpus[0].Health.Problems, mig.Health.Problems) + } + // No driver reports utilisation per instance, and none is made up. + if mig.UtilizationPercent != 0 { + t.Errorf("an instance reported utilisation %d", mig.UtilizationPercent) + } + if gpus[2].IsMIG() || gpus[2].Index != 1 || gpus[2].CardIndex() != 1 || gpus[0].CardIndex() != 0 { + t.Errorf("the whole card beside it changed: %+v", gpus[2]) + } +} + +func TestAMIGInstanceSurvivesTheTripToTheControlPlaneWithItsClass(t *testing.T) { + g := migMachine(t)[0] + b, err := json.Marshal(g) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"class":"a100-80g-mig-3g.40gb"`) { + t.Errorf("the JSON carries no class for the instance: %s", b) + } + var back GPU + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if back.Class() != g.Class() || !reflect.DeepEqual(back.MIG, g.MIG) { + t.Errorf("came back as %s %+v", back.Class(), back.MIG) + } +} + +func TestMIGClassesAreNamedFromTheCardAndTheProfile(t *testing.T) { + for _, c := range []struct { + name string + cardMB int + profile string + want string + }{ + {"NVIDIA A100-SXM4-80GB MIG 3g.40gb", 81920, "3g.40gb", "a100-80g-mig-3g.40gb"}, + // The same profile name on a 40 GB card is another class: its + // instance is the same size, but the card is not, and neither is + // the set of profiles that card can be cut to. + {"NVIDIA A100-SXM4-40GB MIG 3g.20gb", 40960, "3g.20gb", "a100-40g-mig-3g.20gb"}, + {"NVIDIA H100 80GB HBM3 MIG 1g.10gb+me", 81559, "1g.10gb+me", "h100-80g-mig-1g.10gb-me"}, + {"NVIDIA H100 80GB HBM3 MIG 7g.80gb", 81559, "7g.80gb", "h100-80g-mig-7g.80gb"}, + } { + g := GPU{Name: c.name, MemoryTotalMB: 10000, MIG: &MIGDevice{ParentMemoryTotalMB: c.cardMB, Profile: c.profile}} + if got := g.Class(); got != c.want { + t.Errorf("%s -> %s, want %s", c.name, got, c.want) + } + if !IsMIGClass(g.Class()) { + t.Errorf("%s is not recognised as a MIG class", g.Class()) + } + } + if IsMIGClass("a100-80g") || IsMIGClass("6000-24g") { + t.Error("a whole card's class was taken for a MIG one") + } + if MIGProfileMemoryGB("a100-80g-mig-3g.40gb") != 40 || MIGProfileMemoryGB("1g.10gb+me") != 10 || MIGProfileMemoryGB("a100-80g") != 0 { + t.Error("the instance's memory is misread from its profile") + } +} + +func TestDeviceNumbersAreShownAsCardAndInstance(t *testing.T) { + for i, want := range map[int]string{0: "0", 7: "7", MIGIndex(0, 2): "0/mig2", MIGIndex(3, 0): "3/mig0", 1302: "3/mig2"} { + if got := DeviceLabel(i); got != want { + t.Errorf("DeviceLabel(%d) = %s, want %s", i, got, want) + } + } + if IsMIGIndex(15) || !IsMIGIndex(MIGIndex(0, 0)) { + t.Error("whole cards and instances are told apart wrongly") + } +} + +func TestACardInMIGModeWithNoInstancesIsKeptOutOfPlacement(t *testing.T) { + cards, _ := parseSMI(migQuery) + report, _ := parseMIGReport([]byte(strings.Replace(migReport, + migReport[strings.Index(migReport, ""):strings.Index(migReport, "")+len("")], + "None", 1))) + gpus := expandMIG(cards, parseSMIList("GPU 0: NVIDIA A100-SXM4-80GB (UUID: GPU-x)\nGPU 1: NVIDIA A100-SXM4-80GB (UUID: GPU-y)\n"), report) + if len(gpus) != 2 || gpus[0].IsMIG() { + t.Fatalf("got %+v", gpus) + } + // CUDA will not open the whole card while MIG is on, and there is no + // instance to open instead: placing anything here fails at start. + if gpus[0].Health.OK() || !strings.Contains(gpus[0].Health.Problems[0], "no instances") { + t.Errorf("card 0's problems: %v", gpus[0].Health.Problems) + } + if !gpus[1].Health.OK() { + t.Errorf("card 1, not in MIG mode, was flagged: %v", gpus[1].Health.Problems) + } + + // Instances the report sees but -L could not name cannot be given to an + // engine either; the card says why. + report, _ = parseMIGReport([]byte(migReport)) + gpus = expandMIG(cards, nil, report) + if len(gpus) != 2 || gpus[0].Health.OK() || !strings.Contains(gpus[0].Health.Problems[0], "did not name") { + t.Errorf("with -L failing: %+v", gpus) + } +} + +func TestInstancesAreFoundFromTheListAloneWhenTheReportCannotBeRead(t *testing.T) { + cards, _ := parseSMI(migQuery) + gpus := expandMIG(cards, parseSMIList(migList), nil) + if len(gpus) != 3 || !gpus[0].IsMIG() || gpus[0].MemoryTotalMB != 40*1024 { + t.Errorf("without the report: %+v", gpus) + } +} + +func TestAnOlderDriversInstanceUUIDIsTakenAsItIs(t *testing.T) { + // R450 and R460 name an instance by its card and ids, and CUDA of that + // era takes exactly that in CUDA_VISIBLE_DEVICES. + l := parseSMIList("GPU 0: A100-SXM4-40GB (UUID: GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77)\n" + + " MIG 1g.5gb Device 0: (UUID: MIG-GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77/7/0)\n") + if len(l[0]) != 1 || l[0][0].UUID != "MIG-GPU-5d5ba0d6-d33d-2b2c-524d-9e3d8d2b8a77/7/0" || l[0][0].Profile != "1g.5gb" { + t.Errorf("parsed %+v", l) + } +} + +func TestProcessesOnAMIGCardAreChargedToTheirOwnInstance(t *testing.T) { + mem, err := parseGPUMemory([]byte(migReport)) + if err != nil { + t.Fatal(err) + } + want := []GPUMemory{ + {Index: 1000, UsedMB: 37}, + {Index: 1001, UsedMB: 21000, Procs: []GPUProcess{{PID: 4100, UsedMB: 20900}}}, + {Index: 1, UsedMB: 300, Procs: []GPUProcess{{PID: 5200, UsedMB: 250}}}, + } + if !reflect.DeepEqual(mem, want) { + t.Errorf("got %+v\nwant %+v", mem, want) + } + // In the order discovery reports the devices, so a heartbeat's readings + // line up with the inventory one for one. + gpus := migMachine(t) + for i := range gpus { + if mem[i].Index != gpus[i].Index { + t.Errorf("reading %d is for device %d, inventory has %d", i, mem[i].Index, gpus[i].Index) + } + } + procs, _ := parseGPUProcesses([]byte(migReport)) + if !reflect.DeepEqual(procs, map[int][]int{1001: {4100}, 1: {5200}}) { + t.Errorf("processes by device: %v", procs) + } +} + +func TestAMIGInstanceTakesItsCardsPlaceInTheTopologyButNoNVLink(t *testing.T) { + gpus := migMachine(t) + topo := Topology{ + NVLink: map[int]map[int]int{0: {1: 12}, 1: {0: 12}}, + NUMA: map[int]int{0: 0, 1: 1}, + NICPath: map[int]map[string]string{0: {"mlx5_0": "PIX"}}, + } + topo.Apply(gpus) + if len(gpus[0].NVLinkPeers) != 0 || len(gpus[1].NVLinkPeers) != 0 { + t.Errorf("MIG instances were given NVLink peers: %v %v", gpus[0].NVLinkPeers, gpus[1].NVLinkPeers) + } + if gpus[1].NUMANode != 0 || gpus[1].NICPaths["mlx5_0"] != "PIX" { + t.Errorf("instance 1 did not take its card's NUMA node and adapters: %+v", gpus[1]) + } + if gpus[2].NUMANode != 1 || !slices.Equal(gpus[2].NVLinkPeers, []int{0}) { + t.Errorf("the whole card's topology: %+v", gpus[2]) + } +} + +func TestMIGInstancesAreDiscoveredThroughNvidiaSMI(t *testing.T) { + // The real code path against a stub binary that answers each command as + // the machine above would. + dir := t.TempDir() + stub := filepath.Join(dir, "nvidia-smi") + script := "#!/bin/sh\ncase \"$1\" in\n" + + "-L) cat <<'EOF'\n" + migList + "EOF\n;;\n" + + "-q) cat <<'EOF'\n" + migReport + "\nEOF\n;;\n" + + "*) cat <<'EOF'\n" + migQuery + "EOF\n;;\nesac\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + gpus, err := DiscoverGPUsSMI(t.Context(), stub) + if err != nil { + t.Fatal(err) + } + inv := Inventory{GPUs: gpus} + if !slices.Equal(inv.Classes(), []string{"a100-80g", "a100-80g-mig-3g.40gb"}) || len(gpus) != 3 { + t.Errorf("classes %v from %d devices", inv.Classes(), len(gpus)) + } +} + +// fakeMIGCard is NVML's view of one card, as nvmlDeviceGetMigMode, +// nvmlDeviceGetMaxMigDeviceCount and nvmlDeviceGetMigDeviceHandleByIndex +// would give it. +type fakeMIGCard struct { + mode, pending, supported bool + max int + devs map[int]fakeMIGInstance +} + +func (c fakeMIGCard) MIGMode() (bool, bool, bool) { return c.mode, c.pending, c.supported } +func (c fakeMIGCard) MaxMIGDevices() int { return c.max } +func (c fakeMIGCard) MIGDevice(i int) (nvmlMIGInstance, bool) { + d, ok := c.devs[i] + return d, ok +} + +type fakeMIGInstance struct { + uuid, name string + total, used int + gi, ci int + slices, attrMem int +} + +func (m fakeMIGInstance) UUID() string { return m.uuid } +func (m fakeMIGInstance) Name() string { return m.name } +func (m fakeMIGInstance) Memory() (int, int, bool) { + return m.total, m.used, m.total > 0 +} +func (m fakeMIGInstance) Instances() (int, int) { return m.gi, m.ci } +func (m fakeMIGInstance) Attributes() (int, int, bool) { + return m.slices, m.attrMem, m.slices > 0 +} + +func TestNVMLReportsACardInMIGModeAsItsInstances(t *testing.T) { + card := GPU{Index: 2, UUID: "GPU-card", Name: "NVIDIA H100 80GB HBM3", MemoryTotalMB: 81559, + ComputeCapability: "9.0", NUMANode: 1, Health: GPUHealth{TemperatureC: 40}} + // Seven slots, three filled, one of them on a driver that does not put + // the profile in the device's name. + dev := fakeMIGCard{mode: true, pending: true, supported: true, max: 7, devs: map[int]fakeMIGInstance{ + 0: {uuid: "MIG-aaa", name: "NVIDIA H100 80GB HBM3 MIG 3g.40gb", total: 40448, used: 10, gi: 2, ci: 0}, + 1: {uuid: "MIG-bbb", name: "NVIDIA H100 80GB HBM3 MIG 3g.40gb", total: 40448, used: 10, gi: 1, ci: 0}, + 4: {uuid: "MIG-ccc", name: "NVIDIA H100 80GB HBM3", total: 9984, gi: 9, ci: 0, slices: 1, attrMem: 9984}, + }} + gpus, isMIG := migFromNVML(card, dev) + if !isMIG || len(gpus) != 3 { + t.Fatalf("got %d devices, MIG %v", len(gpus), isMIG) + } + var classes []string + for _, g := range gpus { + classes = append(classes, DeviceLabel(g.Index)+" "+g.UUID+" "+g.Class()) + } + want := []string{"2/mig0 MIG-aaa h100-80g-mig-3g.40gb", "2/mig1 MIG-bbb h100-80g-mig-3g.40gb", + "2/mig4 MIG-ccc h100-80g-mig-1g.10gb"} + if !slices.Equal(classes, want) { + t.Errorf("devices %q, want %q", classes, want) + } + if g := gpus[1]; g.MemoryTotalMB != 40448 || g.MIG.GPUInstance != 1 || g.NUMANode != 1 || + g.Health.TemperatureC != 40 || len(g.NVLinkPeers) != 0 { + t.Errorf("instance 1: %+v", g) + } + + // MIG on and nothing carved: the card, kept out of placement. + gpus, isMIG = migFromNVML(card, fakeMIGCard{mode: true, supported: true, max: 7}) + if !isMIG || len(gpus) != 1 || gpus[0].IsMIG() || gpus[0].Health.OK() { + t.Errorf("an empty MIG card: %+v", gpus) + } + // MIG off, or a card without it: the card, as it always was. + for _, d := range []fakeMIGCard{{supported: true, max: 7}, {}} { + gpus, isMIG = migFromNVML(card, d) + if isMIG || len(gpus) != 1 || !reflect.DeepEqual(gpus[0], card) { + t.Errorf("a card not in MIG mode came back as %+v", gpus) + } + } +} + +func TestMIGInstanceTablesAreRead(t *testing.T) { + gis := ParseMIGInstances(migLGI) + if len(gis) != 2 || gis[0] != (MIGInstance{GPU: 0, Profile: "3g.40gb", ProfileID: 9, GPUInstance: 1, + ComputeInstance: -1, Placement: "4:4"}) { + t.Errorf("-lgi: %+v", gis) + } + cis := ParseMIGInstances(migLCI) + if len(cis) != 2 || cis[1] != (MIGInstance{GPU: 0, GPUInstance: 2, Profile: "3g.40gb", ProfileID: 2, + ComputeInstance: 0, Placement: "0:3"}) { + t.Errorf("-lci: %+v", cis) + } + if got := ParseMIGInstances("No GPU instances found: Not Found\n"); len(got) != 0 { + t.Errorf("nothing to list read as %+v", got) + } + + cards, err := buildMIGCards([]byte(migReport), migList, migLGI, migLCI) + if err != nil { + t.Fatal(err) + } + if len(cards) != 2 || !cards[0].Enabled || len(cards[0].Instances) != 2 || len(cards[0].GPUInstances) != 2 || + len(cards[0].ComputeInstances) != 2 || cards[1].Enabled { + t.Errorf("cards: %+v", cards) + } +} diff --git a/internal/agent/discovery/topo.go b/internal/agent/discovery/topo.go index 13230a4..caea337 100644 --- a/internal/agent/discovery/topo.go +++ b/internal/agent/discovery/topo.go @@ -166,19 +166,25 @@ func splitTabs(l string) []string { // Apply puts the topology on the GPUs it describes. What a GPU already knows // — NVLink peers from NVML, which is the more exact source — is kept. +// +// The matrix is of whole cards. A MIG instance takes its card's NUMA node and +// adapters, and none of its NVLink: instances cannot reach each other or any +// other card over it, and peers listed for one would draw a tensor-parallel +// group onto it. func (t Topology) Apply(gpus []GPU) { for i := range gpus { g := &gpus[i] - if len(g.NVLinkPeers) == 0 { - for peer := range t.NVLink[g.Index] { + card := g.CardIndex() + if len(g.NVLinkPeers) == 0 && !g.IsMIG() { + for peer := range t.NVLink[card] { g.NVLinkPeers = append(g.NVLinkPeers, peer) } sort.Ints(g.NVLinkPeers) } - if n, ok := t.NUMA[g.Index]; ok { + if n, ok := t.NUMA[card]; ok { g.NUMANode = n } - if paths := t.NICPath[g.Index]; len(paths) > 0 { + if paths := t.NICPath[card]; len(paths) > 0 { g.NICPaths = paths } } diff --git a/internal/agent/engines/engine.go b/internal/agent/engines/engine.go index 484ffed..a370cc3 100644 --- a/internal/agent/engines/engine.go +++ b/internal/agent/engines/engine.go @@ -62,6 +62,12 @@ type ShardSpec struct { // GPUIndices are the local GPUs this shard uses. GPUIndices []int + // GPUUUIDs name the same devices by UUID, in the same order, and are set + // by the agent only when one of them is a MIG instance. An instance has + // no index CUDA knows: CUDA_VISIBLE_DEVICES=MIG- is the only way to + // give a process one, and the indices placement uses for it (1000 and + // up) mean nothing to CUDA. + GPUUUIDs []string `json:"gpu_uuids,omitempty"` // HCAs are the adapters of the chosen fabric, and only that fabric. A shard // must never be given HCAs from two fabrics. HCAs []string @@ -168,6 +174,13 @@ type CPUFallback interface { Devices(ctx context.Context) ([]string, error) } +// HelpCommand is an engine whose program is not asked for its help by +// running the command's path with --help, because the path is an interpreter +// starting it through a launcher. +type HelpCommand interface { + HelpCommand(c Command) (path string, args []string) +} + // GPUInfo is the subset of GPU facts validation needs. type GPUInfo struct { Index int @@ -187,6 +200,7 @@ func NewRegistry(cfg Config) *Registry { r.Add(&VLLM{cfg: cfg.VLLM}) r.Add(&SGLang{cfg: cfg.SGLang}) r.Add(&LlamaCpp{cfg: cfg.LlamaCpp}) + r.Add(&TRTLLM{cfg: cfg.TRTLLM}) r.Add(&Sim{cfg: cfg.Sim}) return r } @@ -232,7 +246,10 @@ type Config struct { VLLM VLLMConfig `koanf:"vllm"` SGLang SGLangConfig `koanf:"sglang"` LlamaCpp LlamaCppConfig `koanf:"llamacpp"` - Sim SimConfig `koanf:"sim"` + // TRTLLM is TensorRT-LLM, which the agent does not install: a node + // offers it only when its interpreter is named here. + TRTLLM TRTLLMConfig `koanf:"trtllm"` + Sim SimConfig `koanf:"sim"` } // --- shared helpers --- @@ -257,7 +274,7 @@ func NCCLEnv(s ShardSpec) map[string]string { return baseEnv(s) } // one instead of timing out. func baseEnv(s ShardSpec) map[string]string { env := map[string]string{ - "CUDA_VISIBLE_DEVICES": joinInts(s.GPUIndices), + "CUDA_VISIBLE_DEVICES": visibleDevices(s), // The indices above are nvidia-smi's, which counts by PCI bus. CUDA // counts fastest first unless told otherwise, and on a node with two // kinds of GPU the shard would get a different card than placement @@ -329,6 +346,21 @@ func materializeEnv(generated map[string]string) []string { return out } +// visibleDevices is CUDA_VISIBLE_DEVICES for a shard: the indices placement +// gave it, or their UUIDs when the agent named them, which it does for a MIG +// instance. +func visibleDevices(s ShardSpec) string { + if len(s.GPUUUIDs) > 0 { + return strings.Join(s.GPUUUIDs, ",") + } + return joinInts(s.GPUIndices) +} + +// OnMIG reports whether the shard was given a MIG instance. +func (s ShardSpec) OnMIG() bool { + return slices.ContainsFunc(s.GPUUUIDs, func(u string) bool { return strings.HasPrefix(u, "MIG-") }) +} + func joinInts(v []int) string { parts := make([]string, len(v)) for i, n := range v { diff --git a/internal/agent/engines/engines_test.go b/internal/agent/engines/engines_test.go index 049f0a2..c795bcf 100644 --- a/internal/agent/engines/engines_test.go +++ b/internal/agent/engines/engines_test.go @@ -457,14 +457,14 @@ func TestValidateRequiresModelPath(t *testing.T) { func TestRegistry(t *testing.T) { r := NewRegistry(Config{}) - if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "vllm"}) { + if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "trtllm", "vllm"}) { t.Errorf("registry contents: %v", got) } if _, err := r.Get("vllm"); err != nil { t.Errorf("vllm missing: %v", err) } err := func() error { _, err := r.Get("nope"); return err }() - if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, vllm") { + if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, trtllm, vllm") { t.Errorf("an unknown engine should list what is available: %v", err) } } diff --git a/internal/agent/engines/llamacpp.go b/internal/agent/engines/llamacpp.go index 02a27a6..fb7bf22 100644 --- a/internal/agent/engines/llamacpp.go +++ b/internal/agent/engines/llamacpp.go @@ -178,7 +178,11 @@ func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) { // as the driver lists them, which on NVIDIA follows the same bus order // as nvidia-smi; were it not to, the shard would not hold memory on its // own GPUs, and starting it checks exactly that. - if len(s.GPUIndices) > 0 { + // + // Not on a MIG instance, which Vulkan does not see at all: only CUDA + // opens one, by the UUID CUDA_VISIBLE_DEVICES carries, and an index here + // would name some other card. + if len(s.GPUIndices) > 0 && !s.OnMIG() { env["GGML_VK_VISIBLE_DEVICES"] = joinInts(s.GPUIndices) } return []Command{{ diff --git a/internal/agent/engines/mig_test.go b/internal/agent/engines/mig_test.go new file mode 100644 index 0000000..08cf458 --- /dev/null +++ b/internal/agent/engines/mig_test.go @@ -0,0 +1,49 @@ +package engines + +import "testing" + +const migUUID = "MIG-c6d4f1ef-42e4-5de3-91c7-45d71c87eb3f" + +func TestAShardOnAMIGInstanceIsGivenItByUUID(t *testing.T) { + // Placement numbers card 0's instance 1 as 1001, which CUDA has never + // heard of: CUDA_VISIBLE_DEVICES=1001 is no device at all, and =1 would + // be the wrong card. + s := singleNodeSpec() + s.GPUIndices, s.GPUUUIDs = []int{1001}, []string{migUUID} + s.Profile.TP = 1 + + env := envMap(buildOne(t, &VLLM{}, s).Env) + if env["CUDA_VISIBLE_DEVICES"] != migUUID { + t.Errorf("vLLM: CUDA_VISIBLE_DEVICES=%q, want the instance's UUID", env["CUDA_VISIBLE_DEVICES"]) + } + + l := llamaSpec([]int{1001}) + l.GPUUUIDs = s.GPUUUIDs + cmds, err := (&LlamaCpp{}).Commands(l) + if err != nil { + t.Fatal(err) + } + env = envMap(cmds[0].Env) + if env["CUDA_VISIBLE_DEVICES"] != migUUID { + t.Errorf("llama.cpp: CUDA_VISIBLE_DEVICES=%q, want the instance's UUID", env["CUDA_VISIBLE_DEVICES"]) + } + // Vulkan does not see MIG instances, and an index for it would name + // some other card. + if v, set := env["GGML_VK_VISIBLE_DEVICES"]; set { + t.Errorf("GGML_VK_VISIBLE_DEVICES=%q on a MIG instance", v) + } + if v, _ := flagValue(cmds[0].Args, "--n-gpu-layers"); v != "999" { + t.Errorf("the model was not offloaded to the instance: --n-gpu-layers %q", v) + } +} + +func TestAShardOnWholeCardsIsStillGivenThemByIndex(t *testing.T) { + env := envMap(buildOne(t, &VLLM{}, singleNodeSpec()).Env) + if env["CUDA_VISIBLE_DEVICES"] != "0,1,2,3" { + t.Errorf("CUDA_VISIBLE_DEVICES=%q", env["CUDA_VISIBLE_DEVICES"]) + } + cmds, _ := (&LlamaCpp{}).Commands(llamaSpec([]int{2})) + if env := envMap(cmds[0].Env); env["GGML_VK_VISIBLE_DEVICES"] != "2" { + t.Errorf("GGML_VK_VISIBLE_DEVICES=%q", env["GGML_VK_VISIBLE_DEVICES"]) + } +} diff --git a/internal/agent/engines/trtllm.go b/internal/agent/engines/trtllm.go new file mode 100644 index 0000000..c3f50ad --- /dev/null +++ b/internal/agent/engines/trtllm.go @@ -0,0 +1,218 @@ +package engines + +import ( + "context" + "fmt" + "strconv" +) + +// TRTLLMConfig locates a TensorRT-LLM the operator installed. +// +// llmgw does not install TensorRT-LLM. It is a pip package pinned to one +// CUDA, one PyTorch and an MPI the machine must provide, and NVIDIA's own +// advice is its container; an installer that guessed at all of that would +// fail on most machines it met, a long way into a large download. So it is +// brought by the operator — a virtual environment, or NVIDIA's container — +// and named here. +type TRTLLMConfig struct { + // Python is the interpreter of the environment TensorRT-LLM is installed + // in: /bin/python for a virtual environment, python3 in NVIDIA's + // container. Naming it is what makes this node offer the engine; empty + // offers nothing. + // + // The interpreter rather than trtllm-serve, because llmgw starts the + // server through a few lines of its own that make it demand the + // gateway's key, which trtllm-serve has no option for. + Python string `koanf:"python"` +} + +// TRTLLM builds TensorRT-LLM launch commands: its OpenAI-compatible server, +// trtllm-serve, on one node. +// +// Written against TensorRT-LLM's documented command line and server, and not +// yet run on hardware by anyone working on llmgw: no datacenter GPU was at +// hand. `llmgw ctl engine-check trtllm` asks an installed one whether it +// takes the flags built here. +type TRTLLM struct{ cfg TRTLLMConfig } + +// Name identifies the engine in the catalog. +func (*TRTLLM) Name() string { return "trtllm" } + +// HealthPath is trtllm-serve's liveness endpoint: 200 once the executor is +// up, 503 while it is not. The supervisor's /v1/models step is what says the +// model is being served. +func (*TRTLLM) HealthPath() string { return "/health" } + +// MetricsPath is trtllm-serve's Prometheus endpoint. Its /metrics is a +// different thing — a JSON list of iteration statistics, drained by each +// read — and a scrape there would both fail to parse and steal the numbers +// from anyone else reading them. +func (*TRTLLM) MetricsPath() string { return "/prometheus/metrics" } + +// ServesHTTP reports whether this rank answers the API; a TensorRT-LLM +// replica is one node, and so one rank. +func (*TRTLLM) ServesHTTP(s ShardSpec) bool { return s.Leader() } + +// Validate checks the profile against the GPUs it would run on. +// +// One node: trtllm-serve spans machines only when started under MPI or +// Slurm from one of them (trtllm-llmapi-launch), and the agents each start +// their own node's part, which is not that. Ampere or newer: the oldest +// generation current TensorRT-LLM builds kernels for. +func (*TRTLLM) Validate(p Profile, gpus []GPUInfo) error { + if p.Nodes > 1 { + return fmt.Errorf("profile %q: trtllm runs a replica on one node; "+ + "use vllm or sglang for a %d-node replica", p.Name, p.Nodes) + } + if err := validateCommon(p, gpus); err != nil { + return err + } + return requireComputeCapability(p, gpus, 8.0, "TensorRT-LLM") +} + +// Commands returns the processes for one shard, which is one process. Tensor +// and pipeline parallel ranks on the node are trtllm-serve's own MPI workers, +// started by it. +func (t *TRTLLM) Commands(s ShardSpec) ([]Command, error) { + if s.NNodes > 1 { + return nil, fmt.Errorf("trtllm: a replica spanning %d nodes is not something llmgw starts; "+ + "TensorRT-LLM runs one node per replica here", s.NNodes) + } + p := s.Profile + args := []string{"-c", trtllmLauncher, + // The subcommand by name: trtllm-serve takes a bare model as "serve", + // but saying it costs nothing and does not depend on that default. + "serve", p.ModelPath, + "--host", s.Host(), + "--port", strconv.Itoa(s.HTTPPort), + "--tp_size", strconv.Itoa(p.TP), + } + if p.PP > 1 { + args = append(args, "--pp_size", strconv.Itoa(p.PP)) + } + if p.ServedModelName != "" { + // Without it the model is named after its directory, and the agent's + // readiness check, which looks for this name in /v1/models, never + // finds it. + args = append(args, "--served_model_name", p.ServedModelName) + } + // What makes the queue and the cache measurable: the options file the + // launcher writes turns on the Prometheus endpoint and the iteration + // statistics its gauges are set from. A placeholder here, so the flag is + // one engine-check sees and checks; the launcher replaces it. + args = append(args, "--extra_llm_api_options", trtllmOptionsPlaceholder) + args = append(args, p.Args...) + + env := baseEnv(s) + if s.APIKey != "" { + env[trtllmKeyEnv] = s.APIKey + } + // TensorRT-LLM reports usage to NVIDIA unless told not to. A gateway on + // somebody's own network does not phone home on their behalf, and a + // model's profile cannot make it: these are set after the profile's env. + env["TRTLLM_NO_USAGE_STATS"] = "1" + env["DO_NOT_TRACK"] = "1" + return []Command{{Name: "trtllm-serve", Path: t.python(), Args: args, Env: materializeEnv(env)}}, nil +} + +// python is the interpreter to start trtllm-serve with. +func (t *TRTLLM) python() string { + if t.cfg.Python != "" { + return t.cfg.Python + } + return "python3" +} + +// Configured reports whether the configuration names TensorRT-LLM's +// interpreter, which is the only way a node offers it. +func (t *TRTLLM) Configured() bool { return t.cfg.Python != "" } + +// Probe reports the installed TensorRT-LLM's version. +// +// From the package's metadata rather than by importing it: the import loads +// CUDA, MPI and PyTorch and takes longer than a probe may, while what +// placement needs is whether the package is there. Whether it runs is +// engine-check's question, and the replica's. +func (t *TRTLLM) Probe(ctx context.Context) (string, error) { + return versionOfModule(ctx, t.python(), "-c", + "import importlib.metadata as m, importlib.util as u, sys\n"+ + "if u.find_spec('tensorrt_llm') is None: sys.exit(1)\n"+ + "sys.stdout.write(m.version('tensorrt_llm'))") +} + +// HelpCommand is how to ask trtllm-serve for its help: through its module, +// since the command itself is llmgw's launcher. +func (t *TRTLLM) HelpCommand(Command) (string, []string) { + return t.python(), []string{"-m", "tensorrt_llm.commands.serve", "serve", "--help"} +} + +const ( + trtllmKeyEnv = "LLMGW_ENGINE_KEY" + trtllmOptionsPlaceholder = "@llmgw-options" +) + +// trtllmLauncher starts trtllm-serve inside the process, after two things +// trtllm-serve has no flag for. +// +// The key. trtllm-serve checks none, and an engine listens on the node's +// management address, where without one anybody who can reach the port talks +// to the model past the gateway's authentication, quotas and accounting. So +// every FastAPI application the process makes — trtllm-serve's server is one +// — is given a middleware demanding it, before trtllm-serve imports anything. +// /health is left open: it says only up or down, and a supervisor of +// TensorRT-LLM's own may ask it without a key. The key comes in the +// environment and is taken out of it, so the MPI workers and anything else +// started from here never hold it. +// +// The options. The Prometheus endpoint exists only with return_perf_metrics, +// and its queue gauges move only with enable_iter_perf_stats; both are +// settings of TensorRT-LLM's options file, not flags. The file is written to +// an unnamed temporary file and passed by descriptor: nothing is left on disk +// however the server ends. +// +// trtllm-serve can start further HTTP front ends as copies of its own command +// line (--num_serve_frontends); those would not run this, so the flag is +// refused in a profile, and a copy started anyway would be given a program +// path that cannot exist — /dev/null is no directory — and fail rather than +// serve without the key. Click names itself by the path's last element, so +// trtllm-serve's messages still say trtllm-serve. +const trtllmLauncher = `import hmac, os, sys, tempfile +key = os.environ.pop("` + trtllmKeyEnv + `", "") +if key: + import fastapi + want = ("Bearer " + key).encode() + denied = b'{"error":{"message":"a valid API key is required","type":"invalid_request_error","code":"invalid_api_key"}}' + class RequireKey: + def __init__(self, app): + self.app = app + async def __call__(self, scope, receive, send): + if scope["type"] in ("http", "websocket") and scope.get("path") != "/health": + got = b"" + for name, value in scope.get("headers") or (): + if name == b"authorization": + got = value + if not hmac.compare_digest(got, want): + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + await send({"type": "http.response.start", "status": 401, + "headers": [(b"content-type", b"application/json")]}) + await send({"type": "http.response.body", "body": denied}) + return + await self.app(scope, receive, send) + plain_init = fastapi.FastAPI.__init__ + def init_with_key(self, *a, **kw): + plain_init(self, *a, **kw) + self.add_middleware(RequireKey) + fastapi.FastAPI.__init__ = init_with_key +args = sys.argv[1:] +if "` + trtllmOptionsPlaceholder + `" in args: + options = tempfile.TemporaryFile() + options.write(b"return_perf_metrics: true\nenable_iter_perf_stats: true\n") + options.flush() + options.seek(0) + args[args.index("` + trtllmOptionsPlaceholder + `")] = "/dev/fd/%d" % options.fileno() +sys.argv = ["/dev/null/trtllm-serve"] + args +from tensorrt_llm.commands.serve import main +main() +` diff --git a/internal/agent/engines/trtllm_test.go b/internal/agent/engines/trtllm_test.go new file mode 100644 index 0000000..e59bea1 --- /dev/null +++ b/internal/agent/engines/trtllm_test.go @@ -0,0 +1,316 @@ +package engines + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func trtllmSpec() ShardSpec { + s := singleNodeSpec() + s.Profile.Engine = "trtllm" + s.Profile.ServedModelName = "llama-3.1-70b" + return s +} + +func TestTRTLLMRunsTrtllmServeWithTheFlagsItDocuments(t *testing.T) { + s := trtllmSpec() + s.BindAddr = "10.0.0.147" + s.Profile.Args = []string{"--max_batch_size", "64"} + cmd := buildOne(t, &TRTLLM{cfg: TRTLLMConfig{Python: "/opt/trtllm/bin/python"}}, s) + + if cmd.Path != "/opt/trtllm/bin/python" { + t.Errorf("Path=%q, expected the configured interpreter", cmd.Path) + } + if cmd.Args[0] != "-c" || !strings.Contains(cmd.Args[1], "from tensorrt_llm.commands.serve import main") { + t.Fatalf("not started through the launcher: %v", cmd.Args[:2]) + } + rest := cmd.Args[2:] + if rest[0] != "serve" || rest[1] != "/models/llama" { + t.Errorf("expected serve and the model first, got %v", rest[:2]) + } + for flag, want := range map[string]string{ + "--host": "10.0.0.147", "--port": "20000", "--tp_size": "4", + "--served_model_name": "llama-3.1-70b", + "--extra_llm_api_options": trtllmOptionsPlaceholder, + } { + if got := argValue(rest, flag); got != want { + t.Errorf("%s=%q, want %q (args %v)", flag, got, want, rest) + } + } + // One pipeline stage is the default, and saying so would only be one + // more flag for a version to rename. + if slices.Contains(rest, "--pp_size") { + t.Errorf("--pp_size given for a single stage: %v", rest) + } + // The profile's own, last, so a tuning flag is the operator's to set. + if got := rest[len(rest)-2:]; !slices.Equal(got, []string{"--max_batch_size", "64"}) { + t.Errorf("profile args are not last: %v", rest) + } + + s.Profile.TP, s.Profile.PP = 2, 2 + if got := argValue(buildOne(t, &TRTLLM{}, s).Args, "--pp_size"); got != "2" { + t.Errorf("--pp_size=%q for two stages", got) + } + if got := buildOne(t, &TRTLLM{}, s).Path; got != "python3" { + t.Errorf("unconfigured, the interpreter is %q", got) + } +} + +func TestTRTLLMsKeyIsNotOnItsCommandLine(t *testing.T) { + s := trtllmSpec() + s.APIKey = "k1" + cmd := buildOne(t, &TRTLLM{}, s) + for _, a := range cmd.Args { + if strings.Contains(a, "k1") && a != cmd.Args[1] { + t.Errorf("the key is in the arguments: %v", cmd.Args) + } + } + if envMap(cmd.Env)[trtllmKeyEnv] != "k1" { + t.Error("the key is not in the environment") + } +} + +func TestTRTLLMDoesNotReportUsageToNVIDIA(t *testing.T) { + s := trtllmSpec() + s.Profile.Env = map[string]string{"TRTLLM_NO_USAGE_STATS": "0", "TRTLLM_ENABLE_PDL": "1"} + env := envMap(buildOne(t, &TRTLLM{}, s).Env) + if env["TRTLLM_NO_USAGE_STATS"] != "1" || env["DO_NOT_TRACK"] != "1" { + t.Errorf("usage reporting is not off: %q %q", env["TRTLLM_NO_USAGE_STATS"], env["DO_NOT_TRACK"]) + } + // Tuning still reaches it. + if env["TRTLLM_ENABLE_PDL"] != "1" { + t.Error("a profile's TensorRT-LLM tuning was dropped") + } +} + +func TestTRTLLMIsReadFromItsPrometheusEndpoint(t *testing.T) { + // /metrics on trtllm-serve is JSON, and each read drains what it returns. + if got := (&TRTLLM{}).MetricsPath(); got != "/prometheus/metrics" { + t.Errorf("MetricsPath=%q", got) + } + if !(&TRTLLM{}).ServesHTTP(trtllmSpec()) { + t.Error("the one rank of a replica serves its API") + } +} + +func TestTRTLLMRunsAReplicaOnOneNode(t *testing.T) { + p := trtllmSpec().Profile + p.Nodes, p.GPUsPerNode, p.TP = 2, 4, 8 + err := (&TRTLLM{}).Validate(p, nil) + if err == nil || !strings.Contains(err.Error(), "one node") { + t.Errorf("a two-node profile was accepted: %v", err) + } + s := multiNodeSpec(0) + s.Profile.Engine = "trtllm" + if _, err := (&TRTLLM{}).Commands(s); err == nil { + t.Error("a command was built for a shard of a two-node replica") + } +} + +func TestTRTLLMNeedsAmpereOrNewer(t *testing.T) { + p := trtllmSpec().Profile + p.TP = 2 + v100 := []GPUInfo{{Index: 0, Name: "Tesla V100", ComputeCapability: "7.0"}, {Index: 1, Name: "Tesla V100", ComputeCapability: "7.0"}} + if err := (&TRTLLM{}).Validate(p, v100); err == nil { + t.Error("a V100 was accepted") + } + h100 := []GPUInfo{{Index: 0, Name: "H100", ComputeCapability: "9.0"}, {Index: 1, Name: "H100", ComputeCapability: "9.0"}} + if err := (&TRTLLM{}).Validate(p, h100); err != nil { + t.Errorf("two H100s were refused: %v", err) + } +} + +func TestTRTLLMProfilesMayNotOpenWhatTheAgentCloses(t *testing.T) { + for _, args := range [][]string{ + {"--trust_remote_code"}, + {"--num_serve_frontends", "4"}, + {"--grpc"}, + {"--config", "/etc/shadow"}, + {"--extra_llm_api_options=/tmp/x.yaml"}, + {"--served_model_name", "other"}, + {"--middleware", "evil.mw"}, + {"--tp_size", "8"}, + } { + if err := UnsafeArgs("trtllm", args); err == nil { + t.Errorf("%v was allowed", args) + } + } + if err := UnsafeArgs("trtllm", []string{"--max_batch_size", "64", "--set", "kv_cache_config.enable_block_reuse=false", + "--free_gpu_memory_fraction", "0.85"}); err != nil { + t.Errorf("tuning was refused: %v", err) + } +} + +func TestTRTLLMIsOfferedOnlyWhenItsPackageIsThere(t *testing.T) { + there := script(t, "python3", `echo "1.3.0rc29"`) + r := NewRegistry(Config{}) + r.engines = map[string]Engine{"trtllm": &TRTLLM{cfg: TRTLLMConfig{Python: there}}} + if got := r.ProbeConfigured(t.Context()); got["trtllm"] != "1.3.0rc29" { + t.Errorf("expected the version, got %v", got) + } + + // An interpreter without the package, which every machine has. + absent := script(t, "python3", "exit 1") + r.engines = map[string]Engine{"trtllm": &TRTLLM{cfg: TRTLLMConfig{Python: absent}}} + if got := r.ProbeConfigured(t.Context()); len(got) != 0 { + t.Errorf("reported without its package: %v", got) + } + + // Not named, not offered: python3 on PATH is nobody's TensorRT-LLM. + r.engines = map[string]Engine{"trtllm": &TRTLLM{}} + if got := r.ProbeConfigured(t.Context()); len(got) != 0 { + t.Errorf("offered without being configured: %v", got) + } +} + +// fakeTRTLLM is a Python path holding stand-ins for fastapi and +// tensorrt_llm: enough of each for the launcher to do what it does in front +// of the real server, and for the stand-in server to report what it saw. +func fakeTRTLLM(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "fastapi/__init__.py": ` +class FastAPI: + def __init__(self, *a, **kw): + self.user_middleware = [] + def add_middleware(self, cls, **kw): + self.user_middleware.append(cls) +`, + "tensorrt_llm/__init__.py": "", + "tensorrt_llm/commands/__init__.py": "", + "tensorrt_llm/commands/serve.py": ` +import asyncio, json, os, sys +import fastapi + +def main(): + args = sys.argv[1:] + with open(args[args.index("--extra_llm_api_options") + 1]) as f: + options = f.read() + app = fastapi.FastAPI() + + async def server(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + handler = server + for mw in reversed(app.user_middleware): + handler = mw(handler) + + async def ask(path, auth=None): + headers = [(b"authorization", auth.encode())] if auth else [] + sent = [] + async def send(msg): + sent.append(msg) + async def receive(): + return {"type": "http.request"} + await handler({"type": "http", "path": path, "headers": headers}, receive, send) + return sent[0]["status"] + + async def run(): + return { + "health": await ask("/health"), + "anonymous": await ask("/v1/chat/completions"), + "wrong": await ask("/v1/models", "Bearer nope"), + "keyed": await ask("/prometheus/metrics", "Bearer k1"), + } + + out = asyncio.run(run()) + out.update(options=options, argv0=sys.argv[0], args=args, + key_left=os.environ.get("LLMGW_ENGINE_KEY")) + print(json.dumps(out)) +`, + } + for name, body := range files { + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +type launched struct { + Health, Anonymous, Wrong, Keyed int + Options, Argv0 string + Args []string + KeyLeft *string `json:"key_left"` +} + +func launch(t *testing.T, s ShardSpec) launched { + t.Helper() + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("no python3 here to run the launcher with") + } + cmd := buildOne(t, &TRTLLM{cfg: TRTLLMConfig{Python: python}}, s) + c := exec.Command(cmd.Path, cmd.Args...) //nolint:gosec // the command under test + c.Env = append(cmd.Env, "PYTHONPATH="+fakeTRTLLM(t)) + out, err := c.Output() + if err != nil { + var stderr string + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + t.Fatalf("the launcher failed: %v\n%s", err, stderr) + } + var got launched + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("%v: %s", err, out) + } + return got +} + +func TestTheTRTLLMLauncherDemandsTheKey(t *testing.T) { + s := trtllmSpec() + s.APIKey = "k1" + got := launch(t, s) + + if got.Anonymous != 401 || got.Wrong != 401 { + t.Errorf("served without the key: anonymous %d, wrong key %d", got.Anonymous, got.Wrong) + } + if got.Keyed != 200 { + t.Errorf("refused the key: %d", got.Keyed) + } + // Up or down is all /health says, and it is asked without one. + if got.Health != 200 { + t.Errorf("/health answered %d", got.Health) + } + // Taken out of the environment, so nothing the server starts holds it. + if got.KeyLeft != nil { + t.Errorf("the key was left in the environment: %q", *got.KeyLeft) + } + // A copy of this command line, as trtllm-serve's extra front ends are, + // names no program that exists and so cannot serve without the key. + if _, err := os.Stat(got.Argv0); err == nil || filepath.Base(got.Argv0) != "trtllm-serve" { + t.Errorf("argv[0] is %q", got.Argv0) + } +} + +func TestTheTRTLLMLauncherTurnsOnTheMetricsTheRouterReads(t *testing.T) { + got := launch(t, trtllmSpec()) + for _, want := range []string{"return_perf_metrics: true", "enable_iter_perf_stats: true"} { + if !strings.Contains(got.Options, want) { + t.Errorf("the options file lacks %q: %q", want, got.Options) + } + } + path := argValue(got.Args, "--extra_llm_api_options") + if !strings.HasPrefix(path, "/dev/fd/") { + t.Errorf("the options were not passed by descriptor: %q", path) + } + if slices.Contains(got.Args, trtllmOptionsPlaceholder) { + t.Error("the placeholder reached trtllm-serve") + } + // Without a key there is nothing to demand. + if got.Anonymous != 200 { + t.Errorf("with no key set, a request was refused: %d", got.Anonymous) + } +} diff --git a/internal/agent/engines/unsafeenv.go b/internal/agent/engines/unsafeenv.go index 277b8a8..26c8cc8 100644 --- a/internal/agent/engines/unsafeenv.go +++ b/internal/agent/engines/unsafeenv.go @@ -24,6 +24,7 @@ var envFamilies = []string{ "CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_", "VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_", "HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_", + "TRTLLM_", "TLLM_", } // envExact are single variables that are tuning too. @@ -118,6 +119,32 @@ var flagsNever = map[string]map[string]string{ "--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent", "--lora-paths": "loads files", "--download-dir": "writes files", }, + // trtllm-serve spells its flags with underscores, so the shared list's + // hyphenated names do not reach them. + "trtllm": { + "--trust_remote_code": "runs Python the model repository ships", + "--served_model_name": "is set by the agent", + "--config": "reads a file; the agent writes the options file itself", + "--extra_llm_api_options": "reads a file; the agent writes the options file itself", + "--tokenizer": "reads another repository or path", + "--custom_tokenizer": "imports Python", "--post_processor_hook": "imports Python", + "--custom_module_dirs": "imports Python", "--middleware": "imports Python", + "--chat_template": "reads a file", + "--metadata_server_config_file": "reads a file", + "--visual_gen_args": "reads a file", + "--report_addr": "writes a file", + "--otlp_traces_endpoint": "sends request traces elsewhere", + "--disagg_cluster_uri": "joins the server to another cluster", + "--server_role": "makes the server part of another cluster", + "--allow_request_chat_template": "lets a request supply the chat template", + "--grpc": "serves without the key the agent sets", + "--num_serve_frontends": "starts front ends that do not demand the key the agent sets", + "--telemetry": "sends usage to NVIDIA", + "--tensor_parallel_size": "is set by the agent from tp", + "--tp_size": "is set by the agent from tp", + "--pipeline_parallel_size": "is set by the agent from pp", + "--pp_size": "is set by the agent from pp", + }, } // unsafeFlag is why an engine may not be given a flag, or "". diff --git a/internal/agent/mig_test.go b/internal/agent/mig_test.go new file mode 100644 index 0000000..109d3ec --- /dev/null +++ b/internal/agent/mig_test.go @@ -0,0 +1,40 @@ +package agent + +import ( + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" +) + +func TestAShardPlacedOnAMIGInstanceIsLaunchedWithItsUUID(t *testing.T) { + // The control plane sends device 1001: card 0's instance 1, by the number + // placement gives it. Only this node knows the instance's UUID, which is + // the one thing CUDA will open it by. + a := &Agent{} + a.inv.GPUs = []discovery.GPU{ + {Index: 1000, UUID: "MIG-aaa", MIG: &discovery.MIGDevice{ParentIndex: 0, Device: 0, Profile: "3g.40gb"}}, + {Index: 1001, UUID: "MIG-bbb", MIG: &discovery.MIGDevice{ParentIndex: 0, Device: 1, Profile: "3g.40gb"}}, + {Index: 1, UUID: "GPU-whole"}, + } + + spec := engines.ShardSpec{GPUIndices: []int{1001}} + a.localizeSpec(&spec) + if got := engines.NCCLEnv(spec)["CUDA_VISIBLE_DEVICES"]; got != "MIG-bbb" { + t.Errorf("CUDA_VISIBLE_DEVICES=%q, want MIG-bbb", got) + } + + // A whole card is given by index, as it always was. + whole := engines.ShardSpec{GPUIndices: []int{1}} + a.localizeSpec(&whole) + if whole.GPUUUIDs != nil || engines.NCCLEnv(whole)["CUDA_VISIBLE_DEVICES"] != "1" { + t.Errorf("a whole card was given as %v / %q", whole.GPUUUIDs, engines.NCCLEnv(whole)["CUDA_VISIBLE_DEVICES"]) + } + + // A device this node does not have is not guessed at. + gone := engines.ShardSpec{GPUIndices: []int{1002}} + a.localizeSpec(&gone) + if gone.GPUUUIDs != nil { + t.Errorf("an unknown device was named %v", gone.GPUUUIDs) + } +} diff --git a/internal/agent/provision/install.go b/internal/agent/provision/install.go index 740759b..c262d64 100644 --- a/internal/agent/provision/install.go +++ b/internal/agent/provision/install.go @@ -20,7 +20,11 @@ import ( "strings" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" ) // Want is one engine at one version. @@ -251,7 +255,17 @@ func (in *Installer) ready(st Status, b Build, bin, version string) Status { // // Into a scratch directory renamed into place at the end, so a failure halfway // leaves nothing that looks installed. -func (in *Installer) install(ctx context.Context, r Recipe, w Want, b Build, dir string, st *Status) error { +// +// Traced as "install engine", a trace of its own: an install is not part of +// any one start, and runs once per version rather than once per replica. +func (in *Installer) install(ctx context.Context, r Recipe, w Want, b Build, dir string, st *Status) (err error) { + ctx, span := obs.Tracer("gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision").Start(ctx, "install engine", + trace.WithAttributes( + attribute.String("llmgw.engine", w.Engine), + attribute.String("llmgw.engine_version", w.Version), + attribute.String("llmgw.engine_build", w.Variant), + )) + defer func() { obs.End(span, err) }() if b.Python != "" { return in.installPython(ctx, r, w, b, dir, st) } diff --git a/internal/agent/supervisor/supervisor.go b/internal/agent/supervisor/supervisor.go index 2050e7b..e0c365e 100644 --- a/internal/agent/supervisor/supervisor.go +++ b/internal/agent/supervisor/supervisor.go @@ -18,9 +18,19 @@ import ( "syscall" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" ) +// tracerName names the spans the supervisor opens. There is no restart span +// here because the supervisor never restarts an engine on its own: a restart +// is the control plane's decision, traced there, and reaches a node as a stop +// and a start. +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" + // Config configures the supervisor. type Config struct { // WorkDir holds pid files and per-shard scratch space. @@ -195,7 +205,25 @@ var ErrAlreadyRunning = errors.New("shard is already running") // It is idempotent by ID: a repeated StartShard for the same shard is a no-op // rather than a second process, because the control plane retries commands // (plan §5.6). -func (s *Supervisor) Start(ctx context.Context, id string, spec engines.ShardSpec, cmds []engines.Command) (*Shard, error) { +// +// Traced as "launch engine": from the first process to the engine's being +// started, which for a multi-node worker includes waiting for its leader's +// rendezvous point. Loading the model is the readiness wait's, not this. +func (s *Supervisor) Start(ctx context.Context, id string, spec engines.ShardSpec, cmds []engines.Command) (_ *Shard, err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "launch engine", trace.WithAttributes( + attribute.String("llmgw.shard", id), + attribute.String("llmgw.engine", spec.Profile.Engine), + attribute.Int("llmgw.processes", len(cmds)), + )) + defer func() { + if errors.Is(err, ErrAlreadyRunning) { + // A repeated command, which is not this start failing. + span.SetAttributes(attribute.Bool("llmgw.already_running", true)) + span.End() + return + } + obs.End(span, err) + }() if len(cmds) == 0 { return nil, fmt.Errorf("shard %s: no commands to run", id) } @@ -301,6 +329,7 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma sh.pid = c.Process.Pid sh.state = Starting sh.mu.Unlock() + trace.SpanFromContext(ctx).SetAttributes(attribute.Int("llmgw.pid", c.Process.Pid)) if err := s.writeState(sh); err != nil { s.log.Warn("could not write pid file; adoption after a restart will not work", @@ -337,7 +366,13 @@ func (s *Supervisor) spawn(ctx context.Context, sh *Shard, c engines.Command) (* } // waitForPort blocks until addr accepts a connection. -func waitForPort(ctx context.Context, addr string, timeout time.Duration) error { +// +// Traced: a worker's start that took ten minutes because its leader was +// still loading reads as exactly that. +func waitForPort(ctx context.Context, addr string, timeout time.Duration) (err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "wait for rendezvous", + trace.WithAttributes(attribute.String("llmgw.rendezvous", addr))) + defer func() { obs.End(span, err) }() ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -416,7 +451,26 @@ func (s *Supervisor) wait(sh *Shard) { // The timeout comes from the profile because it is model-dependent: loading a // 70B model across two nodes legitimately takes tens of minutes, while a small // model failing to load for ten minutes means something is wrong. -func (s *Supervisor) WaitReady(ctx context.Context, sh *Shard, baseURL, model string, timeout time.Duration) error { +// +// Traced as "wait for engine": the model loading, which is usually most of a +// start. The span says how many times the engine was asked and what it last +// answered, so one that never came up says why. +func (s *Supervisor) WaitReady(ctx context.Context, sh *Shard, baseURL, model string, timeout time.Duration) (err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "wait for engine", trace.WithAttributes( + attribute.String("llmgw.shard", sh.ID), + attribute.String("llmgw.readiness", "health check"), + attribute.String("llmgw.timeout", timeout.String()), + )) + polls := 0 + var answer error + defer func() { + span.SetAttributes(attribute.Int("llmgw.polls", polls)) + if answer != nil && err != nil { + span.SetAttributes(attribute.String("llmgw.last_answer", answer.Error())) + } + obs.End(span, err) + }() + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -432,7 +486,8 @@ func (s *Supervisor) WaitReady(ctx context.Context, sh *Shard, baseURL, model st last := strings.Join(sh.Logs(10), "\n") return fmt.Errorf("shard %s not ready within %v; last output:\n%s", sh.ID, timeout, last) case <-t.C: - if err := s.health.Ready(ctx, baseURL, model, sh.Spec.APIKey); err != nil { + polls++ + if answer = s.health.Ready(ctx, baseURL, model, sh.Spec.APIKey); answer != nil { continue } sh.setState(Ready, nil) @@ -449,7 +504,13 @@ func (s *Supervisor) WaitReady(ctx context.Context, sh *Shard, baseURL, model st // leader answering, which only happens once every worker has joined. Polling a // worker's port instead would time out and fail a replica that is coming up // perfectly well. -func (s *Supervisor) WaitAlive(ctx context.Context, sh *Shard) error { +func (s *Supervisor) WaitAlive(ctx context.Context, sh *Shard) (err error) { + _, span := obs.Tracer(tracerName).Start(ctx, "wait for engine", trace.WithAttributes( + attribute.String("llmgw.shard", sh.ID), + attribute.String("llmgw.readiness", "stays alive"), + attribute.String("llmgw.timeout", s.cfg.AliveGrace.String()), + )) + defer func() { obs.End(span, err) }() sh.setState(Loading, nil) select { @@ -483,7 +544,14 @@ func (s *Supervisor) Drain(id string) error { // Stop terminates a shard: SIGTERM to the whole group, then SIGKILL after // StopTimeout. -func (s *Supervisor) Stop(ctx context.Context, id string) error { +// +// Traced as "stop engine", saying whether it had to be killed: an engine +// that ignores SIGTERM for a minute is a minute its replacement waits for +// the GPU. +func (s *Supervisor) Stop(ctx context.Context, id string) (err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "stop engine", + trace.WithAttributes(attribute.String("llmgw.shard", id))) + defer func() { obs.End(span, err) }() sh, err := s.Get(id) if err != nil { return err @@ -527,6 +595,7 @@ func (s *Supervisor) Stop(ctx context.Context, id string) error { // force kills the group outright. exec.Cmd's cancel does this for shards we // started; an adopted shard has none, so signal its group directly. force := func() { + span.SetAttributes(attribute.Bool("llmgw.killed", true)) if sh.cancel != nil { sh.cancel() return diff --git a/internal/agent/trace_test.go b/internal/agent/trace_test.go new file mode 100644 index 0000000..5e4e8af --- /dev/null +++ b/internal/agent/trace_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "testing" + "time" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs/obstest" +) + +func TestAStartOnTheNodeIsInTheControlPlanesTrace(t *testing.T) { + rec := obstest.Record(t) + h := start(t, func(c *Config) { + c.Engines.VLLM.Bin = servingEngine(t) + }) + + // The control plane's side, as the reconciler sends it: the command + // dispatched under a span of its own, over the real stream. + ctx, sent := obs.Tracer("test").Start(t.Context(), "send start", + trace.WithSpanKind(trace.SpanKindProducer)) + if err := h.svc.StartShard(ctx, shardCommand(t, "shard-a")); err != nil { + t.Fatal(err) + } + sent.End() + h.rec.await(t, 30*time.Second, func(s status) bool { + return s.shardID == "shard-a" && s.state == supervisor.Ready + }) + + // The node's part ends just after it reports the shard ready. + tid := sent.SpanContext().TraceID() + node := rec.Await(t, tid, "start shard", 5*time.Second) + if node.Parent().SpanID() != sent.SpanContext().SpanID() { + t.Fatal("the node's start is not beneath the control plane's command") + } + if node.SpanKind() != trace.SpanKindConsumer { + t.Errorf("the node's start is a %v span", node.SpanKind()) + } + if a := obstest.Attrs(node); a["llmgw.node"] != "n1" || a["llmgw.replica"] != "rep-1" || + a["llmgw.model"] != "test-model" || a["llmgw.engine"] != "vllm" { + t.Errorf("the node's start does not say what it started: %v", a) + } + if node.Status().Code == codes.Error { + t.Errorf("a start that served is %v", node.Status()) + } + for _, name := range []string{"fetch weights", "launch engine", "wait for engine"} { + if !obstest.ChildOf(rec.One(t, tid, name), node) { + t.Errorf("%s is not beneath the node's start", name) + } + } + if a := obstest.Attrs(rec.One(t, tid, "fetch weights")); a["llmgw.weights.outcome"] != "local" { + t.Errorf("the weights do not say how they were found: %v", a) + } + if a := obstest.Attrs(rec.One(t, tid, "wait for engine")); a["llmgw.polls"] == "" || a["llmgw.polls"] == "0" { + t.Errorf("the readiness wait does not say how often it asked: %v", a) + } + + // A stop joins the trace it was sent in, too. + sctx, stop := obs.Tracer("test").Start(t.Context(), "stop replica") + if err := h.svc.StopShard(sctx, "n1", "shard-a"); err != nil { + t.Fatal(err) + } + stop.End() + h.rec.await(t, 15*time.Second, func(s status) bool { + return s.shardID == "shard-a" && s.state == supervisor.Stopped + }) + stid := stop.SpanContext().TraceID() + nodeStop := rec.Await(t, stid, "stop shard", 5*time.Second) + if !obstest.ChildOf(nodeStop, rec.One(t, stid, "stop replica")) { + t.Error("the node's stop is not beneath the control plane's") + } + if !obstest.ChildOf(rec.One(t, stid, "stop engine"), nodeStop) { + t.Error("stopping the engine is not beneath the node's stop") + } +} + +func TestAnAdoptionIsTracedAndItsStopsAreInIt(t *testing.T) { + rec := obstest.Record(t) + h := start(t, func(c *Config) { + c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`) + }) + if err := h.svc.StartShard(t.Context(), shardCommand(t, "shard-orphaned")); err != nil { + t.Fatal(err) + } + h.rec.await(t, 15*time.Second, func(s status) bool { + return s.shardID == "shard-orphaned" && s.state == supervisor.Starting + }) + + // The control plane, restarted, claims nothing the node is running. + h.rec.markUnclaimed("shard-orphaned") + h.ln.drop() + h.rec.await(t, 20*time.Second, func(s status) bool { + return s.shardID == "shard-orphaned" && s.state == supervisor.Stopped + }) + + adopt := rec.Named("adopt") + if len(adopt) != 1 { + t.Fatalf("%d adoptions traced", len(adopt)) + } + if a := obstest.Attrs(adopt[0]); a["llmgw.node"] != "n1" || a["llmgw.shards"] != "1" || a["llmgw.unclaimed"] != "1" { + t.Errorf("the adoption does not say what it found: %v", a) + } + stop := rec.Await(t, adopt[0].SpanContext().TraceID(), "stop shard", 5*time.Second) + if !obstest.ChildOf(stop, adopt[0]) { + t.Error("stopping the unclaimed shard is not beneath the adoption") + } +} + +func TestAStartThatFailsOnTheNodeSaysWhyInTheTrace(t *testing.T) { + rec := obstest.Record(t) + h := start(t, func(c *Config) { + c.Engines.VLLM.Bin = fakeEngineScript(t, `echo "CUDA out of memory" >&2; exit 1`) + }) + + ctx, sent := obs.Tracer("test").Start(t.Context(), "send start") + if err := h.svc.StartShard(ctx, shardCommand(t, "shard-b")); err != nil { + t.Fatal(err) + } + sent.End() + h.rec.await(t, 30*time.Second, func(s status) bool { + return s.shardID == "shard-b" && s.state == supervisor.Failed + }) + + tid := sent.SpanContext().TraceID() + node := rec.Await(t, tid, "start shard", 5*time.Second) + if node.Status().Code != codes.Error { + t.Errorf("a start that failed is %v", node.Status()) + } + if wait := rec.One(t, tid, "wait for engine"); wait.Status().Code != codes.Error { + t.Errorf("the readiness wait of an engine that exited is %v", wait.Status()) + } +} diff --git a/internal/agent/weights/fetch.go b/internal/agent/weights/fetch.go index 8813e34..63e4891 100644 --- a/internal/agent/weights/fetch.go +++ b/internal/agent/weights/fetch.go @@ -26,6 +26,11 @@ import ( "strings" "sync" "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" ) // Progress reports how a fetch is going, for anything that wants to say so. @@ -80,13 +85,48 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa // local disk, a Lustre mount or an image exported over the fabric, and a // downloader that second-guessed that would copy a hundred gigabytes that were // already present. -func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) { +// +// Traced as "fetch weights", with what it came to — the file was local, +// already cached, downloaded, or downloaded by another shard this one waited +// for — since that is most of the difference between a replica that starts +// in a minute and one that takes an hour. +func (f *Fetcher) Ensure(ctx context.Context, source string) (path string, err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "fetch weights", + trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source)))) + defer func() { + span.SetAttributes(attribute.String("llmgw.weights.path", path)) + obs.End(span, err) + }() + return f.ensure(ctx, source) +} + +// tracerName names the spans this package opens. +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights" + +// outcome says on the current span how a source was made present. +func outcome(ctx context.Context, how string) { + trace.SpanFromContext(ctx).SetAttributes(attribute.String("llmgw.weights.outcome", how)) +} + +// redact is a source fit for a trace: a URL without its credentials or +// query, which is where a token would be. +func redact(source string) string { + u, err := url.Parse(source) + if err != nil || u.Scheme == "" || u.Host == "" { + return source + } + u.User, u.RawQuery, u.Fragment = nil, "", "" + return u.String() +} + +func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) { if source == "" { return "", fmt.Errorf("no source given") } // A version rather than a file. Everything below assumes one URL is one // file, and a set has to be handled before any of it. if IsSet(source) { + outcome(ctx, "set") return f.ensureSet(ctx, source) } if !isRemote(source) { @@ -95,9 +135,11 @@ func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) { // was never meant to be one. vLLM and SGLang resolve those // themselves, and doing it for them would be both a duplicate // downloader and a change in documented behaviour. + outcome(ctx, "left to the engine") return source, nil } if _, err := os.Stat(source); err == nil { + outcome(ctx, "local") return source, nil } return "", fmt.Errorf("%w: %s", ErrNotLocal, source) @@ -108,6 +150,7 @@ func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) { return "", err } if _, err := os.Stat(target); err == nil { + outcome(ctx, "cached") return target, nil } @@ -117,6 +160,7 @@ func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) { } if call, running := f.inflight[source]; running { f.mu.Unlock() + outcome(ctx, "fetched by another shard") select { case <-call.done: return call.path, call.err @@ -128,6 +172,7 @@ func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) { f.inflight[source] = call f.mu.Unlock() + outcome(ctx, "downloaded") call.path, call.err = f.fetch(ctx, source, target) close(call.done) @@ -147,7 +192,15 @@ func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, err // The two differ for a set: the shards waiting on it asked for the version, not // for the fourth file in it, and progress reported under the file's own URL // reaches nobody. What they see is one number climbing across the whole set. -func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (string, error) { +// +// Each file is a "download" span, with how many bytes it took and where a +// resumed one started from. +func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (_ string, err error) { + ctx, span := obs.Tracer(tracerName).Start(ctx, "download", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source)))) + defer func() { obs.End(span, err) }() + url, err := resolveURL(source) if err != nil { return "", err @@ -208,6 +261,10 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) total := existing + resp.ContentLength f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total}) written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total) + span.SetAttributes( + attribute.Int64("llmgw.weights.resumed_from", existing), + attribute.Int64("llmgw.weights.bytes", written), + ) closeErr := out.Close() if err != nil { // The part file is kept on purpose, so the next attempt resumes. diff --git a/internal/agent/weights/trace_test.go b/internal/agent/weights/trace_test.go new file mode 100644 index 0000000..0fc5242 --- /dev/null +++ b/internal/agent/weights/trace_test.go @@ -0,0 +1,43 @@ +package weights + +import ( + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/obs/obstest" +) + +func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) { + rec := obstest.Record(t) + srv := serveBytes(t, []byte("forty gigabytes, give or take"), nil) + f := &Fetcher{CacheDir: t.TempDir()} + src := strings.Replace(srv.URL, "http://", "http://user:secret@", 1) + "/m/model.gguf?token=hunter2" + + if _, err := f.Ensure(t.Context(), src); err != nil { + t.Fatal(err) + } + fetch := rec.One(t, [16]byte{}, "fetch weights") + a := obstest.Attrs(fetch) + if a["llmgw.weights.outcome"] != "downloaded" { + t.Errorf("the first fetch says %v", a) + } + if strings.Contains(a["llmgw.weights.source"], "secret") || strings.Contains(a["llmgw.weights.source"], "hunter2") { + t.Errorf("the trace holds the source's credentials: %s", a["llmgw.weights.source"]) + } + dl := rec.One(t, fetch.SpanContext().TraceID(), "download") + if !obstest.ChildOf(dl, fetch) || obstest.Attrs(dl)["llmgw.weights.bytes"] != "29" { + t.Errorf("the download does not say how much it moved: %v", obstest.Attrs(dl)) + } + + // The second start on this node finds it here. + if _, err := f.Ensure(t.Context(), src); err != nil { + t.Fatal(err) + } + again := rec.Named("fetch weights") + if len(again) != 2 || obstest.Attrs(again[1])["llmgw.weights.outcome"] != "cached" { + t.Errorf("the second fetch says %v", obstest.Attrs(again[len(again)-1])) + } + if len(rec.Named("download")) != 1 { + t.Error("a cached file was downloaded again") + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 1270003..e51a732 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -120,8 +120,14 @@ func API(sink Sink, actor func(*http.Request) string, log *slog.Logger, next htt } var body []byte if r.Body != nil { + // The first 64 KiB are what is recorded; the handler is given the + // whole body, or a larger one — a quality suite sent whole — would + // reach it cut off. body, _ = io.ReadAll(io.LimitReader(r.Body, 64<<10)) - r.Body = io.NopCloser(bytes.NewReader(body)) + r.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(body), r.Body), r.Body} } sw := &statusWriter{ResponseWriter: w} next.ServeHTTP(sw, r) diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 8286806..f0687bf 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -2,6 +2,7 @@ package audit import ( "context" + "io" "net/http" "net/http/httptest" "net/url" @@ -58,3 +59,16 @@ func TestEveryChangeToTheAdminAPIIsRecorded(t *testing.T) { t.Errorf("%+v", e) } } + +func TestABodyLargerThanWhatIsRecordedReachesTheHandlerWhole(t *testing.T) { + big := `{"content":"` + strings.Repeat("x", 200<<10) + `"}` + var got int + h := API(&memSink{}, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + got = len(b) + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/v1/models/m/eval", strings.NewReader(big))) + if got != len(big) { + t.Errorf("the handler was given %d of %d bytes", got, len(big)) + } +} diff --git a/internal/bench/eval/run.go b/internal/bench/eval/run.go new file mode 100644 index 0000000..c02455e --- /dev/null +++ b/internal/bench/eval/run.go @@ -0,0 +1,260 @@ +package eval + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// Caller sends a chat completion to a model as the gateway does for itself: +// the router's Replay, past admission, accounting and the guardrails — a +// suite measures the weights, and a tenant's quota or a guardrail's refusal +// would measure something else. It answers the status, the body, and how +// long it took. +type Caller func(ctx context.Context, path, model string, body []byte) (int, []byte, time.Duration, error) + +// Options are how a suite is run. +type Options struct { + // Model is the catalog model asked. + Model string + // Concurrency is how many cases are asked at once; 4 unless given. + Concurrency int + // Judge grades the cases scored by a rubric, instead of the suite's. + Judge string + // MinScore is the score to pass at, instead of the suite's threshold. + MinScore float64 + // CaseTimeout bounds one case, its judging included; five minutes + // unless given, which a model loading on demand needs. + CaseTimeout time.Duration + // Progress is told as each case finishes. + Progress func(done, total int) +} + +// PassAt is the score a case passes at: a judge's 0.5 is a coin toss, and +// counts as passed so a lenient rubric is not a hidden zero. +const PassAt = 0.5 + +// keptAnswer is how much of an answer is kept with its result. +const keptAnswer = 2000 + +// Evaluate asks a model every case of a suite and scores the answers. A +// case that got no answer scores 0 with its error; a suite that cannot be +// run at all — a rubric and no judge to read it — is an error before +// anything is asked. +func Evaluate(ctx context.Context, call Caller, s Suite, o Options) (store.EvalRun, error) { + if o.Model == "" { + return store.EvalRun{}, errors.New("no model to ask") + } + judge := o.Judge + if judge == "" { + judge = s.Judge + } + for _, c := range s.Cases { + if c.Judge != "" && c.JudgeModel == "" && judge == "" { + return store.EvalRun{}, fmt.Errorf("case %s is graded by a judge and the suite names none: "+ + "give one with --judge MODEL, or judge: in the suite", c.ID) + } + } + threshold := s.Threshold + if o.MinScore > 0 { + threshold = o.MinScore + } + if threshold <= 0 { + threshold = DefaultThreshold + } + workers := o.Concurrency + if workers <= 0 { + workers = 4 + } + timeout := o.CaseTimeout + if timeout <= 0 { + timeout = 5 * time.Minute + } + + run := store.EvalRun{Model: o.Model, Suite: s.Name, SuiteHash: s.Hash, At: time.Now().UTC(), + Threshold: threshold, Cases: len(s.Cases), Results: make([]store.EvalCase, len(s.Cases))} + if s.NeedsJudge() { + run.Judge = judge + } + todo := make(chan int) + var wg sync.WaitGroup + var mu sync.Mutex + done := 0 + for range min(workers, len(s.Cases)) { + wg.Go(func() { + for i := range todo { + cctx, cancel := context.WithTimeout(ctx, timeout) + res := askOne(cctx, call, s, &s.Cases[i], o.Model, judge) + cancel() + mu.Lock() + run.Results[i] = res + done++ + if o.Progress != nil { + o.Progress(done, len(s.Cases)) + } + mu.Unlock() + } + }) + } + for i := range s.Cases { + select { + case todo <- i: + case <-ctx.Done(): + } + if ctx.Err() != nil { + break + } + } + close(todo) + wg.Wait() + run.TookMs = time.Since(run.At).Milliseconds() + + var sum, weights float64 + firstErr := "" + for i, r := range run.Results { + if r.ID == "" { + // Not asked: the run was stopped first. + r = store.EvalCase{ID: s.Cases[i].ID, Kind: s.Cases[i].Kind(), Weight: s.Cases[i].Weight, + Error: "not asked: the run was stopped"} + run.Results[i] = r + } + sum += r.Weight * r.Score + weights += r.Weight + if !r.Passed { + run.Below++ + } + if r.Error != "" { + run.Errors++ + if firstErr == "" { + firstErr = r.Error + } + } + } + if weights > 0 { + run.Score = sum / weights + } + run.Passed = run.Score >= threshold + if run.Errors == run.Cases { + run.Passed = false + run.Error = "no case was answered: " + firstErr + } + return run, nil +} + +// askedOf is the conversation a case sends. +func askedOf(c *Case, suiteSystem string) []Message { + var out []Message + system := c.System + if system == "" { + system = suiteSystem + } + if system != "" && (len(c.Messages) == 0 || c.Messages[0].Role != "system") { + out = append(out, Message{Role: "system", Content: system}) + } + if len(c.Messages) > 0 { + return append(out, c.Messages...) + } + return append(out, Message{Role: "user", Content: c.Prompt}) +} + +// askOne asks one case and scores its answer. +func askOne(ctx context.Context, call Caller, s Suite, c *Case, model, judge string) store.EvalCase { + res := store.EvalCase{ID: c.ID, Kind: c.Kind(), Weight: c.Weight} + start := time.Now() + defer func() { res.TookMs = time.Since(start).Milliseconds() }() + + maxTokens := c.MaxTokens + if maxTokens <= 0 { + maxTokens = s.MaxTokens + } + if maxTokens <= 0 { + maxTokens = defaultMaxTokens + } + temp := 0.0 + if c.Temperature != nil { + temp = *c.Temperature + } + answer, finish, err := chat(ctx, call, model, askedOf(c, s.System), maxTokens, temp) + if err != nil { + res.Error = err.Error() + return res + } + answer = cleanAnswer(answer) + res.Answer = answer + if len(res.Answer) > keptAnswer { + res.Answer = res.Answer[:keptAnswer] + } + + var v verdict + if c.Judge != "" { + jm := c.JudgeModel + if jm == "" { + jm = judge + } + reply, _, err := chat(ctx, call, jm, judgeRequest(c, answer), 256, 0) + if err != nil { + res.Error = "the judge " + jm + ": " + err.Error() + return res + } + if v, err = readJudgement(reply); err != nil { + res.Error = err.Error() + return res + } + } else { + v = scoreRule(c, answer) + } + res.Score, res.Detail = v.score, v.detail + res.Passed = res.Score >= PassAt + if finish == "length" && !res.Passed { + res.Detail += fmt.Sprintf("; the answer was cut off at %d tokens", maxTokens) + } + if answer == "" && !res.Passed { + res.Detail += "; it answered nothing" + } + return res +} + +// chat asks a model one conversation and says what it answered and why it +// stopped. +func chat(ctx context.Context, call Caller, model string, msgs []Message, maxTokens int, temp float64) (string, string, error) { + body, err := json.Marshal(map[string]any{"model": model, "messages": msgs, "max_tokens": maxTokens, + "temperature": temp, "stream": false}) + if err != nil { + return "", "", err + } + status, out, _, err := call(ctx, "/v1/chat/completions", model, body) + if err != nil { + return "", "", err + } + if status != http.StatusOK { + var e struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + msg := strings.TrimSpace(string(out)) + if json.Unmarshal(out, &e) == nil && e.Error.Message != "" { + msg = e.Error.Message + } + return "", "", fmt.Errorf("%s answered %d: %s", model, status, clip(msg, 200)) + } + var resp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + } + if err := json.Unmarshal(out, &resp); err != nil || len(resp.Choices) == 0 { + return "", "", fmt.Errorf("%s's answer is not a chat completion: %s", model, clip(string(out), 120)) + } + return resp.Choices[0].Message.Content, resp.Choices[0].FinishReason, nil +} diff --git a/internal/bench/eval/run_test.go b/internal/bench/eval/run_test.go new file mode 100644 index 0000000..be5dc18 --- /dev/null +++ b/internal/bench/eval/run_test.go @@ -0,0 +1,292 @@ +package eval + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/fstest" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +const arith = ` +name: arith +threshold: 0.75 +judge: grader +cases: + - id: add + prompt: "What is 17 + 25? Answer with the number only." + number: 42 + - id: mul + prompt: "What is 6 * 7?" + number: 42 + weight: 2 + - id: capital + prompt: "What is the capital of France?" + contains: Paris + - id: polite + prompt: "Say hello." + judge: "A greeting of any kind passes." +` + +// fakeModel answers by the prompt, and as a judge by what it was asked to +// grade; it counts what it was asked. +type fakeModel struct { + mu sync.Mutex + answers map[string]string + asked []string + inFlight, most atomic.Int32 +} + +func (f *fakeModel) call(_ context.Context, path, model string, body []byte) (int, []byte, time.Duration, error) { + n := f.inFlight.Add(1) + defer f.inFlight.Add(-1) + for { + m := f.most.Load() + if n <= m || f.most.CompareAndSwap(m, n) { + break + } + } + time.Sleep(5 * time.Millisecond) + var req struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + } + if err := json.Unmarshal(body, &req); err != nil || path != "/v1/chat/completions" || req.Model != model { + return 400, []byte(`{"error":{"message":"bad request"}}`), 0, nil + } + f.mu.Lock() + f.asked = append(f.asked, model) + f.mu.Unlock() + last := req.Messages[len(req.Messages)-1].Content + answer := "I do not know" + switch { + case model == "grader": + answer = `{"score": 0, "reason": "no greeting"}` + if strings.Contains(last, "Answer:\nHello") { + answer = `{"score": 1, "reason": "a greeting"}` + } + case model == "missing": + return 404, []byte(`{"error":{"message":"the model \"missing\" does not exist"}}`), 0, nil + default: + for k, v := range f.answers { + if strings.Contains(last, k) { + answer = v + } + } + } + out, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{ + "message": map[string]any{"content": answer}, "finish_reason": "stop"}}}) + return 200, out, 0, nil +} + +func TestASuiteIsAskedAndScoredByWeight(t *testing.T) { + s, err := Parse("arith.yaml", []byte(arith)) + if err != nil { + t.Fatal(err) + } + f := &fakeModel{answers: map[string]string{"17 + 25": "carry42", "6 * 7": "41", + "France": "Paris.", "hello": "Hello there!"}} + var progress atomic.Int32 + run, err := Evaluate(context.Background(), f.call, s, Options{Model: "qwen", Concurrency: 2, + Progress: func(done, total int) { progress.Store(int32(done)) }}) + if err != nil { + t.Fatal(err) + } + // add 1, mul 0 at weight 2, capital 1, polite 1: 3 of 5. + if run.Score != 0.6 || run.Passed || run.Threshold != 0.75 || run.Below != 1 || run.Cases != 4 || + run.Suite != "arith" || run.Judge != "grader" || len(run.SuiteHash) != 64 { + t.Fatalf("%+v", run) + } + byID := map[string]store.EvalCase{} + for _, r := range run.Results { + byID[r.ID] = r + } + if r := byID["add"]; !r.Passed || r.Answer != "42" || r.Kind != "number" { + t.Errorf("add: %+v", r) + } + if r := byID["mul"]; r.Passed || !strings.Contains(r.Detail, "read 41, wanted 42") || r.Weight != 2 { + t.Errorf("mul: %+v", r) + } + if r := byID["polite"]; !r.Passed || !strings.Contains(r.Detail, "the judge: a greeting") { + t.Errorf("polite: %+v", r) + } + if progress.Load() != 4 || f.most.Load() > 2 { + t.Errorf("progress %d, %d at once with a concurrency of 2", progress.Load(), f.most.Load()) + } + + // A lower bar passes the same answers. + run, _ = Evaluate(context.Background(), f.call, s, Options{Model: "qwen", MinScore: 0.5}) + if !run.Passed || run.Threshold != 0.5 { + t.Errorf("at 0.5: %+v", run) + } +} + +func TestAModelThatAnswersNothingFailsWithWhy(t *testing.T) { + s, _ := Parse("arith.yaml", []byte(arith)) + run, err := Evaluate(context.Background(), (&fakeModel{}).call, s, Options{Model: "missing"}) + if err != nil { + t.Fatal(err) + } + if run.Passed || run.Errors != 4 || !strings.Contains(run.Error, `"missing" does not exist`) { + t.Fatalf("%+v", run) + } +} + +func TestARubricWithNoJudgeIsRefusedBeforeAnythingIsAsked(t *testing.T) { + s, _ := Parse("arith.yaml", []byte(strings.Replace(arith, "judge: grader\n", "", 1))) + f := &fakeModel{} + if _, err := Evaluate(context.Background(), f.call, s, Options{Model: "qwen"}); err == nil || + !strings.Contains(err.Error(), "--judge") { + t.Fatalf("%v", err) + } + if len(f.asked) != 0 { + t.Errorf("asked %v first", f.asked) + } +} + +func TestASuiteIsReadFromJSONLinesAsFromYAML(t *testing.T) { + jsonl := `{"name": "fmt", "threshold": 0.5} +{"id": "a", "prompt": "Give JSON", "json_schema": {"type": "object", "required": ["ok"]}} +{"prompt": "Say yes", "regex": "^yes", "ignore_case": true, "weight": 3} +` + s, err := Parse("format.jsonl", []byte(jsonl)) + if err != nil { + t.Fatal(err) + } + if s.Name != "fmt" || s.Threshold != 0.5 || len(s.Cases) != 2 || s.Cases[1].ID != "case-2" || + s.Cases[1].Weight != 3 || s.Cases[0].schema == nil || s.Cases[1].re == nil { + t.Fatalf("%+v", s) + } + // Named by its file when it names nothing, and 0.8 to pass. + s, err = Parse("dir/plain.jsonl", []byte(`{"prompt": "q", "exact": "a"}`)) + if err != nil || s.Name != "plain" || s.Threshold != DefaultThreshold { + t.Fatalf("%+v %v", s, err) + } +} + +func TestASuiteThatCannotBeScoredIsRefusedSayingWhy(t *testing.T) { + for _, tc := range []struct{ suite, why string }{ + {"cases: []", "no cases"}, + {"cases:\n - prompt: q", "no way to score it"}, + {"cases:\n - prompt: q\n exact: a\n regex: b", "exact and regex"}, + {"cases:\n - exact: a", "nothing to ask"}, + {"cases:\n - prompt: q\n regex: '('", "the regex"}, + {"cases:\n - id: x\n prompt: q\n exact: a\n - id: x\n prompt: q\n exact: a", "two cases are called x"}, + {"threshold: 2\ncases:\n - prompt: q\n exact: a", "not 0 to 1"}, + {"cases:\n - prompt: q\n exactly: a", "exactly"}, + {"cases:\n - messages: [{role: robot, content: hi}]\n exact: a", "role"}, + } { + if _, err := Parse("s.yaml", []byte(tc.suite)); err == nil || !strings.Contains(err.Error(), tc.why) { + t.Errorf("%q: %v, wanted it to say %q", tc.suite, err, tc.why) + } + } +} + +type memStore struct { + mu sync.Mutex + runs []store.EvalRun +} + +func (m *memStore) PutEvalRun(_ context.Context, r store.EvalRun) error { + m.mu.Lock() + defer m.mu.Unlock() + m.runs = append(m.runs, r) + return nil +} +func (m *memStore) EvalRuns(_ context.Context, model string, _ int) ([]store.EvalRun, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []store.EvalRun + for i := len(m.runs) - 1; i >= 0; i-- { + if r := m.runs[i]; model == "" || r.Model == model || r.Target == model { + r.Results = nil + out = append(out, r) + } + } + return out, nil +} +func (m *memStore) EvalRunByID(_ context.Context, id string) (store.EvalRun, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, r := range m.runs { + if r.ID == id { + return r, nil + } + } + return store.EvalRun{}, store.ErrNotFound +} + +func TestTheControlPlaneRunsSuitesByNameAndFilesACanarysRunUnderItsModel(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "arith.yaml"), []byte(arith), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "broken.yaml"), []byte("cases: [}"), 0o600); err != nil { + t.Fatal(err) + } + builtin := fstest.MapFS{ + "arith.yaml": {Data: []byte("cases:\n - prompt: shadowed\n exact: x\n")}, + "json-format.yaml": {Data: []byte("description: JSON\ncases:\n - prompt: q\n json_schema: {type: object}\n")}, + } + f := &fakeModel{answers: map[string]string{"17 + 25": "42", "6 * 7": "42", "France": "Paris", "hello": "Hello"}} + st := &memStore{} + svc := NewService(Deps{Call: f.call, Store: st, Dir: dir, Builtin: builtin, Log: slog.New(slog.DiscardHandler), + Origin: func(m string) (string, string) { return "o/r", "rev-of-" + m }, + CanaryOf: func(n string) string { + if parent, ok := strings.CutSuffix(n, "-canary"); ok { + return parent + } + return "" + }, + }) + + suites := svc.Suites() + if len(suites) != 3 || suites[0].Name != "arith" || suites[0].From != filepath.Join(dir, "arith.yaml") || + suites[0].Cases != 4 || !suites[0].Judge || suites[1].Error == "" || suites[2].From != "built in" { + t.Fatalf("%+v", suites) + } + if _, err := svc.Load(Request{Suite: "../etc/passwd"}); err == nil { + t.Error("a path outside the eval directory was read") + } + + live, err := svc.Start("qwen-canary", Request{Suite: "arith"}, "tester") + if err != nil { + t.Fatal(err) + } + if live.State != Running || live.Model != "qwen" || live.Target != "qwen-canary" || live.Cases != 4 { + t.Fatalf("%+v", live) + } + deadline := time.Now().Add(5 * time.Second) + for live.State == Running && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + live, _ = svc.Get(context.Background(), live.ID) + } + if live.State != Done || live.Score != 1 || !live.Passed || live.Revision != "rev-of-qwen-canary" || live.By != "tester" { + t.Fatalf("%+v", live) + } + if kept, err := st.EvalRunByID(context.Background(), live.ID); err != nil || len(kept.Results) != 4 { + t.Fatalf("kept %+v %v", kept, err) + } + + // A gate waits for its run, and files it with the job. + run, err := svc.Run(context.Background(), "qwen", Request{Suite: "json-format"}, "tester", "update-1") + if err != nil || run.Job != "update-1" || run.Passed || run.Target != "" { + t.Fatalf("%+v %v", run, err) + } + hist, err := svc.History(context.Background(), "qwen", 10) + if err != nil || len(hist) != 2 || hist[0].ID != run.ID || hist[1].ID != live.ID { + t.Fatalf("%+v %v", hist, err) + } + if _, err := svc.Get(context.Background(), "eval-nope"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("%v", err) + } +} diff --git a/internal/bench/eval/schema.go b/internal/bench/eval/schema.go new file mode 100644 index 0000000..ff7cbf7 --- /dev/null +++ b/internal/bench/eval/schema.go @@ -0,0 +1,338 @@ +package eval + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "regexp" + "slices" + "sort" + "strings" + "unicode/utf8" +) + +// A JSON Schema validator for what a suite asks of an answer's shape: types, +// properties, required ones, items, enums, bounds and patterns. Not the whole +// standard — no $ref, no oneOf — and a schema using what is not here is +// refused when the suite is read rather than passed by an answer it never +// checked. + +type schema struct { + types []string + properties map[string]*schema + required []string + additional *schema // nil: anything; noneAllowed: nothing + items *schema + enum []any + constant any + hasConst bool + minimum, maximum *float64 + exclMin, exclMax *float64 + minLength, maxLength *int + minItems, maxItems *int + pattern *regexp.Regexp + anyOf, allOf []*schema + never bool +} + +// noneAllowed is additionalProperties: false. +var noneAllowed = &schema{never: true} + +// annotations are keywords that describe rather than constrain. +var annotations = map[string]bool{"$schema": true, "$id": true, "title": true, "description": true, + "default": true, "examples": true, "format": true, "$comment": true} + +func compileSchema(v any) (*schema, error) { + // Through JSON, so a schema written in YAML and one in JSON are the same + // thing by the time they are read: numbers as float64, maps as map[string]any. + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var raw any + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + return compile(raw, "") +} + +func compile(raw any, at string) (*schema, error) { + if b, ok := raw.(bool); ok { + if b { + return &schema{}, nil + } + return noneAllowed, nil + } + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s is not a schema object", where(at)) + } + s := &schema{} + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := m[k] + var err error + switch k { + case "type": + switch t := v.(type) { + case string: + s.types = []string{t} + case []any: + for _, x := range t { + if str, ok := x.(string); ok { + s.types = append(s.types, str) + } + } + } + for _, t := range s.types { + if !slices.Contains([]string{"object", "array", "string", "number", "integer", "boolean", "null"}, t) { + return nil, fmt.Errorf("%s: %q is not a JSON type", where(at), t) + } + } + case "properties": + props, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: properties is not an object", where(at)) + } + s.properties = map[string]*schema{} + for name, p := range props { + if s.properties[name], err = compile(p, at+"."+name); err != nil { + return nil, err + } + } + case "required": + list, _ := v.([]any) + for _, x := range list { + if str, ok := x.(string); ok { + s.required = append(s.required, str) + } + } + case "additionalProperties": + if s.additional, err = compile(v, at+".*"); err != nil { + return nil, err + } + case "items": + if s.items, err = compile(v, at+"[]"); err != nil { + return nil, err + } + case "enum": + s.enum, _ = v.([]any) + case "const": + s.constant, s.hasConst = v, true + case "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum": + f, ok := v.(float64) + if !ok { + return nil, fmt.Errorf("%s: %s is not a number", where(at), k) + } + *map[string]**float64{"minimum": &s.minimum, "maximum": &s.maximum, + "exclusiveMinimum": &s.exclMin, "exclusiveMaximum": &s.exclMax}[k] = &f + case "minLength", "maxLength", "minItems", "maxItems": + f, ok := v.(float64) + if !ok || f < 0 || f != math.Trunc(f) { + return nil, fmt.Errorf("%s: %s is not a whole number", where(at), k) + } + n := int(f) + *map[string]**int{"minLength": &s.minLength, "maxLength": &s.maxLength, + "minItems": &s.minItems, "maxItems": &s.maxItems}[k] = &n + case "pattern": + str, _ := v.(string) + if s.pattern, err = regexp.Compile(str); err != nil { + return nil, fmt.Errorf("%s: pattern: %w", where(at), err) + } + case "anyOf", "allOf": + list, ok := v.([]any) + if !ok || len(list) == 0 { + return nil, fmt.Errorf("%s: %s is not a list of schemas", where(at), k) + } + for i, x := range list { + sub, err := compile(x, fmt.Sprintf("%s(%s %d)", at, k, i+1)) + if err != nil { + return nil, err + } + if k == "anyOf" { + s.anyOf = append(s.anyOf, sub) + } else { + s.allOf = append(s.allOf, sub) + } + } + default: + if !annotations[k] { + return nil, fmt.Errorf("%s: %s is not supported here (type, properties, required, "+ + "additionalProperties, items, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, "+ + "minLength, maxLength, minItems, maxItems, pattern, anyOf and allOf are)", where(at), k) + } + } + } + return s, nil +} + +func where(at string) string { + if at == "" { + return "the schema" + } + return "the schema at " + strings.TrimPrefix(at, ".") +} + +// validate says what is wrong with v, or "". +func (s *schema) validate(v any, at string) string { + if s.never { + return fmt.Sprintf("%s is not allowed", place(at)) + } + if len(s.types) > 0 && !slices.ContainsFunc(s.types, func(t string) bool { return isType(v, t) }) { + return fmt.Sprintf("%s is %s, not %s", place(at), typeOf(v), strings.Join(s.types, " or ")) + } + if s.hasConst && !equal(v, s.constant) { + return fmt.Sprintf("%s is %s, not %s", place(at), short(v), short(s.constant)) + } + if s.enum != nil && !slices.ContainsFunc(s.enum, func(e any) bool { return equal(v, e) }) { + return fmt.Sprintf("%s is %s, not one of %s", place(at), short(v), short(s.enum)) + } + switch x := v.(type) { + case float64: + switch { + case s.minimum != nil && x < *s.minimum: + return fmt.Sprintf("%s is %v, below %v", place(at), x, *s.minimum) + case s.maximum != nil && x > *s.maximum: + return fmt.Sprintf("%s is %v, above %v", place(at), x, *s.maximum) + case s.exclMin != nil && x <= *s.exclMin: + return fmt.Sprintf("%s is %v, not above %v", place(at), x, *s.exclMin) + case s.exclMax != nil && x >= *s.exclMax: + return fmt.Sprintf("%s is %v, not below %v", place(at), x, *s.exclMax) + } + case string: + n := utf8.RuneCountInString(x) + switch { + case s.minLength != nil && n < *s.minLength: + return fmt.Sprintf("%s is %d characters, fewer than %d", place(at), n, *s.minLength) + case s.maxLength != nil && n > *s.maxLength: + return fmt.Sprintf("%s is %d characters, more than %d", place(at), n, *s.maxLength) + case s.pattern != nil && !s.pattern.MatchString(x): + return fmt.Sprintf("%s, %s, does not match %s", place(at), short(x), s.pattern) + } + case []any: + switch { + case s.minItems != nil && len(x) < *s.minItems: + return fmt.Sprintf("%s has %d items, fewer than %d", place(at), len(x), *s.minItems) + case s.maxItems != nil && len(x) > *s.maxItems: + return fmt.Sprintf("%s has %d items, more than %d", place(at), len(x), *s.maxItems) + } + if s.items != nil { + for i, e := range x { + if why := s.items.validate(e, fmt.Sprintf("%s[%d]", at, i)); why != "" { + return why + } + } + } + case map[string]any: + for _, r := range s.required { + if _, ok := x[r]; !ok { + return fmt.Sprintf("%s has no %s", place(at), r) + } + } + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if p, ok := s.properties[k]; ok { + if why := p.validate(x[k], at+"."+k); why != "" { + return why + } + } else if s.additional != nil { + if why := s.additional.validate(x[k], at+"."+k); why != "" { + return why + } + } + } + } + for _, sub := range s.allOf { + if why := sub.validate(v, at); why != "" { + return why + } + } + if len(s.anyOf) > 0 { + var first string + for _, sub := range s.anyOf { + why := sub.validate(v, at) + if why == "" { + return "" + } + if first == "" { + first = why + } + } + return first + " (nor any other choice)" + } + return "" +} + +func place(at string) string { + if at == "" { + return "the answer" + } + return strings.TrimPrefix(at, ".") +} + +func isType(v any, t string) bool { + switch t { + case "object": + _, ok := v.(map[string]any) + return ok + case "array": + _, ok := v.([]any) + return ok + case "string": + _, ok := v.(string) + return ok + case "number": + _, ok := v.(float64) + return ok + case "integer": + f, ok := v.(float64) + return ok && f == math.Trunc(f) + case "boolean": + _, ok := v.(bool) + return ok + case "null": + return v == nil + } + return false +} + +func typeOf(v any) string { + switch x := v.(type) { + case map[string]any: + return "an object" + case []any: + return "a list" + case string: + return "a string" + case float64: + if x == math.Trunc(x) { + return "an integer" + } + return "a number" + case bool: + return "a boolean" + case nil: + return "null" + } + return fmt.Sprintf("%T", v) +} + +func equal(a, b any) bool { return reflect.DeepEqual(a, b) } + +func short(v any) string { + b, _ := json.Marshal(v) + s := string(b) + if len(s) > 60 { + s = s[:57] + "..." + } + return s +} diff --git a/internal/bench/eval/score.go b/internal/bench/eval/score.go new file mode 100644 index 0000000..947e41d --- /dev/null +++ b/internal/bench/eval/score.go @@ -0,0 +1,214 @@ +package eval + +import ( + "encoding/json" + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +// verdict is how one answer scored, 0 to 1, and why. +type verdict struct { + score float64 + detail string +} + +func pass(detail string) verdict { return verdict{1, detail} } +func fail(detail string) verdict { return verdict{0, detail} } + +var thinkBlock = regexp.MustCompile(`(?s).*?`) + +// cleanAnswer is what a model said, without a reasoning model's thinking — +// what a reader is shown — and without the white space around it. +func cleanAnswer(s string) string { + s = thinkBlock.ReplaceAllString(s, "") + // A reply cut off while still thinking has an opening tag and no close. + if i := strings.Index(s, ""); i >= 0 { + s = s[:i] + } + return strings.TrimSpace(s) +} + +// scoreRule scores an answer by a case's rule; a case graded by a judge is +// scored elsewhere, since it takes another request. +func scoreRule(c *Case, answer string) verdict { + fold := func(s string) string { + if c.IgnoreCase { + return strings.ToLower(s) + } + return s + } + switch { + case c.Exact != nil: + if fold(answer) == fold(strings.TrimSpace(*c.Exact)) { + return pass("the answer is exactly that") + } + return fail(fmt.Sprintf("wanted exactly %q", strings.TrimSpace(*c.Exact))) + case len(c.Contains) > 0: + var missing []string + for _, want := range c.Contains { + if !strings.Contains(fold(answer), fold(want)) { + missing = append(missing, strconv.Quote(want)) + } + } + if len(missing) == 0 { + return pass("has everything asked for") + } + return fail("has no " + strings.Join(missing, ", ")) + case c.re != nil: + if c.re.MatchString(answer) { + return pass("matches") + } + return fail("does not match " + c.re.String()) + case c.Number != nil: + got, ok := lastNumber(answer) + if !ok { + return fail(fmt.Sprintf("no number in the answer; wanted %v", *c.Number)) + } + if math.Abs(got-*c.Number) <= c.Tolerance { + return pass(fmt.Sprintf("read %v", got)) + } + want := fmt.Sprintf("%v", *c.Number) + if c.Tolerance > 0 { + want += fmt.Sprintf(" ± %v", c.Tolerance) + } + return fail(fmt.Sprintf("read %v, wanted %s", got, want)) + case c.schema != nil: + v, err := readJSON(answer) + if err != nil { + return fail(err.Error()) + } + if why := c.schema.validate(v, ""); why != "" { + return fail(why) + } + return pass("JSON of the shape asked for") + } + return fail("no rule to score it by") +} + +// number is a number as a model writes one: a sign, thousands separated by +// commas, a decimal part, an exponent. +var number = regexp.MustCompile(`[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][-+]?\d+)?`) + +// lastNumber is the last number in a text: a model that shows its working +// ends with the result, and one that does not says only the result. +func lastNumber(s string) (float64, bool) { + all := number.FindAllStringIndex(s, -1) + if len(all) == 0 { + return 0, false + } + i, j := all[len(all)-1][0], all[len(all)-1][1] + // A hyphen after a digit or letter joins (3-5, 2026-10-04); it is not a + // minus sign. + if (s[i] == '-' || s[i] == '+') && i > 0 && isWordByte(s[i-1]) { + i++ + } + f, err := strconv.ParseFloat(strings.ReplaceAll(s[i:j], ",", ""), 64) + return f, err == nil +} + +func isWordByte(b byte) bool { + return b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' +} + +var fence = regexp.MustCompile("(?s)```(?:json|JSON)?\\s*(.*?)```") + +// readJSON reads an answer as JSON: the whole of it, else what is inside a +// code fence, else from the first brace or bracket to the last — models +// asked for JSON wrap it in a sentence often enough that refusing that would +// measure manners rather than the shape. +func readJSON(answer string) (any, error) { + try := []string{answer} + if m := fence.FindStringSubmatch(answer); m != nil { + try = append(try, m[1]) + } + for _, pair := range [][2]string{{"{", "}"}, {"[", "]"}} { + i, j := strings.Index(answer, pair[0]), strings.LastIndex(answer, pair[1]) + if i >= 0 && j > i { + try = append(try, answer[i:j+1]) + } + } + for _, t := range try { + var v any + if json.Unmarshal([]byte(strings.TrimSpace(t)), &v) == nil { + return v, nil + } + } + if answer == "" { + return nil, fmt.Errorf("the answer is empty, not JSON") + } + return nil, fmt.Errorf("the answer is not JSON: %s", clip(answer, 80)) +} + +func clip(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= n { + return s + } + return s[:n-3] + "..." +} + +// judgePrompt asks a model to grade an answer by a rubric. +const judgePrompt = "You grade an answer to a question against a rubric. Read the question, the answer " + + "and the rubric, then reply with only a JSON object: {\"score\": a number from 0 to 1, " + + "\"reason\": \"one short sentence\"}. 1 means the answer meets the rubric fully, 0 not at all." + +// judgeRequest is what a judge is asked about one answer. +func judgeRequest(c *Case, answer string) []Message { + var q strings.Builder + for _, m := range askedOf(c, "") { + if m.Role == "system" { + continue + } + fmt.Fprintf(&q, "%s: %s\n", m.Role, m.Content) + } + return []Message{ + {Role: "system", Content: judgePrompt}, + {Role: "user", Content: fmt.Sprintf("Question:\n%s\nAnswer:\n%s\n\nRubric:\n%s", q.String(), answer, c.Judge)}, + } +} + +var ( + passWord = regexp.MustCompile(`(?i)\b(pass|passed|yes|correct)\b`) + failWord = regexp.MustCompile(`(?i)\b(fail|failed|no|incorrect|wrong)\b`) +) + +// readJudgement reads a judge's reply: a JSON object with a score (or a +// pass true or false), else a lone number, else the words pass or fail. +func readJudgement(reply string) (verdict, error) { + reply = cleanAnswer(reply) + if v, err := readJSON(reply); err == nil { + if m, ok := v.(map[string]any); ok { + reason, _ := m["reason"].(string) + if f, ok := m["score"].(float64); ok { + return verdict{clamp(f), "the judge: " + clip(reason, 120)}, nil + } + if s, ok := m["score"].(string); ok { + if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { + return verdict{clamp(f), "the judge: " + clip(reason, 120)}, nil + } + } + if b, ok := m["pass"].(bool); ok { + if b { + return pass("the judge: " + clip(reason, 120)), nil + } + return fail("the judge: " + clip(reason, 120)), nil + } + } + } + if f, err := strconv.ParseFloat(strings.TrimSpace(reply), 64); err == nil { + return verdict{clamp(f), "the judge gave " + strings.TrimSpace(reply)}, nil + } + p, f := passWord.MatchString(reply), failWord.MatchString(reply) + switch { + case p && !f: + return pass("the judge: " + clip(reply, 120)), nil + case f && !p: + return fail("the judge: " + clip(reply, 120)), nil + } + return verdict{}, fmt.Errorf("the judge's answer could not be read as a score: %s", clip(reply, 120)) +} + +func clamp(f float64) float64 { return math.Max(0, math.Min(1, f)) } diff --git a/internal/bench/eval/score_test.go b/internal/bench/eval/score_test.go new file mode 100644 index 0000000..af8d8ce --- /dev/null +++ b/internal/bench/eval/score_test.go @@ -0,0 +1,133 @@ +package eval + +import ( + "strings" + "testing" +) + +func caseOf(t *testing.T, yamlCase string) *Case { + t.Helper() + s, err := Parse("t.yaml", []byte("cases:\n - prompt: q\n"+indent(yamlCase))) + if err != nil { + t.Fatal(err) + } + return &s.Cases[0] +} + +func indent(s string) string { + var b strings.Builder + for _, l := range strings.Split(strings.TrimSpace(s), "\n") { + b.WriteString(" " + l + "\n") + } + return b.String() +} + +func TestEachRuleScoresTheAnswersItShouldAndRefusesTheRest(t *testing.T) { + for _, tc := range []struct { + rule string + answer string + want float64 + }{ + {`exact: "42"`, "42", 1}, + {`exact: "42"`, " 42\n", 1}, + {`exact: "42"`, "42.", 0}, + {"exact: Paris\nignore_case: true", "PARIS", 1}, + {`contains: Paris`, "The capital is Paris.", 1}, + {`contains: [Paris, France]`, "Paris is in France", 1}, + {`contains: [Paris, France]`, "Paris", 0}, + {"contains: paris\nignore_case: true", "PARIS", 1}, + {`regex: '^\s*\d{4}-\d{2}-\d{2}\s*$'`, "2026-10-04", 1}, + {`regex: '^\d+$'`, "about 12", 0}, + {"regex: '^yes'\nignore_case: true", "Yes, it is", 1}, + {`number: 42`, "17 + 25 = 42", 1}, + {`number: 42`, "The answer is 42.", 1}, + {`number: 42`, "It is 41", 0}, + {`number: 1234567`, "1,234,567", 1}, + {"number: 3.14\ntolerance: 0.01", "pi is about 3.1416", 1}, + {"number: 3.14\ntolerance: 0.001", "pi is about 3.1416", 0}, + {`number: -5`, "3 - 8 = -5", 1}, + // A date's last part is a number, not a negative one. + {`number: 4`, "from 2026-10-04", 1}, + {`number: 1`, "no number here", 0}, + } { + c := caseOf(t, tc.rule) + v := scoreRule(c, cleanAnswer(tc.answer)) + if v.score != tc.want { + t.Errorf("%s against %q scored %v (%s), wanted %v", strings.ReplaceAll(tc.rule, "\n", "; "), tc.answer, v.score, v.detail, tc.want) + } + } +} + +func TestAnAnswerIsReadAsJSONAndHeldToItsSchema(t *testing.T) { + c := caseOf(t, ` +json_schema: + type: object + required: [name, age, tags] + additionalProperties: false + properties: + name: {type: string, minLength: 1} + age: {type: integer, minimum: 0, maximum: 150} + tags: {type: array, items: {type: string}, maxItems: 3} + kind: {enum: [cat, dog]}`) + for _, tc := range []struct { + answer string + pass bool + why string + }{ + {`{"name": "Ada", "age": 36, "tags": ["math"]}`, true, ""}, + {"Here it is:\n```json\n{\"name\": \"Ada\", \"age\": 36, \"tags\": []}\n```", true, ""}, + {`Sure! {"name": "Ada", "age": 36, "tags": []} Hope that helps.`, true, ""}, + {`{"name": "Ada", "age": 36.5, "tags": []}`, false, "age is a number, not integer"}, + {`{"name": "Ada", "tags": []}`, false, "has no age"}, + {`{"name": "Ada", "age": 36, "tags": [], "extra": 1}`, false, "extra is not allowed"}, + {`{"name": "Ada", "age": 200, "tags": []}`, false, "above 150"}, + {`{"name": "Ada", "age": 3, "tags": [1]}`, false, "tags[0] is an integer, not string"}, + {`{"name": "Ada", "age": 3, "tags": [], "kind": "fish"}`, false, "not one of"}, + {`{"name": "", "age": 3, "tags": []}`, false, "fewer than 1"}, + {`not json at all`, false, "not JSON"}, + } { + v := scoreRule(c, tc.answer) + if (v.score == 1) != tc.pass || (tc.why != "" && !strings.Contains(v.detail, tc.why)) { + t.Errorf("%q: %v %q, wanted pass=%v saying %q", tc.answer, v.score, v.detail, tc.pass, tc.why) + } + } +} + +func TestASchemaUsingWhatIsNotCheckedIsRefusedWhenTheSuiteIsRead(t *testing.T) { + _, err := Parse("t.yaml", []byte("cases:\n - prompt: q\n json_schema: {oneOf: [{type: string}]}\n")) + if err == nil || !strings.Contains(err.Error(), "oneOf is not supported") { + t.Fatalf("a schema with oneOf was accepted: %v", err) + } +} + +func TestAJudgesReplyIsReadAsAScore(t *testing.T) { + for _, tc := range []struct { + reply string + want float64 + bad bool + }{ + {`{"score": 0.8, "reason": "mostly right"}`, 0.8, false}, + {"hmm\n```json\n{\"score\": 1, \"reason\": \"ok\"}\n```", 1, false}, + {`{"score": "0.25"}`, 0.25, false}, + {`{"pass": false, "reason": "wrong city"}`, 0, false}, + {`{"score": 7}`, 1, false}, + {`0.6`, 0.6, false}, + {`PASS`, 1, false}, + {`Fail: it named the wrong city.`, 0, false}, + {`I cannot say.`, 0, true}, + } { + v, err := readJudgement(tc.reply) + if (err != nil) != tc.bad || v.score != tc.want { + t.Errorf("%q: %v %v, wanted %v (unreadable: %v)", tc.reply, v.score, err, tc.want, tc.bad) + } + } +} + +func TestAReasoningModelsThinkingIsNotPartOfItsAnswer(t *testing.T) { + if got := cleanAnswer("\n17+25... carry the one\n\n\n42"); got != "42" { + t.Errorf("%q", got) + } + if got := cleanAnswer("still thinking when it was cut off"); got != "" { + t.Errorf("%q", got) + } +} diff --git a/internal/bench/eval/service.go b/internal/bench/eval/service.go new file mode 100644 index 0000000..ef4205f --- /dev/null +++ b/internal/bench/eval/service.go @@ -0,0 +1,434 @@ +package eval + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// The control plane's side: suites found on its disk or carried in the +// binary, runs started and followed, and kept in the store. + +// Store keeps finished runs. +type Store interface { + PutEvalRun(ctx context.Context, r store.EvalRun) error + EvalRuns(ctx context.Context, model string, limit int) ([]store.EvalRun, error) + EvalRunByID(ctx context.Context, id string) (store.EvalRun, error) +} + +// Deps are what the service runs suites with. +type Deps struct { + // Call asks a model: the router's Replay. + Call Caller + // Store keeps runs; without one they are kept here until a restart. + Store Store + // Dir is where suites are read from by name: control.eval_dir. + Dir string + // Builtin are the suites the binary carries, found when Dir has none + // of the name. + Builtin fs.FS + // Origin is where a catalog model's weights came from, when known. + Origin func(model string) (repo, revision string) + // CanaryOf is the model a name is the canary of, or "": a run on a + // canary is filed under the model it is a canary of. + CanaryOf func(name string) string + Log *slog.Logger +} + +// Request is a suite to run against a model: one on the control plane, by +// its name, or one sent whole. +type Request struct { + // Suite is a suite's name in the eval directory, or carried in the + // binary (arithmetic, json-format). + Suite string `json:"suite,omitempty"` + // Content is a suite's text instead, and File the name of the file it + // was read from, which names the suite unless it names itself. + Content string `json:"content,omitempty"` + File string `json:"file,omitempty"` + // Concurrency, Judge and MinScore are as Options has them. + Concurrency int `json:"concurrency,omitempty"` + Judge string `json:"judge,omitempty"` + MinScore float64 `json:"min_score,omitempty"` +} + +// Named says which suite a request is, for a step's detail. +func (r Request) Named() string { + if r.Suite != "" { + return r.Suite + } + return r.File +} + +// The states a run can be in. +const ( + Running = "running" + Done = "done" + Failed = "failed" +) + +// Live is a run as it stands: one running, with how many of its cases are +// done, or one finished. +type Live struct { + store.EvalRun + State string `json:"state"` + Done int `json:"done"` +} + +// SuiteInfo is a suite the control plane can run by name. +type SuiteInfo struct { + Name string `json:"name"` + // From is the file it is read from, or "built in". + From string `json:"from"` + Description string `json:"description,omitempty"` + Cases int `json:"cases"` + Threshold float64 `json:"threshold"` + Judge bool `json:"judge,omitempty"` + Hash string `json:"hash,omitempty"` + // Error is why the file cannot be run. + Error string `json:"error,omitempty"` +} + +// Service runs suites and remembers them. +type Service struct { + deps Deps + mu sync.Mutex + live map[string]*Live + // order is the live runs oldest first, so the finished ones past + // keepLive are forgotten — the store has them. + order []string +} + +const keepLive = 50 + +// NewService builds a service. +func NewService(d Deps) *Service { + if d.Log == nil { + d.Log = slog.Default() + } + return &Service{deps: d, live: map[string]*Live{}} +} + +var suiteExts = []string{".yaml", ".yml", ".jsonl", ".json"} + +// Load reads the suite a request names or carries. +func (s *Service) Load(r Request) (Suite, error) { + if r.Content != "" { + file := r.File + if file == "" { + file = "suite" + } + return Parse(file, []byte(r.Content)) + } + name := strings.TrimSpace(r.Suite) + if name == "" { + return Suite{}, errors.New("no suite: name one on the control plane, or send one's text") + } + // A name, not a path: this reads the control plane's disk on behalf of + // whoever holds the admin API, and only inside the eval directory. + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + return Suite{}, fmt.Errorf("%q is not a suite's name: the control plane reads suites from %s by name; "+ + "send a file from elsewhere with its text", name, s.dirOrNone()) + } + tries := []string{name} + if !slices.Contains(suiteExts, filepath.Ext(name)) { + tries = nil + for _, e := range suiteExts { + tries = append(tries, name+e) + } + } + if s.deps.Dir != "" { + for _, t := range tries { + b, err := os.ReadFile(filepath.Join(s.deps.Dir, t)) + if err == nil { + return Parse(t, b) + } + if !errors.Is(err, fs.ErrNotExist) { + return Suite{}, err + } + } + } + if s.deps.Builtin != nil { + for _, t := range tries { + if b, err := fs.ReadFile(s.deps.Builtin, t); err == nil { + return Parse(t, b) + } + } + } + return Suite{}, fmt.Errorf("no suite called %s in %s or built in: `llmgw ctl eval --suites` lists them", name, s.dirOrNone()) +} + +func (s *Service) dirOrNone() string { + if s.deps.Dir == "" { + return "no eval directory" + } + return s.deps.Dir +} + +// Suites are those the control plane can run by name: its directory's, and +// the binary's that the directory does not replace. +func (s *Service) Suites() []SuiteInfo { + var out []SuiteInfo + seen := map[string]bool{} + add := func(fsys fs.FS, from func(string) string) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return + } + for _, e := range entries { + ext := filepath.Ext(e.Name()) + if e.IsDir() || !slices.Contains(suiteExts, ext) { + continue + } + name := strings.TrimSuffix(e.Name(), ext) + if seen[name] { + continue + } + seen[name] = true + info := SuiteInfo{Name: name, From: from(e.Name())} + b, err := fs.ReadFile(fsys, e.Name()) + var su Suite + if err == nil { + su, err = Parse(e.Name(), b) + } + if err != nil { + info.Error = err.Error() + } else { + info.Description, info.Cases, info.Threshold = su.Description, len(su.Cases), su.Threshold + info.Judge, info.Hash = su.NeedsJudge(), su.Hash + } + out = append(out, info) + } + } + if s.deps.Dir != "" { + add(os.DirFS(s.deps.Dir), func(n string) string { return filepath.Join(s.deps.Dir, n) }) + } + if s.deps.Builtin != nil { + add(s.deps.Builtin, func(string) string { return "built in" }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func newID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("eval-%s-%s", time.Now().UTC().Format("20060102-150405"), hex.EncodeToString(b)) +} + +// prepare reads the suite and checks it can be run against model, before +// anything is asked. +func (s *Service) prepare(model string, r Request) (Suite, error) { + if s.deps.Call == nil { + return Suite{}, errors.New("this control plane does not route requests, so it cannot ask a model") + } + if strings.TrimSpace(model) == "" { + return Suite{}, errors.New("no model to ask") + } + su, err := s.Load(r) + if err != nil { + return Suite{}, err + } + if r.MinScore < 0 || r.MinScore > 1 { + return Suite{}, fmt.Errorf("the minimum score %v is not 0 to 1", r.MinScore) + } + if su.NeedsJudge() && r.Judge == "" && su.Judge == "" { + for _, c := range su.Cases { + if c.Judge != "" && c.JudgeModel == "" { + return Suite{}, fmt.Errorf("case %s of %s is graded by a judge and the suite names none: give one "+ + "with --judge MODEL", c.ID, su.Name) + } + } + } + return su, nil +} + +// Check says whether a suite can be run against a model, without asking it +// anything: for a gate, refused before its job starts rather than after the +// new weights are fetched. +func (s *Service) Check(model string, r Request) error { + _, err := s.prepare(model, r) + return err +} + +// file fills in what a run is filed under: the model it is about, the one +// that answered, and where its weights came from. +func (s *Service) file(run *store.EvalRun, asked string) { + run.Model = asked + if s.deps.CanaryOf != nil { + if parent := s.deps.CanaryOf(asked); parent != "" { + run.Model, run.Target = parent, asked + } + } + if s.deps.Origin != nil { + run.Repo, run.Revision = s.deps.Origin(asked) + } +} + +// Start runs a suite against a model in the background and returns at once; +// Get follows it. +func (s *Service) Start(model string, r Request, by string) (Live, error) { + su, err := s.prepare(model, r) + if err != nil { + return Live{}, err + } + l := &Live{State: Running} + l.ID, l.Suite, l.SuiteHash, l.Cases, l.By, l.At = newID(), su.Name, su.Hash, len(su.Cases), by, time.Now().UTC() + s.file(&l.EvalRun, model) + s.mu.Lock() + s.keep(l) + s.mu.Unlock() + go func() { + _, _ = s.run(context.Background(), l, model, su, r) + }() + return s.snapshot(l), nil +} + +// Run runs a suite against a model and waits for it: for a gate, which +// cannot go on until it knows. The run is kept like any other; job is the +// update or promotion it gates. +func (s *Service) Run(ctx context.Context, model string, r Request, by, job string) (store.EvalRun, error) { + su, err := s.prepare(model, r) + if err != nil { + return store.EvalRun{}, err + } + l := &Live{State: Running} + l.ID, l.Suite, l.SuiteHash, l.Cases, l.By, l.Job, l.At = newID(), su.Name, su.Hash, len(su.Cases), by, job, time.Now().UTC() + s.file(&l.EvalRun, model) + s.mu.Lock() + s.keep(l) + s.mu.Unlock() + return s.run(ctx, l, model, su, r) +} + +func (s *Service) run(ctx context.Context, l *Live, model string, su Suite, r Request) (store.EvalRun, error) { + run, err := Evaluate(ctx, s.deps.Call, su, Options{Model: model, Concurrency: r.Concurrency, Judge: r.Judge, + MinScore: r.MinScore, Progress: func(done, _ int) { + s.mu.Lock() + l.Done = done + s.mu.Unlock() + }}) + s.mu.Lock() + if err != nil { + l.State, l.Error = Failed, err.Error() + out := l.EvalRun + s.mu.Unlock() + return out, err + } + run.ID, run.By, run.Job = l.ID, l.By, l.Job + run.Model, run.Target, run.Repo, run.Revision = l.Model, l.Target, l.Repo, l.Revision + s.mu.Unlock() + + // Kept before it is said to be done, so a history read in between does + // not find it in neither place. + if s.deps.Store != nil { + // Its own context: a gate's caller giving up does not unkeep a run + // that finished. + sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.deps.Store.PutEvalRun(sctx, run); err != nil { + s.deps.Log.Warn("could not keep an eval run", "run", run.ID, "model", run.Model, "err", err) + } + } + s.mu.Lock() + l.EvalRun, l.State, l.Done = run, Done, run.Cases + s.mu.Unlock() + s.deps.Log.Info("eval run finished", "run", run.ID, "model", run.Model, "target", run.Target, + "suite", run.Suite, "score", fmt.Sprintf("%.3f", run.Score), "threshold", run.Threshold, "passed", run.Passed) + return run, nil +} + +// keep remembers a run, and forgets the oldest finished ones past keepLive. +// Called with mu held. +func (s *Service) keep(l *Live) { + s.live[l.ID] = l + s.order = append(s.order, l.ID) + for len(s.order) > keepLive { + oldest := s.live[s.order[0]] + if oldest != nil && oldest.State == Running { + break + } + delete(s.live, s.order[0]) + s.order = s.order[1:] + } +} + +func (s *Service) snapshot(l *Live) Live { + s.mu.Lock() + defer s.mu.Unlock() + out := *l + out.Results = slices.Clone(l.Results) + return out +} + +// Get is a run by its id: one running or finished here, else one kept. +func (s *Service) Get(ctx context.Context, id string) (Live, error) { + s.mu.Lock() + l, ok := s.live[id] + s.mu.Unlock() + if ok { + return s.snapshot(l), nil + } + if s.deps.Store == nil { + return Live{}, store.ErrNotFound + } + r, err := s.deps.Store.EvalRunByID(ctx, id) + if err != nil { + return Live{}, err + } + return Live{EvalRun: r, State: Done, Done: r.Cases}, nil +} + +// History is a model's runs, newest first, without their cases: those +// running here, then those kept. +func (s *Service) History(ctx context.Context, model string, limit int) ([]Live, error) { + if limit <= 0 { + limit = 20 + } + var out []Live + seen := map[string]bool{} + s.mu.Lock() + for i := len(s.order) - 1; i >= 0; i-- { + l := s.live[s.order[i]] + if l == nil || (model != "" && l.Model != model && l.Target != model) { + continue + } + // Finished ones are listed from the store, when there is one, in + // its order. + if l.State != Running && s.deps.Store != nil { + continue + } + v := *l + v.Results = nil + out = append(out, v) + seen[l.ID] = true + } + s.mu.Unlock() + if s.deps.Store != nil { + runs, err := s.deps.Store.EvalRuns(ctx, model, limit) + if err != nil { + return out, err + } + for _, r := range runs { + if !seen[r.ID] { + out = append(out, Live{EvalRun: r, State: Done, Done: r.Cases}) + } + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/internal/bench/eval/suite.go b/internal/bench/eval/suite.go new file mode 100644 index 0000000..5dfae83 --- /dev/null +++ b/internal/bench/eval/suite.go @@ -0,0 +1,332 @@ +// Package eval asks a model a suite of questions and scores its answers, so +// new weights can be held to the quality of the old before they replace them. +// +// internal/bench measures how fast a replica answers; this measures whether +// the answers are any good. A repository that republishes its weights — a +// new quantisation, a fixed chat template — can make a model faster and +// worse at once, and nothing in the latency numbers says so. A suite is a +// handful of cases with known answers, scored by rules where a rule can +// decide (an exact string, a number, JSON of a shape) and by another model +// with a rubric where only judgement can. +package eval + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "regexp" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Suite is a set of cases a model is asked, and the score it must reach. +type Suite struct { + // Name is what runs are filed under; the file's name without its + // extension unless the suite gives one. + Name string `json:"name,omitempty" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description"` + // Threshold is the score, 0 to 1, the suite is passed at. Zero is 0.8. + Threshold float64 `json:"threshold,omitempty" yaml:"threshold"` + // Judge is the model asked to grade the cases scored by a rubric, unless + // a case names its own. + Judge string `json:"judge,omitempty" yaml:"judge"` + // System and MaxTokens are every case's unless it gives its own. + System string `json:"system,omitempty" yaml:"system"` + MaxTokens int `json:"max_tokens,omitempty" yaml:"max_tokens"` + Cases []Case `json:"cases" yaml:"cases"` + + // Hash is the sha256 of the text the suite was read from: two runs + // under one name compare only when they asked the same questions. + Hash string `json:"-" yaml:"-"` +} + +// Message is one turn of a conversation a case sends. +type Message struct { + Role string `json:"role" yaml:"role"` + Content string `json:"content" yaml:"content"` +} + +// Case is one question and how its answer is scored: by exactly one of +// Exact, Contains, Regex, Number, JSONSchema or Judge. +type Case struct { + // ID names the case in results; case-N, by its place, unless given. + ID string `json:"id,omitempty" yaml:"id"` + // Prompt is the user's message; Messages a whole conversation instead. + Prompt string `json:"prompt,omitempty" yaml:"prompt"` + Messages []Message `json:"messages,omitempty" yaml:"messages"` + System string `json:"system,omitempty" yaml:"system"` + // MaxTokens bounds the answer; the suite's, else 512. + MaxTokens int `json:"max_tokens,omitempty" yaml:"max_tokens"` + // Temperature is 0 unless given: a suite is a measurement, and sampling + // would make two runs of the same weights disagree. + Temperature *float64 `json:"temperature,omitempty" yaml:"temperature"` + // Weight is how much the case counts against the others; 1 unless given. + Weight float64 `json:"weight,omitempty" yaml:"weight"` + + // Exact is the whole answer, surrounding white space aside. + Exact *string `json:"exact,omitempty" yaml:"exact"` + // Contains are strings that must all be in the answer. + Contains StringList `json:"contains,omitempty" yaml:"contains"` + // Regex must match somewhere in the answer. + Regex string `json:"regex,omitempty" yaml:"regex"` + // IgnoreCase makes Exact, Contains and Regex blind to case. + IgnoreCase bool `json:"ignore_case,omitempty" yaml:"ignore_case"` + // Number is the value the last number in the answer must be, within + // Tolerance either side. + Number *float64 `json:"number,omitempty" yaml:"number"` + Tolerance float64 `json:"tolerance,omitempty" yaml:"tolerance"` + // JSONSchema is a schema the answer, read as JSON, must satisfy. + JSONSchema any `json:"json_schema,omitempty" yaml:"json_schema"` + // Judge is a rubric another model grades the answer by, 0 to 1; + // JudgeModel names that model, else the suite's judge. + Judge string `json:"judge,omitempty" yaml:"judge"` + JudgeModel string `json:"judge_model,omitempty" yaml:"judge_model"` + + re *regexp.Regexp + schema *schema +} + +// StringList is one string or a list of them. +type StringList []string + +// UnmarshalYAML takes a string or a list. +func (l *StringList) UnmarshalYAML(n *yaml.Node) error { + if n.Kind == yaml.ScalarNode { + *l = StringList{n.Value} + return nil + } + var v []string + if err := n.Decode(&v); err != nil { + return err + } + *l = v + return nil +} + +// UnmarshalJSON takes a string or a list. +func (l *StringList) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + *l = StringList{s} + return nil + } + var v []string + if err := json.Unmarshal(b, &v); err != nil { + return errors.New("contains is a string or a list of strings") + } + *l = v + return nil +} + +// Kind is how a case is scored: exact, contains, regex, number, +// json_schema or judge. +func (c Case) Kind() string { + var kinds []string + if c.Exact != nil { + kinds = append(kinds, "exact") + } + if len(c.Contains) > 0 { + kinds = append(kinds, "contains") + } + if c.Regex != "" { + kinds = append(kinds, "regex") + } + if c.Number != nil { + kinds = append(kinds, "number") + } + if c.JSONSchema != nil { + kinds = append(kinds, "json_schema") + } + if c.Judge != "" { + kinds = append(kinds, "judge") + } + return strings.Join(kinds, "+") +} + +// DefaultThreshold is the score a suite that names none is passed at. +const DefaultThreshold = 0.8 + +// defaultMaxTokens bounds an answer when neither the case nor the suite do. +const defaultMaxTokens = 512 + +// Parse reads a suite: YAML (a mapping with its cases under cases:), or +// JSON lines (one case on each, and optionally a line without a prompt or +// messages that holds the suite's own settings). The name is the file's, +// used when the suite gives none. +func Parse(file string, data []byte) (Suite, error) { + var s Suite + var err error + if isJSONLines(data) { + s, err = parseJSONLines(data) + } else { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + err = dec.Decode(&s) + if errors.Is(err, io.EOF) { + err = errors.New("the file is empty") + } + } + if err != nil { + return Suite{}, fmt.Errorf("%s: %w", file, err) + } + if s.Name == "" { + base := path.Base(strings.ReplaceAll(file, "\\", "/")) + s.Name = strings.TrimSuffix(base, path.Ext(base)) + } + sum := sha256.Sum256(data) + s.Hash = hex.EncodeToString(sum[:]) + if err := s.check(); err != nil { + return Suite{}, fmt.Errorf("%s: %w", file, err) + } + return s, nil +} + +// isJSONLines is whether every line that is not blank is a JSON object, and +// there is more than one, or the one there is is a case rather than a whole +// suite. +func isJSONLines(data []byte) bool { + n := 0 + for _, line := range bytes.Split(data, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if line[0] != '{' || !json.Valid(line) { + return false + } + n++ + } + if n == 1 { + var probe struct { + Cases json.RawMessage `json:"cases"` + } + _ = json.Unmarshal(bytes.TrimSpace(data), &probe) + return probe.Cases == nil + } + return n > 0 +} + +func parseJSONLines(data []byte) (Suite, error) { + var s Suite + for i, line := range bytes.Split(data, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var probe struct { + Prompt *string `json:"prompt"` + Messages json.RawMessage `json:"messages"` + } + _ = json.Unmarshal(line, &probe) + dec := json.NewDecoder(bytes.NewReader(line)) + dec.DisallowUnknownFields() + if probe.Prompt == nil && probe.Messages == nil { + cases := s.Cases + if err := dec.Decode(&s); err != nil { + return Suite{}, fmt.Errorf("line %d: %w", i+1, err) + } + s.Cases = append(cases, s.Cases...) + continue + } + var c Case + if err := dec.Decode(&c); err != nil { + return Suite{}, fmt.Errorf("line %d: %w", i+1, err) + } + s.Cases = append(s.Cases, c) + } + return s, nil +} + +// check fills in what a suite left out and refuses what cannot be run. +func (s *Suite) check() error { + if len(s.Cases) == 0 { + return errors.New("no cases: a suite has them under cases:, or one on each line of JSON") + } + if s.Threshold < 0 || s.Threshold > 1 { + return fmt.Errorf("threshold %v is not 0 to 1", s.Threshold) + } + if s.Threshold == 0 { + s.Threshold = DefaultThreshold + } + seen := map[string]bool{} + for i := range s.Cases { + c := &s.Cases[i] + if c.ID == "" { + c.ID = fmt.Sprintf("case-%d", i+1) + } + if seen[c.ID] { + return fmt.Errorf("two cases are called %s", c.ID) + } + seen[c.ID] = true + if err := c.check(); err != nil { + return fmt.Errorf("case %s: %w", c.ID, err) + } + } + return nil +} + +func (c *Case) check() error { + if strings.TrimSpace(c.Prompt) == "" && len(c.Messages) == 0 { + return errors.New("no prompt and no messages: nothing to ask") + } + if c.Prompt != "" && len(c.Messages) > 0 { + return errors.New("a prompt and messages both: give one") + } + for _, m := range c.Messages { + switch m.Role { + case "system", "user", "assistant": + default: + return fmt.Errorf("a message's role is system, user or assistant, not %q", m.Role) + } + } + switch k := c.Kind(); { + case k == "": + return errors.New("no way to score it: give one of exact, contains, regex, number, json_schema or judge") + case strings.Contains(k, "+"): + return fmt.Errorf("scored by %s: give one", strings.ReplaceAll(k, "+", " and ")) + } + if c.Weight < 0 { + return fmt.Errorf("weight %v is negative", c.Weight) + } + if c.Weight == 0 { + c.Weight = 1 + } + if c.Tolerance < 0 { + return fmt.Errorf("tolerance %v is negative", c.Tolerance) + } + if c.Regex != "" { + expr := c.Regex + if c.IgnoreCase { + expr = "(?i)" + expr + } + re, err := regexp.Compile(expr) + if err != nil { + return fmt.Errorf("the regex: %w", err) + } + c.re = re + } + if c.JSONSchema != nil { + sc, err := compileSchema(c.JSONSchema) + if err != nil { + return fmt.Errorf("the JSON schema: %w", err) + } + c.schema = sc + } + return nil +} + +// NeedsJudge is whether any case is graded by a model. +func (s Suite) NeedsJudge() bool { + for _, c := range s.Cases { + if c.Judge != "" { + return true + } + } + return false +} diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 102bf3f..1ee6d3e 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -16,6 +16,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/agent" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" "gitea.mynodes.xyz/ilker/llmgw/internal/version" ) @@ -64,6 +65,21 @@ func newAgentCmd() *cobra.Command { } cfg.Version = version.Version + // Before the agent, so its tracer is the configured one. + shutdownTracing, err := obs.SetupTracing(cmd.Context(), obs.TracingOptions{ + Endpoint: cfg.Tracing.Endpoint, SampleRatio: cfg.Tracing.SampleRatio, + Service: "llmgw-agent", Version: version.Version, Instance: cfg.NodeID, + }) + if err != nil { + return err + } + defer func() { + // Bounded: a collector that is gone must not hold up a shutdown. + sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = shutdownTracing(sctx) + }() + httpClient, err := agent.Client(cfg.TLS, 30*time.Second) if err != nil { return err @@ -159,13 +175,13 @@ func printInventory(inv discovery.Inventory) { if !g.Health.OK() { health = g.Health.Problems[0] } - fmt.Fprintf(w, " %d\t%s\t%s\t%d MiB\t%s\t%s\n", - g.Index, g.Class(), g.Name, g.MemoryTotalMB, g.ComputeCapability, health) + fmt.Fprintf(w, " %s\t%s\t%s\t%d MiB\t%s\t%s\n", + discovery.DeviceLabel(g.Index), g.Class(), g.Name, g.MemoryTotalMB, g.ComputeCapability, health) } fmt.Fprintf(w, " classes: %v\n", inv.Classes()) for _, g := range inv.GPUs { if len(g.NVLinkPeers) > 0 { - fmt.Fprintf(w, " GPU %d NVLink peers: %v\n", g.Index, g.NVLinkPeers) + fmt.Fprintf(w, " GPU %s NVLink peers: %v\n", discovery.DeviceLabel(g.Index), g.NVLinkPeers) } } } diff --git a/internal/cli/answercache.go b/internal/cli/answercache.go new file mode 100644 index 0000000..f6ee577 --- /dev/null +++ b/internal/cli/answercache.go @@ -0,0 +1,85 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func newAnswerCacheCmd(open opener) *cobra.Command { + var ( + project string + ttl time.Duration + anyAnswer bool + off bool + ) + cmd := &cobra.Command{ + Use: "cache", + Short: "Reuse a project's answers: the same request is answered again without the model", + Long: "With this on, a request of the project that is the same as one answered within\n" + + "--ttl, to the same model, gets that answer again, from the gateway's memory,\n" + + "rather than asking the model. It is answered with x-llmgw-cache: hit, recorded\n" + + "as a request with no tokens, and costs nothing against a budget.\n\n" + + "Only answers that would come out the same are reused: a chat or completion\n" + + "asked at temperature 0 or with a seed, and embeddings, rerank and score.\n" + + "--any reuses every answer, sampled or not. A client sending\n" + + "Cache-Control: no-cache or x-llmgw-cache: no is always asked afresh, and so is\n" + + "a request continuing a stored conversation or under a guardrails no_store\n" + + "policy. The guardrails judge a reused answer as they judge any other.\n\n" + + "Off for every project until this is run. Without --ttl or --off, says how\n" + + "it stands. The answers are held in memory, router.cache bounds how much.", + Example: " llmgw keys cache --project Acme/ml/train --ttl 1h\n" + + " llmgw keys cache --project Acme/ml/eval --ttl 24h --any\n" + + " llmgw keys cache --project Acme/ml/train --off", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + switch { + case off && (ttl != 0 || anyAnswer): + return fmt.Errorf("--off, or --ttl with --any: not both") + case ttl < 0: + return fmt.Errorf("--ttl is how long an answer is reused, like 1h") + case ttl > 0 && ttl < time.Second: + return fmt.Errorf("--ttl is at least a second") + case anyAnswer && ttl == 0: + return fmt.Errorf("--any goes with --ttl: say how long answers are reused") + } + s, err := open(cmd.Context()) + if err != nil { + return err + } + defer s.Close() + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + if off || ttl > 0 { + if err := s.SetAnswerCache(cmd.Context(), p.ID, ttl, anyAnswer); err != nil { + return err + } + } + c, err := s.AnswerCacheOf(cmd.Context(), p.ID) + if err != nil { + return err + } + switch { + case c.TTL == 0: + fmt.Fprintf(cmd.OutOrStdout(), "%s reuses no answers.\n", p.Path) + case c.Any: + fmt.Fprintf(cmd.OutOrStdout(), "%s reuses every answer for %s, sampled or not.\n", p.Path, c.TTL) + default: + fmt.Fprintf(cmd.OutOrStdout(), "%s reuses answers for %s: those asked at temperature 0 or with a seed, "+ + "and embeddings, rerank and score.\n", p.Path, c.TTL) + } + if off || ttl > 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Gateways see the change within 30 seconds.") + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name") + cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long an answer is reused, like 1h") + cmd.Flags().BoolVar(&anyAnswer, "any", false, "reuse sampled answers too, not only reproducible ones") + cmd.Flags().BoolVar(&off, "off", false, "stop reusing answers for this project") + return cmd +} diff --git a/internal/cli/answercache_test.go b/internal/cli/answercache_test.go new file mode 100644 index 0000000..15d1e85 --- /dev/null +++ b/internal/cli/answercache_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestAProjectIsToldToReuseItsAnswersAndToStop(t *testing.T) { + db := filepath.Join(t.TempDir(), "s.db") + mustRun(t, db, "org", "add", "org", "Acme") + mustRun(t, db, "org", "add", "team", "ml", "--in", "Acme") + mustRun(t, db, "org", "add", "project", "train", "--in", "Acme/ml") + + if out := mustRun(t, db, "keys", "cache", "--project", "Acme/ml/train"); !strings.Contains(out, "reuses no answers") { + t.Errorf("before: %s", out) + } + if out := mustRun(t, db, "keys", "cache", "--project", "Acme/ml/train", "--ttl", "1h"); !strings.Contains(out, "for 1h0m0s: those asked at temperature 0") { + t.Errorf("on: %s", out) + } + if out := mustRun(t, db, "keys", "cache", "--project", "train", "--ttl", "24h", "--any"); !strings.Contains(out, "every answer for 24h0m0s") { + t.Errorf("any: %s", out) + } + for _, bad := range [][]string{{"--off", "--ttl", "1h"}, {"--any"}, {"--ttl", "10ms"}} { + if _, err := run(t, db, append([]string{"keys", "cache", "--project", "train"}, bad...)...); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if out := mustRun(t, db, "keys", "cache", "--project", "train", "--off"); !strings.Contains(out, "reuses no answers") { + t.Errorf("off: %s", out) + } +} diff --git a/internal/cli/bench.go b/internal/cli/bench.go index 5c59691..b17f7f1 100644 --- a/internal/cli/bench.go +++ b/internal/cli/bench.go @@ -106,7 +106,7 @@ func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { f := cmd.Flags() f.StringVar(&target, "target", "http://127.0.0.1:8080", "base URL: a replica, or the gateway") f.StringVar(&metrics, "metrics", "", "the replica's /metrics, to observe queue depth and KV usage") - f.StringVar(&engine, "engine", "", "which metric names --metrics uses: vllm | sglang | llamacpp") + f.StringVar(&engine, "engine", "", "which metric names --metrics uses: vllm | sglang | llamacpp | trtllm (whose are at /prometheus/metrics)") f.StringVar(&key, "key", "", "API key, when the gateway requires one") f.IntSliceVar(&steps, "concurrency", nil, "levels to try (default 1,2,4,…,128)") f.DurationVar(&cfg.Duration, "duration", cfg.Duration, "how long to hold each level") diff --git a/internal/cli/ctl.go b/internal/cli/ctl.go index 4cf0b82..e92456f 100644 --- a/internal/cli/ctl.go +++ b/internal/cli/ctl.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" ) @@ -147,6 +148,7 @@ func newCtlCmd() *cobra.Command { // cert works on files rather than the admin API, so --server does not // apply to it; it lives here because this is where operators look. newBenchCmd(client, &asJSON), + newEvalCmd(client, &asJSON), newStatusCmd(client, &asJSON), newModelCmd(client, &asJSON), newScaleCmd(client), @@ -212,7 +214,7 @@ func newNodesCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { // at when a model will not place and the hardware looks // fine. join(n.Engines), - join(n.Classes), join(n.Fabrics), n.Replicas, n.LastSeenAgo) + classesColumn(n), join(n.Fabrics), n.Replicas, n.LastSeenAgo) } _ = w.Flush() @@ -410,7 +412,9 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { for _, r := range reps { var nodes []string for _, s := range r.Shards { - nodes = append(nodes, fmt.Sprintf("%s:%v", s.Node, s.GPUs)) + // "[0/mig2]" for a MIG instance, not the 1002 it is + // numbered by. + nodes = append(nodes, fmt.Sprintf("%s:%v", s.Node, discovery.DeviceLabels(s.GPUs))) } // The state says stale rather than a column of its own: most // replicas are not, a column would be empty for all of them, @@ -540,6 +544,25 @@ func emitJSON(v any) error { return enc.Encode(v) } +// classesColumn is a node's GPU classes, with how many of each when it has +// more than one or MIG instances among them: a card cut into two 3g.40gb +// instances beside a whole card is "a100-80g-mig-3g.40gb×2,a100-80g×1", where +// the GPUS column's 3 alone would read as three of something. +func classesColumn(n adminapi.NodeView) string { + counted := len(n.ClassGPUs) > 1 + for _, c := range n.ClassGPUs { + counted = counted || c.MIG + } + if !counted { + return join(n.Classes) + } + parts := make([]string, len(n.ClassGPUs)) + for i, c := range n.ClassGPUs { + parts[i] = fmt.Sprintf("%s×%d", c.Class, c.Total) + } + return strings.Join(parts, ",") +} + func join(v []string) string { if len(v) == 0 { return "-" diff --git a/internal/cli/ctl_eval.go b/internal/cli/ctl_eval.go new file mode 100644 index 0000000..86a533f --- /dev/null +++ b/internal/cli/ctl_eval.go @@ -0,0 +1,408 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/control" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + "gitea.mynodes.xyz/ilker/llmgw/internal/router" +) + +// newEvalService is the control plane's runner of quality suites: through +// the router as its own requests, with the suites of control.eval_dir and +// those the binary carries, kept in the store when there is one. +func newEvalService(cfg config.Server, rt *router.Router, admin *control.Controller, log *slog.Logger) *eval.Service { + builtin, err := fs.Sub(llmgw.EvalSuites, "deploy/eval") + if err != nil { + builtin = nil + } + d := eval.Deps{Call: rt.Replay, Dir: cfg.Control.EvalDir, Builtin: builtin, Log: log, + Origin: func(name string) (string, string) { + if m, ok := admin.Catalog().Resolve(name); ok && m.Origin != nil { + return m.Origin.Repo, m.Origin.Revision + } + return "", "" + }, + CanaryOf: func(name string) string { return admin.Catalog().CanaryOf(name) }, + } + // Only a store that is there: a nil one in the interface would look + // like one. + if st := rt.Store(); st != nil { + d.Store = st + } + return eval.NewService(d) +} + +// suiteRequest is a suite named on the command line: a file here, sent +// whole, or else the name of one on the control plane. +func suiteRequest(arg string) (eval.Request, error) { + if st, err := os.Stat(arg); err == nil && !st.IsDir() { + b, err := os.ReadFile(arg) + if err != nil { + return eval.Request{}, err + } + // Read here first, so a mistake in it is said before anything is sent. + if _, err := eval.Parse(filepath.Base(arg), b); err != nil { + return eval.Request{}, err + } + return eval.Request{Content: string(b), File: filepath.Base(arg)}, nil + } + if strings.ContainsAny(arg, `/\`) { + return eval.Request{}, fmt.Errorf("%s: no such file here", arg) + } + return eval.Request{Suite: arg}, nil +} + +func newEvalCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { + var ( + suite, judge, run string + history, suites, detach bool + concurrency, limit int + minScore float64 + ) + c := &cobra.Command{ + Use: "eval MODEL --suite FILE|NAME", + Short: "Ask a model a quality suite and score its answers", + Long: "A suite is a set of cases — a prompt or a conversation, and how its answer\n" + + "is scored: exact, contains, regex, a number within a tolerance, JSON that\n" + + "satisfies a schema, or a rubric another model on the cluster grades — with\n" + + "a weight each and a score, 0 to 1, the suite is passed at (docs/install.md,\n" + + "\"Quality suites\").\n\n" + + "It runs on the control plane, which asks the model through the router as\n" + + "its own requests, as a replay does: past admission, quotas, accounting and\n" + + "the guardrails, so the score is the weights' and not a tenant's limits'.\n" + + "--suite is a file here, sent whole, or the name of a suite in the control\n" + + "plane's control.eval_dir (/etc/llmgw/eval) or built in: arithmetic,\n" + + "json-format. Every run is kept, with the revision of the weights that\n" + + "answered; --history lists them.\n\n" + + "It exits non-zero when the model scores below the suite's threshold, or\n" + + "--min-score.", + Example: " llmgw ctl eval qwen-7b --suite arithmetic\n" + + " llmgw ctl eval qwen-7b --suite ./support.yaml --judge llama-70b --concurrency 8\n" + + " llmgw ctl eval --history qwen-7b\n" + + " llmgw ctl eval --run eval-20261004-101500-1a2b3c4d\n" + + " llmgw ctl eval --suites", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + switch { + case suites: + var list []eval.SuiteInfo + if err := client().get(cmd.Context(), "/api/v1/eval/suites", &list); err != nil { + return err + } + if *asJSON { + return emitJSON(list) + } + w := tabTo(out) + fmt.Fprintln(w, "SUITE\tCASES\tPASS AT\tJUDGE\tFROM\tABOUT") + for _, s := range list { + if s.Error != "" { + fmt.Fprintf(w, "%s\t-\t-\t-\t%s\tcannot be run: %s\n", s.Name, s.From, truncate(s.Error, 70)) + continue + } + fmt.Fprintf(w, "%s\t%d\t%.2f\t%s\t%s\t%s\n", s.Name, s.Cases, s.Threshold, yesNo(s.Judge), s.From, + truncate(s.Description, 60)) + } + return w.Flush() + case run != "": + var l eval.Live + if err := client().get(cmd.Context(), "/api/v1/eval/runs/"+url.PathEscape(run), &l); err != nil { + return err + } + if l.State == eval.Running && !*asJSON { + return followEval(cmd, client, l, false) + } + return showEval(cmd, l, *asJSON) + case history: + q := url.Values{} + if len(args) == 1 { + q.Set("model", args[0]) + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + var runs []eval.Live + if err := client().get(cmd.Context(), "/api/v1/eval/runs?"+q.Encode(), &runs); err != nil { + return err + } + if *asJSON { + return emitJSON(runs) + } + return printEvalHistory(out, runs) + } + if len(args) != 1 { + return errors.New("name the model to ask: llmgw ctl eval MODEL --suite FILE|NAME") + } + if suite == "" { + return errors.New("--suite is required: a file, or a suite's name on the control plane (`llmgw ctl eval --suites`)") + } + req, err := suiteRequest(suite) + if err != nil { + return err + } + req.Concurrency, req.Judge, req.MinScore = concurrency, judge, minScore + body, _ := json.Marshal(req) + var l eval.Live + if err := client().post(cmd.Context(), "/api/v1/models/"+url.PathEscape(args[0])+"/eval", body, &l); err != nil { + return err + } + if detach { + fmt.Fprintf(out, "started %s on the control plane\nfollow it: llmgw ctl eval --run %s\n", l.ID, l.ID) + return nil + } + return followEval(cmd, client, l, *asJSON) + }, + } + f := c.Flags() + f.StringVar(&suite, "suite", "", "the suite: a file here, or a suite's name on the control plane") + f.IntVar(&concurrency, "concurrency", 4, "cases asked at once") + f.StringVar(&judge, "judge", "", "the model that grades rubric cases, instead of the suite's judge") + f.Float64Var(&minScore, "min-score", 0, "the score to pass at, 0 to 1, instead of the suite's threshold") + f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane") + f.BoolVar(&history, "history", false, "list the runs kept, of MODEL or of every model") + f.IntVar(&limit, "limit", 20, "with --history, how many runs") + f.StringVar(&run, "run", "", "show one run, by its id, with each case") + f.BoolVar(&suites, "suites", false, "list the suites the control plane can run by name") + return c +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "-" +} + +// followEval waits for a run, saying how far it has got, then shows it. +func followEval(cmd *cobra.Command, client func() *ctlClient, l eval.Live, asJSON bool) error { + out := cmd.OutOrStdout() + if !asJSON { + fmt.Fprintf(out, "run %s: %s asked %s, %d cases\n", l.ID, evalTarget(l), l.Suite, l.Cases) + } + shown := -1 + for l.State == eval.Running { + if !asJSON && l.Done != shown { + fmt.Fprintf(out, " %d of %d answered\n", l.Done, l.Cases) + shown = l.Done + } + select { + case <-cmd.Context().Done(): + return cmd.Context().Err() + case <-time.After(time.Second): + } + if err := client().get(cmd.Context(), "/api/v1/eval/runs/"+url.PathEscape(l.ID), &l); err != nil { + return err + } + } + if !asJSON { + fmt.Fprintln(out) + } + return showEval(cmd, l, asJSON) +} + +func evalTarget(l eval.Live) string { + if l.Target != "" { + return l.Target + } + return l.Model +} + +// showEval prints a finished run's cases and score, and fails below it. +func showEval(cmd *cobra.Command, l eval.Live, asJSON bool) error { + out := cmd.OutOrStdout() + if asJSON { + if err := emitJSON(l); err != nil { + return err + } + } else { + if l.State == eval.Failed { + return fmt.Errorf("the run could not be scored: %s", l.Error) + } + w := tabTo(out) + fmt.Fprintln(w, "CASE\tKIND\tWEIGHT\tSCORE\tTIME\tANSWER\tWHY") + for _, r := range l.Results { + score := fmt.Sprintf("%.2f", r.Score) + if !r.Passed { + score += " ✗" + } + why := r.Detail + if r.Error != "" { + why = "error: " + r.Error + } + fmt.Fprintf(w, "%s\t%s\t%g\t%s\t%s\t%s\t%s\n", r.ID, r.Kind, r.Weight, score, + (time.Duration(r.TookMs) * time.Millisecond).Round(10*time.Millisecond), dash(truncate(oneLine(r.Answer), 30)), + truncate(why, 70)) + } + if err := w.Flush(); err != nil { + return err + } + fmt.Fprintln(out) + fmt.Fprintln(out, evalSummary(l)) + if l.Error != "" { + fmt.Fprintln(out, l.Error) + } + } + if !l.Passed { + return fmt.Errorf("%s scored %.2f on %s, below %.2f", evalTarget(l), l.Score, l.Suite, l.Threshold) + } + return nil +} + +func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } + +// evalSummary is a run in a sentence. +func evalSummary(l eval.Live) string { + verdict := "passed" + if !l.Passed { + verdict = "failed" + } + s := fmt.Sprintf("%s scored %.2f on %s: %d of %d cases passed, %.2f asked: %s", evalTarget(l), l.Score, l.Suite, + l.Cases-l.Below, l.Cases, l.Threshold, verdict) + if l.Revision != "" { + s += fmt.Sprintf(" (weights %s at %s)", dash(l.Repo), catalog.ShortRevision(l.Revision)) + } + return s + "." +} + +func printEvalHistory(out io.Writer, runs []eval.Live) error { + if len(runs) == 0 { + fmt.Fprintln(out, "No runs kept: `llmgw ctl eval MODEL --suite NAME` makes one.") + return nil + } + w := tabTo(out) + fmt.Fprintln(w, "WHEN\tRUN\tMODEL\tWEIGHTS\tSUITE\tSCORE\tPASS AT\tRESULT\tFOR") + for _, r := range runs { + model := r.Model + if r.Target != "" { + model += " (" + r.Target + ")" + } + result := "passed" + switch { + case r.State == eval.Running: + result = fmt.Sprintf("running, %d of %d", r.Done, r.Cases) + case r.State == eval.Failed: + result = "could not run" + case !r.Passed: + result = "failed" + } + score := fmt.Sprintf("%.2f", r.Score) + if r.State == eval.Running { + score = "-" + } + forWhat := r.Job + if forWhat == "" { + forWhat = r.By + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%.2f\t%s\t%s\n", ago(r.At, time.Now()), r.ID, model, + dash(catalog.ShortRevision(r.Revision)), r.Suite, score, r.Threshold, result, dash(forWhat)) + } + return w.Flush() +} + +// gateFlags are how an update or a promotion is held to a quality suite. +type gateFlags struct { + suite, judge string + minScore float64 + skip bool +} + +func (g *gateFlags) add(c *cobra.Command) { + f := c.Flags() + f.StringVar(&g.suite, "eval", "", "a quality suite the new weights must pass first: a file here, or a suite's name on the control plane") + f.Float64Var(&g.minScore, "min-score", 0, "with --eval, the score to pass at, 0 to 1; the suite's threshold otherwise") + f.StringVar(&g.judge, "judge", "", "with --eval, the model that grades rubric cases") + f.BoolVar(&g.skip, "skip-eval", false, "move without the model's own quality suite (`llmgw ctl model gate`)") +} + +// body is the change, as the API takes it. +func (g gateFlags) body() ([]byte, error) { + var ch modelserve.Change + if g.suite != "" { + if g.skip { + return nil, errors.New("--eval and --skip-eval together: give one") + } + r, err := suiteRequest(g.suite) + if err != nil { + return nil, err + } + r.MinScore, r.Judge = g.minScore, g.judge + ch.Eval = &r + } else if g.minScore != 0 || g.judge != "" { + return nil, errors.New("--min-score and --judge go with --eval") + } + ch.SkipEval = g.skip + return json.Marshal(ch) +} + +func newModelGateCmd(client func() *ctlClient) *cobra.Command { + var ( + g adminapi.EvalGateRequest + off bool + ) + c := &cobra.Command{ + Use: "gate MODEL --eval SUITE [--min-score N]", + Short: "Hold a model's updates and promotions to a quality suite", + Long: "From then on `llmgw ctl model update` and `model promote` (and the\n" + + "console's) ask the new weights the suite before they replace the old,\n" + + "and stop below its score: an update runs them first as a canary given\n" + + "none of the model's requests, and takes it out again when it fails. The\n" + + "suite is one on the control plane, by name: in control.eval_dir, or\n" + + "built in. --skip-eval on an update moves without it, once; --off takes it\n" + + "away.", + Example: " llmgw ctl model gate qwen-7b --eval arithmetic --min-score 0.8\n" + + " llmgw ctl model gate qwen-7b --off", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "/api/v1/models/" + url.PathEscape(args[0]) + "/eval-gate" + var out struct { + Model string `json:"model"` + Eval *catalog.EvalGate `json:"eval"` + } + if off { + if err := client().delete(cmd.Context(), path, &out); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s's updates are no longer held to a quality suite\n", args[0]) + return nil + } + if g.Suite == "" { + return errors.New("--eval names the suite, or --off takes the gate away") + } + body, _ := json.Marshal(g) + if err := client().put(cmd.Context(), path, body, &out); err != nil { + return err + } + at := "the suite's threshold" + if g.MinScore > 0 { + at = strconv.FormatFloat(g.MinScore, 'f', -1, 64) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s's new weights must score at least %s on %s before they replace the old\n", + args[0], at, g.Suite) + return nil + }, + } + c.Flags().StringVar(&g.Suite, "eval", "", "the suite: its name on the control plane") + c.Flags().Float64Var(&g.MinScore, "min-score", 0, "the score to pass at, 0 to 1; the suite's threshold otherwise") + c.Flags().StringVar(&g.Judge, "judge", "", "the model that grades the suite's rubric cases") + c.Flags().BoolVar(&off, "off", false, "take the gate away") + return c +} diff --git a/internal/cli/ctl_eval_test.go b/internal/cli/ctl_eval_test.go new file mode 100644 index 0000000..19046b3 --- /dev/null +++ b/internal/cli/ctl_eval_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func TestTheSuitesTheBinaryCarriesCanBeRun(t *testing.T) { + sub, err := fs.Sub(llmgw.EvalSuites, "deploy/eval") + if err != nil { + t.Fatal(err) + } + got := eval.NewService(eval.Deps{Builtin: sub}).Suites() + var names []string + for _, s := range got { + if s.Error != "" || s.Cases < 5 || s.Judge { + t.Errorf("%s: %+v", s.Name, s) + } + names = append(names, s.Name) + } + if strings.Join(names, " ") != "arithmetic json-format" { + t.Errorf("built in: %v", names) + } +} + +func TestCtlEvalSendsAFileAndFailsBelowTheScore(t *testing.T) { + var mu sync.Mutex + var sent []string + finished := eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Suite: "mine", Score: 0.5, + Threshold: 0.8, Cases: 2, Below: 1, Revision: "abcdef1234567", Repo: "o/r", Results: []store.EvalCase{ + {ID: "add", Kind: "number", Weight: 1, Score: 1, Passed: true, Answer: "42", Detail: "read 42"}, + {ID: "mul", Kind: "number", Weight: 1, Score: 0, Answer: "43", Detail: "read 43, wanted 42"}}}, + State: eval.Done, Done: 2} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + sent = append(sent, r.Method+" "+r.URL.Path+" "+string(b)) + mu.Unlock() + switch r.URL.Path { + case "/api/v1/models/qwen/eval": + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Cases: 2}, + State: eval.Running}) + case "/api/v1/eval/runs/eval-1": + _ = json.NewEncoder(w).Encode(finished) + case "/api/v1/models/qwen/update": + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(modelserve.Job{ID: "update-1", Model: "qwen", State: modelserve.Done}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + file := filepath.Join(t.TempDir(), "mine.yaml") + if err := os.WriteFile(file, []byte("cases:\n - prompt: 40 + 2?\n number: 42\n"), 0o600); err != nil { + t.Fatal(err) + } + ctl := func(args ...string) (string, error) { + cmd := newCtlCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(append([]string{"--server", srv.URL}, args...)) + err := cmd.ExecuteContext(context.Background()) + return out.String(), err + } + + out, err := ctl("eval", "qwen", "--suite", file, "--concurrency", "2") + if err == nil || !strings.Contains(err.Error(), "qwen scored 0.50 on mine, below 0.80") { + t.Fatalf("a run below its score did not fail: %v\n%s", err, out) + } + for _, want := range []string{"read 43, wanted 42", "1 of 2 cases passed", "weights o/r at abcdef1"} { + if !strings.Contains(out, want) { + t.Errorf("the output lacks %q:\n%s", want, out) + } + } + mu.Lock() + var req eval.Request + _ = json.Unmarshal([]byte(strings.SplitN(sent[0], " ", 3)[2]), &req) + mu.Unlock() + if req.File != "mine.yaml" || !strings.Contains(req.Content, "40 + 2?") || req.Concurrency != 2 { + t.Errorf("sent %+v", req) + } + + // A broken file is said here, before anything is sent. + if err := os.WriteFile(file, []byte("cases: []\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ctl("eval", "qwen", "--suite", file); err == nil || !strings.Contains(err.Error(), "no cases") { + t.Errorf("%v", err) + } + + // An update held to a suite by name carries it. + if _, err := ctl("model", "update", "qwen", "--eval", "arithmetic", "--min-score", "0.9"); err != nil { + t.Fatal(err) + } + mu.Lock() + last := sent[len(sent)-1] + mu.Unlock() + var ch modelserve.Change + if err := json.Unmarshal([]byte(strings.SplitN(last, " ", 3)[2]), &ch); err != nil || ch.Eval == nil || + ch.Eval.Suite != "arithmetic" || ch.Eval.MinScore != 0.9 { + t.Errorf("sent %s", last) + } + if _, err := ctl("model", "update", "qwen", "--min-score", "0.9"); err == nil { + t.Error("--min-score without --eval was sent") + } +} diff --git a/internal/cli/ctl_request.go b/internal/cli/ctl_request.go index 74cba2e..226c104 100644 --- a/internal/cli/ctl_request.go +++ b/internal/cli/ctl_request.go @@ -56,6 +56,9 @@ func newRequestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { fmt.Fprintf(out, "status %d\n", v.Status) fmt.Fprintf(out, "time %d ms total: %d queued, %d to the first token\n", v.LatencyMs, v.QueueMs, v.TTFTMs) fmt.Fprintf(out, "tokens %d in, %d out\n", v.PromptTokens, v.CompletionTokens) + if v.CacheHit { + fmt.Fprintf(out, "cache answered from the project's answer cache: nothing generated, nothing charged\n") + } fmt.Fprintf(out, "who key %s, project %s%s\n", v.KeyID, v.ProjectID, prefixed(", user ", v.UserID)) if v.EndUser != "" { fmt.Fprintf(out, "end user %s (the client's own name for them)\n", v.EndUser) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index a46250c..13d30fb 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -110,9 +110,10 @@ func printDoctor(out io.Writer, f doctor.Facts, findings []doctor.Finding, verbo w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) if len(f.GPUs) > 0 { - fmt.Fprintln(w, "\nGPU\tNAME\tNUMA\tNVLINK PEERS\tNEAREST ADAPTER") + fmt.Fprintln(w, "\nGPU\tNAME\tCLASS\tNUMA\tNVLINK PEERS\tNEAREST ADAPTER") for _, g := range f.GPUs { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\t%s\n", g.Index, g.Name, g.NUMANode, + // A MIG instance as 0/mig2, under its card. + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\n", discovery.DeviceLabel(g.Index), g.Name, g.Class(), g.NUMANode, orDash(ints(g.NVLinkPeers)), orDash(nearest(g.NICPaths))) } } diff --git a/internal/cli/enginecheck.go b/internal/cli/enginecheck.go index 7726570..bed0d0b 100644 --- a/internal/cli/enginecheck.go +++ b/internal/cli/enginecheck.go @@ -57,6 +57,8 @@ func newEngineCheckCmd() *cobra.Command { cfg.VLLM.Bin = bin case "sglang": cfg.SGLang.Python = bin + case "trtllm": + cfg.TRTLLM.Python = bin case "sim": cfg.Sim.Bin = bin } @@ -97,7 +99,7 @@ func newEngineCheckCmd() *cobra.Command { continue default: var err error - if text, err = runHelp(cmd.Context(), c); err != nil { + if text, err = runHelp(cmd.Context(), eng, c); err != nil { skipped++ fmt.Fprintf(errOut, " skipped: %v\n", err) continue @@ -167,9 +169,10 @@ func newEngineCheckCmd() *cobra.Command { // representativeShard is the widest command llmgw would ever build, because a // flag that only appears in the multi-node case is exactly the one nobody -// notices has been renamed. +// notices has been renamed. For an engine that runs a replica on one node, +// that is one node. func representativeShard(engine string, nodes, tp, perNode int) engines.ShardSpec { - if nodes < 1 { + if nodes < 1 || engine == "trtllm" { nodes = 1 } return engines.ShardSpec{ @@ -203,9 +206,10 @@ var helpArgs = map[string][]string{ } // runHelp asks one program to describe itself. -func runHelp(ctx context.Context, c engines.Command) (string, error) { +func runHelp(ctx context.Context, eng engines.Engine, c engines.Command) (string, error) { // Everything up to the first flag, plus the help request. Passing the real // arguments would start the engine. + path := c.Path var args []string for _, a := range c.Args { if strings.HasPrefix(a, "-") { @@ -218,11 +222,16 @@ func runHelp(ctx context.Context, c engines.Command) (string, error) { ask = []string{"--help"} } args = append(args, ask...) + // An engine started through a launcher of llmgw's is asked through its + // own entry point: the interpreter's --help describes the interpreter. + if h, ok := eng.(engines.HelpCommand); ok { + path, args = h.HelpCommand(c) + } ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() - ec := exec.CommandContext(ctx, c.Path, args...) + ec := exec.CommandContext(ctx, path, args...) // vLLM refuses to do anything at all on a machine with no accelerator, // including print its help, unless it is told what to assume. Asking an // engine about its flags should not require the hardware it runs on. @@ -231,7 +240,7 @@ func runHelp(ctx context.Context, c engines.Command) (string, error) { raw, err := ec.CombinedOutput() if err != nil && len(raw) == 0 { return "", fmt.Errorf("could not run %s: %w (install it here, or pass --help-output)", - c.Path, err) + path, err) } return string(raw), nil } diff --git a/internal/cli/enginecheck_test.go b/internal/cli/enginecheck_test.go index becd8e0..1b4d85f 100644 --- a/internal/cli/enginecheck_test.go +++ b/internal/cli/enginecheck_test.go @@ -1,6 +1,11 @@ package cli import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" "testing" ) @@ -69,3 +74,75 @@ func TestATruncatedHelpIsSuspected(t *testing.T) { t.Error("a truncated help was taken for a complete one") } } + +// fakeInterpreter stands in for TensorRT-LLM's Python: asked for +// trtllm-serve's help through its module, it prints the help given; asked +// anything else, it fails, as running the launcher here would. +func fakeInterpreter(t *testing.T, help string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "help.txt"), []byte(help), 0o644); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "python") + body := "#!/bin/sh\n" + + `if [ "$*" = "-m tensorrt_llm.commands.serve serve --help" ]; then cat "` + filepath.Join(dir, "help.txt") + `"; exit 0; fi` + "\n" + + "echo \"unexpected: $*\" >&2; exit 2\n" + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { //nolint:gosec // an executable stand-in + t.Fatal(err) + } + return p +} + +// trtllmHelp is the part of trtllm-serve serve --help (1.3) that holds the +// flags llmgw builds. +const trtllmHelp = `Usage: trtllm-serve serve [OPTIONS] MODEL + +Options: + --host TEXT Hostname of the server. + --port INTEGER Port of the server. + --tensor_parallel_size, --tp_size INTEGER + Tensor parallelism size. + --pipeline_parallel_size, --pp_size INTEGER + Pipeline parallelism size. + --config, --extra_llm_api_options TEXT + Path to a YAML configuration file. + --served_model_name TEXT The model name used in the API. + --help Show this message and exit. +` + +func runEngineCheck(t *testing.T, args ...string) (string, error) { + t.Helper() + c := newEngineCheckCmd() + var out bytes.Buffer + c.SetOut(&out) + c.SetErr(&out) + c.SetArgs(args) + err := c.ExecuteContext(context.Background()) + return out.String(), err +} + +func TestEngineCheckAsksTrtllmServeThroughItsModule(t *testing.T) { + out, err := runEngineCheck(t, "trtllm", "--bin", fakeInterpreter(t, trtllmHelp)) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if !strings.Contains(out, "every flag llmgw builds is accepted") { + t.Errorf("unexpected report:\n%s", out) + } + // The interpreter's own help would describe Python, not trtllm-serve; and + // one node is what trtllm runs, so no multi-node flag is in the command. + if strings.Contains(out, "unexpected:") || strings.Contains(out, "skipped") { + t.Errorf("the help was not asked of trtllm-serve:\n%s", out) + } +} + +func TestEngineCheckCatchesATensorRTLLMTooOldForTheServedName(t *testing.T) { + // TensorRT-LLM 1.2 has no --served_model_name, and a replica of it would + // never be seen serving the name the agent waits for. + old := strings.ReplaceAll(trtllmHelp, " --served_model_name TEXT The model name used in the API.\n", "") + out, err := runEngineCheck(t, "trtllm", "--bin", fakeInterpreter(t, old)) + if err == nil || !strings.Contains(out, "trtllm-serve: --served_model_name") { + t.Errorf("an old trtllm-serve passed: %v\n%s", err, out) + } +} diff --git a/internal/cli/keys.go b/internal/cli/keys.go index 0ae9e93..67a5dec 100644 --- a/internal/cli/keys.go +++ b/internal/cli/keys.go @@ -44,6 +44,7 @@ func newTenancyCmd() *cobra.Command { newThreadsCmd(open), newRequestLogCmd(open), newResidencyCmd(open), + newAnswerCacheCmd(open), newMemoryCmd(open), newMoveStoreCmd(), ) diff --git a/internal/cli/mig_test.go b/internal/cli/mig_test.go new file mode 100644 index 0000000..8408489 --- /dev/null +++ b/internal/cli/mig_test.go @@ -0,0 +1,40 @@ +package cli + +import ( + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" + "gitea.mynodes.xyz/ilker/llmgw/internal/modelplan" +) + +func TestAPlanSeesANodesMIGInstancesAsAClassOfTheirOwn(t *testing.T) { + // Card 0 cut into two 3g.40gb instances, both free, and card 1 whole and + // busy: the instances are a pool of forty-gigabyte devices, and the busy + // card no pool at all. + hw := hardwareFrom([]adminapi.NodeView{{ID: "a100", State: "ready", GPUTotal: 3, GPUFree: 2, + Classes: []string{"a100-80g-mig-3g.40gb", "a100-80g"}, + ClassGPUs: []adminapi.ClassGPUs{ + {Class: "a100-80g-mig-3g.40gb", Total: 2, Free: 2, MIG: true}, + {Class: "a100-80g", Total: 1, Free: 0}, + }}}) + if len(hw) != 1 { + t.Fatalf("pools: %+v", hw) + } + want := modelplan.Hardware{Class: "a100-80g-mig-3g.40gb", GPUMemGB: 40, GPUsPerNode: 2, Nodes: 1} + if hw[0] != want { + t.Errorf("pool %+v, want %+v", hw[0], want) + } +} + +func TestCtlNodesCountsANodesDevicesByClassWhenItHasMIG(t *testing.T) { + mig := adminapi.NodeView{Classes: []string{"a100-80g-mig-3g.40gb", "a100-80g"}, ClassGPUs: []adminapi.ClassGPUs{ + {Class: "a100-80g-mig-3g.40gb", Total: 2, MIG: true}, {Class: "a100-80g", Total: 1}}} + if got := classesColumn(mig); got != "a100-80g-mig-3g.40gb×2,a100-80g×1" { + t.Errorf("MIG node: %q", got) + } + // A node of one kind of whole card reads as it always did. + plain := adminapi.NodeView{Classes: []string{"h100-80g"}, ClassGPUs: []adminapi.ClassGPUs{{Class: "h100-80g", Total: 8}}} + if got := classesColumn(plain); got != "h100-80g" { + t.Errorf("plain node: %q", got) + } +} diff --git a/internal/cli/mirror.go b/internal/cli/mirror.go index db6ef70..c63ea54 100644 --- a/internal/cli/mirror.go +++ b/internal/cli/mirror.go @@ -44,6 +44,8 @@ func newMirrorCmd() *cobra.Command { c.AddCommand( newMirrorAddCmd(&dir), + newMirrorImportCmd(&dir), + newMirrorImportOllamaCmd(&dir), newMirrorListCmd(&dir), newMirrorUnusedCmd(&dir), newMirrorRemoveCmd(&dir), diff --git a/internal/cli/mirror_import.go b/internal/cli/mirror_import.go new file mode 100644 index 0000000..fe177a9 --- /dev/null +++ b/internal/cli/mirror_import.go @@ -0,0 +1,216 @@ +package cli + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "gitea.mynodes.xyz/ilker/llmgw/internal/mirror" +) + +// placeholderBase is how the mirror's address is written when the operator +// has not said it, as `mirror add` writes it. +const placeholderBase = "http://:8081" + +func newMirrorImportCmd(dir *string) *cobra.Command { + var model, version, as, sum, base string + + c := &cobra.Command{ + Use: "import ... --model NAME", + Short: "Copy model files on this machine into the mirror", + Long: "For weights already here: a GGUF you converted or downloaded by hand,\n" + + "or the files of a safetensors model. The same discipline as add — copied\n" + + "under a temporary name and renamed when whole, the checksum kept beside\n" + + "it for nodes to check against, an existing version never replaced.\n\n" + + "A file named .gguf is checked to be one. Several files go into one\n" + + "version, as the files of one model; its source is then the version,\n" + + "ending in a slash. Run it on the mirror; for a model in Ollama, use\n" + + "import-ollama.", + Example: " llmgw mirror import ./qwen2.5-7b-instruct-q4_k_m.gguf --model qwen2.5-7b-instruct", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if model == "" { + return fmt.Errorf("--model is required: it is the mirror's directory for the model") + } + if as != "" && len(args) > 1 { + return fmt.Errorf("--as names one file; with %d files each keeps its own name", len(args)) + } + if sum != "" && len(args) > 1 { + return fmt.Errorf("--sha256 is one file's checksum; with %d files, import them one at a time", len(args)) + } + if version == "" { + version = time.Now().Format("2006-01-02") + } + out := cmd.OutOrStdout() + var added []mirror.Added + for _, src := range args { + abs, err := filepath.Abs(src) + if err != nil { + return err + } + file := as + if file == "" { + file = filepath.Base(src) + } + e := mirror.Entry{Model: model, Version: version, File: file} + fmt.Fprintf(out, "copying %s\n", src) + a, err := mirror.Import(cmd.Context(), *dir, abs, e, sum, "file://"+abs, progressTo(out)) + if err != nil { + return err + } + fmt.Fprintf(out, "%s/%s/%s %s\n sha256 %s\n", model, version, file, fileSize(a.Entry.Bytes), a.SHA256) + added = append(added, a) + } + fmt.Fprintf(out, "\nPoint a model at it with:\n source: %s\n", importedSource(base, added)) + return nil + }, + } + c.Flags().StringVar(&model, "model", "", "the model's name, one directory level") + c.Flags().StringVar(&version, "version", "", "the version, one directory level (default today's date)") + c.Flags().StringVar(&as, "as", "", "the filename to keep it as, for one file; its own name when omitted") + c.Flags().StringVar(&sum, "sha256", "", "the checksum its publisher gave, to refuse a file that does not match") + c.Flags().StringVar(&base, "base", placeholderBase, "the address nodes reach this mirror at, for the source printed") + return c +} + +func newMirrorImportOllamaCmd(dir *string) *cobra.Command { + var o ollamaImport + + c := &cobra.Command{ + Use: "import-ollama [NAME[:TAG]]", + Short: "Put a model you already have in Ollama into the mirror, for llama.cpp", + Long: "Ollama keeps each model as a GGUF under its models directory, named by\n" + + "its sha256. This copies that file into the mirror — checked against the\n" + + "digest it is named by, and with a vision model's projector beside it —\n" + + "so llama.cpp can serve it on any node, from your own network.\n\n" + + "With no name it lists the models Ollama has. The mirror version is the\n" + + "tag and the start of the digest, since a tag such as latest moves in\n" + + "Ollama and a mirror version must not; --model and --version choose\n" + + "otherwise.\n\n" + + "Ollama's prompt template is not carried: llama.cpp renders the chat\n" + + "template inside the GGUF. Run it on the mirror, where Ollama's directory\n" + + "is readable — ~/.ollama/models, /usr/share/ollama/.ollama/models for\n" + + "Ollama's Linux service, or OLLAMA_MODELS.", + Example: " llmgw mirror import-ollama\n" + + " sudo llmgw mirror import-ollama llama3.2:3b --ollama-dir /usr/share/ollama/.ollama/models", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + o.mirrorDir = *dir + if len(args) == 1 { + o.name = args[0] + } + return o.run(cmd.Context(), cmd.OutOrStdout()) + }, + } + c.Flags().StringVar(&o.ollamaDir, "ollama-dir", "", "Ollama's models directory (default: found where Ollama keeps it)") + c.Flags().StringVar(&o.model, "model", "", "the mirror's directory for it (default the Ollama model's name)") + c.Flags().StringVar(&o.version, "version", "", "the mirror version (default its tag and the start of its digest)") + c.Flags().StringVar(&o.base, "base", placeholderBase, "the address nodes reach this mirror at, for the source printed") + return c +} + +// ollamaImport is one import-ollama, from the command line or the console. +type ollamaImport struct { + mirrorDir, ollamaDir, name, model, version, base string + // quiet leaves out the copy's progress, for the console, which shows + // the output once the import is over. + quiet bool +} + +func (o ollamaImport) run(ctx context.Context, out io.Writer) error { + dir, err := mirror.OllamaDir(o.ollamaDir) + if err != nil { + return err + } + if o.name == "" { + names, err := mirror.ListOllama(dir) + if err != nil { + return err + } + if len(names) == 0 { + fmt.Fprintf(out, "Ollama in %s has no models.\n", dir) + return nil + } + fmt.Fprintf(out, "Ollama's models in %s:\n", dir) + for _, n := range names { + fmt.Fprintf(out, " %s\n", n) + } + fmt.Fprintln(out, "\nImport one with: llmgw mirror import-ollama NAME:TAG") + return nil + } + + n, err := mirror.ParseOllamaName(o.name) + if err != nil { + return err + } + m, err := mirror.ReadOllama(dir, n) + if err != nil { + return err + } + fmt.Fprintf(out, "importing %s from %s\n", n, dir) + last := "" + added, err := mirror.ImportOllama(ctx, o.mirrorDir, dir, m, o.model, o.version, func(file string, done, total int64) { + if file != last { + fmt.Fprintf(out, "copying %s\n", file) + last = file + } + if !o.quiet { + progressTo(out)(done, total) + } + }) + if err != nil { + return err + } + for _, a := range added { + fmt.Fprintf(out, "%s/%s/%s %s\n sha256 %s, as Ollama named it\n", + a.Entry.Model, a.Entry.Version, a.Entry.File, fileSize(a.Entry.Bytes), a.SHA256) + } + for _, l := range m.Left { + fmt.Fprintf(out, "Not carried: %s.\n", l) + } + + name := n.Model + if n.Tag != "latest" { + name += "-" + n.Tag + } + fmt.Fprintf(out, "\nA model definition for it, for llmgw ctl model add -f:\n\n"+ + "name: %s\nsource: %s\nprofiles:\n - name: cpu\n engine: llamacpp\n", + name, importedSource(o.base, added)) + if m.ContextLength > 0 { + // Ollama's context, which llama.cpp's own default would not match; + // llama.cpp divides it between its parallel slots. + fmt.Fprintf(out, " args: [\"--ctx-size\", %q] # Ollama ran it with num_ctx %d\n", + strconv.Itoa(m.ContextLength), m.ContextLength) + } + return nil +} + +// importedSource is what a model's source says for what was just imported: +// the file, or the version's directory when it is several. +func importedSource(base string, added []mirror.Added) string { + if base == "" { + base = placeholderBase + } + if len(added) == 1 { + return added[0].Entry.URL(base) + } + e := added[0].Entry + return strings.TrimSuffix(mirror.Entry{Model: e.Model, Version: e.Version}.URL(base), "/") + "/" +} + +// progressTo reports a copy's progress the way add reports a download's. +func progressTo(out io.Writer) func(done, total int64) { + return func(done, total int64) { + if total > 0 { + fmt.Fprintf(out, " %s of %s (%d%%)\n", fileSize(done), fileSize(total), done*100/total) + return + } + fmt.Fprintf(out, " %s so far\n", fileSize(done)) + } +} diff --git a/internal/cli/mirror_import_test.go b/internal/cli/mirror_import_test.go new file mode 100644 index 0000000..b1e4f41 --- /dev/null +++ b/internal/cli/mirror_import_test.go @@ -0,0 +1,144 @@ +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// runMirror runs `llmgw mirror ...` as an operator would and returns what it +// printed. +func runMirror(t *testing.T, args ...string) (string, error) { + t.Helper() + c := Root() + var out bytes.Buffer + c.SetOut(&out) + c.SetErr(&out) + c.SetArgs(append([]string{"mirror"}, args...)) + err := c.ExecuteContext(t.Context()) + return out.String(), err +} + +// ollamaWith lays out an Ollama models directory holding one model, as +// Ollama writes it, and returns the directory and the weights' sha256. +func ollamaWith(t *testing.T, name, tag string, weights []byte, params string) (string, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "models") + blob := func(content []byte) string { + s := sha256.Sum256(content) + h := hex.EncodeToString(s[:]) + if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "blobs", "sha256-"+h), content, 0o644); err != nil { + t.Fatal(err) + } + return h + } + w := blob(weights) + p := blob([]byte(params)) + manifest := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json",` + + `"config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:` + blob([]byte("{}")) + `","size":2},` + + `"layers":[{"mediaType":"application/vnd.ollama.image.model","digest":"sha256:` + w + `","size":` + strconv.Itoa(len(weights)) + `},` + + `{"mediaType":"application/vnd.ollama.image.params","digest":"sha256:` + p + `","size":` + strconv.Itoa(len(params)) + `}]}` + mdir := filepath.Join(dir, "manifests", "registry.ollama.ai", "library", name) + if err := os.MkdirAll(mdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mdir, tag), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + return dir, w +} + +func TestImportOllamaListsThenImportsAModelForLlamaCpp(t *testing.T) { + weights := []byte("GGUF\x03\x00\x00\x00qwen weights") + ollama, sum := ollamaWith(t, "qwen2.5", "7b", weights, `{"num_ctx":16384}`) + root := t.TempDir() + + out, err := runMirror(t, "--dir", root, "import-ollama", "--ollama-dir", ollama) + if err != nil || !strings.Contains(out, " qwen2.5:7b\n") { + t.Fatalf("listing: %v\n%s", err, out) + } + + out, err = runMirror(t, "--dir", root, "import-ollama", "qwen2.5:7b", "--ollama-dir", ollama, + "--base", "http://models.internal:8081") + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + file := filepath.Join(root, "qwen2.5", "7b-"+sum[:12], "qwen2.5-7b.gguf") + if b, err := os.ReadFile(file); err != nil || !bytes.Equal(b, weights) { + t.Fatalf("the GGUF is not in the mirror at %s: %v", file, err) + } + for _, want := range []string{ + "sha256 " + sum, + "source: http://models.internal:8081/qwen2.5/7b-" + sum[:12] + "/qwen2.5-7b.gguf", + "engine: llamacpp", + `args: ["--ctx-size", "16384"]`, + } { + if !strings.Contains(out, want) { + t.Errorf("the output does not say %q:\n%s", want, out) + } + } +} + +func TestTheConsoleImportsFromOllamaIntoTheMirrorBesideIt(t *testing.T) { + weights := []byte("GGUF\x03\x00\x00\x00llama weights") + ollama, sum := ollamaWith(t, "llama3.2", "3b", weights, `{}`) + root := t.TempDir() + + m, _ := consoleWith(t, cMirror) + var tm tea.Model = press(m, "i") + f := tm.(consoleModel).formD + if f == nil || f.title != "Import from Ollama" { + t.Fatalf("I opened %+v", f) + } + f.fields[0].text.SetValue("llama3.2:3b") + f.fields[1].text.SetValue(ollama) + f.fields[2].text.SetValue(root) + for range len(f.fields) { + tm = press(tm, "tab") + } + tm = pressRun(tm, "enter") + + if _, err := os.Stat(filepath.Join(root, "llama3.2", "3b-"+sum[:12], "llama3.2-3b.gguf")); err != nil { + t.Fatalf("not imported: %v", err) + } + text := tm.(consoleModel).dialogText + if !strings.Contains(text, "engine: llamacpp") || strings.Contains(text, "(100%)") { + t.Errorf("the console said:\n%s", text) + } +} + +func TestImportCopiesALocalGGUFIntoTheMirror(t *testing.T) { + src := filepath.Join(t.TempDir(), "q4_k_m.gguf") + content := []byte("GGUF\x03\x00\x00\x00converted") + if err := os.WriteFile(src, content, 0o644); err != nil { + t.Fatal(err) + } + root := t.TempDir() + out, err := runMirror(t, "--dir", root, "import", src, "--model", "qwen", "--version", "2026-10") + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if b, err := os.ReadFile(filepath.Join(root, "qwen", "2026-10", "q4_k_m.gguf")); err != nil || !bytes.Equal(b, content) { + t.Fatalf("not imported: %v", err) + } + if !strings.Contains(out, "source: http://:8081/qwen/2026-10/q4_k_m.gguf") { + t.Errorf("no source given:\n%s", out) + } + // The layout is the point: a version is never replaced. + if _, err := runMirror(t, "--dir", root, "import", src, "--model", "qwen", "--version", "2026-10"); err == nil { + t.Error("an existing version was replaced") + } + if _, err := runMirror(t, "--dir", root, "import", src); err == nil { + t.Error("imported without --model") + } +} diff --git a/internal/cli/model.go b/internal/cli/model.go index 2a19bff..579e6af 100644 --- a/internal/cli/model.go +++ b/internal/cli/model.go @@ -37,7 +37,8 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { newTargetCmd(client, asJSON), newAdaptersCmd(client), newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client), newModelRollbackCmd(client), newModelOriginCmd(client), - newModelCanaryCmd(client), newModelPromoteCmd(client), newModelCanariesCmd(client, asJSON)) + newModelCanaryCmd(client), newModelPromoteCmd(client), newModelCanariesCmd(client, asJSON), + newModelGateCmd(client)) return c } diff --git a/internal/cli/model_canary.go b/internal/cli/model_canary.go index ad353e9..0fa9f91 100644 --- a/internal/cli/model_canary.go +++ b/internal/cli/model_canary.go @@ -83,17 +83,29 @@ func newModelCanaryCmd(client func() *ctlClient) *cobra.Command { } func newModelPromoteCmd(client func() *ctlClient) *cobra.Command { - var detach bool + var ( + detach bool + gate gateFlags + ) c := &cobra.Command{ Use: "promote MODEL", Short: "Move a model to its canary's weights, and take the canary out", Long: "The update the canary was a trial of: the catalog names the canary's\n" + "files, the model's replicas are replaced one at a time, and the canary\n" + - "is removed. `llmgw ctl model rollback MODEL` goes back.", + "is removed. `llmgw ctl model rollback MODEL` goes back.\n\n" + + "Held to a quality suite (--eval, or the model's own: `llmgw ctl model\n" + + "gate`), the canary is asked it first, and below its score nothing\n" + + "moves: the canary stays, with its share, until it is taken out.", + Example: " llmgw ctl model promote qwen-7b\n" + + " llmgw ctl model promote qwen-7b --eval ./support.yaml --min-score 0.9 --judge llama-70b", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + body, err := gate.body() + if err != nil { + return err + } var job modelserve.Job - if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/promote", []byte("{}"), &job); err != nil { + if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/promote", body, &job); err != nil { return err } if detach { @@ -103,6 +115,7 @@ func newModelPromoteCmd(client func() *ctlClient) *cobra.Command { }, } c.Flags().BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it") + gate.add(c) return c } diff --git a/internal/cli/model_update.go b/internal/cli/model_update.go index 1795956..74aa6f1 100644 --- a/internal/cli/model_update.go +++ b/internal/cli/model_update.go @@ -82,7 +82,10 @@ func updateCell(u modelserve.UpdateInfo) string { } func newModelUpdateCmd(client func() *ctlClient) *cobra.Command { - var detach bool + var ( + detach bool + gate gateFlags + ) c := &cobra.Command{ Use: "update MODEL", Short: "Move a model to the weights its repository publishes now", @@ -90,12 +93,23 @@ func newModelUpdateCmd(client func() *ctlClient) *cobra.Command { "checked against the registry's checksums and the nodes' llama.cpp; then\n" + "the catalog names them and the replicas are replaced one at a time, each\n" + "new one up before an old one stops, and one is asked to say hello.\n\n" + + "Held to a quality suite (--eval, or the model's own: `llmgw ctl model\n" + + "gate`), the new weights first run beside the old as MODEL-canary, given\n" + + "none of its requests, and are asked the suite; below its score they are\n" + + "taken out again and the model keeps its weights. That needs room for one\n" + + "more replica while it runs.\n\n" + "The old files stay on the mirror: `llmgw ctl model rollback MODEL` goes\n" + "back to them. A model from server.yaml is changed there instead.", + Example: " llmgw ctl model update qwen-7b\n" + + " llmgw ctl model update qwen-7b --eval arithmetic --min-score 0.8", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + body, err := gate.body() + if err != nil { + return err + } var job modelserve.Job - if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/update", []byte("{}"), &job); err != nil { + if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/update", body, &job); err != nil { return err } if detach { @@ -105,6 +119,7 @@ func newModelUpdateCmd(client func() *ctlClient) *cobra.Command { }, } c.Flags().BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it") + gate.add(c) return c } diff --git a/internal/cli/modelplan.go b/internal/cli/modelplan.go index cb5eb16..6ee413d 100644 --- a/internal/cli/modelplan.go +++ b/internal/cli/modelplan.go @@ -155,28 +155,41 @@ func hardwareFrom(nodes []adminapi.NodeView) []modelplan.Hardware { if len(n.Fabrics) > 0 { fabric = n.Fabrics[0] } - class := "" - if len(n.Classes) > 0 { - class = n.Classes[0] + // Each class of the node's devices is a pool of its own: a node with + // a card in MIG mode has its instances' classes, and any whole card's + // beside them. A node that does not count by class is its first + // class, as it always was. + kinds := n.ClassGPUs + if len(kinds) == 0 { + class := "" + if len(n.Classes) > 0 { + class = n.Classes[0] + } + kinds = []adminapi.ClassGPUs{{Class: class, Total: n.GPUTotal, Free: n.GPUFree}} } - k := key{class, fabric} - if pools[k] == nil { - pools[k] = &modelplan.Hardware{Class: class, Fabric: fabric} - } - p := pools[k] - p.Nodes++ - // The widest a single node could contribute, not the total: a tensor - // group takes the same number of cards from every node it spans. - if n.GPUFree > p.GPUsPerNode { - p.GPUsPerNode = n.GPUFree - } - if n.MemoryGB > p.HostMemGB { - p.HostMemGB = n.MemoryGB - } - // Card memory from the class label, which carries it by construction: - // "h100-80g" is eighty gigabytes and says so. - if class != "" && p.GPUMemGB == 0 { - p.GPUMemGB = memFromClass(class) + for _, kind := range kinds { + class := kind.Class + k := key{class, fabric} + if pools[k] == nil { + pools[k] = &modelplan.Hardware{Class: class, Fabric: fabric} + } + p := pools[k] + p.Nodes++ + // The widest a single node could contribute, not the total: a + // tensor group takes the same number of cards from every node it + // spans. + if kind.Free > p.GPUsPerNode { + p.GPUsPerNode = kind.Free + } + if n.MemoryGB > p.HostMemGB { + p.HostMemGB = n.MemoryGB + } + // Card memory from the class label, which carries it by + // construction: "h100-80g" is eighty gigabytes and says so, and + // "a100-80g-mig-3g.40gb" is an instance of forty. + if class != "" && p.GPUMemGB == 0 { + p.GPUMemGB = memFromClass(class) + } } } @@ -191,17 +204,7 @@ func hardwareFrom(nodes []adminapi.NodeView) []modelplan.Hardware { } // memFromClass reads the card size out of a class label such as "h100-80g". -func memFromClass(class string) int { - i := strings.LastIndex(class, "-") - if i < 0 || !strings.HasSuffix(class, "g") { - return 0 - } - var gb int - if _, err := fmt.Sscanf(class[i+1:], "%dg", &gb); err != nil { - return 0 - } - return gb -} +func memFromClass(class string) int { return modelplan.MemFromClass(class) } // catalogEntry renders a fit as the YAML that would serve it. func catalogEntry(m modelplan.Model, f modelplan.Fit, source string, context int) string { diff --git a/internal/cli/server.go b/internal/cli/server.go index 693e5e7..9ec796d 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -9,6 +9,7 @@ import ( "maps" "net" "net/http" + "os" "os/signal" "slices" "strings" @@ -32,6 +33,7 @@ import ( llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1" "gitea.mynodes.xyz/ilker/llmgw/internal/audit" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" @@ -102,10 +104,12 @@ func newServerCmd() *cobra.Command { "or give --endpoint for a router-only run") } - // Before the router, so its tracer is the configured one. + // Before the router, so its tracer is the configured one. The + // host tells two control planes' traces apart. + host, _ := os.Hostname() shutdownTracing, err := obs.SetupTracing(cmd.Context(), obs.TracingOptions{ Endpoint: cfg.Tracing.Endpoint, SampleRatio: cfg.Tracing.SampleRatio, - Service: "llmgw-server", Version: version.Version, + Service: "llmgw-server", Version: version.Version, Instance: host, }) if err != nil { return err @@ -286,6 +290,7 @@ func newServerCmd() *cobra.Command { var ( adminAPI *adminapi.API catalogSvc *modelsvc.Service + evals *eval.Service ) if admin != nil { api := adminapi.New(admin) @@ -303,6 +308,9 @@ func newServerCmd() *cobra.Command { api.SetReleases(cfg.Releases) api.SetNotifier(notifier) api.SetReplayer(rt.Replay) + // Quality suites, asked of a model the way a replay is. + evals = newEvalService(cfg, rt, admin, log) + api.SetEval(evals) if st := rt.Store(); st != nil && gate != nil { api.SetGuard(&adminapi.GuardAdmin{Library: cfg.Guard, Store: st, Invalidate: gate.Invalidate}) } @@ -419,6 +427,8 @@ func newServerCmd() *cobra.Command { Pool: adminAPI.PoolNodes, Notify: notifier, // Canaries: how the router saw each side serve. CanaryStats: rt.CanaryStats, ResetCanary: rt.ResetCanary, + // Updates and promotions held to a quality suite. + Eval: evals, }) go serve.WatchCanaries(ctx, 30*time.Second) adminAPI.SetServe(serve) diff --git a/internal/cli/tui_actions.go b/internal/cli/tui_actions.go index b73c109..0d88fbc 100644 --- a/internal/cli/tui_actions.go +++ b/internal/cli/tui_actions.go @@ -423,6 +423,7 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog { }}, } items = append(items, m.updateItems(md)...) + items = append(items, m.evalItems(md)...) if md.Parked { items = append(items, menuItem{"Try again (it is parked)", func(m *consoleModel) tea.Cmd { return m.call("Retry "+md.Name, http.MethodPost, path+"/retry", map[string]string{}, nil) @@ -706,7 +707,7 @@ func (m consoleModel) canaryItems(md adminapi.ModelView) []menuItem { {"Promote the canary…", func(m *consoleModel) tea.Cmd { m.confirmD = &confirmDialog{title: "Promote " + cn.Model, text: md.Name + " moves to the canary's weights: its replicas are replaced one at a time,\n" + - "and the canary is removed. Going back is \"Go back to\" on this menu.", + "and the canary is removed. Going back is \"Go back to\" on this menu." + gateNote(md), yes: func(m *consoleModel) tea.Cmd { return m.call("Promote "+cn.Model, http.MethodPost, path+"/promote", map[string]string{}, startedSays) }} @@ -780,8 +781,8 @@ func (m consoleModel) updateItems(md adminapi.ModelView) []menuItem { m.confirmD = &confirmDialog{title: "Update " + md.Name, text: fmt.Sprintf("%s has new weights at %s: %s.\n\n"+ "They are fetched into the mirror beside the ones in use and checked, then the\n"+ - "replicas are replaced one at a time. The old files stay, to go back to.", - u.Repo, catalog.ShortRevision(u.Latest), strings.Join(u.Changed, ", ")), + "replicas are replaced one at a time. The old files stay, to go back to.%s", + u.Repo, catalog.ShortRevision(u.Latest), strings.Join(u.Changed, ", "), gateNote(md)), yes: func(m *consoleModel) tea.Cmd { return m.call("Update "+md.Name, http.MethodPost, "/api/v1/models/"+url.PathEscape(md.Name)+"/update", map[string]string{}, startedSays) diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index e96af5e..5fc1d12 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" @@ -154,6 +155,11 @@ type consoleModel struct { formD *formDialog confirmD *confirmDialog busy string + // busyNote says how far the request under way has got, when it can; + // evalWatch is the quality run being followed, which Esc stops + // following while it carries on. + busyNote string + evalWatch string quotas []adminapi.QuotaView quotasErr error @@ -337,8 +343,12 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.people = peopleData(msg) case consoleQuotasMsg: m.quotas, m.quotasErr = msg.rows, msg.err + case evalSuitesMsg: + return m.onEvalSuites(msg) + case evalPollMsg: + return m.onEvalPoll(msg) case actionDoneMsg: - m.busy = "" + m.busy, m.busyNote, m.evalWatch = "", "", "" m.dialog, m.dialogTitle = "error", msg.title if msg.err != nil { m.dialogText = sBad.Render("✗ ") + msg.err.Error() @@ -367,6 +377,9 @@ func (m consoleModel) storeReplicated() bool { return len(m.data.sum.Store) > 0 func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.busy != "" { + if m.evalWatch != "" && k.Type == tea.KeyEsc { + m.busy, m.busyNote, m.evalWatch = "", "", "" + } return m, nil } if m.dialog != "" { @@ -536,6 +549,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) { case cOrgs: m.createOrgForm() } + case "i": + if m.menu == cMirror { + m.mirrorImportOllamaForm() + } case "n": if m.menu == cMonitor { return m, m.runCLI("Test the webhooks", "ctl", "--server="+m.client.base, "notify", "test") @@ -973,7 +990,11 @@ func (m consoleModel) View() string { } else if t, d := m.docs.dialog(); d != "" && m.menu == cManual { f.DialogTitle, f.Dialog = t, d } else if m.busy != "" { - f.DialogTitle, f.Dialog = m.busy, m.spin.View()+" asking the control plane…" + note := "asking the control plane…" + if m.busyNote != "" { + note = m.busyNote + } + f.DialogTitle, f.Dialog = m.busy, m.spin.View()+" "+note } else if t, d := m.actionDialog(); d != "" { f.DialogTitle, f.Dialog = t, d } @@ -1080,7 +1101,7 @@ func (m consoleModel) panel() (title, body, keys string) { for _, r := range m.data.replicas { var nodes []string for _, s := range r.Shards { - nodes = append(nodes, fmt.Sprintf("%s%v", s.Node, s.GPUs)) + nodes = append(nodes, fmt.Sprintf("%s%v", s.Node, discovery.DeviceLabels(s.GPUs))) } rows = append(rows, []string{r.ID, r.Model, r.State, r.Pool, strings.Join(nodes, " "), (time.Duration(r.AgeSec) * time.Second).Round(time.Second).String()}) @@ -1224,14 +1245,31 @@ func (m consoleModel) gpuRows() [][]string { for _, n := range m.data.nodes { for _, g := range n.GPUs { busy, mem, temp, power := "—", "—", "—", "—" + // A MIG instance under its card: its own memory, the card's heat + // and power, and no utilisation, which no driver measures per + // instance — said, rather than shown as the card's or as zero. + mig := g.MIGProfile != "" + if mig { + busy = sDim.Render("n/a on MIG") + if g.MemoryTotalMB > 0 { + mem = fmt.Sprintf("%.1f/%.0f GB", float64(g.MemoryUsedMB)/1024, float64(g.MemoryTotalMB)/1024) + } + } + card := g.Card + if g.Label == "" { + // A control plane older than MIG, which sends no card. + card = g.Index + } if n.Load != nil { for _, s := range n.Load.GPUs { - if s.Index != g.Index { + if s.Index != card { continue } - busy = fmt.Sprintf("%.0f%%", s.UtilPercent) - if s.MemoryTotalMB > 0 { - mem = fmt.Sprintf("%.1f/%.0f GB", s.MemoryUsedMB/1024, s.MemoryTotalMB/1024) + if !mig { + busy = fmt.Sprintf("%.0f%%", s.UtilPercent) + if s.MemoryTotalMB > 0 { + mem = fmt.Sprintf("%.1f/%.0f GB", s.MemoryUsedMB/1024, s.MemoryTotalMB/1024) + } } if s.TempC > 0 { temp = fmt.Sprintf("%.0f°C", s.TempC) @@ -1245,7 +1283,12 @@ func (m consoleModel) gpuRows() [][]string { if holder == "" { holder = sDim.Render("free") } - rows = append(rows, []string{n.ID, strconv.Itoa(g.Index), g.Name, busy, mem, temp, power, holder}) + label := g.Label + if label == "" { + // A control plane older than labels. + label = strconv.Itoa(g.Index) + } + rows = append(rows, []string{n.ID, label, g.Name, busy, mem, temp, power, holder}) } } return rows @@ -1266,6 +1309,15 @@ func (m consoleModel) nodeDetail(n adminapi.NodeView) string { } kv("disk", fmt.Sprintf("%d GB free of %d", n.DiskFreeGB, n.DiskTotalGB)) kv("replicas", strconv.Itoa(n.Replicas)) + // By class, which on a node with a card in MIG mode is the instances' + // profiles beside any whole card: what a profile's gpu_class can name. + for _, c := range n.ClassGPUs { + what := fmt.Sprintf("%d, %d free", c.Total, c.Free) + if c.MIG { + what += " · MIG instances, one to a replica" + } + kv(c.Class, what) + } if len(n.Provision) > 0 { b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Engines") + "\n") for _, p := range n.Provision { diff --git a/internal/cli/tui_docs.go b/internal/cli/tui_docs.go index 4f76b98..6e73fb6 100644 --- a/internal/cli/tui_docs.go +++ b/internal/cli/tui_docs.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "io/fs" "sort" "strings" @@ -24,60 +23,17 @@ type docTopic struct { src func() string } -// docOrder is the documents in the order someone new reads them. -var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding", - "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data", "reference", "development"} - +// docTopics is the manual: the documents in llmgw.Manual's order, which the +// gateway's docs portal shares, with a page for the commands after the +// configuration reference — the one page only the command tree can write. func docTopics() []docTopic { - read := func(path string) func() string { - return func() string { - b, err := fs.ReadFile(llmgw.Docs, path) - if err != nil { - return "# " + path + "\n\nNot in this build." - } - return string(b) - } - } - titleOf := func(path string) string { - b, _ := fs.ReadFile(llmgw.Docs, path) - for _, l := range strings.Split(string(b), "\n") { - if strings.HasPrefix(l, "# ") { - return strings.TrimPrefix(l, "# ") - } - } - return path - } var out []docTopic - seen := map[string]bool{} - add := func(key, path string) { - if seen[key] { - return - } - if _, err := fs.Stat(llmgw.Docs, path); err != nil { - return - } - seen[key] = true - out = append(out, docTopic{key: key, title: titleOf(path), src: read(path)}) - } - for _, k := range docOrder { - add(k, "docs/"+k+".md") - if k == "reference" { + for _, d := range llmgw.Manual() { + out = append(out, docTopic{key: d.Key, title: d.Title, src: d.Source}) + if d.Key == "reference" { out = append(out, docTopic{key: "commands", title: "Commands", src: commandsManual}) - seen["commands"] = true } } - // Any document not placed above, so a new one is never missing. - entries, _ := fs.ReadDir(llmgw.Docs, "docs") - var rest []string - for _, e := range entries { - rest = append(rest, strings.TrimSuffix(e.Name(), ".md")) - } - sort.Strings(rest) - for _, k := range rest { - add(k, "docs/"+k+".md") - } - add("readme", "README.md") - add("changes", "CHANGELOG.md") return out } diff --git a/internal/cli/tui_mirror.go b/internal/cli/tui_mirror.go index 68d0f69..7dade93 100644 --- a/internal/cli/tui_mirror.go +++ b/internal/cli/tui_mirror.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "errors" "fmt" @@ -178,10 +179,41 @@ func (m *consoleModel) mirrorFetchForm() { }}) } +// mirrorImportOllamaForm puts a model from an Ollama on this machine into a +// mirror on this machine: the control plane's own, when the mirror runs +// beside it. A mirror elsewhere cannot read this machine's files, and its +// API is given none; there it is `llmgw mirror import-ollama`, on the mirror. +func (m *consoleModel) mirrorImportOllamaForm() { + m.openForm(&formDialog{title: "Import from Ollama", + note: "From an Ollama on this machine into the mirror's directory on this machine.\n" + + "A mirror elsewhere: run llmgw mirror import-ollama there.", + fields: []formField{ + textF("model", "", "as Ollama names it, like llama3.2:3b"), + textF("ollama dir", "", "optional: found where Ollama keeps it"), + textF("mirror dir", "/srv/models", "the mirror's directory on this machine"), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + if v[0] == "" { + return nil, fmt.Errorf("a model, as Ollama names it") + } + if st, err := os.Stat(v[2]); err != nil || !st.IsDir() { + return nil, fmt.Errorf("%s is not a directory on this machine", v[2]) + } + title := "Import " + v[0] + m.busy = title + o := ollamaImport{name: v[0], ollamaDir: v[1], mirrorDir: v[2], base: m.mirror.res.Base, quiet: true} + return func() tea.Msg { + var out bytes.Buffer + err := o.run(context.Background(), &out) + return actionDoneMsg{title: title, text: strings.TrimSpace(out.String()), err: err} + }, nil + }}) +} + func (m consoleModel) mirrorPanel(pw int) (string, string, string) { keys := "→ or Enter open · ↑↓ choose · Q leave" if m.inPanel { - keys = "↑↓ row · Enter actions · A download one · ← back · Q leave" + keys = "↑↓ row · Enter actions · A download one · I import from Ollama · ← back · Q leave" } if m.mirror.err != nil { return "Mirror", "\n " + sBad.Render(m.mirror.err.Error()), keys @@ -216,7 +248,7 @@ func (m consoleModel) mirrorPanel(pw int) (string, string, string) { rows = append(rows, []string{g.model, g.version, what, state, bytesShort(total), used}) } if len(rows) == 0 { - return "Mirror", head + " " + sDim.Render("Empty. A downloads one."), keys + return "Mirror", head + " " + sDim.Render("Empty. A downloads one; I imports one from Ollama."), keys } return "Mirror", head + table([]string{"MODEL", "VERSION", "FILE", "STATE", "SIZE", "USED BY"}, rows, m.row, pw, m.inPanel), keys } diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go index 7dfab76..cfd9cff 100644 --- a/internal/cli/tui_people.go +++ b/internal/cli/tui_people.go @@ -72,6 +72,8 @@ type peopleData struct { notes map[string]int // project id → hours kept, agent notes // requests is how each project keeps its requests and answers. requests map[string]store.RequestLog + // cache is how each project reuses answers. + cache map[string]store.AnswerCache // guardPolicies are server.yaml's guardrail policies, guardDefault those // applied everywhere, and guardAt which are attached where. guardPolicies []string @@ -125,6 +127,7 @@ func (m consoleModel) fetchPeople() tea.Cmd { d.guardAt, _ = s.GuardAttachments(ctx) } d.threads, d.notes, d.requests = map[string]int{}, map[string]int{}, map[string]store.RequestLog{} + d.cache = map[string]store.AnswerCache{} for _, r := range d.orgs { if r.level != "project" { continue @@ -138,6 +141,9 @@ func (m consoleModel) fetchPeople() tea.Cmd { if l, err := s.RequestLogOf(ctx, r.id); err == nil { d.requests[r.id] = l } + if c, err := s.AnswerCacheOf(ctx, r.id); err == nil { + d.cache[r.id] = c + } } return consolePeopleMsg(d) } @@ -552,6 +558,39 @@ func (m consoleModel) orgMenu(r orgRow) *menuDialog { return m.runCLI("Keep requests", append(args, "--retain-hours="+v[0], "--sample="+v[1])...), nil }}) return nil + }}, + menuItem{"Cache answers…", func(m *consoleModel) tea.Cmd { + c := m.people.cache[r.id] + ttl, which := "off", "reproducible" + if c.TTL > 0 { + ttl = c.TTL.String() + } + if c.Any { + which = "any" + } + m.openForm(&formDialog{title: "Cache answers · " + r.path, + note: "Off by default. On, the same request to the same model within the time below is\n" + + "answered again from the gateway's memory, without the model, and costs nothing.\n" + + "Reproducible: only temperature 0 or a seed, and embeddings. Any: sampled answers too.", + fields: []formField{ + textF("reuse for", ttl, "like 1h; off reuses none"), + choiceF("answers", []string{"reproducible", "any"}, which), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + args := []string{"keys", "cache", "--project=" + r.id} + if v[0] == "off" || v[0] == "0" { + return m.runCLI("Cache answers", append(args, "--off")...), nil + } + if d, err := time.ParseDuration(v[0]); err != nil || d < time.Second { + return nil, fmt.Errorf("a duration like 1h, or off") + } + args = append(args, "--ttl="+v[0]) + if v[1] == "any" { + args = append(args, "--any") + } + return m.runCLI("Cache answers", args...), nil + }}) + return nil }}) } items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd { diff --git a/internal/cli/tui_quality.go b/internal/cli/tui_quality.go new file mode 100644 index 0000000..27bb955 --- /dev/null +++ b/internal/cli/tui_quality.go @@ -0,0 +1,291 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +// Quality suites from the model menu: ask a model one of the suites the +// control plane has (its eval directory's, and those built in) and watch +// its score come in; list its past runs; hold its updates to one — the same +// as `llmgw ctl eval` and `llmgw ctl model gate`. + +// evalSuitesMsg is the suites the control plane can run, fetched to open +// the form that runs one (or, with gate, the one that sets a model's). +type evalSuitesMsg struct { + model adminapi.ModelView + suites []eval.SuiteInfo + gate bool + err error +} + +// evalPollMsg is a run still going, to be asked about again. +type evalPollMsg struct { + title string + live eval.Live + err error +} + +// gateNote says, in an update's or a promotion's question, that the new +// weights are asked the model's suite first. +func gateNote(md adminapi.ModelView) string { + g := md.Eval + if g == nil { + return "" + } + at := "its threshold" + if g.MinScore > 0 { + at = fmt.Sprintf("%.2f", g.MinScore) + } + return fmt.Sprintf("\n\nFirst the new weights are asked the quality suite %s, and must score %s;\n"+ + "below it nothing moves.", g.Suite, at) +} + +// evalItems are the model menu's quality entries. +func (m consoleModel) evalItems(md adminapi.ModelView) []menuItem { + gate := "Hold its updates to a quality suite…" + if g := md.Eval; g != nil { + gate = "Its updates' quality suite (" + g.Suite + ")…" + } + return []menuItem{ + {"Ask it a quality suite…", func(m *consoleModel) tea.Cmd { return m.fetchSuites(md, false) }}, + {"Its quality runs", func(m *consoleModel) tea.Cmd { + return m.call("Quality runs of "+md.Name, http.MethodGet, + "/api/v1/eval/runs?limit=15&model="+url.QueryEscape(md.Name), nil, func(raw json.RawMessage) string { + var runs []eval.Live + if err := json.Unmarshal(raw, &runs); err != nil { + return string(raw) + } + return evalRunsText(runs) + }) + }}, + {gate, func(m *consoleModel) tea.Cmd { return m.fetchSuites(md, true) }}, + } +} + +func (m *consoleModel) fetchSuites(md adminapi.ModelView, gate bool) tea.Cmd { + m.busy = "Quality suites" + c := m.client + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var list []eval.SuiteInfo + err := c.get(ctx, "/api/v1/eval/suites", &list) + return evalSuitesMsg{model: md, suites: list, gate: gate, err: err} + } +} + +func (m consoleModel) onEvalSuites(msg evalSuitesMsg) (tea.Model, tea.Cmd) { + m.busy = "" + md := msg.model + var names []string + for _, s := range msg.suites { + if s.Error == "" { + names = append(names, s.Name) + } + } + if msg.err == nil && len(names) == 0 { + msg.err = errors.New("the control plane has no suite it can run: put one in its control.eval_dir " + + "(/etc/llmgw/eval); docs/install.md, \"Quality suites\", says how one is written") + } + if msg.err != nil { + m.dialog, m.dialogTitle = "error", "Quality suites" + m.dialogText = sBad.Render("✗ ") + msg.err.Error() + "\n\n" + sDim.Render("any key") + return m, nil + } + about := func(name string) string { + for _, s := range msg.suites { + if s.Name == name { + return fmt.Sprintf("%s: %d cases, passed at %.2f, %s", s.Name, s.Cases, s.Threshold, s.From) + } + } + return "" + } + path := "/api/v1/models/" + url.PathEscape(md.Name) + if msg.gate { + current, minScore := "none", "" + if g := md.Eval; g != nil { + current = g.Suite + if g.MinScore > 0 { + minScore = strconv.FormatFloat(g.MinScore, 'f', -1, 64) + } + if !slices.Contains(names, current) { + names = append(names, current) + } + } + m.openForm(&formDialog{title: "Quality gate of " + md.Name, + note: "An update or a promotion asks the new weights this suite first, and stops below its score;\n" + + "an update tries them as a canary given none of " + md.Name + "'s requests, and takes it out again.", + fields: []formField{ + choiceF("suite", append([]string{"none"}, names...), current), + textF("min score", minScore, "0 to 1; empty is the suite's own threshold"), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + if v[0] == "none" { + return m.call("Quality gate of "+md.Name, http.MethodDelete, path+"/eval-gate", nil, + func(json.RawMessage) string { return md.Name + "'s updates are no longer held to a suite." }), nil + } + score, err := minScoreOf(v[1]) + if err != nil { + return nil, err + } + return m.call("Quality gate of "+md.Name, http.MethodPut, path+"/eval-gate", + adminapi.EvalGateRequest{Suite: v[0], MinScore: score}, func(json.RawMessage) string { + return fmt.Sprintf("%s's new weights must pass %s before they replace the old.", md.Name, v[0]) + }), nil + }}) + return m, nil + } + m.openForm(&formDialog{title: "Ask " + md.Name + " a quality suite", + note: "Asked on the control plane, through the router as its own requests: past quotas and guardrails.\n" + + about(names[0]), + fields: []formField{ + choiceF("suite", names, names[0]), + textF("at once", "4", "cases asked at the same time"), + textF("min score", "", "0 to 1; empty is the suite's own threshold"), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + n, err := strconv.Atoi(v[1]) + if err != nil || n < 1 { + return nil, fmt.Errorf("at once is a whole number, 1 or more") + } + score, err := minScoreOf(v[2]) + if err != nil { + return nil, err + } + return m.startEval(md.Name, eval.Request{Suite: v[0], Concurrency: n, MinScore: score}), nil + }}) + return m, nil +} + +func minScoreOf(s string) (float64, error) { + if s == "" { + return 0, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || f < 0 || f > 1 { + return 0, fmt.Errorf("the min score is a number from 0 to 1") + } + return f, nil +} + +// startEval starts a run and follows it. +func (m *consoleModel) startEval(model string, req eval.Request) tea.Cmd { + title := model + ": " + req.Suite + m.busy, m.busyNote = title, "starting…" + c := m.client + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var l eval.Live + err := c.do(ctx, http.MethodPost, "/api/v1/models/"+url.PathEscape(model)+"/eval", req, &l) + if err != nil { + return actionDoneMsg{title: title, err: err} + } + return evalPollMsg{title: title, live: l} + } +} + +func (m consoleModel) onEvalPoll(msg evalPollMsg) (tea.Model, tea.Cmd) { + if m.evalWatch != "" && m.evalWatch != msg.live.ID { + return m, nil + } + if m.evalWatch == "" && m.busy != msg.title { + // Esc: no longer followed here; it carries on on the control plane. + return m, nil + } + m.evalWatch = msg.live.ID + m.busy = msg.title + m.busyNote = fmt.Sprintf("%d of %d answered · Esc stops watching (it carries on)", msg.live.Done, msg.live.Cases) + c, id, title := m.client, msg.live.ID, msg.title + return m, func() tea.Msg { + time.Sleep(time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var l eval.Live + if err := c.get(ctx, "/api/v1/eval/runs/"+url.PathEscape(id), &l); err != nil { + return actionDoneMsg{title: title, err: err} + } + if l.State == eval.Running { + return evalPollMsg{title: title, live: l} + } + var runs []eval.Live + _ = c.get(ctx, "/api/v1/eval/runs?limit=6&model="+url.QueryEscape(l.Model), &runs) + text := evalRunText(l) + if len(runs) > 1 { + text += "\n\nIts last runs:\n" + evalRunsText(runs) + } + if l.State == eval.Failed || !l.Passed { + return actionDoneMsg{title: title, err: errors.New(text)} + } + return actionDoneMsg{title: title, text: text} + } +} + +// evalRunText is a finished run: its score, then each case. +func evalRunText(l eval.Live) string { + if l.State == eval.Failed { + return "The run could not be scored: " + l.Error + } + var b strings.Builder + b.WriteString(evalSummary(l) + "\n") + if l.Error != "" { + b.WriteString(l.Error + "\n") + } + b.WriteString("\n") + shown := min(len(l.Results), 14) + for _, r := range l.Results[:shown] { + mark := sOK.Render("✓") + if !r.Passed { + mark = sBad.Render("✗") + } + why := r.Detail + if r.Error != "" { + why = r.Error + } + fmt.Fprintf(&b, "%s %-18s %4.2f %s\n", mark, truncate(r.ID, 18), r.Score, truncate(why, 60)) + } + if len(l.Results) > shown { + fmt.Fprintf(&b, " and %d more: llmgw ctl eval --run %s\n", len(l.Results)-shown, l.ID) + } + return strings.TrimRight(b.String(), "\n") +} + +// evalRunsText is a model's runs, one a line. +func evalRunsText(runs []eval.Live) string { + if len(runs) == 0 { + return "No runs kept yet: \"Ask it a quality suite\" on this menu makes one." + } + var b strings.Builder + for _, r := range runs { + result := sOK.Render("passed") + switch { + case r.State == eval.Running: + result = fmt.Sprintf("running, %d of %d", r.Done, r.Cases) + case r.State == eval.Failed: + result = sBad.Render("could not run") + case !r.Passed: + result = sBad.Render("failed") + } + who := r.Model + if r.Target != "" { + who = r.Target + } + fmt.Fprintf(&b, "%-9s %-14s %-16s %4.2f of %4.2f %s\n", ageShort(r.At), truncate(r.Suite, 14), + truncate(who, 16), r.Score, r.Threshold, result) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/cli/tui_quality_test.go b/internal/cli/tui_quality_test.go new file mode 100644 index 0000000..ae9c8c3 --- /dev/null +++ b/internal/cli/tui_quality_test.go @@ -0,0 +1,116 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func TestAModelIsAskedASuiteFromItsMenuAndItsScoreShown(t *testing.T) { + var mu sync.Mutex + var asked eval.Request + polls := 0 + done := eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Suite: "arithmetic", Score: 0.75, + Threshold: 0.7, Passed: true, Cases: 2, Below: 1, Results: []store.EvalCase{ + {ID: "add", Kind: "number", Score: 1, Passed: true, Detail: "read 42"}, + {ID: "mul", Kind: "number", Score: 0, Detail: "read 43, wanted 42"}}}, State: eval.Done, Done: 2} + admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch { + case r.URL.Path == "/api/v1/eval/suites": + _ = json.NewEncoder(w).Encode([]eval.SuiteInfo{ + {Name: "arithmetic", From: "built in", Cases: 2, Threshold: 0.7}, + {Name: "broken", From: "/etc/llmgw/eval/broken.yaml", Error: "no cases"}, + {Name: "json-format", From: "built in", Cases: 7, Threshold: 0.7}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models/qwen/eval": + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &asked) + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", + Suite: "arithmetic", Cases: 2}, State: eval.Running}) + case r.URL.Path == "/api/v1/eval/runs/eval-1": + polls++ + if polls == 1 { + _ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", + Suite: "arithmetic", Cases: 2}, State: eval.Running, Done: 1}) + return + } + _ = json.NewEncoder(w).Encode(done) + case r.URL.Path == "/api/v1/eval/runs": + older := done + older.ID, older.Score, older.Passed = "eval-0", 0.5, false + _ = json.NewEncoder(w).Encode([]eval.Live{done, older}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(admin.Close) + m := newConsoleModel(admin.URL, "/nonexistent/server.yaml") + m.loaded, m.menu, m.inPanel = true, cModels, true + md := adminapi.ModelView{Name: "qwen"} + + // run plays a command and what it leads to, the way the program would. + run := func(tm tea.Model, cmd tea.Cmd) tea.Model { + for cmd != nil { + msg := cmd() + tm, cmd = tm.Update(msg) + if _, ok := msg.(actionDoneMsg); ok { + break + } + } + return tm + } + var tm tea.Model = m + items := m.evalItems(md) + if len(items) != 3 || !strings.Contains(items[0].label, "quality suite") { + t.Fatalf("%+v", items) + } + tm = run(tm, items[0].act(&m)) + f := tm.(consoleModel).formD + if f == nil || f.fields[0].options == nil || len(f.fields[0].options) != 2 { + t.Fatalf("no form offering the suites that can run: %+v", f) + } + tm = press(tm, "tab") + f.fields[1].text.SetValue("2") + tm = press(tm, "tab", "tab") + tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if busy := tm.(consoleModel).busy; busy != "qwen: arithmetic" { + t.Fatalf("busy %q", busy) + } + // Started, then followed: one poll still running, then its score. + tm, next := tm.Update(cmd()) + if note := tm.(consoleModel).busyNote; !strings.Contains(note, "0 of 2 answered") { + t.Errorf("progress %q", note) + } + tm, next = tm.Update(next()) + if note := tm.(consoleModel).busyNote; !strings.Contains(note, "1 of 2 answered") { + t.Errorf("progress %q", note) + } + tm, _ = tm.Update(next()) + view := tm.View() + mu.Lock() + sent := asked + mu.Unlock() + if sent.Suite != "arithmetic" || sent.Concurrency != 2 { + t.Errorf("asked %+v", sent) + } + for _, want := range []string{"qwen scored 0.75 on arithmetic", "1 of 2 cases passed", "read 43, wanted 42", "Its last runs"} { + if !strings.Contains(view, want) { + t.Errorf("the result does not say %q:\n%s", want, view) + } + } + if c := tm.(consoleModel); c.busy != "" || c.evalWatch != "" { + t.Errorf("still busy: %q %q", c.busy, c.evalWatch) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ce78b2d..3c5c8ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/guard" "gitea.mynodes.xyz/ilker/llmgw/internal/notify" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" "gitea.mynodes.xyz/ilker/llmgw/internal/secrets" "gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf" ) @@ -94,10 +95,11 @@ type Server struct { // Monitoring is where the dashboard reads its history from. Monitoring Monitoring `koanf:"monitoring"` - // Tracing sends a trace of each inference request to an OpenTelemetry - // collector. Off unless an endpoint is set here or in + // Tracing sends traces to an OpenTelemetry collector: each inference + // request, and on a control plane each placement, replica start and + // model job. Off unless an endpoint is set here or in // OTEL_EXPORTER_OTLP_ENDPOINT. - Tracing Tracing `koanf:"tracing"` + Tracing obs.Tracing `koanf:"tracing"` // Mirror points the control plane at a model mirror it can administer. // Empty means there is none, and /admin/mirror stays unmounted. @@ -197,21 +199,6 @@ type Monitoring struct { Timeout time.Duration `koanf:"timeout"` } -// Tracing is where request traces go, over OTLP/HTTP. -// -// One span per inference request, with admission, scheduling and each attempt -// at an engine beneath it, and the trace carried on to the engine in a -// traceparent header. The request's X-Request-Id is on the span, so a trace -// and `llmgw ctl request` name the same request. -type Tracing struct { - // Endpoint is the collector, for example http://otel.internal:4318. - // Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing. - Endpoint string `koanf:"endpoint"` - // SampleRatio is the share of requests traced, 0 to 1, when the caller - // did not decide it with a sampled traceparent of its own. Zero takes 1. - SampleRatio float64 `koanf:"sample_ratio"` -} - // Mirror is a model mirror this control plane administers over its API. // // The mirror is a separate machine with a large disk, not part of the gateway, @@ -265,6 +252,13 @@ type Landing struct { // a language with the switcher at the foot of every page and whose // browser asks for none it speaks: "en" or "tr". Empty is English. Language string `koanf:"language"` + // DocsSignIn serves the docs portal at /docs only to someone signed in. + // Off, the portal is public like this page: its client guide is for the + // person who has no account yet, and the operator documents describe + // llmgw in general — no address, model or key of this installation — + // which the project's repository publishes anyway. On, a visitor is sent + // to sign in, and without accounts the portal needs an API key. + DocsSignIn bool `koanf:"docs_sign_in"` } // Threads configures conversation and memory storage for a gateway without @@ -396,6 +390,11 @@ type Control struct { // Timezone is where models' scaling schedules are read, an IANA name // like Europe/Istanbul; empty is the control plane's own. Timezone string `koanf:"timezone"` + // EvalDir is where quality suites are read from by name, for `llmgw ctl + // eval MODEL --suite NAME`, a model's eval gate and the console. The + // suites the binary carries (arithmetic, json-format) are found when it + // has none of the name; a suite here of the same name replaces one. + EvalDir string `koanf:"eval_dir"` // Enabled runs the control plane even when configuration declares no // models. @@ -480,6 +479,11 @@ type Router struct { // another. Needs the store. Batch Batch `koanf:"batch"` + // Cache bounds the answers kept for the projects that reuse them + // (llmgw keys cache). Which projects do, and for how long, is theirs + // to set; this is only how much memory it may take. + Cache Cache `koanf:"cache"` + // TrustedProxies are the reverse proxies in front of the client port, // as addresses or CIDR prefixes. Their X-Forwarded-For, -Proto and -Host // are believed; anyone else's are not, since they decide the address @@ -613,6 +617,18 @@ type Batch struct { RetainDays int `koanf:"retain_days"` } +// Cache is how much of this process's memory reused answers may take. The +// answers are held here and nowhere else: each gateway process keeps its +// own, and a restart begins them again. +type Cache struct { + // MaxBytes bounds every project's answers together; 128 MB unless set. + // Past it, the answers used longest ago go first. + MaxBytes int64 `koanf:"max_bytes"` + // MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB + // unless set. + MaxEntryBytes int64 `koanf:"max_entry_bytes"` +} + // Default returns the configuration used when nothing is specified. func Default() Server { return Server{ @@ -629,6 +645,7 @@ func Default() Server { LostAfter: 60 * time.Second, PortRange: []int{20000, 20999}, MgmtInterface: "eth0", + EvalDir: "/etc/llmgw/eval", }, Router: Router{ ScrapeInterval: 2 * time.Second, diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 920b88b..2a85e79 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -28,6 +28,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/bench" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" "gitea.mynodes.xyz/ilker/llmgw/internal/control" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrics" @@ -43,6 +44,8 @@ import ( type API struct { // replay sends a kept request again; nil without a router here. replay Replayer + // evals runs quality suites against models; nil without a router here. + evals *eval.Service // notifier is the webhooks `ctl notify test` reaches; nil without any. notifier *notify.Notifier // releases is where release binaries are fetched from, for upgrades. @@ -75,13 +78,26 @@ func (a *API) SetRevoker(f func(ctx context.Context, nodeID string, at time.Time a.revoke = f } +// Mux is where the endpoints are mounted. An *http.ServeMux is one; a test +// that records the patterns is another, and is how the route table is checked +// against what is actually served. +type Mux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) +} + // Register mounts the endpoints on a mux under /api/v1. -func (a *API) Register(mux *http.ServeMux) { +// +// Every pattern registered here, or by the register helpers it calls, must +// also be in Routes: that table is what the OpenAPI document is generated +// from, and a test fails when the two disagree. +func (a *API) Register(mux Mux) { mux.HandleFunc("GET /api/v1/summary", a.summary) - // Adding a model is a write, and the only one this API has. It lives here - // rather than on the client-facing listener for the same reason the rest of - // it does: reaching this port already means holding the control plane. + mux.HandleFunc("GET /api/v1/openapi.json", serveOpenAPI) + // Changes to the catalog live here rather than on the client-facing + // listener for the same reason the rest of this does: reaching this port + // already means holding the control plane. mux.HandleFunc("POST /api/v1/models", a.addModel) + mux.HandleFunc("GET /api/v1/models/{name}", a.getModel) mux.HandleFunc("DELETE /api/v1/models/{name}", a.removeModel) mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel) mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources) @@ -97,9 +113,7 @@ func (a *API) Register(mux *http.ServeMux) { writeError(w, http.StatusNotImplemented, "this control plane has no store") return } - var req struct { - Class string `json:"class"` - } + var req ClassRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -109,7 +123,7 @@ func (a *API) Register(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "class": m.EffectiveClass()}) + writeJSON(w, http.StatusOK, ClassView{Model: m.Name, Class: m.EffectiveClass()}) }) mux.HandleFunc("PUT /api/v1/models/{name}/capacity", a.putCapacity) mux.HandleFunc("GET /api/v1/capacity", a.listCapacity) @@ -157,6 +171,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/models/{name}/update", a.updateModel) mux.HandleFunc("POST /api/v1/models/{name}/rollback", a.rollbackModel) a.registerCanary(mux) + a.registerEval(mux) mux.HandleFunc("POST /api/v1/models/{name}/origin", a.setOrigin) mux.HandleFunc("GET /api/v1/plan", a.planModel) mux.HandleFunc("POST /api/v1/metrics/range", a.metricsBatch) @@ -228,7 +243,12 @@ type NodeView struct { DiskFreeGB int `json:"disk_free_gb,omitempty"` DiskTotalGB int `json:"disk_total_gb,omitempty"` Classes []string `json:"gpu_classes,omitempty"` - Fabrics []string `json:"fabrics,omitempty"` + // ClassGPUs counts the node's devices by class, in the order of Classes. + // A node with a card in MIG mode has several classes — each profile its + // instances are cut to, and any whole card beside them — and GPUTotal + // alone would add 3g.40gb instances to 80 GB cards. + ClassGPUs []ClassGPUs `json:"gpus_by_class,omitempty"` + Fabrics []string `json:"fabrics,omitempty"` // Unhealthy lists GPUs kept out of placement and why, which is usually the // answer when a node looks idle but nothing will schedule onto it. @@ -255,10 +275,34 @@ type NodeView struct { IMEXChannels int `json:"imex_channels,omitempty"` } +// ClassGPUs is how many of a node's devices are of one class, and how many +// of those are free or kept out of placement. +type ClassGPUs struct { + Class string `json:"class"` + Total int `json:"total"` + Free int `json:"free"` + Unhealthy int `json:"unhealthy,omitempty"` + // MIG marks a class of MIG instances, each of which takes a replica of + // its own and never joins a tensor-parallel group. + MIG bool `json:"mig,omitempty"` +} + // GPUView is one card as a node's page shows it. type GPUView struct { Index int `json:"index"` Name string `json:"name"` + // Label is the device as people are shown it: "3" for a whole card, + // "0/mig2" for card 0's MIG instance 2. + Label string `json:"label"` + Class string `json:"class"` + // Card is the physical card's index: the device's own, or for a MIG + // instance its parent's, which is what the card's sensors are under. + Card int `json:"card"` + // MIGProfile is the instance's profile, like 3g.40gb; empty for a + // whole card. + MIGProfile string `json:"mig_profile,omitempty"` + MemoryTotalMB int `json:"memory_total_mb,omitempty"` + MemoryUsedMB int `json:"memory_used_mb,omitempty"` // ComputeCapability is what decides which engine builds run on the card: // CUDA 13 builds need 7.5, so a V100 (7.0) runs only older ones. ComputeCapability string `json:"compute_capability,omitempty"` @@ -339,6 +383,8 @@ type ModelView struct { // set on the candidate, naming the model it is a canary of. Canary *catalog.Canary `json:"canary,omitempty"` CanaryOf string `json:"canary_of,omitempty"` + // Eval is the quality suite its new weights must pass, when it has one. + Eval *catalog.EvalGate `json:"eval,omitempty"` // Description is what the model is for, when somebody said. Description string `json:"description,omitempty"` Aliases []string `json:"aliases,omitempty"` @@ -556,15 +602,25 @@ func (a *API) Nodes() []NodeView { for _, w := range n.Weights { v.Models = append(v.Models, w.Path) } - seen := map[string]struct{}{} + seen := map[string]int{} + free := inv.FreeGPUs(n.ID) for _, g := range n.GPUs { - if _, dup := seen[g.Class()]; !dup { - seen[g.Class()] = struct{}{} + k, dup := seen[g.Class()] + if !dup { + k = len(v.ClassGPUs) + seen[g.Class()] = k v.Classes = append(v.Classes, g.Class()) + v.ClassGPUs = append(v.ClassGPUs, ClassGPUs{Class: g.Class(), MIG: g.IsMIG()}) } - if !g.Health.OK() { + c := &v.ClassGPUs[k] + c.Total++ + switch { + case !g.Health.OK(): + c.Unhealthy++ v.Unhealthy = append(v.Unhealthy, - fmt.Sprintf("gpu %d: %s", g.Index, g.Health.Problems[0])) + fmt.Sprintf("gpu %s: %s", discovery.DeviceLabel(g.Index), g.Health.Problems[0])) + case slices.Contains(free, g.Index): + c.Free++ } } if !n.LastSeen.IsZero() { @@ -574,7 +630,12 @@ func (a *API) Nodes() []NodeView { v.NVLinkDomain, v.IMEX, v.IMEXChannels = n.NVLinkDomain(), n.IMEX.State, n.IMEX.Channels for _, g := range n.GPUs { gv := GPUView{Index: g.Index, Name: g.Name, ComputeCapability: g.ComputeCapability, - NUMA: g.NUMANode, NVLinkPeers: g.NVLinkPeers} + NUMA: g.NUMANode, NVLinkPeers: g.NVLinkPeers, + Label: discovery.DeviceLabel(g.Index), Class: g.Class(), Card: g.CardIndex(), + MemoryTotalMB: g.MemoryTotalMB, MemoryUsedMB: g.MemoryUsedMB} + if g.MIG != nil { + gv.MIGProfile = g.MIG.Profile + } for hca, rel := range g.NICPaths { if gv.NearestNIC == "" || discovery.PathRank(rel) < discovery.PathRank(gv.NICPath) || (discovery.PathRank(rel) == discovery.PathRank(gv.NICPath) && hca < gv.NearestNIC) { @@ -660,7 +721,7 @@ func (a *API) Models() []ModelView { Parked: h.Parked, Reason: h.Reason, Unplaceable: h.Unplaceable, Class: m.EffectiveClass(), Owner: m.Owner, Preempted: h.Preempted, HeldFor: h.HeldFor, External: externalLabel(m), Adapters: m.Adapters, - Canary: m.Canary, CanaryOf: a.ctl.Catalog().CanaryOf(m.Name), + Canary: m.Canary, CanaryOf: a.ctl.Catalog().CanaryOf(m.Name), Eval: m.Eval, } for _, p := range m.Profiles { v.Profiles = append(v.Profiles, p.Name) @@ -953,7 +1014,25 @@ func (a *API) addModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"added": m.Name}) + writeJSON(w, http.StatusOK, ModelAdded{Added: m.Name}) +} + +// getModel answers with one model's whole definition as it is in force: what +// was added, with the defaults the catalog filled in. The listing shows a +// model's state and only the names of its profiles; this is what a tool +// managing the definition — Terraform, say — compares against what it wants. +// +// By its name only, not an alias: an alias answering would let a tool believe +// a model it manages exists when it is another one. +func (a *API) getModel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + for _, m := range a.ctl.Catalog().Models() { + if m.Name == name { + writeJSON(w, http.StatusOK, m) + return + } + } + writeError(w, http.StatusNotFound, "no model named "+name+" is in the catalog") } // ScaleRequest is how many replicas a model should run. @@ -1039,7 +1118,7 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"removed": name}) + writeJSON(w, http.StatusOK, Removed{Removed: name}) } func (a *API) summary(w http.ResponseWriter, _ *http.Request) { @@ -1137,15 +1216,13 @@ func (a *API) removeNode(w http.ResponseWriter, r *http.Request) { } inv.Remove(id) a.ctl.Reconciler().Notify() - writeJSON(w, http.StatusOK, map[string]string{"id": id, "removed": "true"}) + writeJSON(w, http.StatusOK, NodeRemoved{ID: id, Removed: "true"}) } func (a *API) setNodeState(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var body struct { - State string `json:"state"` - } + var body NodeStateRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1173,7 +1250,7 @@ func (a *API) setNodeState(w http.ResponseWriter, r *http.Request) { // is usually about to do something to it. a.ctl.Reconciler().Notify() - writeJSON(w, http.StatusOK, map[string]string{"id": id, "state": st.String()}) + writeJSON(w, http.StatusOK, NodeStateView{ID: id, State: st.String()}) } func writeJSON(w http.ResponseWriter, code int, v any) { @@ -1183,7 +1260,7 @@ func writeJSON(w http.ResponseWriter, code int, v any) { } func writeError(w http.ResponseWriter, code int, msg string) { - writeJSON(w, code, map[string]string{"error": msg}) + writeJSON(w, code, ErrorView{Error: msg}) } // profileChanged reports whether a replica is running a profile the catalog no @@ -1384,7 +1461,7 @@ func (a *API) retryModel(w http.ResponseWriter, r *http.Request) { return } was := a.ctl.Reconciler().Retry(name) - writeJSON(w, http.StatusOK, map[string]bool{"was_parked": was}) + writeJSON(w, http.StatusOK, RetryView{WasParked: was}) } // requestFinder looks a request up by the id it was given. Asked of the usage @@ -1419,6 +1496,8 @@ type RequestView struct { // What the client said about it: OpenAI's user field, and its tags. EndUser string `json:"end_user,omitempty"` Tags map[string]string `json:"tags,omitempty"` + // CacheHit is an answer reused from the project's answer cache. + CacheHit bool `json:"cache_hit,omitempty"` } func (a *API) request(w http.ResponseWriter, r *http.Request) { @@ -1442,7 +1521,7 @@ func (a *API) request(w http.ResponseWriter, r *http.Request) { LatencyMs: u.LatencyMs, QueueMs: u.QueueMs, TTFTMs: u.TTFTMs, PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, CostMicros: u.CostMicros, GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardMs: u.GuardMs, - EndUser: u.EndUser, Tags: u.Tags, + EndUser: u.EndUser, Tags: u.Tags, CacheHit: u.CacheHit, }) } @@ -1477,7 +1556,7 @@ func (a *API) upgradeNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadGateway, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"node": r.PathValue("id"), "version": req.Version}) + writeJSON(w, http.StatusOK, UpgradeView{Node: r.PathValue("id"), Version: req.Version}) } // SetServe supplies what serves a model from the registry. @@ -1559,7 +1638,11 @@ func (a *API) updateModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotImplemented, "updates need a mirror and a store, and this control plane lacks one") return } - job, err := a.serve.StartUpdate(r.PathValue("name"), actorOf(r)) + ch, ok := readChange(w, r) + if !ok { + return + } + job, err := a.serve.StartUpdate(r.PathValue("name"), actorOf(r), ch) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1577,7 +1660,7 @@ func (a *API) rollbackModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) } // OriginRequest names the repository a model's weights came from. @@ -1617,6 +1700,13 @@ func (a *API) PoolNodes() []modelplan.PoolNode { p.Class = n.Classes[0] p.GPUMemGB = modelplan.MemFromClass(p.Class) } + // By class, with each class's own count of devices: a node with a + // card in MIG mode is several, and its first class alone would + // count every instance and card as that one. + for _, c := range n.ClassGPUs { + p.Kinds = append(p.Kinds, modelplan.PoolKind{Class: c.Class, + GPUMemGB: modelplan.MemFromClass(c.Class), GPUs: c.Total, FreeGPUs: c.Free}) + } if len(n.Fabrics) > 0 { p.Fabric = n.Fabrics[0] } diff --git a/internal/control/adminapi/canary.go b/internal/control/adminapi/canary.go index bba0821..d341a5f 100644 --- a/internal/control/adminapi/canary.go +++ b/internal/control/adminapi/canary.go @@ -8,7 +8,13 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" ) -func (a *API) registerCanary(mux *http.ServeMux) { +// CanaryPercentRequest changes the share of a model's requests its canary +// takes. +type CanaryPercentRequest struct { + Percent int `json:"percent"` +} + +func (a *API) registerCanary(mux Mux) { need := func(w http.ResponseWriter) bool { if a.serve == nil { writeError(w, http.StatusNotImplemented, "a canary needs a mirror and a store, and this control plane lacks one") @@ -45,9 +51,7 @@ func (a *API) registerCanary(mux *http.ServeMux) { if !need(w) { return } - var req struct { - Percent int `json:"percent"` - } + var req CanaryPercentRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -57,7 +61,7 @@ func (a *API) registerCanary(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) }) mux.HandleFunc("DELETE /api/v1/models/{name}/canary", func(w http.ResponseWriter, r *http.Request) { if !need(w) { @@ -68,13 +72,17 @@ func (a *API) registerCanary(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) }) mux.HandleFunc("POST /api/v1/models/{name}/promote", func(w http.ResponseWriter, r *http.Request) { if !need(w) { return } - job, err := a.serve.StartPromote(r.PathValue("name"), actorOf(r)) + ch, ok := readChange(w, r) + if !ok { + return + } + job, err := a.serve.StartPromote(r.PathValue("name"), actorOf(r), ch) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/control/adminapi/eval.go b/internal/control/adminapi/eval.go new file mode 100644 index 0000000..252d756 --- /dev/null +++ b/internal/control/adminapi/eval.go @@ -0,0 +1,186 @@ +package adminapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// Quality suites: run against a model on the control plane, through the +// router as the gateway's own requests (like a replay), followed while they +// run, and kept; and a model's standing suite that its updates must pass. + +// SetEval gives the API the service that runs quality suites. +func (a *API) SetEval(s *eval.Service) { a.evals = s } + +// maxSuiteBytes bounds a suite sent whole: a few hundred cases with their +// prompts, which is more than a gate should take to run. +const maxSuiteBytes = 4 << 20 + +// EvalGateRequest sets a model's standing quality suite; an empty suite +// takes it away. +type EvalGateRequest struct { + Suite string `json:"suite"` + MinScore float64 `json:"min_score,omitempty"` + Judge string `json:"judge,omitempty"` +} + +type evalGater interface { + SetEval(ctx context.Context, name string, g *catalog.EvalGate, by string) (catalog.Model, error) +} + +// readChange reads how an update or a promotion is held to quality; an +// empty body is no change to how. +func readChange(w http.ResponseWriter, r *http.Request) (modelserve.Change, bool) { + var ch modelserve.Change + b, err := io.ReadAll(io.LimitReader(r.Body, maxSuiteBytes+1)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return ch, false + } + if len(b) > maxSuiteBytes { + writeError(w, http.StatusRequestEntityTooLarge, "the suite is over 4 MiB") + return ch, false + } + if len(strings.TrimSpace(string(b))) == 0 { + return ch, true + } + if err := json.Unmarshal(b, &ch); err != nil { + writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) + return ch, false + } + return ch, true +} + +func (a *API) registerEval(mux Mux) { + need := func(w http.ResponseWriter) bool { + if a.evals == nil { + writeError(w, http.StatusNotImplemented, "this control plane does not route requests, so it cannot ask a model a suite") + return false + } + return true + } + mux.HandleFunc("GET /api/v1/eval/suites", func(w http.ResponseWriter, r *http.Request) { + if !need(w) { + return + } + out := a.evals.Suites() + if out == nil { + out = []eval.SuiteInfo{} + } + writeJSON(w, http.StatusOK, out) + }) + // Started and answered at once: a suite against a model on CPUs takes + // minutes, longer than a client waits on one request. + mux.HandleFunc("POST /api/v1/models/{name}/eval", func(w http.ResponseWriter, r *http.Request) { + if !need(w) { + return + } + var req eval.Request + if err := json.NewDecoder(io.LimitReader(r.Body, maxSuiteBytes)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) + return + } + name := r.PathValue("name") + if a.ctl != nil { + // By its own name, so an alias's runs are filed with the rest. + m, ok := a.ctl.Catalog().Resolve(name) + if !ok { + writeError(w, http.StatusNotFound, "no model called "+name+": `llmgw ctl models` lists them") + return + } + name = m.Name + } + live, err := a.evals.Start(name, req, actorOf(r)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, live) + }) + mux.HandleFunc("GET /api/v1/eval/runs", func(w http.ResponseWriter, r *http.Request) { + if !need(w) { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + out, err := a.evals.History(r.Context(), r.URL.Query().Get("model"), limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if out == nil { + out = []eval.Live{} + } + writeJSON(w, http.StatusOK, out) + }) + mux.HandleFunc("GET /api/v1/eval/runs/{id}", func(w http.ResponseWriter, r *http.Request) { + if !need(w) { + return + } + live, err := a.evals.Get(r.Context(), r.PathValue("id")) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "no eval run "+r.PathValue("id")) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, live) + }) + setGate := func(w http.ResponseWriter, r *http.Request, g *catalog.EvalGate) { + gater, ok := a.models.(evalGater) + if !ok { + writeError(w, http.StatusNotImplemented, "this control plane has no store to keep the setting in") + return + } + name := r.PathValue("name") + if g != nil && a.evals != nil { + if err := a.evals.Check(name, eval.Request{Suite: g.Suite, Judge: g.Judge, MinScore: g.MinScore}); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + m, err := gater.SetEval(r.Context(), name, g, actorOf(r)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, EvalGateView{Model: m.Name, Eval: m.Eval}) + } + mux.HandleFunc("PUT /api/v1/models/{name}/eval-gate", func(w http.ResponseWriter, r *http.Request) { + var req EvalGateRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) + return + } + if strings.TrimSpace(req.Suite) == "" { + writeError(w, http.StatusBadRequest, "name a suite: one in the control plane's eval directory, or built in") + return + } + if req.MinScore < 0 || req.MinScore > 1 { + writeError(w, http.StatusBadRequest, "the minimum score is 0 to 1") + return + } + setGate(w, r, &catalog.EvalGate{Suite: strings.TrimSpace(req.Suite), MinScore: req.MinScore, Judge: req.Judge}) + }) + mux.HandleFunc("DELETE /api/v1/models/{name}/eval-gate", func(w http.ResponseWriter, r *http.Request) { + setGate(w, r, nil) + }) +} + +// EvalGateView is a model's standing quality gate, as it is now; Eval is +// absent when it has none. +type EvalGateView struct { + Model string `json:"model"` + Eval *catalog.EvalGate `json:"eval,omitempty"` +} diff --git a/internal/control/adminapi/eval_test.go b/internal/control/adminapi/eval_test.go new file mode 100644 index 0000000..ca7c8aa --- /dev/null +++ b/internal/control/adminapi/eval_test.go @@ -0,0 +1,84 @@ +package adminapi + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" +) + +// gatingModels is the catalog's service, keeping the gate it is given. +type gatingModels struct { + *fakeModels + gate *catalog.EvalGate + set bool +} + +func (g *gatingModels) SetEval(_ context.Context, name string, gate *catalog.EvalGate, _ string) (catalog.Model, error) { + g.gate, g.set = gate, true + return catalog.Model{Name: name, Eval: gate}, nil +} + +func TestASuiteIsStartedByAModelsAliasAndAGateNeedsASuiteThatIsThere(t *testing.T) { + a, _ := setup(t, []catalog.Model{{Name: "qwen", Aliases: []string{"q"}, Source: "/m.gguf", + Scaling: catalog.Scaling{Min: 1, Max: 1}, Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp"}}}}) + answer := func(_ context.Context, _, _ string, _ []byte) (int, []byte, time.Duration, error) { + return 200, []byte(`{"choices":[{"message":{"content":"42"}}]}`), 0, nil + } + a.SetEval(eval.NewService(eval.Deps{Call: answer, Log: slog.New(slog.DiscardHandler), + Builtin: fstest.MapFS{"arith.yaml": {Data: []byte("cases:\n - prompt: 40 + 2?\n number: 42\n")}}})) + models := &gatingModels{fakeModels: &fakeModels{}} + a.SetModels(models) + mux := http.NewServeMux() + a.Register(mux) + do := func(method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec + } + + rec := do(http.MethodPost, "/api/v1/models/q/eval", `{"suite": "arith"}`) + var live eval.Live + if rec.Code != http.StatusAccepted || json.Unmarshal(rec.Body.Bytes(), &live) != nil || live.Model != "qwen" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if rec := do(http.MethodPost, "/api/v1/models/nobody/eval", `{"suite": "arith"}`); rec.Code != http.StatusNotFound { + t.Errorf("a model that is not there: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(http.MethodPost, "/api/v1/models/qwen/eval", `{"suite": "nope"}`); rec.Code != http.StatusBadRequest || + !strings.Contains(rec.Body.String(), "--suites") { + t.Errorf("a suite that is not there: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(http.MethodGet, "/api/v1/eval/suites", ""); !strings.Contains(rec.Body.String(), `"name":"arith"`) { + t.Errorf("suites: %s", rec.Body.String()) + } + + if rec := do(http.MethodPut, "/api/v1/models/qwen/eval-gate", `{"suite": "nope"}`); rec.Code != http.StatusBadRequest || models.set { + t.Errorf("a gate on a suite that is not there was set: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(http.MethodPut, "/api/v1/models/qwen/eval-gate", `{"suite": "arith", "min_score": 0.9}`); rec.Code != http.StatusOK || + models.gate == nil || models.gate.Suite != "arith" || models.gate.MinScore != 0.9 { + t.Errorf("%d %s %+v", rec.Code, rec.Body.String(), models.gate) + } + if rec := do(http.MethodDelete, "/api/v1/models/qwen/eval-gate", ""); rec.Code != http.StatusOK || models.gate != nil { + t.Errorf("%d %s", rec.Code, rec.Body.String()) + } + + deadline := time.Now().Add(5 * time.Second) + for live.State == eval.Running && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + _ = json.Unmarshal(do(http.MethodGet, "/api/v1/eval/runs/"+live.ID, "").Body.Bytes(), &live) + } + if live.State != eval.Done || live.Score != 1 || len(live.Results) != 1 { + t.Fatalf("%+v", live) + } +} diff --git a/internal/control/adminapi/fabrictest.go b/internal/control/adminapi/fabrictest.go index fbe4f4d..5c028ae 100644 --- a/internal/control/adminapi/fabrictest.go +++ b/internal/control/adminapi/fabrictest.go @@ -34,10 +34,7 @@ type RailView struct { // sends traffic over the fabric for seconds a rail, so it is a POST and only // ever run when asked. func (a *API) fabricTest(w http.ResponseWriter, r *http.Request) { - var body struct { - A, B, Fabric string - Seconds int - } + var body FabricTestRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/control/adminapi/guard.go b/internal/control/adminapi/guard.go index 33d76da..78a744f 100644 --- a/internal/control/adminapi/guard.go +++ b/internal/control/adminapi/guard.go @@ -66,7 +66,17 @@ func (a *API) guardTarget(ctx context.Context, target string) (guard.Attachment, return guard.Attachment{}, fmt.Errorf("%q: attach to org, team, project, key or user", kind) } -func (a *API) registerGuard(mux *http.ServeMux) { +// GuardAttachmentRequest attaches a policy to a part of the tenancy tree, a +// key or a person, or with DELETE takes it off again. +type GuardAttachmentRequest struct { + // Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID + // after the colon), key:KEY-ID or user:USER-ID. + Target string `json:"target"` + // Policy is a policy's name in server.yaml's guard.policies. + Policy string `json:"policy"` +} + +func (a *API) registerGuard(mux Mux) { mux.HandleFunc("GET /api/v1/guard", func(w http.ResponseWriter, r *http.Request) { if a.guard == nil { writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml") @@ -97,12 +107,7 @@ func (a *API) registerGuard(mux *http.ServeMux) { writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml") return } - var at struct { - // Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT - // (or the ID after the colon), key:KEY-ID or user:USER-ID. - Target string `json:"target"` - Policy string `json:"policy"` - } + var at GuardAttachmentRequest if err := json.NewDecoder(r.Body).Decode(&at); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/control/adminapi/mig_test.go b/internal/control/adminapi/mig_test.go new file mode 100644 index 0000000..34bbf7f --- /dev/null +++ b/internal/control/adminapi/mig_test.go @@ -0,0 +1,65 @@ +package adminapi + +import ( + "reflect" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" +) + +// migNode has card 0 cut into two 3g.40gb instances and card 1 whole. +func migNode(id string) inventory.Node { + nd := inventory.Node{ID: id, State: inventory.Ready, AgentVersion: "v1", MemoryTotalMB: 512 * 1024} + for dev := range 2 { + nd.GPUs = append(nd.GPUs, discovery.GPU{ + Index: discovery.MIGIndex(0, dev), Name: "NVIDIA A100-SXM4-80GB MIG 3g.40gb", UUID: "MIG-x", + MemoryTotalMB: 40192, MemoryUsedMB: 37, + MIG: &discovery.MIGDevice{ParentIndex: 0, ParentMemoryTotalMB: 81920, Device: dev, Profile: "3g.40gb"}, + }) + } + nd.GPUs = append(nd.GPUs, discovery.GPU{Index: 1, Name: "NVIDIA A100-SXM4-80GB", MemoryTotalMB: 81920}) + return nd +} + +func TestANodeWithACardInMIGModeCountsItsDevicesByClass(t *testing.T) { + a, ctl := setup(t, testModels(), migNode("a100")) + ctl.Inventory().Allocate("a100", inventory.Allocation{ShardID: "s", ReplicaID: "r", GPUIndices: []int{1000}}) + + n := a.Nodes()[0] + want := []ClassGPUs{ + {Class: "a100-80g-mig-3g.40gb", Total: 2, Free: 1, MIG: true}, + {Class: "a100-80g", Total: 1, Free: 1}, + } + if !reflect.DeepEqual(n.ClassGPUs, want) { + t.Errorf("by class: %+v\nwant %+v", n.ClassGPUs, want) + } + // Each device as people are shown it: the instance under its card, with + // its own memory, and the card its sensors are read under. + g := n.GPUs[1] + if g.Label != "0/mig1" || g.Card != 0 || g.MIGProfile != "3g.40gb" || g.MemoryTotalMB != 40192 || + g.Class != "a100-80g-mig-3g.40gb" { + t.Errorf("instance view: %+v", g) + } + if w := n.GPUs[2]; w.Label != "1" || w.Card != 1 || w.MIGProfile != "" { + t.Errorf("whole card view: %+v", w) + } + + // The fleet summary attributes each device to its own class, where it + // once put every card of a node under the node's only class. + s := a.Summary() + got := map[string][2]int{} + for _, c := range s.GPUs { + got[c.Class] = [2]int{c.Total, c.Used} + } + if !reflect.DeepEqual(got, map[string][2]int{"a100-80g-mig-3g.40gb": {2, 1}, "a100-80g": {1, 0}}) { + t.Errorf("summary by class: %v", got) + } + + // And a plan sees both classes, each with its own count. + pool := a.PoolNodes() + if len(pool) != 1 || len(pool[0].Kinds) != 2 || pool[0].Kinds[0].GPUMemGB != 40 || + pool[0].Kinds[0].FreeGPUs != 1 || pool[0].Kinds[1].GPUMemGB != 80 { + t.Errorf("pool: %+v", pool) + } +} diff --git a/internal/control/adminapi/quotas.go b/internal/control/adminapi/quotas.go index 3810bcd..05b9361 100644 --- a/internal/control/adminapi/quotas.go +++ b/internal/control/adminapi/quotas.go @@ -50,7 +50,26 @@ type QuotaRequest struct { Limit store.Resources `json:"limit"` } -func (a *API) registerQuotas(mux *http.ServeMux) { +// QuotaSet is the scope a quota was set on, as org:ID, team:ID or +// project:ID whatever name it was asked by. +type QuotaSet struct { + Scope string `json:"scope"` +} + +// OwnerRequest sets the part of the tenancy tree a model's capacity counts +// against: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or their IDs. +// Empty makes the model shared. +type OwnerRequest struct { + Owner string `json:"owner"` +} + +// OwnerView is a model's owner now, as scope:ID. +type OwnerView struct { + Model string `json:"model"` + Owner string `json:"owner"` +} + +func (a *API) registerQuotas(mux Mux) { mux.HandleFunc("GET /api/v1/quotas", func(w http.ResponseWriter, r *http.Request) { if a.quotas == nil { writeError(w, http.StatusNotImplemented, "this control plane has no store") @@ -78,7 +97,7 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"scope": key}) + writeJSON(w, http.StatusOK, QuotaSet{Scope: key}) }) mux.HandleFunc("DELETE /api/v1/quotas/{scope}", func(w http.ResponseWriter, r *http.Request) { if a.quotas == nil { @@ -90,16 +109,14 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"removed": key}) + writeJSON(w, http.StatusOK, Removed{Removed: key}) }) mux.HandleFunc("POST /api/v1/models/{name}/owner", func(w http.ResponseWriter, r *http.Request) { if a.models == nil || a.quotas == nil { writeError(w, http.StatusNotImplemented, "this control plane has no store") return } - var req struct { - Owner string `json:"owner"` - } + var req OwnerRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -118,7 +135,7 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "owner": m.Owner}) + writeJSON(w, http.StatusOK, OwnerView{Model: m.Name, Owner: m.Owner}) }) } diff --git a/internal/control/adminapi/replies.go b/internal/control/adminapi/replies.go new file mode 100644 index 0000000..d221c9e --- /dev/null +++ b/internal/control/adminapi/replies.go @@ -0,0 +1,84 @@ +package adminapi + +// The small bodies the endpoints read and answer with. Named types rather +// than maps and anonymous structs, so the OpenAPI document generated from +// Routes describes exactly what a handler decodes and writes: a map literal +// in a handler is a shape no generator can see. + +// ErrorView is every refusal: a 4xx or 5xx status with what went wrong. +type ErrorView struct { + Error string `json:"error"` +} + +// Result is what a change that has no other answer says it did. +type Result struct { + Result string `json:"result"` +} + +// ModelAdded names the model added to the catalog. +type ModelAdded struct { + Added string `json:"added"` +} + +// Removed names what was taken away: a model, or a quota's scope. +type Removed struct { + Removed string `json:"removed"` +} + +// ClassRequest sets a model's capacity class: critical, standard or +// opportunistic, or empty to go back to what its scaling implies. +type ClassRequest struct { + Class string `json:"class"` +} + +// ClassView is a model's capacity class now. +type ClassView struct { + Model string `json:"model"` + Class string `json:"class"` +} + +// RetryView says whether the model had been parked, given up on after +// failing to start, before it was retried. +type RetryView struct { + WasParked bool `json:"was_parked"` +} + +// NodeStateRequest cordons, drains or readies a node: ready (or uncordon), +// cordoned (or cordon), draining (or drain). +type NodeStateRequest struct { + State string `json:"state"` +} + +// NodeStateView is a node's state after the change. +type NodeStateView struct { + ID string `json:"id"` + State string `json:"state"` +} + +// NodeRemoved names the node forgotten. Removed is the string "true". +type NodeRemoved struct { + ID string `json:"id"` + Removed string `json:"removed"` +} + +// UpgradeView is the release a node was told to run. +type UpgradeView struct { + Node string `json:"node"` + Version string `json:"version"` +} + +// FabricTestRequest is a bandwidth test between two nodes. +type FabricTestRequest struct { + A string `json:"a"` + B string `json:"b"` + // Fabric is the declared fabric to test over; empty is the one the two + // nodes share. + Fabric string `json:"fabric,omitempty"` + // Seconds each rail is measured for, from 1 to 30. + Seconds int `json:"seconds,omitempty"` +} + +// ReplayRequest names the model a kept request is sent to again. +type ReplayRequest struct { + Model string `json:"model"` +} diff --git a/internal/control/adminapi/requestbody.go b/internal/control/adminapi/requestbody.go index edfbe2c..27e89bc 100644 --- a/internal/control/adminapi/requestbody.go +++ b/internal/control/adminapi/requestbody.go @@ -60,9 +60,7 @@ func (a *API) replayRequest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotImplemented, "this control plane does not route requests") return } - var req struct { - Model string `json:"model"` - } + var req ReplayRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil || req.Model == "" { writeError(w, http.StatusBadRequest, "say which model: {\"model\": \"...\"}") return diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go new file mode 100644 index 0000000..b7b5107 --- /dev/null +++ b/internal/control/adminapi/routes.go @@ -0,0 +1,272 @@ +package adminapi + +import ( + "net/http" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrics" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/nvlink" + "gitea.mynodes.xyz/ilker/llmgw/internal/guard" + "gitea.mynodes.xyz/ilker/llmgw/internal/notify" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// Route is one endpoint as the OpenAPI document describes it. +// +// A table written by hand beside Register rather than read out of the +// handlers, because what a handler decodes and answers is not visible from +// outside it. The table is checked against Register by a test, so an endpoint +// cannot be added without being described; the bodies are Go values whose +// types the generator reads, so a field added to one is in the document the +// next time it is generated. +type Route struct { + Method string + Path string + // Summary is one line; Description, when there is more to say, the rest. + Summary string + Description string + // Query is the query parameters the handler reads. + Query []Param + // Request is a value of the body's type; nil reads none. + Request any + // OptionalBody is set when the body may be left out. + OptionalBody bool + // Response is a value of the type a success answers with. + Response any + // Status is the success status; zero is 200. + Status int + // CSV is set when the answer is CSV unless JSON is asked for. + CSV bool +} + +// Param is a query parameter. +type Param struct { + Name string + Description string + // Type is a JSON Schema type: string when empty, or integer, number, + // boolean. + Type string + Required bool +} + +// PathParams describes the parameters in Routes' paths, by the segment +// before them and their name. +var PathParams = map[string]string{ + "models/{name}": "The model's name in the catalog.", + "nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.", + "jobs/{id}": "The job's ID, as starting it answered.", + "runs/{id}": "The quality run's ID, as starting it answered.", + "requests/{id}": "The request's ID: the x-request-id a client was answered with.", + "fabrics/{id}": "The physical fabric's ID, as GET /api/v1/fabrics lists it.", + "quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.", +} + +// Routes is every endpoint Register mounts. +var Routes = []Route{ + // The document itself. + {Method: "GET", Path: "/api/v1/openapi.json", Summary: "This document", + Description: "The admin API in OpenAPI 3.1, generated from the route table and built into the binary.", + Response: map[string]any{}}, + + // The fleet. + {Method: "GET", Path: "/api/v1/summary", Summary: "The whole fleet in one answer", + Response: Summary{}}, + {Method: "GET", Path: "/api/v1/nodes", Summary: "Every node, with its cards, load and fabrics", + Response: []NodeView{}}, + {Method: "POST", Path: "/api/v1/nodes/{id}/state", Summary: "Cordon, drain or ready a node", + Request: NodeStateRequest{}, Response: NodeStateView{}}, + {Method: "POST", Path: "/api/v1/nodes/{id}/upgrade", Summary: "Have a node run a release", + Request: UpgradeRequest{}, Response: UpgradeView{}}, + {Method: "POST", Path: "/api/v1/nodes/{id}/engines", Summary: "Choose an engine's build for a node", + Description: "The node installs the build chosen. Build is auto, a build such as cu129, or none.", + Request: NodeEngineRequest{}, Response: NodeEngineRequest{}}, + {Method: "DELETE", Path: "/api/v1/nodes/{id}", Summary: "Forget a node that has gone", + Description: "Only a node that is lost, or unreachable with nothing on it; its certificate is revoked.", + Response: NodeRemoved{}}, + {Method: "GET", Path: "/api/v1/nodes/{id}/logs", Summary: "The last lines of a node's logs", + Query: []Param{ + {Name: "lines", Type: "integer", Description: "How many lines; 300 when absent, at most 2000."}, + {Name: "source", Description: "Which log: agent when absent, or an engine the node lists in sources."}, + }, + Response: LogsView{}}, + {Method: "GET", Path: "/api/v1/fabrics", Summary: "The physical fabrics and the names nodes give them", + Response: fabrics.Report{}}, + {Method: "GET", Path: "/api/v1/fabrics/{id}/topology", Summary: "One fabric's map, with the cluster's adapters live on it", + Response: TopologyView{}}, + {Method: "GET", Path: "/api/v1/nvlink", Summary: "The multi-node NVLink domains and what keeps nodes out of them", + Response: nvlink.Report{}}, + {Method: "POST", Path: "/api/v1/fabric-test", Summary: "Measure bandwidth between two nodes, rail by rail", + Request: FabricTestRequest{}, Response: FabricTestView{}}, + {Method: "GET", Path: "/api/v1/weights/orphans", Summary: "Weights on nodes that no model uses", + Response: []OrphanView{}}, + {Method: "POST", Path: "/api/v1/weights/collect", Summary: "Delete weights that no model uses", + Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}}, + + // The catalog. + {Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state", + Response: []ModelView{}}, + {Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog", + Description: "Kept in the store across restarts. Refused when the name is taken, a model in " + + "configuration has it, or the definition would make the catalog invalid.", + Request: catalog.Model{}, Response: ModelAdded{}}, + {Method: "GET", Path: "/api/v1/models/{name}", Summary: "One model's definition, as it is in force", + Description: "With the defaults the catalog filled in. By its name only, not an alias.", + Response: catalog.Model{}}, + {Method: "DELETE", Path: "/api/v1/models/{name}", Summary: "Remove a model added at runtime", + Description: "A model declared in configuration is refused: it would come back on the next restart.", + Response: Removed{}}, + {Method: "POST", Path: "/api/v1/models/{name}/scale", Summary: "Set how many replicas a model runs", + Request: ScaleRequest{}, Response: ScaleResponse{}}, + {Method: "POST", Path: "/api/v1/models/{name}/resources", Summary: "Set what a model's profiles reserve of a node", + Request: ResourcesRequest{}, Response: catalog.Model{}}, + {Method: "POST", Path: "/api/v1/models/{name}/class", Summary: "Set a model's capacity class", + Request: ClassRequest{}, Response: ClassView{}}, + {Method: "POST", Path: "/api/v1/models/{name}/owner", Summary: "Set the part of the tenancy tree a model's capacity counts against", + Request: OwnerRequest{}, Response: OwnerView{}}, + {Method: "POST", Path: "/api/v1/models/{name}/adapters", Summary: "Replace a model's LoRA adapters", + Description: "Each new file is read first, to check it is an adapter for the model's architecture.", + Request: AdaptersRequest{}, Response: AdaptersRequest{}}, + {Method: "POST", Path: "/api/v1/models/{name}/retry", Summary: "Try a parked model again", + Response: RetryView{}}, + {Method: "GET", Path: "/api/v1/models/{name}/target", Summary: "How many replicas a throughput needs, and whether they fit", + Query: []Param{{Name: "tokens_per_sec", Type: "number", Required: true, + Description: "The output tokens a second wanted of the model."}}, + Response: TargetView{}}, + {Method: "GET", Path: "/api/v1/models/{name}/headroom", Summary: "How many more replicas of a model fit now, and where", + Query: []Param{{Name: "limit", Type: "integer", + Description: "The most to try placing; 16 when absent or past 64."}}, + Response: HeadroomView{}}, + {Method: "PUT", Path: "/api/v1/models/{name}/capacity", Summary: "Record what a replica was measured to serve", + Description: "Replaces an earlier measurement of the same model on the same profile.", + Request: bench.Capacity{}, Response: bench.Capacity{}}, + {Method: "GET", Path: "/api/v1/capacity", Summary: "Every recorded measurement", + Response: []CapacityView{}}, + {Method: "GET", Path: "/api/v1/replicas", Summary: "Every replica, with its shards", + Query: []Param{{Name: "model", Description: "Only this model's replicas."}}, + Response: []ReplicaView{}}, + {Method: "GET", Path: "/api/v1/explain-placement", Summary: "Why a model could not be placed", + Query: []Param{{Name: "model", Required: true, Description: "The model's name or an alias."}}, + Response: ExplainView{}}, + {Method: "POST", Path: "/api/v1/restart", Summary: "Restart replicas, every replica of some models, or all", + Request: RestartRequest{}, Response: RestartResult{}, Status: http.StatusAccepted}, + + // Serving from the registry, and newer weights. + {Method: "GET", Path: "/api/v1/plan", Summary: "How a registry repository would be served here", + Query: []Param{ + {Name: "repo", Required: true, Description: "The repository, owner/name."}, + {Name: "format", Description: "gguf or safetensors; gguf when absent."}, + {Name: "context", Type: "integer", Description: "The context length to plan for, in tokens."}, + }, + Response: modelserve.Plan{}}, + {Method: "POST", Path: "/api/v1/models/serve", Summary: "Fetch a registry repository and serve it", + Request: modelserve.Request{}, Response: modelserve.Job{}, Status: http.StatusAccepted}, + {Method: "GET", Path: "/api/v1/jobs", Summary: "Every job since the control plane started", + Response: []modelserve.Job{}}, + {Method: "GET", Path: "/api/v1/jobs/{id}", Summary: "One job", + Response: modelserve.Job{}}, + {Method: "GET", Path: "/api/v1/updates", Summary: "Newer weights the registry has for models", + Response: []modelserve.UpdateInfo{}}, + {Method: "POST", Path: "/api/v1/updates/check", Summary: "Ask the registry now, about one model or every one", + Request: CheckRequest{}, OptionalBody: true, Response: []modelserve.UpdateInfo{}}, + {Method: "POST", Path: "/api/v1/models/{name}/update", Summary: "Move a model to its repository's newer weights", + Description: "With a quality suite, or the model's standing gate, the new weights are asked it first as a hidden canary.", + Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted}, + {Method: "POST", Path: "/api/v1/models/{name}/rollback", Summary: "Go back to the weights the last update replaced", + Response: Result{}}, + {Method: "POST", Path: "/api/v1/models/{name}/origin", Summary: "Record the repository a model's weights came from", + Request: OriginRequest{}, Response: OriginView{}}, + {Method: "GET", Path: "/api/v1/canaries", Summary: "Every canary, and how its two sides serve", + Response: []modelserve.CanaryView{}}, + {Method: "POST", Path: "/api/v1/models/{name}/canary", Summary: "Start a canary of a model's newer weights", + Request: catalog.Canary{}, Response: modelserve.Job{}, Status: http.StatusAccepted}, + {Method: "PUT", Path: "/api/v1/models/{name}/canary", Summary: "Change the share of requests a canary takes", + Request: CanaryPercentRequest{}, Response: Result{}}, + {Method: "DELETE", Path: "/api/v1/models/{name}/canary", Summary: "Take a canary out: its share first, then the candidate", + Response: Result{}}, + {Method: "POST", Path: "/api/v1/models/{name}/promote", Summary: "Make a canary's weights the model's", + Description: "With a quality suite, or the model's standing gate, the canary is asked it first and kept below the score.", + Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted}, + + // Quality suites. + {Method: "GET", Path: "/api/v1/eval/suites", Summary: "The quality suites the control plane can run by name", + Response: []eval.SuiteInfo{}}, + {Method: "POST", Path: "/api/v1/models/{name}/eval", Summary: "Ask a model a quality suite", + Description: "Started at once and answered with the run, to be followed at /api/v1/eval/runs/{id}.", + Request: eval.Request{}, Response: eval.Live{}, Status: http.StatusAccepted}, + {Method: "GET", Path: "/api/v1/eval/runs", Summary: "Quality runs, the newest first", + Query: []Param{ + {Name: "model", Description: "Only this model's runs."}, + {Name: "limit", Type: "integer", Description: "How many; a sensible number when absent."}, + }, + Response: []eval.Live{}}, + {Method: "GET", Path: "/api/v1/eval/runs/{id}", Summary: "One quality run, with each case", + Response: eval.Live{}}, + {Method: "PUT", Path: "/api/v1/models/{name}/eval-gate", Summary: "Hold a model's updates and promotions to a quality suite", + Request: EvalGateRequest{}, Response: EvalGateView{}}, + {Method: "DELETE", Path: "/api/v1/models/{name}/eval-gate", Summary: "Stop holding a model's updates to a suite", + Response: EvalGateView{}}, + + // Tenancy: quotas and guardrails. + {Method: "GET", Path: "/api/v1/quotas", Summary: "Every capacity quota, with what its scope holds now", + Response: []QuotaView{}}, + {Method: "PUT", Path: "/api/v1/quotas", Summary: "Set a scope's capacity quota", + Description: "Replaces the scope's quota whole. The scope may be named by path or by ID; the answer names it by ID.", + Request: QuotaRequest{}, Response: QuotaSet{}}, + {Method: "DELETE", Path: "/api/v1/quotas/{scope}", Summary: "Remove a scope's capacity quota", + Response: Removed{}}, + {Method: "GET", Path: "/api/v1/guard", Summary: "The guardrail policies and where they are attached", + Response: GuardView{}}, + {Method: "POST", Path: "/api/v1/guard/attachments", Summary: "Attach a guardrail policy", + Request: GuardAttachmentRequest{}, Response: guard.Attachment{}}, + {Method: "DELETE", Path: "/api/v1/guard/attachments", Summary: "Detach a guardrail policy", + Request: GuardAttachmentRequest{}, Response: guard.Attachment{}}, + + // Usage, requests and the record of changes. + {Method: "GET", Path: "/api/v1/usage", Summary: "What each group of the tenancy tree spent", + Query: []Param{ + {Name: "by", Description: "org, team or project; team when absent."}, + {Name: "since", Description: "A duration back from now, like 24h; 24h when absent."}, + }, + Response: []ScopeUsageView{}}, + {Method: "GET", Path: "/api/v1/usage/export", Summary: "Usage totals by period and dimension, as CSV or JSON", + Query: []Param{ + {Name: "from", Description: "A date (2026-09-01) or an RFC 3339 time; the first of this month when absent."}, + {Name: "to", Description: "A date or an RFC 3339 time; now when absent."}, + {Name: "period", Description: "hour, day or month; day when absent."}, + {Name: "by", Description: "Dimensions, comma-separated: org, team, project, user, key, model."}, + {Name: "format", Description: "json for JSON; CSV otherwise."}, + }, + Response: []store.ExportRow{}, CSV: true}, + {Method: "GET", Path: "/api/v1/requests/{id}", Summary: "What one request went through", + Response: RequestView{}}, + {Method: "GET", Path: "/api/v1/requests/{id}/body", Summary: "A kept request and its answer", + Response: store.RequestBody{}}, + {Method: "POST", Path: "/api/v1/requests/{id}/replay", Summary: "Send a kept request again, to any model", + Request: ReplayRequest{}, Response: ReplayView{}}, + {Method: "GET", Path: "/api/v1/audit", Summary: "Changes made, newest first", + Query: []Param{ + {Name: "since", Description: "How far back: 90m, 24h, 7d; 7d when absent."}, + {Name: "limit", Type: "integer", Description: "The most events to answer with."}, + {Name: "actor", Description: "Only changes by this actor."}, + {Name: "contains", Description: "Only events whose action or target contains this."}, + }, + Response: []store.AuditEvent{}}, + + // Operations. + {Method: "POST", Path: "/api/v1/notify/test", Summary: "Send a test notification to every webhook", + Response: []notify.Result{}}, + {Method: "POST", Path: "/api/v1/metrics/range", Summary: "Several Prometheus range queries at once", + Description: "Read from the Prometheus monitoring.prometheus names; configured is false without one.", + Request: MetricsRequest{}, Response: MetricsResult{}}, +} + +// serveOpenAPI answers with the document generated from Routes, as it was +// when this binary was built. +func serveOpenAPI(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(llmgw.AdminOpenAPI) +} diff --git a/internal/control/adminapi/routes_test.go b/internal/control/adminapi/routes_test.go new file mode 100644 index 0000000..4831e58 --- /dev/null +++ b/internal/control/adminapi/routes_test.go @@ -0,0 +1,119 @@ +package adminapi + +import ( + "net/http" + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" +) + +// patterns records what Register mounts. +type patterns []string + +func (p *patterns) HandleFunc(pattern string, _ func(http.ResponseWriter, *http.Request)) { + *p = append(*p, pattern) +} + +func TestEveryRegisteredEndpointIsInTheRouteTable(t *testing.T) { + // The OpenAPI document is generated from Routes, so an endpoint missing + // from it is one no client generated from the document can call, and one + // in it that is not served is a promise the server breaks. + a, _ := setup(t, testModels()) + var got patterns + a.Register(&got) + + table := map[string]bool{} + for _, r := range Routes { + key := r.Method + " " + r.Path + if table[key] { + t.Errorf("%s is in Routes twice", key) + } + table[key] = true + } + served := map[string]bool{} + for _, p := range got { + served[p] = true + if !table[p] { + t.Errorf("%s is registered and not in Routes: describe it there", p) + } + } + for key := range table { + if !served[key] { + t.Errorf("%s is in Routes and not registered", key) + } + } +} + +func TestEveryRouteSaysWhatItAnswers(t *testing.T) { + for _, r := range Routes { + if r.Summary == "" || r.Response == nil { + t.Errorf("%s %s needs a summary and a response type", r.Method, r.Path) + } + if r.Request != nil && (r.Method == "GET" || r.Method == "HEAD") { + t.Errorf("%s %s reads a body on a GET", r.Method, r.Path) + } + for _, seg := range strings.Split(strings.Trim(r.Path, "/"), "/") { + if !strings.HasPrefix(seg, "{") { + continue + } + found := false + for k := range PathParams { + if strings.HasSuffix(k, "/"+seg) && strings.Contains(r.Path, k) { + found = true + } + } + if !found { + t.Errorf("%s %s: %s is not described in PathParams", r.Method, r.Path, seg) + } + } + } +} + +func TestTheAPIServesItsOwnDescription(t *testing.T) { + a, _ := setup(t, testModels()) + var doc struct { + OpenAPI string `json:"openapi"` + Paths map[string]map[string]any `json:"paths"` + } + if code := call(t, a, "GET", "/api/v1/openapi.json", "", &doc); code != 200 { + t.Fatalf("status %d", code) + } + if doc.OpenAPI != "3.1.0" { + t.Errorf("openapi %q", doc.OpenAPI) + } + // What is built in is the document generated when it was last committed; + // internal/docgen's test is what fails when that is stale. This checks the + // two ends meet: every route the table has is in what is served. + for _, r := range Routes { + if _, ok := doc.Paths[r.Path][strings.ToLower(r.Method)]; !ok { + t.Errorf("the served document has no %s %s", r.Method, r.Path) + } + } +} + +func TestOneModelsDefinitionIsReadByItsNameAlone(t *testing.T) { + a, _ := setup(t, testModels()) + var m catalog.Model + if code := call(t, a, "GET", "/api/v1/models/llama", "", &m); code != 200 { + t.Fatalf("status %d", code) + } + if m.Name != "llama" || m.Source != "/m" || len(m.Profiles) != 1 || m.Profiles[0].TP != 8 { + t.Errorf("got %+v", m) + } + if m.Scaling.Min != 2 || m.Scaling.Max != 4 { + t.Errorf("scaling %+v", m.Scaling) + } + if !slices.Equal(m.Aliases, []string{"l"}) { + t.Errorf("aliases %v", m.Aliases) + } + // An alias answering would let a tool that manages "l" believe it exists + // when what it found is llama. + if code := call(t, a, "GET", "/api/v1/models/l", "", nil); code != http.StatusNotFound { + t.Errorf("an alias answered %d", code) + } + if code := call(t, a, "GET", "/api/v1/models/nothing", "", nil); code != http.StatusNotFound { + t.Errorf("a missing model answered %d", code) + } +} diff --git a/internal/control/adminapi/summary.go b/internal/control/adminapi/summary.go index 27b88b4..f5dfa2b 100644 --- a/internal/control/adminapi/summary.go +++ b/internal/control/adminapi/summary.go @@ -148,12 +148,14 @@ func (a *API) Summary() Summary { byClass[c] = &ClassUse{Class: c} } } - // A node reports one class in practice; attribute its cards to it. - if len(n.Classes) == 1 { - u := byClass[n.Classes[0]] - u.Total += n.GPUTotal - u.Unhealthy += len(n.Unhealthy) - u.Used += n.GPUTotal - n.GPUFree - len(n.Unhealthy) + // Each class its own devices, which a node with a card in MIG mode + // needs: its instances are one class or several, and any whole card + // beside them another. + for _, c := range n.ClassGPUs { + u := byClass[c.Class] + u.Total += c.Total + u.Unhealthy += c.Unhealthy + u.Used += c.Total - c.Free - c.Unhealthy } } for _, u := range byClass { diff --git a/internal/control/adminapi/weights.go b/internal/control/adminapi/weights.go index ac8e904..d7c4592 100644 --- a/internal/control/adminapi/weights.go +++ b/internal/control/adminapi/weights.go @@ -65,6 +65,20 @@ func (a *API) listOrphans(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, a.Orphans()) } +// CollectRequest limits a sweep to one node. Empty means every node, which +// is the common case after removing a model from the catalog. +type CollectRequest struct { + Node string `json:"node,omitempty"` +} + +// CollectResult is what was deleted on one node, or why nothing was. +type CollectResult struct { + Node string `json:"node"` + Files int `json:"files"` + Bytes int64 `json:"bytes"` + Error string `json:"error,omitempty"` +} + // collectWeights deletes them. // // A proposal, not an instruction: each node checks every path again before @@ -79,26 +93,16 @@ func (a *API) collectWeights(w http.ResponseWriter, r *http.Request) { return } - var body struct { - // Node limits it to one node. Empty means every node, which is the - // common case after removing a model from the catalog. - Node string `json:"node,omitempty"` - } + var body CollectRequest // An empty body is the "all nodes" case and must not be an error. _ = json.NewDecoder(r.Body).Decode(&body) - type result struct { - Node string `json:"node"` - Files int `json:"files"` - Bytes int64 `json:"bytes"` - Error string `json:"error,omitempty"` - } - out := make([]result, 0) + out := make([]CollectResult, 0) for _, o := range a.Orphans() { if body.Node != "" && o.Node != body.Node { continue } - res := result{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes} + res := CollectResult{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes} // A node that has gone since the list was built is reported rather than // failing the whole sweep: the other nodes' space is still worth // freeing, and this one will be offered again next time. diff --git a/internal/control/agentsvc/convert.go b/internal/control/agentsvc/convert.go index 294ee3f..67a07e0 100644 --- a/internal/control/agentsvc/convert.go +++ b/internal/control/agentsvc/convert.go @@ -71,6 +71,7 @@ func NodeFromRegister(r *llmgwv1.Register) inventory.Node { TemperatureC: int(g.GetTemperatureC()), Problems: g.GetProblems(), }, + MIG: migFrom(g.GetMig()), }) } n.IMEX = discovery.IMEX{State: r.GetImex().GetState(), Channels: int(r.GetImex().GetChannels())} @@ -131,6 +132,13 @@ func RegisterFromInventory(nodeID, hostname, mgmtAddr, agentVersion string, last.FabricState, last.FabricStatus, last.ClusterUuid = f.State, f.Status, f.ClusterUUID last.CliqueId, last.FabricBandwidth = f.CliqueID, f.Bandwidth } + if m := g.MIG; m != nil { + r.Gpus[len(r.Gpus)-1].Mig = &llmgwv1.MIGDevice{ + ParentIndex: int32(m.ParentIndex), ParentUuid: m.ParentUUID, + ParentMemoryTotalMb: int32(m.ParentMemoryTotalMB), Device: int32(m.Device), + Profile: m.Profile, GpuInstance: int32(m.GPUInstance), ComputeInstance: int32(m.ComputeInstance), + } + } } r.Imex = &llmgwv1.IMEX{State: inv.IMEX.State, Channels: int32(inv.IMEX.Channels)} for _, f := range fabrics { @@ -286,6 +294,19 @@ func int32Slice(v []int) []int32 { return out } +// migFrom is a MIG instance's parentage as the wire carries it, nil for a +// whole card and from every agent older than MIG support. +func migFrom(m *llmgwv1.MIGDevice) *discovery.MIGDevice { + if m == nil { + return nil + } + return &discovery.MIGDevice{ + ParentIndex: int(m.GetParentIndex()), ParentUUID: m.GetParentUuid(), + ParentMemoryTotalMB: int(m.GetParentMemoryTotalMb()), Device: int(m.GetDevice()), + Profile: m.GetProfile(), GPUInstance: int(m.GetGpuInstance()), ComputeInstance: int(m.GetComputeInstance()), + } +} + // fabricFrom is a GPU's NVLink fabric as the wire carries it, nil when the // driver reported none. func fabricFrom(state, status, cluster, clique, bandwidth string) *discovery.GPUFabric { diff --git a/internal/control/agentsvc/mig_test.go b/internal/control/agentsvc/mig_test.go new file mode 100644 index 0000000..f4a895d --- /dev/null +++ b/internal/control/agentsvc/mig_test.go @@ -0,0 +1,27 @@ +package agentsvc + +import ( + "reflect" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" +) + +func TestAMIGInstanceKeepsItsParentageOnTheWire(t *testing.T) { + inst := discovery.GPU{Index: 1001, UUID: "MIG-bbb", Name: "NVIDIA A100-SXM4-80GB MIG 3g.40gb", MemoryTotalMB: 40192, + MIG: &discovery.MIGDevice{ParentIndex: 0, ParentUUID: "GPU-card", ParentMemoryTotalMB: 81920, + Device: 1, Profile: "3g.40gb", GPUInstance: 1, ComputeInstance: 0}} + whole := discovery.GPU{Index: 1, UUID: "GPU-whole", Name: "NVIDIA A100-SXM4-80GB", MemoryTotalMB: 81920} + + reg := RegisterFromInventory("n1", "h", "10.0.0.1", "v", nil, discovery.Inventory{GPUs: []discovery.GPU{inst, whole}}, nil) + n := NodeFromRegister(reg) + if len(n.GPUs) != 2 { + t.Fatalf("got %d GPUs", len(n.GPUs)) + } + if !reflect.DeepEqual(n.GPUs[0].MIG, inst.MIG) || n.GPUs[0].Class() != "a100-80g-mig-3g.40gb" { + t.Errorf("the instance arrived as %s %+v", n.GPUs[0].Class(), n.GPUs[0].MIG) + } + if n.GPUs[1].MIG != nil || n.GPUs[1].Class() != "a100-80g" { + t.Errorf("the whole card arrived as %s %+v", n.GPUs[1].Class(), n.GPUs[1].MIG) + } +} diff --git a/internal/control/agentsvc/service.go b/internal/control/agentsvc/service.go index 3771e2a..5e79d7b 100644 --- a/internal/control/agentsvc/service.go +++ b/internal/control/agentsvc/service.go @@ -14,6 +14,8 @@ import ( "time" "connectrpc.com/connect" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1" "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1/llmgwv1connect" @@ -22,6 +24,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrictest" "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" "gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" ) // StatusSink receives shard transitions and recovers replica state from what @@ -311,7 +314,20 @@ func (s *Service) Connect( // unreachable in production while its unit tests, which call the reconciler // directly, kept passing. func (s *Service) adopt(ctx context.Context, nodeID string, shards []*llmgwv1.RunningShard, c *conn) { + // A trace of its own, and only for a node that reported something: a + // node running nothing registering is not an event anyone looks for. + tctx, span := ctx, trace.SpanFromContext(context.Background()) // the no-op span + if len(shards) > 0 { + tctx, span = obs.Tracer(tracerName).Start(ctx, "adopt", + trace.WithNewRoot(), + trace.WithAttributes( + attribute.String("llmgw.node", nodeID), + attribute.Int("llmgw.shards", len(shards)), + )) + } + defer span.End() unclaimed := s.status.Adopt(nodeID, AdoptedFromRegister(nodeID, shards)) + span.SetAttributes(attribute.Int("llmgw.unclaimed", len(unclaimed))) for _, u := range unclaimed { // The model or profile left the catalog while this was running. Nobody // will ever route to it, and it is holding GPUs. @@ -319,7 +335,8 @@ func (s *Service) adopt(ctx context.Context, nodeID string, shards []*llmgwv1.Ru "node", nodeID, "shard", u.ShardID, "model", u.Model, "profile", u.Profile) select { case c.send <- &llmgwv1.ControlMessage{ - Msg: &llmgwv1.ControlMessage_Stop{Stop: &llmgwv1.StopShard{ShardId: u.ShardID}}, + Msg: &llmgwv1.ControlMessage_Stop{Stop: &llmgwv1.StopShard{ShardId: u.ShardID}}, + Trace: obs.Inject(tctx), }: case <-ctx.Done(): return @@ -715,27 +732,37 @@ type Leader interface{ IsLeader() bool } // and what every deployment before leadership had. func (s *Service) SetLeadership(l Leader) { s.leader = l } +// tracerName names the spans this package opens. +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc" + // ErrNotConnected means no agent stream is open for that node. var ErrNotConnected = errors.New("no agent connected") // StartShard implements reconciler.Dispatcher. -func (s *Service) StartShard(_ context.Context, cmd reconciler.ShardCommand) error { +// +// The command carries the trace of ctx, so the agent's spans for this start +// — the weights, the engine's launch, its readiness — are in the trace of +// the replica they are starting rather than in one of their own. +func (s *Service) StartShard(ctx context.Context, cmd reconciler.ShardCommand) error { return s.send(cmd.NodeID, &llmgwv1.ControlMessage{ - Msg: &llmgwv1.ControlMessage_Start{Start: StartShardFromCommand(cmd)}, + Msg: &llmgwv1.ControlMessage_Start{Start: StartShardFromCommand(cmd)}, + Trace: obs.Inject(ctx), }) } // StopShard implements reconciler.Dispatcher. -func (s *Service) StopShard(_ context.Context, nodeID, shardID string) error { +func (s *Service) StopShard(ctx context.Context, nodeID, shardID string) error { return s.send(nodeID, &llmgwv1.ControlMessage{ - Msg: &llmgwv1.ControlMessage_Stop{Stop: &llmgwv1.StopShard{ShardId: shardID}}, + Msg: &llmgwv1.ControlMessage_Stop{Stop: &llmgwv1.StopShard{ShardId: shardID}}, + Trace: obs.Inject(ctx), }) } // DrainShard implements reconciler.Dispatcher. -func (s *Service) DrainShard(_ context.Context, nodeID, shardID string) error { +func (s *Service) DrainShard(ctx context.Context, nodeID, shardID string) error { return s.send(nodeID, &llmgwv1.ControlMessage{ - Msg: &llmgwv1.ControlMessage_Drain{Drain: &llmgwv1.DrainShard{ShardId: shardID}}, + Msg: &llmgwv1.ControlMessage_Drain{Drain: &llmgwv1.DrainShard{ShardId: shardID}}, + Trace: obs.Inject(ctx), }) } diff --git a/internal/control/catalog/catalog.go b/internal/control/catalog/catalog.go index 602082b..ded29dc 100644 --- a/internal/control/catalog/catalog.go +++ b/internal/control/catalog/catalog.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" "gitea.mynodes.xyz/ilker/llmgw/internal/control/placement" ) @@ -294,6 +295,10 @@ type Model struct { // model — the same model at new weights — to compare the two before // moving to it (`llmgw ctl model canary`). Canary *Canary `json:"canary,omitempty" koanf:"canary"` + // Eval is a quality suite new weights must pass before they replace + // this model's: `llmgw ctl model update` and `model promote` run it + // against them first, and stop below its score. + Eval *EvalGate `json:"eval,omitempty" koanf:"eval"` // RoutingProfile selects how the router scores endpoints for this model. RoutingProfile string `json:"routing_profile,omitempty" koanf:"routing_profile"` SLO SLO `json:"slo,omitempty" koanf:"slo"` @@ -512,6 +517,18 @@ type Canary struct { MaxTTFTRatio float64 `json:"max_ttft_ratio,omitempty" koanf:"max_ttft_ratio"` } +// EvalGate is a quality suite a model's new weights must pass. +type EvalGate struct { + // Suite is a suite's name in control.eval_dir, or one the binary + // carries (arithmetic, json-format). + Suite string `json:"suite" koanf:"suite"` + // MinScore is the score, 0 to 1, to pass at; zero is the suite's own + // threshold. + MinScore float64 `json:"min_score,omitempty" koanf:"min_score"` + // Judge grades the suite's rubric cases, instead of the judge it names. + Judge string `json:"judge,omitempty" koanf:"judge"` +} + // Limits are the rollback thresholds with their defaults filled in. func (c Canary) Limits() (minRequests int, maxErrorIncrease, maxTTFTRatio float64) { minRequests, maxErrorIncrease, maxTTFTRatio = c.MinRequests, c.MaxErrorIncrease, c.MaxTTFTRatio @@ -633,6 +650,14 @@ func (m Model) validate() error { if !validClass(m.Class) { return fmt.Errorf("model %q: class %q is not one of critical, standard, opportunistic", m.Name, m.Class) } + if g := m.Eval; g != nil { + if strings.TrimSpace(g.Suite) == "" { + return fmt.Errorf("model %q: eval names no suite", m.Name) + } + if g.MinScore < 0 || g.MinScore > 1 { + return fmt.Errorf("model %q: eval min_score %v is not 0 to 1", m.Name, g.MinScore) + } + } switch m.Role { case "": if m.Guard != nil { @@ -701,6 +726,17 @@ func (m Model) validate() error { } continue } + // A MIG instance is a device of its own to CUDA, but a process sees + // only one, and instances have no NVLink or peer-to-peer between + // them: a group of them is neither tensor- nor layer-parallel, it is + // a replica that fails at start. One instance, one node, refused here + // rather than placed and found out. + if discovery.IsMIGClass(p.GPUClass) && (p.GPUsPerNode > 1 || p.Nodes > 1 || p.TP > 1 || p.PP > 1) { + return fmt.Errorf("model %q profile %q: %s is a MIG instance, and a replica runs on exactly one: "+ + "a process can open only one MIG device, and instances have no NVLink between them. "+ + "Give it tp 1 and one GPU on one node, or use a whole-card class or a larger MIG profile", + m.Name, p.Name, p.GPUClass) + } // llama.cpp on GPUs splits a model across one node's cards by layer, // and how many is gpus_per_node. Not tp: llama.cpp has no tensor // parallelism, and a tp it was given was a width nothing placed — the @@ -717,6 +753,19 @@ func (m Model) validate() error { } continue } + // TensorRT-LLM spans machines only under MPI started from one of them, + // which the agents, each starting its own node, do not do; and it + // loads a Hugging Face checkpoint, not llama.cpp's format. + if p.Engine == "trtllm" { + if p.Nodes > 1 { + return fmt.Errorf("model %q profile %q: trtllm runs a replica on one node; "+ + "use vllm or sglang for a %d-node replica", m.Name, p.Name, p.Nodes) + } + if strings.HasSuffix(strings.ToLower(strings.TrimSuffix(m.Source, "/")), ".gguf") { + return fmt.Errorf("model %q profile %q: trtllm loads a Hugging Face checkpoint, not a .gguf; "+ + "serve a GGUF with llamacpp", m.Name, p.Name) + } + } // The arithmetic has to add up, or the engine is handed a shard spec it // cannot satisfy and fails minutes later during model loading. if want := p.GPUsPerNode * p.Nodes; want != p.TP*max(p.PP, 1) { diff --git a/internal/control/catalog/mig_test.go b/internal/control/catalog/mig_test.go new file mode 100644 index 0000000..8fd0e25 --- /dev/null +++ b/internal/control/catalog/mig_test.go @@ -0,0 +1,38 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestAProfileOnAMIGClassRunsOnExactlyOneInstance(t *testing.T) { + // One instance, by default or said outright, is a profile like any other. + for _, p := range []Profile{ + {Name: "vllm", Engine: "vllm", GPUClass: "a100-80g-mig-3g.40gb"}, + {Name: "llama", Engine: "llamacpp", GPUClass: "a100-80g-mig-3g.40gb", GPUsPerNode: 1, TP: 1}, + } { + c, err := New([]Model{model("m", p)}) + if err != nil { + t.Fatalf("%s on one MIG instance: %v", p.Name, err) + } + m, _ := c.Resolve("m") + if pl := m.PlacementProfiles()[0]; pl.GPUsPerNode != 1 || pl.Nodes != 1 { + t.Errorf("%s: placement asked for %+v", p.Name, pl) + } + } + // More than one is refused where the model is defined: a process opens + // one MIG device, and instances have no NVLink between them, so the + // replica would be placed and then fail at start. + for _, p := range []Profile{ + {Name: "tp2", Engine: "vllm", GPUClass: "a100-80g-mig-3g.40gb", TP: 2}, + {Name: "pp2", Engine: "vllm", GPUClass: "a100-80g-mig-3g.40gb", TP: 1, PP: 2, GPUsPerNode: 2}, + {Name: "nodes", Engine: "vllm", GPUClass: "a100-80g-mig-3g.40gb", TP: 2, Nodes: 2, GPUsPerNode: 1}, + {Name: "layers", Engine: "llamacpp", GPUClass: "a100-80g-mig-3g.40gb", GPUsPerNode: 2}, + {Name: "old-tp", Engine: "llamacpp", GPUClass: "a100-80g-mig-3g.40gb", TP: 2}, + } { + _, err := New([]Model{model("m", p)}) + if err == nil || !strings.Contains(err.Error(), "MIG instance") { + t.Errorf("%s: %v", p.Name, err) + } + } +} diff --git a/internal/control/catalog/trtllm_test.go b/internal/control/catalog/trtllm_test.go new file mode 100644 index 0000000..e9a8ef5 --- /dev/null +++ b/internal/control/catalog/trtllm_test.go @@ -0,0 +1,53 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestATensorRTLLMProfileIsAGPUProfileOnOneNode(t *testing.T) { + ok := Model{Name: "llama", Source: "meta-llama/Llama-3.1-70B-Instruct", Profiles: []Profile{{ + Name: "h100", Engine: "trtllm", GPUClass: "h100-80g", TP: 4, + Args: []string{"--max_batch_size", "64"}, + }}} + c, err := New([]Model{ok}) + if err != nil { + t.Fatalf("a four-GPU TensorRT-LLM profile was refused: %v", err) + } + m, _ := c.Resolve("llama") + if p := m.PlacementProfiles()[0]; p.GPUsPerNode != 4 || p.Nodes != 1 { + t.Errorf("placed as %+v, expected four GPUs of one node", p) + } + + for want, bad := range map[string]Model{ + // Two nodes need MPI started from one of them. + "one node": {Name: "x", Source: "org/x", Profiles: []Profile{{ + Name: "p", Engine: "trtllm", GPUClass: "h100-80g", TP: 16, Nodes: 2, GPUsPerNode: 8}}}, + // No CPU build exists. + "gpu_class is required": {Name: "x", Source: "org/x", Profiles: []Profile{{Name: "p", Engine: "trtllm"}}}, + // llama.cpp's format. + "not a .gguf": {Name: "x", Source: "http://mirror:8081/x/v1/x-q4.gguf", Profiles: []Profile{{ + Name: "p", Engine: "trtllm", GPUClass: "h100-80g"}}}, + // What its launcher closes, a profile may not open. + "--trust_remote_code is not allowed": {Name: "x", Source: "org/x", Profiles: []Profile{{ + Name: "p", Engine: "trtllm", GPUClass: "h100-80g", Args: []string{"--trust_remote_code"}}}}, + } { + if _, err := New([]Model{bad}); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected %q, got %v", want, err) + } + } +} + +func TestATensorRTLLMModelIsGivenTheRepositoryNotItsPage(t *testing.T) { + // trtllm-serve downloads a repository itself, as vLLM does. + m := Model{Name: "q", Source: "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct", Profiles: []Profile{{ + Name: "p", Engine: "trtllm", GPUClass: "l40s-48g"}}} + c, err := New([]Model{m}) + if err != nil { + t.Fatal(err) + } + got, _ := c.Resolve("q") + if !strings.HasSuffix(got.Source, "Qwen/Qwen2.5-7B-Instruct") || strings.Contains(got.Source, "huggingface.co") { + t.Errorf("source %q", got.Source) + } +} diff --git a/internal/control/fleetmetrics/fleetmetrics.go b/internal/control/fleetmetrics/fleetmetrics.go index f8a3c97..16b9b5b 100644 --- a/internal/control/fleetmetrics/fleetmetrics.go +++ b/internal/control/fleetmetrics/fleetmetrics.go @@ -430,8 +430,18 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) { byClass[class]++ labels := []string{n.ID, strconv.Itoa(g.Index), class} - s, fresh := sensorFor(n.Load, g.Index, now) - if fresh && s.Busy() { + // The sensors are the card's. A MIG instance shares its card's + // heat and power, but not its memory, and has no utilisation of + // its own that any driver reports: its memory is the instance's + // own reading, and its utilisation is left out rather than + // published as the card's or as zero. + s, fresh := sensorFor(n.Load, g.CardIndex(), now) + if g.IsMIG() { + ch <- prometheus.MustNewConstMetric(c.gpuMemoryTotal, prometheus.GaugeValue, + float64(g.MemoryTotalMB)*1024*1024, labels...) + ch <- prometheus.MustNewConstMetric(c.gpuMemoryUsed, prometheus.GaugeValue, + float64(g.MemoryUsedMB)*1024*1024, labels...) + } else if fresh && s.Busy() { // What the card is doing now, sampled every heartbeat. c.gpuBusy(ch, s, labels) } else { diff --git a/internal/control/fleetmetrics/mig_test.go b/internal/control/fleetmetrics/mig_test.go new file mode 100644 index 0000000..bfcabb3 --- /dev/null +++ b/internal/control/fleetmetrics/mig_test.go @@ -0,0 +1,36 @@ +package fleetmetrics + +import ( + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" +) + +func TestAMIGInstanceHasItsOwnMemoryItsCardsHeatAndNoUtilisation(t *testing.T) { + // Card 0 in MIG mode: the sensor sample is the card's, 60 GB used across + // both instances and a utilisation the driver does not give per instance. + inst := discovery.GPU{Index: 1001, Name: "NVIDIA A100-SXM4-80GB MIG 3g.40gb", MemoryTotalMB: 40192, MemoryUsedMB: 21000, + MIG: &discovery.MIGDevice{ParentIndex: 0, ParentMemoryTotalMB: 81920, Device: 1, Profile: "3g.40gb"}} + n := inventory.Node{ID: "g1", State: inventory.Ready, GPUs: []discovery.GPU{inst}, + Load: &inventory.Load{At: time.Now(), GPUs: []inventory.GPUSensor{{Index: 0, TempC: 61, PowerW: 250, + UtilPercent: 0, MemoryUsedMB: 60000, MemoryTotalMB: 81920}}}} + out := gather(t, fakeSource{nodes: []inventory.Node{n}}) + labels := "class=a100-80g-mig-3g.40gb gpu=1001 node=g1" + for _, want := range []string{ + "llmgw_gpu_memory_used_bytes " + labels + " -> 22020096000\n", + "llmgw_gpu_memory_total_bytes " + labels + " -> 42144366592\n", + "llmgw_gpu_temperature_celsius " + labels + " -> 61\n", + "llmgw_gpu_power_watts " + labels + " -> 250\n", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %s in:\n%s", want, out) + } + } + // Not the card's zero, not anything: absent. + if strings.Contains(out, "llmgw_gpu_utilization_percent "+labels) { + t.Errorf("a utilisation was published for a MIG instance:\n%s", out) + } +} diff --git a/internal/control/modelserve/canary.go b/internal/control/modelserve/canary.go index 4eea4bc..aff78e3 100644 --- a/internal/control/modelserve/canary.go +++ b/internal/control/modelserve/canary.go @@ -56,25 +56,33 @@ func (s *Server) StartCanary(name string, cn catalog.Canary, by string) (Job, er func (s *Server) runCanary(j *Job, cn catalog.Canary) { ctx := context.Background() - name := j.Model - p, ok := s.prepare(ctx, j, name) + p, ok := s.prepare(ctx, j, j.Model) if !ok { return } + if _, ok := s.addCandidate(ctx, j, p, cn); ok { + s.finish(j) + } +} + +// addCandidate runs a model's prepared new weights beside it as its canary +// and gives it its share; it fails the job and says false when it cannot. +func (s *Server) addCandidate(ctx context.Context, j *Job, p prepared, cn catalog.Canary) (string, bool) { + name := j.Model // 4. The candidate, beside the model: the same in everything but its // weights, and one replica. cand := canaryName(name) s.step(j, "catalog", Running, "adding "+cand) c := p.m - c.Name, c.Aliases, c.Fallbacks, c.Canary, c.Adapters = cand, nil, nil, nil, nil + c.Name, c.Aliases, c.Fallbacks, c.Canary, c.Adapters, c.Eval = cand, nil, nil, nil, nil, nil c.Source = p.source c.Origin = &catalog.Origin{Repo: p.repo.ID, Revision: p.repo.Revision, Files: originFiles(p.set)} c.Scaling = catalog.Scaling{Min: 1, Max: 1} c.Description = fmt.Sprintf("Canary of %s at %s.", name, p.repo.ShortRevision()) if err := s.deps.Models.Add(ctx, c, j.Request.By); err != nil { s.fail(j, "catalog", err) - return + return "", false } s.step(j, "catalog", Done, cand+" runs "+p.repo.ID+" at "+p.repo.ShortRevision()) @@ -93,21 +101,22 @@ func (s *Server) runCanary(j *Job, cn catalog.Canary) { if err != nil { _ = s.deps.Models.Remove(ctx, cand) s.fail(j, "placement", fmt.Errorf("%w; %s was taken out again", err, cand)) - return + return "", false } - // 6. Its share. + // 6. Its share: none, for new weights only being asked a suite, which + // hides it from clients all the same. cn.Model = cand if _, err := s.deps.Models.SetCanary(ctx, name, &cn, j.Request.By); err != nil { _ = s.deps.Models.Remove(ctx, cand) s.fail(j, "split", err) - return + return "", false } if s.deps.ResetCanary != nil { s.deps.ResetCanary(name) } s.step(j, "split", Done, fmt.Sprintf("%d%% of %s's requests go to %s", cn.Percent, name, cand)) - s.finish(j) + return cand, true } // SetCanaryPercent changes the share of a model's requests its canary is @@ -152,8 +161,9 @@ func (s *Server) AbortCanary(ctx context.Context, name, by string) (string, erro } // StartPromote moves a model to its canary's weights — the update a canary -// was the trial of — and takes the canary out. -func (s *Server) StartPromote(name, by string) (Job, error) { +// was the trial of — and takes the canary out. Held to a quality suite, the +// canary is asked it first, and stays a canary below its score. +func (s *Server) StartPromote(name, by string, ch Change) (Job, error) { m, err := s.current(name) if err != nil { return Job{}, err @@ -161,28 +171,50 @@ func (s *Server) StartPromote(name, by string) (Job, error) { if m.Canary == nil { return Job{}, fmt.Errorf("%s has no canary to promote", name) } + g, skipped := gateFor(m, ch) + if err := s.checkGate(m.Canary.Model, g); err != nil { + return Job{}, err + } s.mu.Lock() + for _, j := range s.jobs { + if (j.Kind == "update" || j.Kind == "canary" || j.Kind == "promote") && j.Model == name && j.State == Running { + s.mu.Unlock() + return Job{}, fmt.Errorf("%s has a job running: %s", name, j.ID) + } + } s.seq++ j := &Job{ID: fmt.Sprintf("promote-%d-%d", time.Now().Unix(), s.seq), Kind: "promote", Model: name, Request: Request{Name: name, By: by}, State: Running, Started: time.Now()} s.keepJob(j) s.mu.Unlock() - go s.runPromote(j) + go func() { + ctx := context.Background() + cand := m.Canary.Model + if !s.gate(ctx, j, cand, g, skipped, func() string { + return fmt.Sprintf("%s stays a canary: `llmgw ctl model canary %s --abort` takes it out", cand, name) + }) { + return + } + if s.promoteTo(ctx, j) { + s.finish(j) + } + }() return s.snapshot(j), nil } -func (s *Server) runPromote(j *Job) { - ctx := context.Background() +// promoteTo moves a model to its canary's weights, replaces its replicas and +// takes the canary out; it fails the job and says false when it cannot. +func (s *Server) promoteTo(ctx context.Context, j *Job) bool { name := j.Model m, err := s.current(name) if err != nil || m.Canary == nil { s.fail(j, "catalog", fmt.Errorf("%s no longer has a canary", name)) - return + return false } c, err := s.current(m.Canary.Model) if err != nil { s.fail(j, "catalog", err) - return + return false } s.step(j, "catalog", Running, fmt.Sprintf("moving %s to %s's weights", name, c.Name)) next := &catalog.Origin{} @@ -195,18 +227,18 @@ func (s *Server) runPromote(j *Job) { } if _, err := s.deps.Models.SetSource(ctx, name, c.Source, next, j.Request.By); err != nil { s.fail(j, "catalog", err) - return + return false } // Its share back to the model, which is about to run the same weights. if _, err := s.deps.Models.SetCanary(ctx, name, nil, j.Request.By); err != nil { s.fail(j, "catalog", err) - return + return false } s.step(j, "catalog", Done, "the catalog names "+c.Source) if err := s.replace(ctx, j, name); err != nil { s.fail(j, "replicas", fmt.Errorf("%w. `llmgw ctl model rollback %s` goes back", err, name)) - return + return false } if err := s.deps.Models.Remove(ctx, c.Name); err != nil { s.step(j, "canary", Failed, fmt.Sprintf("%s could not be removed: %v", c.Name, err)) @@ -222,7 +254,7 @@ func (s *Server) runPromote(j *Job) { Latest: c.Origin.Revision, Previous: next.Previous.Revision, CheckedAt: time.Now()} s.mu.Unlock() } - s.finish(j) + return true } // WatchCanaries takes out, every so often, each canary serving worse than diff --git a/internal/control/modelserve/canary_test.go b/internal/control/modelserve/canary_test.go index 6eedadf..7411129 100644 --- a/internal/control/modelserve/canary_test.go +++ b/internal/control/modelserve/canary_test.go @@ -49,7 +49,7 @@ func TestACanaryRunsTheNewWeightsBesideAndIsPromoted(t *testing.T) { t.Errorf("share: %v", err) } - job, err = s.StartPromote("qwen", "tester") + job, err = s.StartPromote("qwen", "tester", Change{}) if err != nil { t.Fatal(err) } diff --git a/internal/control/modelserve/gate.go b/internal/control/modelserve/gate.go new file mode 100644 index 0000000..d0b3247 --- /dev/null +++ b/internal/control/modelserve/gate.go @@ -0,0 +1,96 @@ +package modelserve + +import ( + "context" + "errors" + "fmt" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// A gate: new weights asked a quality suite before they replace a model's, +// and refused below its score. A promotion asks the canary, which is +// already running; an update starts the new weights as a canary given none +// of the model's requests, asks it, and promotes it or takes it out — so +// the old weights serve until the new have passed. + +// Evaluator runs quality suites: the control plane's eval service. +type Evaluator interface { + // Check says whether a suite can be run, before anything is started + // for it. + Check(model string, r eval.Request) error + // Run asks a model a suite and waits for its score. + Run(ctx context.Context, model string, r eval.Request, by, job string) (store.EvalRun, error) +} + +// Change is how an update or a promotion is held to quality. +type Change struct { + // Eval is a suite the new weights must pass first. Nil takes the + // model's own (eval: in its catalog entry), if it has one. + Eval *eval.Request `json:"eval,omitempty"` + // SkipEval moves without the model's own suite; the job says so. + SkipEval bool `json:"skip_eval,omitempty"` +} + +// gateFor is the suite a change is held to, and whether one was skipped. +func gateFor(m catalog.Model, ch Change) (r *eval.Request, skipped bool) { + switch { + case ch.Eval != nil: + return ch.Eval, false + case m.Eval == nil: + return nil, false + case ch.SkipEval: + return nil, true + } + return &eval.Request{Suite: m.Eval.Suite, MinScore: m.Eval.MinScore, Judge: m.Eval.Judge}, false +} + +// checkGate refuses, before a job starts, a gate that cannot be run. +func (s *Server) checkGate(name string, r *eval.Request) error { + if r == nil { + return nil + } + if s.deps.Eval == nil { + return errors.New("this control plane cannot run quality suites: it routes no requests") + } + if err := s.deps.Eval.Check(name, *r); err != nil { + return fmt.Errorf("the quality suite: %w", err) + } + return nil +} + +// gate asks the new weights, running as target, a suite, and says whether +// they may replace the old; when they may not, the job fails saying why, +// and what refused does about the new weights, which it says too. +func (s *Server) gate(ctx context.Context, j *Job, target string, r *eval.Request, skipped bool, refused func() string) bool { + if skipped { + s.step(j, "eval", Skipped, "the model's quality suite was skipped by "+orUnknown(j.Request.By)) + return true + } + if r == nil { + return true + } + s.step(j, "eval", Running, fmt.Sprintf("asking %s the cases of %s", target, r.Named())) + run, err := s.deps.Eval.Run(ctx, target, *r, j.Request.By, j.ID) + if err != nil { + s.fail(j, "eval", fmt.Errorf("%w; %s", err, refused())) + return false + } + run.Results = nil + s.mu.Lock() + j.Eval = &run + s.mu.Unlock() + detail := fmt.Sprintf("%s scored %.2f on %s, %d of %d cases passed; at least %.2f is asked", target, + run.Score, run.Suite, run.Cases-run.Below, run.Cases, run.Threshold) + if run.Error != "" { + detail += "; " + run.Error + } + if !run.Passed { + s.fail(j, "eval", fmt.Errorf("%s; %s. `llmgw ctl eval --run %s` shows each case", detail, refused(), run.ID)) + return false + } + s.step(j, "eval", Done, detail) + return true +} diff --git a/internal/control/modelserve/gate_test.go b/internal/control/modelserve/gate_test.go new file mode 100644 index 0000000..9cd2247 --- /dev/null +++ b/internal/control/modelserve/gate_test.go @@ -0,0 +1,184 @@ +package modelserve + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// fakeEval scores whatever it is asked what it is told to. +type fakeEval struct { + mu sync.Mutex + score float64 + asked []string +} + +func (f *fakeEval) Check(_ string, r eval.Request) error { + if r.Suite == "missing" { + return errors.New("no suite called missing") + } + return nil +} + +func (f *fakeEval) Run(_ context.Context, model string, r eval.Request, by, job string) (store.EvalRun, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.asked = append(f.asked, model+" "+r.Named()) + threshold := r.MinScore + if threshold == 0 { + threshold = 0.8 + } + below := 0 + if f.score < 1 { + below = 1 + } + return store.EvalRun{ID: "eval-1", Model: "qwen", Target: model, Suite: r.Named(), Score: f.score, + Threshold: threshold, Passed: f.score >= threshold, Cases: 4, Below: below, By: by, Job: job}, nil +} + +func (f *fakeEval) said() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string{}, f.asked...) +} + +func stepOf(j Job, name string) Step { + for _, s := range j.Steps { + if s.Name == name { + return s + } + } + return Step{} +} + +func TestAPromoteBelowTheSuitesScoreIsRefusedAndTheCanaryStays(t *testing.T) { + s, models, _ := canarySetup(t) + ev := &fakeEval{score: 0.5} + s.deps.Eval = ev + job, _ := s.StartCanary("qwen", catalog.Canary{Percent: 10}, "tester") + if j := wait(t, s, job.ID); j.State != Done { + t.Fatalf("%s: %+v", j.Error, j.Steps) + } + oldSource := models.models["qwen"].Source + gate := Change{Eval: &eval.Request{Suite: "arithmetic", MinScore: 0.8}} + + job, err := s.StartPromote("qwen", "tester", gate) + if err != nil { + t.Fatal(err) + } + j := wait(t, s, job.ID) + if j.State != Failed || stepOf(j, "eval").State != Failed || + !strings.Contains(j.Error, "qwen-canary scored 0.50 on arithmetic") || + !strings.Contains(j.Error, "stays a canary") || stepOf(j, "catalog").Name != "" { + t.Fatalf("a canary scoring 0.5 of 0.8 was promoted, or not said so: %s %+v", j.Error, j.Steps) + } + if j.Eval == nil || j.Eval.Score != 0.5 || j.Eval.Job != j.ID { + t.Errorf("the job does not carry the score: %+v", j.Eval) + } + m := models.models["qwen"] + if m.Source != oldSource || m.Canary == nil || m.Canary.Percent != 10 { + t.Fatalf("the model moved, or lost its canary: %s %+v", m.Source, m.Canary) + } + if _, ok := models.models["qwen-canary"]; !ok { + t.Fatal("the canary was taken out; a refused promotion leaves that to its owner") + } + if got := ev.said(); len(got) != 1 || got[0] != "qwen-canary arithmetic" { + t.Errorf("asked %v", got) + } + + ev.score = 0.9 + job, _ = s.StartPromote("qwen", "tester", gate) + if j := wait(t, s, job.ID); j.State != Done || !strings.Contains(stepOf(j, "eval").Detail, "scored 0.90") { + t.Fatalf("%s: %+v", j.Error, j.Steps) + } + if m := models.models["qwen"]; m.Source == oldSource || m.Canary != nil { + t.Fatalf("a canary that passed was not promoted: %s %+v", m.Source, m.Canary) + } +} + +func TestAnUpdateHeldToTheModelsSuiteTriesTheNewWeightsFirstAndKeepsTheOldBelowIt(t *testing.T) { + s, models, fleet := canarySetup(t) + ev := &fakeEval{score: 0.4} + s.deps.Eval = ev + m := models.models["qwen"] + m.Eval = &catalog.EvalGate{Suite: "arithmetic", MinScore: 0.75} + models.models["qwen"] = m + oldSource := m.Source + + job, err := s.StartUpdate("qwen", "tester", Change{}) + if err != nil { + t.Fatal(err) + } + j := wait(t, s, job.ID) + if j.State != Failed || !strings.Contains(j.Error, "scored 0.40") || !strings.Contains(j.Error, "taken out again") { + t.Fatalf("%s: %+v", j.Error, j.Steps) + } + if got := ev.said(); len(got) != 1 || got[0] != "qwen-canary arithmetic" { + t.Fatalf("the new weights were not what was asked: %v", got) + } + m = models.models["qwen"] + if m.Source != oldSource || m.Canary != nil { + t.Fatalf("the model moved to weights that failed, or kept their canary: %s %+v", m.Source, m.Canary) + } + if _, ok := models.models["qwen-canary"]; ok { + t.Fatal("the failed weights were left in the catalog") + } + for _, r := range fleet.Replicas() { + if r.Model == "qwen" && strings.Contains(r.ID, "new") { + t.Fatalf("%s was replaced onto weights that failed", r.ID) + } + } + + // Scoring enough, they replace the old. + ev.score = 1 + job, _ = s.StartUpdate("qwen", "tester", Change{}) + j = wait(t, s, job.ID) + if j.State != Done || stepOf(j, "eval").State != Done || stepOf(j, "split").Detail != "0% of qwen's requests go to qwen-canary" { + t.Fatalf("%s: %+v", j.Error, j.Steps) + } + if m := models.models["qwen"]; m.Source == oldSource || !strings.HasSuffix(m.Source, "/def98765/q4.gguf") || + m.Canary != nil || m.Origin.Previous == nil || m.Origin.Previous.Source != oldSource || m.Eval == nil { + t.Fatalf("not moved to the new weights, or the gate was lost: %s %+v %+v", m.Source, m.Canary, m.Origin) + } + if _, ok := models.models["qwen-canary"]; ok { + t.Error("the canary was left in the catalog") + } +} + +func TestAModelsSuiteCanBeSkippedOnceAndAMissingOneIsRefusedAtOnce(t *testing.T) { + s, models, _ := canarySetup(t) + ev := &fakeEval{score: 0} + s.deps.Eval = ev + m := models.models["qwen"] + m.Eval = &catalog.EvalGate{Suite: "arithmetic"} + models.models["qwen"] = m + + if _, err := s.StartUpdate("qwen", "tester", Change{Eval: &eval.Request{Suite: "missing"}}); err == nil || + !strings.Contains(err.Error(), "no suite called missing") { + t.Fatalf("an update held to a suite that is not there was started: %v", err) + } + + job, err := s.StartUpdate("qwen", "tester", Change{SkipEval: true}) + if err != nil { + t.Fatal(err) + } + j := wait(t, s, job.ID) + if j.State != Done || stepOf(j, "eval").State != Skipped || !strings.Contains(stepOf(j, "eval").Detail, "tester") { + t.Fatalf("%s: %+v", j.Error, j.Steps) + } + if len(ev.said()) != 0 { + t.Errorf("asked %v while skipped", ev.said()) + } + + // Without anything to run suites, a gate is refused rather than ignored. + s.deps.Eval = nil + if _, err := s.StartUpdate("qwen", "tester", Change{}); err == nil { + t.Error("an update held to the model's suite started with nothing to run it") + } +} diff --git a/internal/control/modelserve/modelserve.go b/internal/control/modelserve/modelserve.go index 0676d4a..42b7eab 100644 --- a/internal/control/modelserve/modelserve.go +++ b/internal/control/modelserve/modelserve.go @@ -33,6 +33,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/mirror" "gitea.mynodes.xyz/ilker/llmgw/internal/modelplan" "gitea.mynodes.xyz/ilker/llmgw/internal/notify" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // Registry reads a repository. @@ -95,6 +96,9 @@ type Deps struct { // canary is never rolled back by itself. CanaryStats func() map[string]canary.Stat ResetCanary func(model string) + // Eval runs the quality suites an update or a promotion is held to; + // without it, one held to a suite is refused. + Eval Evaluator } // errBusy is a model the cluster has the hardware for, all of it in use now. @@ -178,6 +182,12 @@ type Job struct { // how fast it said it. Reply string `json:"reply,omitempty"` TokensPerSecond float64 `json:"tokens_per_second,omitempty"` + + // trace is the job's spans, when tracing is on. + trace jobTrace + // Eval is the quality suite the new weights were asked before they + // replaced the old, and how they scored, without each case. + Eval *store.EvalRun `json:"eval,omitempty"` } // Server runs jobs and remembers them. @@ -324,6 +334,7 @@ func (s *Server) snapshot(j *Job) Job { func (s *Server) step(j *Job, name, state, detail string) { s.mu.Lock() defer s.mu.Unlock() + j.traceStep(name, state, detail) if n := len(j.Steps); n > 0 && j.Steps[n-1].Name == name { j.Steps[n-1].State, j.Steps[n-1].Detail, j.Steps[n-1].At = state, detail, time.Now() return @@ -335,6 +346,7 @@ func (s *Server) fail(j *Job, name string, err error) { s.step(j, name, Failed, err.Error()) s.mu.Lock() j.State, j.Error = Failed, err.Error() + j.traceEnd(err) s.mu.Unlock() s.deps.Log.Warn("serving a model failed", "job", j.ID, "repo", j.Request.Repo, "step", name, "err", err) } @@ -623,6 +635,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F func (s *Server) finish(j *Job) { s.mu.Lock() j.State = Done + j.traceEnd(nil) s.mu.Unlock() s.deps.Log.Info("model served", "job", j.ID, "repo", j.Request.Repo, "model", j.Model) } @@ -791,6 +804,7 @@ func (s *Server) waitReady(ctx context.Context, j *Job, name string, hardware bo continue } if r.State == reconciler.Ready { + s.linkReplica(j, r) s.step(j, "placement", Done, fmt.Sprintf("%s ready on %s at %s", r.ID, strings.Join(r.NodeIDs(), ","), r.Endpoint)) return r, nil diff --git a/internal/control/modelserve/trace.go b/internal/control/modelserve/trace.go new file mode 100644 index 0000000..dc8189f --- /dev/null +++ b/internal/control/modelserve/trace.go @@ -0,0 +1,99 @@ +package modelserve + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" +) + +// A job is a trace of its own, and each of its steps a span beneath it — +// registry, mirror, check, catalog, placement, smoke test — opened when the +// step begins and closed when it is done, failed or skipped. The steps are +// what a job already records for `ctl model jobs`; the trace adds where the +// time went, and from the placement step a link to the replica's own start, +// whose trace goes on down to the node. +// +// Begun by the first step rather than where a job is made, so every kind of +// job is traced by recording steps and nothing else. + +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + +// jobTrace is a job's spans. Read and written with Server.mu held. +type jobTrace struct { + span trace.Span + step trace.Span + stepName string +} + +// traceStep records a step on the job's trace. Called with s.mu held. +func (j *Job) traceStep(name, state, detail string) { + if j.trace.span == nil { + kind := j.Kind + if kind == "" { + kind = "serve" + } + _, j.trace.span = obs.Tracer(tracerName).Start(context.Background(), kind+" model", + trace.WithAttributes( + attribute.String("llmgw.job", j.ID), + attribute.String("llmgw.repo", j.Request.Repo), + attribute.String("llmgw.model", j.Model), + )) + } + if j.trace.step != nil && j.trace.stepName != name { + // A step left running when the next began: over, whatever it said. + j.trace.step.End() + j.trace.step = nil + } + if j.trace.step == nil { + ctx := trace.ContextWithSpan(context.Background(), j.trace.span) + _, j.trace.step = obs.Tracer(tracerName).Start(ctx, name) + j.trace.stepName = name + } + span := j.trace.step + span.SetAttributes(attribute.String("llmgw.state", state), attribute.String("llmgw.detail", detail)) + switch state { + case Running: + return + case Failed: + span.SetStatus(codes.Error, detail) + } + span.End() + j.trace.step = nil +} + +// traceEnd closes the job's trace, failed when err is set. Called with s.mu +// held. +func (j *Job) traceEnd(err error) { + if j.trace.step != nil { + j.trace.step.End() + j.trace.step = nil + } + if j.trace.span == nil { + return + } + if j.Model != "" { + j.trace.span.SetAttributes(attribute.String("llmgw.model", j.Model)) + } + obs.End(j.trace.span, err) + j.trace.span = nil +} + +// linkReplica links the step in progress to the start of a replica it waited +// for, which is in the reconciler's trace rather than this one. +func (s *Server) linkReplica(j *Job, r reconciler.Replica) { + sc := r.StartTrace() + if !sc.IsValid() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if j.trace.step != nil { + j.trace.step.AddLink(trace.Link{SpanContext: sc, + Attributes: []attribute.KeyValue{attribute.String("llmgw.replica", r.ID)}}) + } +} diff --git a/internal/control/modelserve/trace_test.go b/internal/control/modelserve/trace_test.go new file mode 100644 index 0000000..f9715f9 --- /dev/null +++ b/internal/control/modelserve/trace_test.go @@ -0,0 +1,65 @@ +package modelserve + +import ( + "strings" + "testing" + + "go.opentelemetry.io/otel/codes" + + "gitea.mynodes.xyz/ilker/llmgw/internal/hf" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs/obstest" +) + +func TestAJobIsATraceWithASpanPerStep(t *testing.T) { + rec := obstest.Record(t) + files := []hf.File{{Path: "Qwen2.5-1.5B-Instruct-Q4_K_M.gguf", Bytes: 1 << 30, SHA256: "bb"}} + s, _, _, _ := setup(t, files, map[string]string{"Qwen2.5-1.5B-Instruct-Q4_K_M.gguf": "BB"}) + job, err := s.Start(Request{Repo: "bartowski/Qwen2.5-1.5B-Instruct-GGUF", Replicas: 1}) + if err != nil { + t.Fatal(err) + } + if j := wait(t, s, job.ID); j.State != Done { + t.Fatalf("job %s: %s", j.State, j.Error) + } + + root := rec.One(t, [16]byte{}, "serve model") + tid := root.SpanContext().TraceID() + if a := obstest.Attrs(root); a["llmgw.job"] != job.ID || a["llmgw.model"] != "qwen2.5-1.5b-instruct" { + t.Errorf("the job's span does not say which job: %v", a) + } + var steps []string + for _, sp := range rec.Ended() { + if sp.SpanContext().TraceID() == tid && obstest.ChildOf(sp, root) { + steps = append(steps, sp.Name()) + } + } + if strings.Join(steps, ",") != "registry,mirror,check,catalog,placement,smoke test" { + t.Errorf("step spans %v", steps) + } + smoke := rec.One(t, tid, "smoke test") + if a := obstest.Attrs(smoke); a["llmgw.state"] != Done || !strings.Contains(a["llmgw.detail"], "Hello!") { + t.Errorf("the smoke test's span says %v", a) + } +} + +func TestAFailedJobsTraceEndsAtTheStepThatFailed(t *testing.T) { + rec := obstest.Record(t) + files := []hf.File{{Path: "m-Q4_K_M.gguf", Bytes: 1 << 30, SHA256: "aaaaaaaaaaaaaaaa"}} + s, _, _, _ := setup(t, files, map[string]string{"m-Q4_K_M.gguf": "bbbbbbbbbbbbbbbb"}) + job, _ := s.Start(Request{Repo: "a/b", Replicas: 1}) + if j := wait(t, s, job.ID); j.State != Failed { + t.Fatalf("job %s", j.State) + } + + root := rec.One(t, [16]byte{}, "serve model") + if root.Status().Code != codes.Error || !strings.Contains(root.Status().Description, "registry published") { + t.Errorf("the failed job's span says %+v", root.Status()) + } + mirror := rec.One(t, root.SpanContext().TraceID(), "mirror") + if mirror.Status().Code != codes.Error { + t.Errorf("the step that failed is %+v", mirror.Status()) + } + if len(rec.Named("catalog")) != 0 { + t.Error("a step after the failure was traced") + } +} diff --git a/internal/control/modelserve/updates.go b/internal/control/modelserve/updates.go index 18b1f4d..0fb32a2 100644 --- a/internal/control/modelserve/updates.go +++ b/internal/control/modelserve/updates.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler" "gitea.mynodes.xyz/ilker/llmgw/internal/gguf" @@ -429,33 +430,79 @@ func (s *Server) current(name string) (catalog.Model, error) { } // StartUpdate moves a model to its repository's latest weights, as a job. -func (s *Server) StartUpdate(name, by string) (Job, error) { +// Held to a quality suite, the new weights run first as a canary given none +// of its requests, are asked the suite, and replace the old only at its +// score. +func (s *Server) StartUpdate(name, by string, ch Change) (Job, error) { if s.deps.Models == nil || s.deps.Restart == nil { return Job{}, errors.New("updating needs the store, which this control plane lacks") } - if _, err := s.current(name); err != nil { + m, err := s.current(name) + if err != nil { return Job{}, err } if s.deps.Models.Declared(name) { return Job{}, fmt.Errorf("%s is declared in server.yaml: serve the new weights there, by its source, "+ "or they would revert on the next restart", name) } + g, skipped := gateFor(m, ch) + if g != nil && m.Canary != nil { + return Job{}, fmt.Errorf("%s has a canary, %s: an update held to a quality suite tries the new weights as "+ + "one. Promote it (`llmgw ctl model promote %s --eval ...`) or take it out first", name, m.Canary.Model, name) + } + if err := s.checkGate(canaryName(name), g); err != nil { + return Job{}, err + } s.mu.Lock() for _, j := range s.jobs { if j.Kind == "update" && j.Model == name && j.State == Running { s.mu.Unlock() return Job{}, fmt.Errorf("%s is already being updated: job %s", name, j.ID) } + if g != nil && (j.Kind == "canary" || j.Kind == "promote") && j.Model == name && j.State == Running { + s.mu.Unlock() + return Job{}, fmt.Errorf("%s has a job running: %s", name, j.ID) + } } s.seq++ j := &Job{ID: fmt.Sprintf("update-%d-%d", time.Now().Unix(), s.seq), Kind: "update", Model: name, Request: Request{Name: name, By: by}, State: Running, Started: time.Now()} s.keepJob(j) s.mu.Unlock() - go s.runUpdate(j) + if g != nil { + go s.runGatedUpdate(j, g) + } else { + go s.runUpdate(j, skipped) + } return s.snapshot(j), nil } +// runGatedUpdate is an update held to a quality suite: the new weights as a +// canary with no share, asked the suite, then promoted or taken out. +func (s *Server) runGatedUpdate(j *Job, g *eval.Request) { + ctx := context.Background() + name := j.Model + p, ok := s.prepare(ctx, j, name) + if !ok { + return + } + cand, ok := s.addCandidate(ctx, j, p, catalog.Canary{Percent: 0}) + if !ok { + return + } + if !s.gate(ctx, j, cand, g, false, func() string { + if _, err := s.AbortCanary(ctx, name, "llmgw"); err != nil { + return fmt.Sprintf("the new weights could not be taken out (%v): `llmgw ctl model canary %s --abort`", err, name) + } + return fmt.Sprintf("the new weights were taken out again; %s still runs %s", name, catalog.ShortRevision(p.o.Revision)) + }) { + return + } + if s.promoteTo(ctx, j) { + s.finish(j) + } +} + // prepared is a model's new weights, fetched and checked: what an update and // a canary both start from. type prepared struct { @@ -559,13 +606,14 @@ func (s *Server) prepare(ctx context.Context, j *Job, name string) (prepared, bo return prepared{m: m, o: o, repo: repo, set: set, source: source}, true } -func (s *Server) runUpdate(j *Job) { +func (s *Server) runUpdate(j *Job, skipped bool) { ctx := context.Background() name := j.Model p, ok := s.prepare(ctx, j, name) if !ok { return } + s.gate(ctx, j, name, nil, skipped, nil) m, o, repo, set, source := p.m, p.o, p.repo, p.set, p.source // 4. The catalog: the new source, and the old one kept to go back to. @@ -636,6 +684,9 @@ func (s *Server) replace(ctx context.Context, j *Job, name string) error { } } if left == 0 && len(ready) > 0 { + for _, r := range ready { + s.linkReplica(j, r) + } s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old))) s.step(j, "smoke test", Running, "asking "+ready[0].ID+" to say hello") reply, tps, err := s.smoke(ctx, ready[0], name) diff --git a/internal/control/modelserve/updates_test.go b/internal/control/modelserve/updates_test.go index 81c9430..f723caa 100644 --- a/internal/control/modelserve/updates_test.go +++ b/internal/control/modelserve/updates_test.go @@ -205,7 +205,7 @@ func updateSays(u UpdateInfo) string { func TestAnUpdateFetchesBesideTheOldReplacesTheReplicasAndCanGoBack(t *testing.T) { s, mir, models, _, fleet := updSetup(t) oldSource := models.models["qwen"].Source - job, err := s.StartUpdate("qwen", "tester") + job, err := s.StartUpdate("qwen", "tester", Change{}) if err != nil { t.Fatal(err) } @@ -243,7 +243,7 @@ func TestAnUpdateFetchesBesideTheOldReplacesTheReplicasAndCanGoBack(t *testing.T } // And forward again, from files already on the mirror. - job, err = s.StartUpdate("qwen", "tester") + job, err = s.StartUpdate("qwen", "tester", Change{}) if err != nil { t.Fatal(err) } @@ -251,7 +251,7 @@ func TestAnUpdateFetchesBesideTheOldReplacesTheReplicasAndCanGoBack(t *testing.T t.Fatalf("%s: %+v", j.Error, j.Steps) } // Then there is nothing newer: refused, saying why. - job, _ = s.StartUpdate("qwen", "tester") + job, _ = s.StartUpdate("qwen", "tester", Change{}) if j := wait(t, s, job.ID); j.State != Failed || !strings.Contains(j.Error, "already runs") { t.Fatalf("%+v", j) } @@ -260,7 +260,7 @@ func TestAnUpdateFetchesBesideTheOldReplacesTheReplicasAndCanGoBack(t *testing.T func TestAModelFromConfigurationIsNotUpdatedBehindItsBack(t *testing.T) { s, _, models, _, _ := updSetup(t) models.declared["qwen"] = true - if _, err := s.StartUpdate("qwen", "t"); err == nil || !strings.Contains(err.Error(), "server.yaml") { + if _, err := s.StartUpdate("qwen", "t", Change{}); err == nil || !strings.Contains(err.Error(), "server.yaml") { t.Fatalf("%v", err) } } @@ -268,7 +268,7 @@ func TestAModelFromConfigurationIsNotUpdatedBehindItsBack(t *testing.T) { func TestAnUpdateToFilesTheRepositoryDroppedIsRefused(t *testing.T) { s, _, _, reg, _ := updSetup(t) reg.repo.Files = []hf.File{{Path: "q4-v2.gguf", SHA256: "cccc"}} - job, _ := s.StartUpdate("qwen", "t") + job, _ := s.StartUpdate("qwen", "t", Change{}) if j := wait(t, s, job.ID); j.State != Failed || !strings.Contains(j.Error, "no longer has q4.gguf") { t.Fatalf("%+v", j) } diff --git a/internal/control/modelsvc/modelsvc.go b/internal/control/modelsvc/modelsvc.go index 36fa40b..392b67d 100644 --- a/internal/control/modelsvc/modelsvc.go +++ b/internal/control/modelsvc/modelsvc.go @@ -323,6 +323,23 @@ func (s *Service) SetCanary(ctx context.Context, name string, c *catalog.Canary, return final, err } +// SetEval sets the quality suite a model's new weights must pass before an +// update or a promotion moves it to them; nil takes it away. +func (s *Service) SetEval(ctx context.Context, name string, g *catalog.EvalGate, by string) (catalog.Model, error) { + final, err := s.update(ctx, name, by, "set its eval gate", func(m *catalog.Model) error { + m.Eval = g + return nil + }) + if err == nil { + if g == nil { + s.log.Info("model eval gate removed", "model", name, "by", by) + } else { + s.log.Info("model eval gate set", "model", name, "suite", g.Suite, "min_score", g.MinScore, "by", by) + } + } + return final, err +} + // SetOrigin records where a model's weights came from without changing them: // for a model added before origins were kept. A model from configuration is // left alone, its origin worked out again when it is needed. diff --git a/internal/control/placement/mig_test.go b/internal/control/placement/mig_test.go new file mode 100644 index 0000000..4120d5f --- /dev/null +++ b/internal/control/placement/mig_test.go @@ -0,0 +1,87 @@ +package placement + +import ( + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" +) + +// migNode is an A100 80GB node as discovery reports it with card 0 cut into +// two 3g.40gb instances and card 1 whole. +func migNode(id string) inventory.Node { + card := discovery.GPU{Name: "NVIDIA A100-SXM4-80GB", MemoryTotalMB: 81920, ComputeCapability: "8.0"} + nd := inventory.Node{ID: id, State: inventory.Ready} + for dev, uuid := range []string{"MIG-c6d4f1ef", "MIG-0f7c3a2e"} { + g := card + g.Index, g.UUID, g.MemoryTotalMB = discovery.MIGIndex(0, dev), uuid, 40192 + g.Name += " MIG 3g.40gb" + g.MIG = &discovery.MIGDevice{ParentIndex: 0, ParentMemoryTotalMB: 81920, Device: dev, Profile: "3g.40gb"} + nd.GPUs = append(nd.GPUs, g) + } + whole := card + whole.Index, whole.UUID = 1, "GPU-whole" + // NVLink to the card in MIG mode, as topo -m still reports it: nothing + // may take that for a link an instance can use. + whole.NVLinkPeers = []int{0} + nd.GPUs = append(nd.GPUs, whole) + return nd +} + +func TestAModelIsPlacedOnAMIGInstanceOfItsClass(t *testing.T) { + st := store(migNode("n1")) + s := New(st) + p := Profile{Name: "mig", GPUClass: "a100-80g-mig-3g.40gb", GPUsPerNode: 1} + + res := s.Place([]Profile{p}, nil) + if res.Placement == nil { + t.Fatalf("no placement:\n%s", res.Explain()) + } + got := res.Placement.Nodes[0].GPUIndices + if !slices.Equal(got, []int{1000}) { + t.Fatalf("placed on %v, want MIG instance 1000", got) + } + if res.Placement.Pool != "none/a100-80g-mig-3g.40gb" { + t.Errorf("pool %q", res.Placement.Pool) + } + + // The next replica takes the other instance, and the one after finds + // none: the whole card beside them is another class. + st.Allocate("n1", inventory.Allocation{ShardID: "s1", ReplicaID: "r1", GPUIndices: got}) + res = s.Place([]Profile{p}, nil) + if res.Placement == nil || !slices.Equal(res.Placement.Nodes[0].GPUIndices, []int{1001}) { + t.Fatalf("second replica: %+v\n%s", res.Placement, res.Explain()) + } + st.Allocate("n1", inventory.Allocation{ShardID: "s2", ReplicaID: "r2", GPUIndices: []int{1001}}) + if res = s.Place([]Profile{p}, nil); res.Placement != nil { + t.Errorf("a third replica was placed on %v", res.Placement.Nodes[0].GPUIndices) + } + + // A whole-card profile takes the whole card, never an instance. + res = s.Place([]Profile{{Name: "whole", GPUClass: "a100-80g", GPUsPerNode: 1}}, nil) + if res.Placement == nil || !slices.Equal(res.Placement.Nodes[0].GPUIndices, []int{1}) { + t.Errorf("whole-card profile: %+v\n%s", res.Placement, res.Explain()) + } +} + +func TestMoreThanOneMIGInstanceForAReplicaIsRefused(t *testing.T) { + // Two instances are free on one node, which is what a tp 2 profile would + // otherwise be given — and fail at start, since a process opens only one + // MIG device and instances have no NVLink between them. + s := New(store(migNode("n1"), migNode("n2"))) + for _, p := range []Profile{ + {Name: "tp2", GPUClass: "a100-80g-mig-3g.40gb", GPUsPerNode: 2}, + {Name: "two-nodes", GPUClass: "a100-80g-mig-3g.40gb", GPUsPerNode: 1, Nodes: 2}, + } { + res := s.Place([]Profile{p}, nil) + if res.Placement != nil { + t.Errorf("%s was placed: %+v", p.Name, res.Placement.Nodes) + continue + } + if !strings.Contains(res.Explain(), "a replica runs on exactly one") { + t.Errorf("%s refused without saying why:\n%s", p.Name, res.Explain()) + } + } +} diff --git a/internal/control/placement/placement.go b/internal/control/placement/placement.go index fdb92d7..6a57db1 100644 --- a/internal/control/placement/placement.go +++ b/internal/control/placement/placement.go @@ -156,6 +156,15 @@ func (s *Scheduler) Place(profiles []Profile, avoid []string) Result { for _, raw := range ordered { p := raw.normalize() + // The catalog refuses these; a profile that reaches here some other + // way is refused again rather than placed on instances that cannot + // work together. MIG instances are placed one to a replica. + if discovery.IsMIGClass(p.GPUClass) && (p.GPUsPerNode > 1 || p.Nodes > 1) { + res.Rejections = append(res.Rejections, Rejection{Profile: p.Name, Pool: "none/" + p.GPUClass, + Reason: fmt.Sprintf("asks for %d MIG instances on %d nodes, but a replica runs on exactly one", + p.GPUsPerNode*p.Nodes, p.Nodes)}) + continue + } if p.Nodes == 1 { if pl, rej := s.placeSingleNode(p, avoid); pl != nil { res.Placement = pl diff --git a/internal/control/reconciler/evict.go b/internal/control/reconciler/evict.go index d7a8bc9..7807545 100644 --- a/internal/control/reconciler/evict.go +++ b/internal/control/reconciler/evict.go @@ -6,6 +6,8 @@ import ( "sort" "time" + "go.opentelemetry.io/otel/attribute" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" ) @@ -53,7 +55,15 @@ func (r *Reconciler) makeRoomFor(ctx context.Context, m catalog.Model) bool { r.log.Info("evicting an idle replica to make room", "stopping", best.ID, "idle_model", best.Model, "idle", best.Idle.Round(time.Second), "gpus", best.GPUs, "for", m.Name) - r.stopReplica(ctx, best.ID) + ctx, span := startSpan(ctx, "evict", + attribute.String("llmgw.for", m.Name), + attribute.String("llmgw.replica", best.ID), + attribute.String("llmgw.model", best.Model), + attribute.Float64("llmgw.idle_seconds", best.Idle.Seconds()), + attribute.Int("llmgw.gpus", best.GPUs), + attribute.Int("llmgw.candidates", len(candidates))) + defer span.End() + r.stopReplica(ctx, best.ID, "evicted, idle, to make room for "+m.Name) return true } diff --git a/internal/control/reconciler/preempt.go b/internal/control/reconciler/preempt.go index 0c93d70..7f27f69 100644 --- a/internal/control/reconciler/preempt.go +++ b/internal/control/reconciler/preempt.go @@ -2,10 +2,13 @@ package reconciler import ( "context" + "fmt" "slices" "sort" "time" + "go.opentelemetry.io/otel/attribute" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/control/placement" "gitea.mynodes.xyz/ilker/llmgw/internal/control/quota" @@ -113,12 +116,24 @@ func (r *Reconciler) preempt(ctx context.Context, m catalog.Model, chosen []vict r.preempting[m.Name] = ids r.mu.Unlock() + victims := make([]string, 0, len(chosen)) for _, v := range chosen { r.log.Warn("preempting a replica to make room", "replica", v.ID, "model", v.Model, "class", v.Class, "above_min", v.AboveMin, "for", m.Name, "for_class", m.EffectiveClass()) + desc := fmt.Sprintf("%s (%s, %s)", v.ID, v.Model, v.Class) + if v.AboveMin { + desc = fmt.Sprintf("%s (%s, %s, above its minimum)", v.ID, v.Model, v.Class) + } + victims = append(victims, desc) } - r.scaleDown(ctx, ids) + ctx, span := startSpan(ctx, "preempt", + attribute.String("llmgw.for", m.Name), + attribute.String("llmgw.for_class", m.EffectiveClass()), + attribute.StringSlice("llmgw.victims", victims), + attribute.String("llmgw.hold", r.cfg.PreemptHold.String())) + defer span.End() + r.scaleDown(ctx, ids, "preempted for "+m.Name) } // awaitingPreemption reports whether replicas stopped for a model are still diff --git a/internal/control/reconciler/reconciler.go b/internal/control/reconciler/reconciler.go index d36a5ea..c3a0dff 100644 --- a/internal/control/reconciler/reconciler.go +++ b/internal/control/reconciler/reconciler.go @@ -11,6 +11,10 @@ import ( "sync/atomic" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" @@ -18,6 +22,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/placement" "gitea.mynodes.xyz/ilker/llmgw/internal/control/quota" "gitea.mynodes.xyz/ilker/llmgw/internal/notify" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" ) // ShardCommand is everything an agent needs to launch one shard. @@ -405,7 +410,9 @@ func (r *Reconciler) Reconcile(ctx context.Context) { if r.leader != nil && !r.leader.IsLeader() { return } - r.expireNodes() + ctx, p := beginPass(ctx) + defer p.end() + r.expireNodes(ctx) r.expireStuckReplicas() r.drainCordonedNodes(ctx) r.reapFailed(ctx) @@ -452,7 +459,7 @@ func (r *Reconciler) stopReplicasOfRemovedModels(ctx context.Context) { for _, rep := range gone { r.log.Info("stopping a replica whose model left the catalog", "replica", rep.ID, "model", rep.Model) - r.stopReplica(ctx, rep.ID) + r.stopReplica(ctx, rep.ID, "its model left the catalog") } } @@ -523,6 +530,7 @@ func (r *Reconciler) expireStuckReplicas() { } rep.State = Failed rep.Err = fmt.Sprintf("did not come up within %s", r.startupTimeout(rep)) + rep.startDone(now) stuck = append(stuck, rep) } r.mu.Unlock() @@ -555,7 +563,7 @@ func (r *Reconciler) startupTimeout(rep *Replica) time.Duration { // The two-stage delay is deliberate: a brief network blip must not tear down // replicas that are still serving traffic perfectly well, and rebuilding a // multi-node replica costs tens of minutes of model loading. -func (r *Reconciler) expireNodes() { +func (r *Reconciler) expireNodes(ctx context.Context) { now := r.now() for _, n := range r.inv.Nodes() { silent := now.Sub(n.LastSeen) @@ -563,14 +571,21 @@ func (r *Reconciler) expireNodes() { case n.State == inventory.Ready && silent > r.cfg.UnreachableAfter: r.inv.SetState(n.ID, inventory.Unreachable) r.log.Warn("node is unreachable", "node", n.ID, "silent_for", silent.Round(time.Second)) + _, span := startSpan(ctx, "node unreachable", attribute.String("llmgw.node", n.ID), + attribute.Float64("llmgw.silent_seconds", silent.Seconds())) + span.End() case n.State == inventory.Unreachable && silent > r.cfg.LostAfter: + _, span := startSpan(ctx, "node lost", attribute.String("llmgw.node", n.ID), + attribute.Float64("llmgw.silent_seconds", silent.Seconds())) r.inv.SetState(n.ID, inventory.Lost) r.log.Error("node lost; its replicas will be rebuilt elsewhere", "node", n.ID, "silent_for", silent.Round(time.Second)) r.notify.Load().Notify(notify.NodeLost, n.ID, fmt.Sprintf("node %s was lost: silent for %s; its replicas are rebuilt elsewhere", n.ID, silent.Round(time.Second)), map[string]any{"node": n.ID, "silent_seconds": int(silent.Seconds())}) - r.failReplicasOn(n.ID, "node "+n.ID+" was lost") + span.SetAttributes(attribute.StringSlice("llmgw.replicas_failed", + r.failReplicasOn(n.ID, "node "+n.ID+" was lost"))) + span.End() } } } @@ -609,24 +624,26 @@ func (r *Reconciler) drainCordonedNodes(ctx context.Context) { for _, rep := range toDrain { r.log.Info("draining replica off a node being taken out of service", "replica", rep.ID, "nodes", rep.NodeIDs()) - for _, s := range shards[rep.ID] { - if err := r.disp.DrainShard(ctx, s.NodeID, s.ID); err != nil { - r.log.Warn("draining a shard failed", "shard", s.ID, "err", err) - } - } + r.drainShards(ctx, rep.ID, shards[rep.ID], "its node is being drained") } } -func (r *Reconciler) failReplicasOn(nodeID, reason string) { +// failReplicasOn fails every replica on a node, and says which. +func (r *Reconciler) failReplicasOn(nodeID, reason string) []string { r.mu.Lock() defer r.mu.Unlock() + var ids []string for _, rep := range r.replicas { if rep.State.Terminal() || !rep.OnNode(nodeID) { continue } rep.State = Failed rep.Err = reason + rep.startDone(r.now()) + ids = append(ids, rep.ID) } + slices.Sort(ids) + return ids } // reapFailed releases the resources of terminal replicas and tells the agents @@ -639,8 +656,10 @@ func (r *Reconciler) reapFailed(ctx context.Context) { doomed = append(doomed, rep) } } + parkedNow := map[string]bool{} for _, rep := range doomed { delete(r.replicas, rep.ID) + rep.startDone(r.now()) if rep.State == Failed { // Carry the failure count on the model, not the replica: the // replica is about to disappear, and the backoff has to survive it. @@ -652,6 +671,7 @@ func (r *Reconciler) reapFailed(ctx context.Context) { r.parked[rep.Model] = parkRecord{ Since: r.now(), Reason: rep.Err, Failures: n, fp: modelFingerprint(m), } + parkedNow[rep.ID] = true r.log.Error("model parked: it failed every time it started, and is not retried "+ "until its definition changes or `llmgw ctl retry` is run", "model", rep.Model, "failures", n, "last_error", rep.Err) @@ -667,23 +687,52 @@ func (r *Reconciler) reapFailed(ctx context.Context) { for _, rep := range doomed { // rep is out of the map now, so no agent report can reach it any more. + sctx, span := startSpan(ctx, "reap replica", replicaAttrs(rep)...) + span.SetAttributes(attribute.String("llmgw.state", rep.State.String())) if rep.State == Failed { r.mu.Lock() - retry := r.backoff(r.attempts[rep.Model]) + attempts := r.attempts[rep.Model] + retry := r.backoff(attempts) r.mu.Unlock() r.log.Error("replica failed", "replica", rep.ID, "model", rep.Model, "err", rep.Err, "retry_in", retry.Round(time.Second)) + span.SetAttributes( + attribute.String("llmgw.error", rep.Err), + attribute.Int("llmgw.failures_in_a_row", attempts), + attribute.Float64("llmgw.retry_in_seconds", retry.Seconds()), + attribute.Bool("llmgw.parked", parkedNow[rep.ID]), + ) } // Stop every shard, including the ones that looked healthy: a partially // alive replica holds GPUs while serving nothing. for _, s := range rep.Shards { - if err := r.disp.StopShard(ctx, s.NodeID, s.ID); err != nil { + if err := r.disp.StopShard(sctx, s.NodeID, s.ID); err != nil { r.log.Warn("stopping a shard of a dead replica failed", "replica", rep.ID, "shard", s.ID, "err", err) } } r.inv.ReleaseReplica(rep.ID) + span.End() + } +} + +// drainShards tells a replica's nodes to stop routing to its shards, as one +// "drain replica" span saying why. +func (r *Reconciler) drainShards(ctx context.Context, id string, shards []Shard, why string) { + nodes := make([]string, 0, len(shards)) + for _, s := range shards { + nodes = append(nodes, s.NodeID) + } + ctx, span := startSpan(ctx, "drain replica", + attribute.String("llmgw.replica", id), + attribute.StringSlice("llmgw.nodes", nodes), + attribute.String("llmgw.reason", why)) + defer span.End() + for _, s := range shards { + if err := r.disp.DrainShard(ctx, s.NodeID, s.ID); err != nil { + r.log.Warn("draining a shard failed", "shard", s.ID, "err", err) + } } } @@ -727,12 +776,14 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) { } } // The next restart an operator queued, once the last one is done. + restarting := "" if len(retiringIDs) == 0 { if id, ok := r.nextRestartLocked(m.Name); ok { r.replicas[id].RetiringAt = r.now() retiringIDs = append(retiringIDs, id) liveIDs = slices.DeleteFunc(liveIDs, func(x string) bool { return x == id }) readyLive-- + restarting = id r.log.Info("restarting a replica: placing its replacement first", "replica", id, "model", m.Name) } @@ -749,6 +800,11 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) { r.log.Info("model unparked: its definition changed", "model", m.Name) } r.mu.Unlock() + if restarting != "" { + _, span := startSpan(ctx, "restart replica", + attribute.String("llmgw.replica", restarting), attribute.String("llmgw.model", m.Name)) + span.End() + } desired := r.desiredFor(m) outcome := notTried @@ -765,9 +821,20 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) { // the real error in noise. return } - outcome = r.scaleUp(ctx, m, avoid) + sctx, span := startSpan(ctx, "scale up", + attribute.String("llmgw.model", m.Name), + attribute.Int("llmgw.replicas", len(liveIDs)), + attribute.Int("llmgw.desired", desired)) + outcome = r.scaleUp(sctx, m, avoid) + span.SetAttributes(attribute.String("llmgw.outcome", outcome.String())) + span.End() case len(liveIDs) > desired: - r.scaleDown(ctx, liveIDs[desired:]) + sctx, span := startSpan(ctx, "scale down", + attribute.String("llmgw.model", m.Name), + attribute.Int("llmgw.replicas", len(liveIDs)), + attribute.Int("llmgw.desired", desired)) + r.scaleDown(sctx, liveIDs[desired:], "more replicas than desired") + span.End() } // A drained replica is stopped once its replacement is serving, or once the @@ -790,17 +857,17 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) { switch { case !ok: case replaced: - r.stopReplica(ctx, id) + r.stopReplica(ctx, id, "drained, and its replacement serves") case expired && preemptedFor != "": r.log.Info("stopping a preempted replica", "replica", id, "for", preemptedFor, "after", grace) - r.stopReplica(ctx, id) + r.stopReplica(ctx, id, "preempted for "+preemptedFor+", and its grace is over") case expired: // The replacement never arrived — often because nowhere else can // host it. Draining forever would leave the node undrainable. r.log.Warn("stopping a drained replica without a replacement", "replica", id, "after", grace) - r.stopReplica(ctx, id) + r.stopReplica(ctx, id, "drained, and no replacement arrived within "+grace.String()) } } @@ -813,6 +880,9 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) { // allocated, and starting several at once from the same inventory snapshot // would hand them the same GPUs. func (r *Reconciler) scaleUp(ctx context.Context, m catalog.Model, avoid []string) scaleOutcome { + _, pspan := startSpan(ctx, "place", + attribute.String("llmgw.model", m.Name), + attribute.StringSlice("llmgw.avoid", avoid)) profiles := r.orderedProfiles(m) res := r.sched.Place(profiles, avoid) if res.Placement == nil && len(avoid) > 0 { @@ -820,7 +890,10 @@ func (r *Reconciler) scaleUp(ctx context.Context, m catalog.Model, avoid []strin // is the only thing standing in the way, take the placement anyway: // a second replica on one node still survives an engine crash. res = r.sched.Place(profiles, nil) + pspan.SetAttributes(attribute.Bool("llmgw.spread_given_up", res.Placement != nil)) } + placementAttrs(pspan, res) + pspan.End() r.mu.Lock() r.lastExplain[m.Name] = res @@ -852,10 +925,12 @@ func (r *Reconciler) scaleUp(ctx context.Context, m catalog.Model, avoid []strin {Profile: res.Placement.Profile.Name, Reason: why}}} r.mu.Unlock() r.notePlacementFailure(m.Name, why) + trace.SpanFromContext(ctx).SetAttributes(attribute.String("llmgw.over_limit", why)) return overLimit } if err := r.startReplica(ctx, m, res.Placement); err != nil { r.log.Error("starting replica failed", "model", m.Name, "err", err) + trace.SpanFromContext(ctx).SetStatus(codes.Error, err.Error()) return noRoom } return placed @@ -872,6 +947,51 @@ const ( overLimit // there is room, but the owner's quota does not allow it ) +// String names an outcome for a trace. +func (o scaleOutcome) String() string { + switch o { + case placed: + return "placed" + case makingRoom: + return "making room" + case noRoom: + return "no room" + case overLimit: + return "over its quota" + default: + return "not tried" + } +} + +// placementAttrs puts a placement's answer on its span: where it went, or +// every candidate it turned down and why, which is what `ctl +// explain-placement` would have printed at that moment. +func placementAttrs(span trace.Span, res placement.Result) { + span.SetAttributes(attribute.Int("llmgw.rejections", len(res.Rejections))) + pl := res.Placement + if pl == nil { + explain := res.Explain() + if len(explain) > 4096 { + explain = explain[:4096] + "…" + } + span.SetAttributes(attribute.String("llmgw.explain", explain)) + span.SetStatus(codes.Error, "fits nowhere") + return + } + nodes := make([]string, 0, len(pl.Nodes)) + for _, a := range pl.Nodes { + nodes = append(nodes, a.NodeID) + } + span.SetAttributes( + attribute.String("llmgw.profile", pl.Profile.Name), + attribute.String("llmgw.engine", pl.Profile.Engine), + attribute.String("llmgw.gpu_class", pl.Profile.GPUClass), + attribute.String("llmgw.pool", pl.Pool), + attribute.String("llmgw.fabric", pl.Fabric), + attribute.StringSlice("llmgw.nodes", nodes), + ) +} + func (r *Reconciler) startReplica(ctx context.Context, m catalog.Model, pl *placement.Placement) error { prof, ok := m.ProfileByName(pl.Profile.Name) if !ok { @@ -936,6 +1056,19 @@ func (r *Reconciler) startReplica(ctx context.Context, m catalog.Model, pl *plac slices.SortFunc(order, func(a, b Shard) int { return a.Rank - b.Rank }) nodeIDs := rep.NodeIDs() + // Open until the replica serves, fails or is stopped, and the parent of + // every node's part of the start: the agents' spans arrive under it. + rctx, rspan := startSpan(ctx, "replica start", append(replicaAttrs(rep), + attribute.String("llmgw.engine", prof.Engine), + attribute.String("llmgw.gpu_class", prof.GPUClass), + attribute.String("llmgw.pool", pl.Pool), + attribute.String("llmgw.fabric", pl.Fabric), + attribute.String("llmgw.endpoint", rep.Endpoint), + attribute.Int("llmgw.attempt", attempt), + attribute.String("llmgw.startup_timeout", r.startupTimeout(rep).String()), + )...) + rep.startSpan, rep.startTrace = rspan, rspan.SpanContext() + r.mu.Lock() r.replicas[id] = rep r.mu.Unlock() @@ -965,7 +1098,19 @@ func (r *Reconciler) startReplica(ctx context.Context, m catalog.Model, pl *plac Fabric: pl.Fabric, ProfileFP: prof.Fingerprint(), } - if err := r.disp.StartShard(ctx, cmd); err != nil { + // The command, sent: its context travels with it to the agent. + sctx, sspan := tracer().Start(rctx, "send start", + trace.WithSpanKind(trace.SpanKindProducer), + trace.WithAttributes( + attribute.String("llmgw.shard", s.ID), + attribute.String("llmgw.node", s.NodeID), + attribute.Int("llmgw.rank", s.Rank), + attribute.IntSlice("llmgw.gpus", s.GPUIndices), + attribute.Int("llmgw.port", s.HTTPPort), + )) + err := r.disp.StartShard(sctx, cmd) + obs.End(sspan, err) + if err != nil { r.markShard(id, s.ID, supervisor.Failed, err.Error()) return fmt.Errorf("starting shard %s: %w", s.ID, err) } @@ -1031,7 +1176,8 @@ func (r *Reconciler) allocatePorts(pl *placement.Placement) int { return r.cfg.PortRange[0] } -func (r *Reconciler) scaleDown(ctx context.Context, excess []string) { +// scaleDown drains replicas; why is for the trace. +func (r *Reconciler) scaleDown(ctx context.Context, excess []string, why string) { for _, id := range excess { r.mu.Lock() rep, ok := r.replicas[id] @@ -1045,15 +1191,12 @@ func (r *Reconciler) scaleDown(ctx context.Context, excess []string) { r.mu.Unlock() r.log.Info("draining replica", "replica", id) - for _, s := range shards { - if err := r.disp.DrainShard(ctx, s.NodeID, s.ID); err != nil { - r.log.Warn("draining a shard failed", "shard", s.ID, "err", err) - } - } + r.drainShards(ctx, id, shards, why) } } -func (r *Reconciler) stopReplica(ctx context.Context, id string) { +// stopReplica stops a replica's shards and forgets it; why is for the trace. +func (r *Reconciler) stopReplica(ctx context.Context, id, why string) { r.mu.Lock() rep, ok := r.replicas[id] if !ok { @@ -1062,6 +1205,10 @@ func (r *Reconciler) stopReplica(ctx context.Context, id string) { } shards := slices.Clone(rep.Shards) rep.State = Stopped + rep.startDone(r.now()) + ctx, span := startSpan(ctx, "stop replica", append(replicaAttrs(rep), + attribute.String("llmgw.reason", why))...) + defer span.End() r.mu.Unlock() for _, s := range shards { @@ -1130,6 +1277,18 @@ func (r *Reconciler) markShard(replicaID, shardID string, st supervisor.State, e prev := rep.State rep.deriveState(r.now()) + if rep.startSpan != nil { + ev := []attribute.KeyValue{ + attribute.String("llmgw.shard", shardID), attribute.String("llmgw.state", st.String()), + } + if errMsg != "" { + ev = append(ev, attribute.String("llmgw.error", errMsg)) + } + rep.startSpan.AddEvent("shard "+st.String(), trace.WithAttributes(ev...)) + } + if rep.State == Ready || rep.State.Terminal() { + rep.startDone(r.now()) + } if rep.State == Ready && prev != Ready { // A model that comes up clears its backoff; the next failure starts // counting from scratch rather than from an old streak. diff --git a/internal/control/reconciler/reconciler_test.go b/internal/control/reconciler/reconciler_test.go index 0c69304..f9e35d8 100644 --- a/internal/control/reconciler/reconciler_test.go +++ b/internal/control/reconciler/reconciler_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "go.opentelemetry.io/otel/trace" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" @@ -24,9 +26,12 @@ type fakeDispatcher struct { stopped []string drained []string failNext error + // traces are the span each start was sent under, which is what the + // real dispatcher puts on the command for the agent. + traces []trace.SpanContext } -func (d *fakeDispatcher) StartShard(_ context.Context, cmd ShardCommand) error { +func (d *fakeDispatcher) StartShard(ctx context.Context, cmd ShardCommand) error { d.mu.Lock() defer d.mu.Unlock() if d.failNext != nil { @@ -35,6 +40,7 @@ func (d *fakeDispatcher) StartShard(_ context.Context, cmd ShardCommand) error { return err } d.started = append(d.started, cmd) + d.traces = append(d.traces, trace.SpanContextFromContext(ctx)) return nil } diff --git a/internal/control/reconciler/replica.go b/internal/control/reconciler/replica.go index 36f341e..ee4de60 100644 --- a/internal/control/reconciler/replica.go +++ b/internal/control/reconciler/replica.go @@ -7,6 +7,8 @@ import ( "slices" "time" + "go.opentelemetry.io/otel/trace" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" ) @@ -166,8 +168,19 @@ type Replica struct { // carried across rebuilds so the backoff does not reset on every attempt. Attempt int Err string + + // startSpan is the "replica start" span, open from placement until the + // replica serves, fails or is stopped; nil once closed, and for a replica + // adopted rather than started. startTrace names it for good. + startSpan trace.Span + startTrace trace.SpanContext } +// StartTrace names the span of this replica's start, for something that +// waited on it — a job serving a model — to link to. Invalid for a replica +// adopted rather than started, and whenever tracing is off. +func (r Replica) StartTrace() trace.SpanContext { return r.startTrace } + // NodeIDs lists the nodes this replica occupies. func (r Replica) NodeIDs() []string { out := make([]string, 0, len(r.Shards)) diff --git a/internal/control/reconciler/restart.go b/internal/control/reconciler/restart.go index 970d731..1af8031 100644 --- a/internal/control/reconciler/restart.go +++ b/internal/control/reconciler/restart.go @@ -7,6 +7,8 @@ import ( "slices" "time" + "go.opentelemetry.io/otel/attribute" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" ) @@ -48,39 +50,39 @@ func (r *Reconciler) retire(ctx context.Context, m catalog.Model, ids []string, } switch { case readyLive >= desired: - r.drainForRestart(ctx, id) + r.drainForRestart(ctx, id, "restarted: its replacement serves") case inPlace: r.log.Warn("restarting a replica in place, as asked: stopped before its replacement starts", "replica", id) - r.stopForRestart(ctx, id) + r.stopForRestart(ctx, id, "restarted in place, as asked") case live >= desired: // A replacement is placed and loading; it is waited for. case parked: - r.refuseRestart(id, "its replacements fail to start; the model is parked (llmgw ctl retry "+m.Name+")") + r.refuseRestart(ctx, id, "its replacements fail to start; the model is parked (llmgw ctl retry "+m.Name+")") case outcome == noRoom && r.fitsWithout(m, nil, []victim{{ID: id, Model: m.Name}}): r.log.Warn("restarting a replica in place: it is the only thing in its replacement's way", "replica", id, "model", m.Name) - r.stopForRestart(ctx, id) + r.stopForRestart(ctx, id, "restarted in place: it was the only thing in its replacement's way") case outcome == noRoom || outcome == overLimit: - r.refuseRestart(id, "its replacement fits on no node even with this replica stopped "+ + r.refuseRestart(ctx, id, "its replacement fits on no node even with this replica stopped "+ "(llmgw ctl explain-placement "+m.Name+")") case outcome == notTried && r.now().Sub(since) > r.cfg.DrainGrace: r.log.Warn("restarting a replica in place: no replacement beside it", "replica", id, "after", r.cfg.DrainGrace) - r.stopForRestart(ctx, id) + r.stopForRestart(ctx, id, "restarted in place: no replacement beside it within "+r.cfg.DrainGrace.String()) } } } // stopForRestart drains a replica and stops it at once, for its replacement // to take its place. -func (r *Reconciler) stopForRestart(ctx context.Context, id string) { - r.drainForRestart(ctx, id) - r.stopReplica(ctx, id) +func (r *Reconciler) stopForRestart(ctx context.Context, id, why string) { + r.drainForRestart(ctx, id, why) + r.stopReplica(ctx, id, why) } // refuseRestart gives up a replica's restart, leaving it serving, and keeps // why for whoever looks at the replica. -func (r *Reconciler) refuseRestart(id, why string) { +func (r *Reconciler) refuseRestart(ctx context.Context, id, why string) { r.mu.Lock() if rep, ok := r.replicas[id]; ok { rep.RetiringAt = time.Time{} @@ -89,9 +91,12 @@ func (r *Reconciler) refuseRestart(id, why string) { r.restartRefused[id] = why r.mu.Unlock() r.log.Warn("not restarting a replica: it keeps serving", "replica", id, "because", why) + _, span := startSpan(ctx, "restart refused", + attribute.String("llmgw.replica", id), attribute.String("llmgw.reason", why)) + span.End() } -func (r *Reconciler) drainForRestart(ctx context.Context, id string) { +func (r *Reconciler) drainForRestart(ctx context.Context, id, why string) { r.mu.Lock() rep, ok := r.replicas[id] var shards []Shard @@ -101,10 +106,8 @@ func (r *Reconciler) drainForRestart(ctx context.Context, id string) { } delete(r.restartInPlace, id) r.mu.Unlock() - for _, s := range shards { - if err := r.disp.DrainShard(ctx, s.NodeID, s.ID); err != nil { - r.log.Warn("draining a shard failed", "shard", s.ID, "err", err) - } + if ok { + r.drainShards(ctx, id, shards, why) } } diff --git a/internal/control/reconciler/trace.go b/internal/control/reconciler/trace.go new file mode 100644 index 0000000..cec9a1b --- /dev/null +++ b/internal/control/reconciler/trace.go @@ -0,0 +1,122 @@ +package reconciler + +import ( + "context" + "errors" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "gitea.mynodes.xyz/ilker/llmgw/internal/obs" +) + +// What is traced here, and why it is shaped this way. +// +// A pass runs every few seconds and nearly always finds nothing to do. A +// trace per pass would bury the few that placed or stopped something under +// seventeen thousand empty ones a day, so a pass's span is begun only when +// the pass first does something, and backdated to when the pass began. +// +// A replica's start outlives the pass that placed it by minutes. Its span, +// "replica start", is opened beneath the placement and closed when the +// replica serves, fails, or is stopped, and the command to each node carries +// it, so the agent's spans for that shard — weights, launch, readiness — are +// its descendants. That one trace is the answer to "why did this model take +// four minutes to start". + +const tracerName = "gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler" + +func tracer() trace.Tracer { return obs.Tracer(tracerName) } + +// pass is one reconcile pass's span, begun on first use. +type pass struct { + start time.Time + base context.Context + + once sync.Once + ctx context.Context + span trace.Span +} + +type passKey struct{} + +// beginPass marks ctx as a pass's, for startSpan to hang its spans on. +func beginPass(ctx context.Context) (context.Context, *pass) { + p := &pass{start: time.Now(), base: ctx} + return context.WithValue(ctx, passKey{}, p), p +} + +// context is the pass's span's context, beginning the span if it has not +// been. +func (p *pass) context() context.Context { + p.once.Do(func() { + p.ctx, p.span = tracer().Start(p.base, "reconcile", trace.WithTimestamp(p.start)) + }) + return p.ctx +} + +// end closes the pass's span, if anything began it. +func (p *pass) end() { + p.once.Do(func() {}) // nothing may begin it after this + if p.span != nil { + p.span.End() + } +} + +// startSpan opens a span for something the reconciler did: beneath the span +// already in ctx, or beneath the pass's — begun now — when ctx holds the +// pass and nothing else. +func startSpan(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + if !trace.SpanContextFromContext(ctx).IsValid() { + if p, ok := ctx.Value(passKey{}).(*pass); ok { + if pctx := p.context(); pctx != nil { + ctx = pctx + } + } + } + return tracer().Start(ctx, name, trace.WithAttributes(attrs...)) +} + +// endSpan closes a span, marking it failed when why is not empty. +func endSpan(span trace.Span, why string) { + if why != "" { + obs.End(span, errors.New(why)) + return + } + span.End() +} + +// replicaAttrs are what every span about one replica says. +func replicaAttrs(rep *Replica) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("llmgw.replica", rep.ID), + attribute.String("llmgw.model", rep.Model), + attribute.String("llmgw.profile", rep.Profile), + attribute.StringSlice("llmgw.nodes", rep.NodeIDs()), + } +} + +// startDone closes a replica's "replica start" span, once: when it serves, +// with how long that took, or when it fails or is stopped first, with why. +// Called with r.mu held, or before the replica is in the map. +func (rep *Replica) startDone(now time.Time) { + if rep.startSpan == nil { + return + } + span := rep.startSpan + rep.startSpan = nil + span.SetAttributes(attribute.String("llmgw.state", rep.State.String())) + switch rep.State { + case Ready: + span.SetAttributes(attribute.Float64("llmgw.ready_after_seconds", now.Sub(rep.CreatedAt).Seconds())) + span.SetStatus(codes.Ok, "") + case Failed: + span.SetStatus(codes.Error, rep.Err) + default: + span.SetStatus(codes.Error, "stopped before it was ready") + } + span.End() +} diff --git a/internal/control/reconciler/trace_test.go b/internal/control/reconciler/trace_test.go new file mode 100644 index 0000000..276dda5 --- /dev/null +++ b/internal/control/reconciler/trace_test.go @@ -0,0 +1,174 @@ +package reconciler + +import ( + "errors" + "strings" + "testing" + + "go.opentelemetry.io/otel/codes" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/obs/obstest" +) + +func TestAPlacementIsTracedFromThePassToTheCommandAndTheReplicaServing(t *testing.T) { + rec := obstest.Record(t) + h := newHarness(t, testModel(1), gpuNode("1", 8)) + + h.r.Reconcile(t.Context()) + + pass := rec.One(t, [16]byte{}, "reconcile") + tid := pass.SpanContext().TraceID() + up := rec.One(t, tid, "scale up") + place := rec.One(t, tid, "place") + if !obstest.ChildOf(up, pass) || !obstest.ChildOf(place, up) { + t.Fatal("the placement is not beneath the scale-up, beneath the pass") + } + if a := obstest.Attrs(place); a["llmgw.model"] != "llama" || a["llmgw.profile"] != "tp4" || + a["llmgw.gpu_class"] != "h100-80g" || a["llmgw.nodes"] != `["1"]` { + t.Errorf("the placement does not say where it went: %v", a) + } + if a := obstest.Attrs(up); a["llmgw.outcome"] != "placed" || a["llmgw.desired"] != "1" { + t.Errorf("the scale-up does not say what it did: %v", a) + } + + // The command went under a span of its own, beneath the replica's start, + // which is still open: the replica is loading. + send := rec.One(t, tid, "send start") + if got := h.disp.traces; len(got) != 1 || got[0].SpanID() != send.SpanContext().SpanID() { + t.Fatalf("the command was not sent under its span: %v", got) + } + if len(rec.Named("replica start")) != 0 { + t.Fatal("the replica's start ended before the replica served") + } + + h.markReady() + start := rec.One(t, tid, "replica start") + if !obstest.ChildOf(start, up) || !obstest.ChildOf(send, start) { + t.Error("the replica's start is not beneath the scale-up, or the command beneath it") + } + a := obstest.Attrs(start) + if a["llmgw.state"] != "ready" || a["llmgw.profile"] != "tp4" || a["llmgw.ready_after_seconds"] == "" { + t.Errorf("the replica's start does not say how it ended: %v", a) + } + if start.Status().Code != codes.Ok { + t.Errorf("a replica that serves is %v", start.Status()) + } + events := start.Events() + if len(events) == 0 || events[len(events)-1].Name != "shard ready" { + t.Errorf("the shard's progress is not on the replica's start: %v", events) + } + if sc := h.r.Replicas()[0].StartTrace(); sc.SpanID() != start.SpanContext().SpanID() { + t.Error("the replica does not name its start's span for a job to link to") + } +} + +func TestAPassThatDoesNothingLeavesNoTrace(t *testing.T) { + rec := obstest.Record(t) + h := newHarness(t, testModel(1), gpuNode("1", 8)) + h.r.Reconcile(t.Context()) + h.markReady() + before := len(rec.Ended()) + + // Every pass from here finds the model as it should be. + for range 3 { + h.r.Reconcile(t.Context()) + } + if got := rec.Ended()[before:]; len(got) != 0 { + var names []string + for _, s := range got { + names = append(names, s.Name()) + } + t.Errorf("passes with nothing to do were traced: %v", names) + } +} + +func TestAPlacementThatFitsNowhereSaysWhy(t *testing.T) { + rec := obstest.Record(t) + m := testModel(1, catalog.Profile{Name: "tp16", GPUClass: "h100-80g", TP: 16}) + h := newHarness(t, m, gpuNode("1", 8)) + + h.r.Reconcile(t.Context()) + + place := rec.One(t, [16]byte{}, "place") + if place.Status().Code != codes.Error { + t.Errorf("a placement that found nowhere is %v", place.Status()) + } + if a := obstest.Attrs(place); a["llmgw.rejections"] == "0" || !strings.Contains(a["llmgw.explain"], "tp16") { + t.Errorf("the placement does not say what it turned down: %v", a) + } + if a := obstest.Attrs(rec.One(t, place.SpanContext().TraceID(), "scale up")); a["llmgw.outcome"] != "no room" { + t.Errorf("the scale-up says %v", a) + } +} + +func TestAFailedStartEndsItsTraceWithTheEnginesWords(t *testing.T) { + rec := obstest.Record(t) + h := newHarness(t, testModel(1), gpuNode("1", 8)) + h.r.Reconcile(t.Context()) + cmd := h.disp.startedShards()[0] + + h.r.OnShardStatus(cmd.ReplicaID, cmd.ShardID, supervisor.Failed, "CUDA out of memory") + start := rec.One(t, [16]byte{}, "replica start") + if start.Status().Code != codes.Error || !strings.Contains(start.Status().Description, "CUDA out of memory") { + t.Errorf("the failed start says %+v", start.Status()) + } + + // The next pass reaps it, in a trace of its own, saying when it is tried + // again. + h.r.Reconcile(t.Context()) + reap := rec.One(t, [16]byte{}, "reap replica") + a := obstest.Attrs(reap) + if a["llmgw.replica"] != cmd.ReplicaID || a["llmgw.failures_in_a_row"] != "1" || a["llmgw.retry_in_seconds"] == "" { + t.Errorf("the reaping does not say what happens next: %v", a) + } +} + +func TestADispatchThatFailsIsTracedAsFailed(t *testing.T) { + rec := obstest.Record(t) + h := newHarness(t, testModel(1), gpuNode("1", 8)) + h.disp.failNext = errors.New("no agent connected") + + h.r.Reconcile(t.Context()) + + send := rec.One(t, [16]byte{}, "send start") + if send.Status().Code != codes.Error { + t.Errorf("a command that was not sent is %v", send.Status()) + } + if start := rec.One(t, [16]byte{}, "replica start"); start.Status().Code != codes.Error { + t.Errorf("a replica none of whose shards was sent is %v", start.Status()) + } +} + +func TestAPreemptionIsTracedWithItsVictims(t *testing.T) { + h := fillThenWant(t, gpuModel("vital", catalog.ClassCritical, 1), gpuModel("chat", "", 1), gpuNode("1", 8)) + rec := obstest.Record(t) + + h.r.Reconcile(t.Context()) + + pre := rec.One(t, [16]byte{}, "preempt") + a := obstest.Attrs(pre) + if a["llmgw.for"] != "vital" || !strings.Contains(a["llmgw.victims"], "chat") { + t.Errorf("the preemption does not say who gave way to whom: %v", a) + } + drain := rec.One(t, pre.SpanContext().TraceID(), "drain replica") + if !obstest.ChildOf(drain, pre) || obstest.Attrs(drain)["llmgw.reason"] != "preempted for vital" { + t.Errorf("the victim's drain is not beneath the preemption: %v", obstest.Attrs(drain)) + } +} + +func TestAStopSaysWhy(t *testing.T) { + h := newHarness(t, testModel(1), gpuNode("1", 8)) + h.r.Reconcile(t.Context()) + h.markReady() + rec := obstest.Record(t) + + h.setModels(t) // the model leaves the catalog + h.r.Reconcile(t.Context()) + + stop := rec.One(t, [16]byte{}, "stop replica") + if a := obstest.Attrs(stop); a["llmgw.model"] != "llama" || a["llmgw.reason"] != "its model left the catalog" { + t.Errorf("the stop does not say why: %v", a) + } +} diff --git a/internal/docgen/gen/main.go b/internal/docgen/gen/main.go index bdf320f..a6c5f85 100644 --- a/internal/docgen/gen/main.go +++ b/internal/docgen/gen/main.go @@ -1,4 +1,5 @@ -// Command gen writes docs/reference.md from the configuration structs. +// Command gen writes docs/reference.md from the configuration structs, and +// docs/admin-api.openapi.json from the admin API route table. // // Run by `go generate ./...`, and checked by a test, so the reference cannot // quietly stop describing the code. @@ -10,7 +11,9 @@ import ( "path/filepath" "runtime" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/docgen" + "gitea.mynodes.xyz/ilker/llmgw/internal/docgen/openapi" ) func main() { @@ -43,4 +46,15 @@ func main() { os.Exit(1) } fmt.Printf("docs/reference.md and internal/docgen/reference_gen.go: %d blocks\n", len(blocks)) + + spec, err := openapi.Generate(root, adminapi.Routes, adminapi.PathParams) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + if err := os.WriteFile(filepath.Join(root, "docs", "admin-api.openapi.json"), spec, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + fmt.Printf("docs/admin-api.openapi.json: %d routes\n", len(adminapi.Routes)) } diff --git a/internal/docgen/openapi/openapi.go b/internal/docgen/openapi/openapi.go new file mode 100644 index 0000000..8c616a1 --- /dev/null +++ b/internal/docgen/openapi/openapi.go @@ -0,0 +1,672 @@ +// Package openapi generates the admin API's OpenAPI 3.1 document from its +// route table, adminapi.Routes. +// +// The schemas are read from the Go types the handlers decode and write, by +// reflection, following encoding/json's rules — tag names, omitted fields, +// embedded structs flattened — so the document says what goes over the wire +// rather than what someone remembered to write down. Their descriptions are +// the types' and fields' doc comments, read from the source, which is why +// generating needs the repository and is done by `go generate` rather than +// at run time: the binary serves the document built into it. +package openapi + +import ( + "bytes" + "encoding" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "reflect" + "slices" + "sort" + "strings" + "time" + "unicode" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +// Module is the import path the repository's packages are under; a type +// from any other is described without comments. +const Module = "gitea.mynodes.xyz/ilker/llmgw" + +// Generate renders the document for routes, reading doc comments from the +// repository at root. +func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) { + g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}} + + // Every named struct reachable from a body first, so a name two + // packages share can be told apart before either is written: by the name + // alone where it is unique, with its package where it is not. + for _, r := range routes { + for _, v := range []any{r.Request, r.Response} { + if v != nil { + g.collect(reflect.TypeOf(v)) + } + } + } + g.collect(errorType) + g.assignNames() + errorSchema := g.schema(errorType) + + paths := map[string]*object{} + var order []string + tags := map[string]bool{} + for _, r := range routes { + item, ok := paths[r.Path] + if !ok { + item = &object{} + paths[r.Path] = item + order = append(order, r.Path) + } + op, err := g.operation(r, pathParams) + if err != nil { + return nil, err + } + tags[tagOf(r.Path)] = true + item.set(strings.ToLower(r.Method), op) + } + slices.Sort(order) + var pathsObj object + for _, p := range order { + pathsObj.set(p, *paths[p]) + } + + var schemas object + names := make([]string, 0, len(g.schemas)) + for n := range g.schemas { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + schemas.set(n, g.schemas[n]) + } + + var tagList []any + for _, t := range sortedKeys(tags) { + tagList = append(tagList, obj("name", t)) + } + + doc := obj( + "openapi", "3.1.0", + "info", obj( + "title", "llmgw admin API", + "version", "v1", + "description", infoDescription, + ), + "servers", []any{obj("url", "http://127.0.0.1:9090", + "description", "The admin listener at its default address, admin_listen.")}, + "security", []any{obj("adminToken", []any{})}, + "tags", tagList, + "paths", pathsObj, + "components", obj( + "securitySchemes", obj("adminToken", obj( + "type", "http", + "scheme", "bearer", + "description", "The admin token: the contents of /var/lib/llmgw/admin-token on the "+ + "control plane (admin_token_file), readable by root and the server. "+ + "Sent as Authorization: Bearer TOKEN.", + )), + "parameters", obj("Actor", obj( + "name", "X-Llmgw-Actor", + "in", "header", + "required", false, + "description", "Who the change is recorded as made by in the audit log: letters, digits "+ + "and ._-@, at most 64. unknown when absent.", + "schema", obj("type", "string"), + )), + "responses", obj( + "Error", obj("description", "Refused: the error says why.", + "content", obj("application/json", obj("schema", errorSchema))), + "Unauthorized", obj("description", "The admin token is missing or wrong.", + "content", obj("application/json", obj("schema", errorSchema))), + ), + "schemas", schemas, + ), + ) + b, err := marshal(doc) + if err != nil { + return nil, err + } + var out bytes.Buffer + if err := json.Indent(&out, b, "", " "); err != nil { + return nil, err + } + out.WriteByte('\n') + return out.Bytes(), nil +} + +const infoDescription = "The control plane's management API: what `llmgw ctl` and the console use.\n\n" + + "It is served on the admin listener (admin_listen, 127.0.0.1:9090 by default), which stays on " + + "loopback unless admin_remote is set: reach it from elsewhere through an SSH tunnel. Every path " + + "under /api needs the admin token as a bearer token.\n\n" + + "The listener also refuses, before the token is looked at: a Host header that is a name rather " + + "than an address or localhost; a request another site's page sent; and any method but GET and " + + "HEAD whose Content-Type is not application/json, even one with no body.\n\n" + + "Durations in bodies are integer nanoseconds, as Go encodes them. Generated from the route " + + "table in internal/control/adminapi; see docs/admin-api.md." + +// operation builds one route's operation object. +func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) { + var op object + op.set("operationId", operationID(r.Method, r.Path)) + op.set("summary", r.Summary) + if r.Description != "" { + op.set("description", r.Description) + } + op.set("tags", []any{tagOf(r.Path)}) + + var params []any + segs := strings.Split(strings.Trim(r.Path, "/"), "/") + for i, s := range segs { + if !strings.HasPrefix(s, "{") { + continue + } + name := strings.Trim(s, "{}") + key := s + if i > 0 { + key = segs[i-1] + "/" + s + } + desc, ok := pathParams[key] + if !ok { + return nil, fmt.Errorf("%s %s: no description of %s in adminapi.PathParams", r.Method, r.Path, key) + } + params = append(params, obj("name", name, "in", "path", "required", true, + "description", desc, "schema", obj("type", "string"))) + } + for _, q := range r.Query { + t := q.Type + if t == "" { + t = "string" + } + p := obj("name", q.Name, "in", "query") + if q.Required { + p.set("required", true) + } + p.set("description", q.Description) + p.set("schema", obj("type", t)) + params = append(params, p) + } + if r.Method != "GET" && r.Method != "HEAD" { + params = append(params, obj("$ref", "#/components/parameters/Actor")) + } + if len(params) > 0 { + op.set("parameters", params) + } + + if r.Request != nil { + body := obj("required", !r.OptionalBody, + "content", obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Request))))) + op.set("requestBody", body) + } + + status := r.Status + if status == 0 { + status = 200 + } + content := obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response)))) + if r.CSV { + content.set("text/csv", obj("schema", obj("type", "string"))) + } + desc := "OK" + if status == 202 { + desc = "Accepted: started, and running on." + } + op.set("responses", obj( + fmt.Sprint(status), obj("description", desc, "content", content), + "401", obj("$ref", "#/components/responses/Unauthorized"), + "default", obj("$ref", "#/components/responses/Error"), + )) + return op, nil +} + +// operationID is the method and the path's words, getModelsNameTarget. +func operationID(method, p string) string { + var b strings.Builder + b.WriteString(strings.ToLower(method)) + for _, s := range strings.Split(strings.TrimPrefix(p, "/api/v1/"), "/") { + for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { + b.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + } + return b.String() +} + +// tagOf groups an operation by the first word of its path. +func tagOf(p string) string { + first, _, _ := strings.Cut(strings.TrimPrefix(p, "/api/v1/"), "/") + return strings.TrimSuffix(first, ".json") +} + +// --- schemas --- + +type typeDoc struct { + doc string + fields map[string]string +} + +type generator struct { + root string + docs map[string]map[string]typeDoc + names map[reflect.Type]string + schemas map[string]any + seen []reflect.Type +} + +var ( + timeType = reflect.TypeOf(time.Time{}) + durationType = reflect.TypeOf(time.Duration(0)) + rawType = reflect.TypeOf(json.RawMessage{}) + errorType = reflect.TypeOf(adminapi.ErrorView{}) + marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() +) + +// collect records every named struct reachable from t. +func (g *generator) collect(t reflect.Type) { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array || t.Kind() == reflect.Map { + if t == rawType { + return + } + t = t.Elem() + } + if t.Kind() != reflect.Struct || special(t) { + return + } + if t.Name() != "" { + if slices.Contains(g.seen, t) { + return + } + g.seen = append(g.seen, t) + } + for _, f := range fields(t) { + g.collect(f.typ) + } +} + +// assignNames names each struct's schema: by its name alone when it is the +// admin API's own, with its package's otherwise — catalog.Model, +// modelserve.Job. Qualified always rather than only on a collision, so a +// type added elsewhere one day cannot rename a schema clients already use. +func (g *generator) assignNames() { + own := reflect.TypeOf(adminapi.Route{}).PkgPath() + for _, t := range g.seen { + n := t.Name() + if t.PkgPath() != own { + n = path.Base(t.PkgPath()) + "." + n + } + g.names[t] = n + } + g.schemas = map[string]any{} +} + +func (g *generator) nameOf(t reflect.Type) string { return g.names[t] } + +// special is a type encoded other than by its fields. +func special(t reflect.Type) bool { + if t == timeType || t == durationType || t == rawType { + return true + } + return t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType) || + t.Implements(textType) || reflect.PointerTo(t).Implements(textType) +} + +func ref(name string) object { return obj("$ref", "#/components/schemas/"+name) } + +// schema describes t, adding the named structs it reaches to the components. +func (g *generator) schema(t reflect.Type) any { + switch { + case t == timeType: + return obj("type", "string", "format", "date-time") + case t == durationType: + return obj("type", "integer", "format", "int64", "description", "A duration, in nanoseconds.") + case t == rawType: + return obj("description", "Any JSON value.") + case t.Implements(textType) || reflect.PointerTo(t).Implements(textType): + return obj("type", "string") + case t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType): + return obj("description", "Encoded by the type's own JSON encoding.") + } + switch t.Kind() { + case reflect.Bool: + return obj("type", "boolean") + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint8, reflect.Uint16, reflect.Uint32: + return obj("type", "integer") + case reflect.Int64, reflect.Uint, reflect.Uint64: + return obj("type", "integer", "format", "int64") + case reflect.Float32, reflect.Float64: + return obj("type", "number") + case reflect.String: + return obj("type", "string") + case reflect.Pointer: + return g.schema(t.Elem()) + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + return obj("type", "string", "contentEncoding", "base64") + } + return obj("type", "array", "items", g.schema(t.Elem())) + case reflect.Map: + return obj("type", "object", "additionalProperties", g.schema(t.Elem())) + case reflect.Interface: + return obj() + case reflect.Struct: + if t.Name() == "" { + return g.structSchema(t, "") + } + name := g.names[t] + if name == "" { + // Reached only through a body collect did not walk, which would be + // a bug here; named anyway rather than inlined so it shows. + name = t.Name() + g.names[t] = name + } + if _, ok := g.schemas[name]; !ok { + g.schemas[name] = nil // placeholder: a type that contains itself + g.schemas[name] = g.structSchema(t, g.typeDocOf(t).doc) + } + return ref(name) + } + return obj() +} + +func (g *generator) structSchema(t reflect.Type, doc string) object { + s := obj("type", "object") + if doc != "" { + s.set("description", doc) + } + var props object + for _, f := range fields(t) { + var p any + if f.asString { + p = obj("type", "string") + } else { + p = g.schema(f.typ) + } + if d := g.typeDocOf(f.owner).fields[f.goName]; d != "" { + p = withDescription(p, d) + } + props.set(f.name, p) + } + s.set("properties", props) + return s +} + +// withDescription puts a field's comment on its schema. A reference keeps +// its own description where it is declared; in 3.1 a sibling description +// beside $ref is allowed and is the field's. +func withDescription(p any, d string) any { + o, ok := p.(object) + if !ok { + return p + } + if prev, ok := o.get("description"); ok { + d = d + "\n\n" + prev.(string) + } + o = slices.Clone(o) + o.set("description", d) + return o +} + +// field is one key of a struct's JSON. +type field struct { + name string + goName string + owner reflect.Type // the struct it is declared in, for its comment + typ reflect.Type + asString bool + depth int +} + +// fields lists a struct's JSON keys as encoding/json writes them: exported +// fields, by tag name, "-" left out, embedded structs without a name of +// their own flattened, and a key at a shallower depth winning over the same +// key from deeper in. +func fields(t reflect.Type) []field { + var out []field + var walk func(t reflect.Type, depth int) + walk = func(t reflect.Type, depth int) { + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + name, opts, _ := strings.Cut(tag, ",") + ft := sf.Type + if sf.Anonymous && name == "" { + et := ft + if et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et.Kind() == reflect.Struct && !special(et) { + walk(et, depth+1) + continue + } + } + if !sf.IsExported() { + continue + } + if name == "" { + name = sf.Name + } + out = append(out, field{name: name, goName: sf.Name, owner: t, typ: ft, depth: depth, + asString: slices.Contains(strings.Split(opts, ","), "string")}) + } + } + walk(t, 0) + // The shallowest of each name, in the order they were met. + best := map[string]int{} + for _, f := range out { + if d, ok := best[f.name]; !ok || f.depth < d { + best[f.name] = f.depth + } + } + var kept []field + taken := map[string]bool{} + for _, f := range out { + if f.depth == best[f.name] && !taken[f.name] { + taken[f.name] = true + kept = append(kept, f) + } + } + return kept +} + +// typeDocOf reads a type's comment and its fields' from its package's source. +func (g *generator) typeDocOf(t reflect.Type) typeDoc { + pkg := t.PkgPath() + if pkg != Module && !strings.HasPrefix(pkg, Module+"/") { + return typeDoc{} + } + docs, ok := g.docs[pkg] + if !ok { + docs = parseDocs(filepath.Join(g.root, filepath.FromSlash(strings.TrimPrefix(strings.TrimPrefix(pkg, Module), "/")))) + g.docs[pkg] = docs + } + return docs[t.Name()] +} + +// parseDocs reads the struct types declared in a directory and their +// comments. A directory it cannot read gives no comments rather than an +// error: the document is still right, only quieter. +func parseDocs(dir string) map[string]typeDoc { + out := map[string]typeDoc{} + entries, err := os.ReadDir(dir) + if err != nil { + return out + } + fset := token.NewFileSet() + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, n), nil, parser.ParseComments) + if err != nil { + continue + } + for _, d := range f.Decls { + gd, ok := d.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, sp := range gd.Specs { + ts := sp.(*ast.TypeSpec) + doc := ts.Doc + if doc == nil && len(gd.Specs) == 1 { + doc = gd.Doc + } + td := typeDoc{doc: text(doc), fields: map[string]string{}} + if st, ok := ts.Type.(*ast.StructType); ok { + for _, fl := range st.Fields.List { + c := fl.Doc + if c == nil { + c = fl.Comment + } + for _, name := range fieldNames(fl) { + td.fields[name] = text(c) + } + } + } + out[ts.Name.Name] = td + } + } + } + return out +} + +func fieldNames(fl *ast.Field) []string { + if len(fl.Names) > 0 { + var out []string + for _, n := range fl.Names { + out = append(out, n.Name) + } + return out + } + // Embedded: named by its type. + t := fl.Type + if s, ok := t.(*ast.StarExpr); ok { + t = s.X + } + switch x := t.(type) { + case *ast.Ident: + return []string{x.Name} + case *ast.SelectorExpr: + return []string{x.Sel.Name} + } + return nil +} + +// text is a comment as prose: the comment markers gone, lines within a +// paragraph joined, paragraphs kept apart. +func text(g *ast.CommentGroup) string { + if g == nil { + return "" + } + var paras []string + var cur []string + for _, line := range strings.Split(g.Text(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + if len(cur) > 0 { + paras = append(paras, strings.Join(cur, " ")) + cur = nil + } + continue + } + cur = append(cur, line) + } + if len(cur) > 0 { + paras = append(paras, strings.Join(cur, " ")) + } + return strings.Join(paras, "\n\n") +} + +// --- an object that keeps its keys in order --- + +type kv struct { + k string + v any +} + +// object is a JSON object written in the order its keys were set, so the +// document reads in the order it was meant to — openapi, info, paths — and +// a struct's properties in the order its fields are declared. +type object []kv + +func obj(pairs ...any) object { + var o object + for i := 0; i+1 < len(pairs); i += 2 { + o.set(pairs[i].(string), pairs[i+1]) + } + return o +} + +func (o *object) set(k string, v any) { + for i := range *o { + if (*o)[i].k == k { + (*o)[i].v = v + return + } + } + *o = append(*o, kv{k, v}) +} + +func (o object) get(k string) (any, bool) { + for _, p := range o { + if p.k == k { + return p.v, true + } + } + return nil, false +} + +// MarshalJSON writes the keys in order. +func (o object) MarshalJSON() ([]byte, error) { + var b bytes.Buffer + b.WriteByte('{') + for i, p := range o { + if i > 0 { + b.WriteByte(',') + } + k, err := marshal(p.k) + if err != nil { + return nil, err + } + b.Write(k) + b.WriteByte(':') + v, err := marshal(p.v) + if err != nil { + return nil, err + } + b.Write(v) + } + b.WriteByte('}') + return b.Bytes(), nil +} + +// marshal is json.Marshal without escaping <, > and &, which are common in +// the descriptions and harmless in a document nobody puts in a page. +func marshal(v any) ([]byte, error) { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(b.Bytes(), "\n"), nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/docgen/openapi/openapi_test.go b/internal/docgen/openapi/openapi_test.go new file mode 100644 index 0000000..54e0704 --- /dev/null +++ b/internal/docgen/openapi/openapi_test.go @@ -0,0 +1,163 @@ +package openapi + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate this test") + } + return filepath.Join(filepath.Dir(file), "..", "..", "..") +} + +func generated(t *testing.T) map[string]any { + t.Helper() + b, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("the document is not JSON: %v", err) + } + return doc +} + +// at walks a decoded document by keys. +func at(t *testing.T, v any, keys ...string) any { + t.Helper() + for _, k := range keys { + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("%s: not an object", strings.Join(keys, ".")) + } + v, ok = m[k] + if !ok { + t.Fatalf("no %s (of %s)", k, strings.Join(keys, ".")) + } + } + return v +} + +func TestTheAdminAPIDocumentIsNotStale(t *testing.T) { + // Like the configuration reference: a description of the API that is not + // regenerated describes some earlier week's API, and the server serves the + // committed copy to every client generated from it. + want, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "admin-api.openapi.json")) + if err != nil { + t.Fatalf("docs/admin-api.openapi.json is missing; run go generate ./internal/docgen: %v", err) + } + if string(got) != string(want) { + t.Error("docs/admin-api.openapi.json no longer matches the admin API's routes and types.\n" + + "Run: go generate ./internal/docgen") + } +} + +func TestEveryReferenceInTheDocumentResolves(t *testing.T) { + doc := generated(t) + var walk func(v any) + n := 0 + walk = func(v any) { + switch x := v.(type) { + case map[string]any: + if r, ok := x["$ref"].(string); ok { + n++ + parts := strings.Split(strings.TrimPrefix(r, "#/"), "/") + at(t, doc, parts...) + } + for _, e := range x { + walk(e) + } + case []any: + for _, e := range x { + walk(e) + } + } + } + walk(doc) + if n < 50 { + t.Errorf("only %d references; the schemas are not being shared", n) + } +} + +func TestABodyIsDescribedByItsGoType(t *testing.T) { + doc := generated(t) + scale := at(t, doc, "paths", "/api/v1/models/{name}/scale", "post") + if r := at(t, scale, "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/ScaleRequest" { + t.Errorf("scale's body is %v", r) + } + props := at(t, doc, "components", "schemas", "ScaleRequest", "properties").(map[string]any) + // The tag's name, not the field's. + for _, k := range []string{"min", "max", "idle_timeout"} { + if _, ok := props[k]; !ok { + t.Errorf("ScaleRequest has no %s: %v", k, props) + } + } + // The field's comment is its description, and a duration says its unit. + if d := at(t, props, "max", "description").(string); !strings.Contains(d, "follows Min") { + t.Errorf("max lost its comment: %q", d) + } + if d := at(t, props, "idle_timeout", "description").(string); !strings.Contains(d, "nanoseconds") { + t.Errorf("idle_timeout does not say it is nanoseconds: %q", d) + } + // A type from another package is named with its package. + if r := at(t, doc, "paths", "/api/v1/models", "post", "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/catalog.Model" { + t.Errorf("adding a model takes %v", r) + } +} + +func TestAnEmbeddedStructsFieldsAreItsOwn(t *testing.T) { + // CapacityView embeds bench.Capacity, and encoding/json writes the + // embedded fields at the top level: so must the schema. + doc := generated(t) + props := at(t, doc, "components", "schemas", "CapacityView", "properties").(map[string]any) + for _, k := range []string{"measured_by", "model", "profile"} { + if _, ok := props[k]; !ok { + t.Errorf("CapacityView has no %s: %v", k, props) + } + } +} + +func TestEveryChangeNeedsTheTokenAndNamesItsActor(t *testing.T) { + doc := generated(t) + if s := at(t, doc, "components", "securitySchemes", "adminToken", "scheme"); s != "bearer" { + t.Errorf("scheme %v", s) + } + if len(at(t, doc, "security").([]any)) != 1 { + t.Error("the token is not required document-wide") + } + del := at(t, doc, "paths", "/api/v1/models/{name}", "delete") + found := false + for _, p := range at(t, del, "parameters").([]any) { + if p.(map[string]any)["$ref"] == "#/components/parameters/Actor" { + found = true + } + } + if !found { + t.Error("removing a model does not take X-Llmgw-Actor") + } + if _, ok := at(t, del, "responses").(map[string]any)["401"]; !ok { + t.Error("removing a model does not say it can be refused for the token") + } +} + +func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) { + routes := []adminapi.Route{{Method: "GET", Path: "/api/v1/things/{id}", Summary: "x", Response: adminapi.Result{}}} + if _, err := Generate(repoRoot(t), routes, adminapi.PathParams); err == nil { + t.Error("a parameter nobody described was let through") + } +} diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 69710c5..b5c03d8 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -44,6 +44,10 @@ var Reference = []Block{ {Key: "concurrency", Type: "int", Doc: "Concurrency is how many of one batch's requests are in flight at\nonce; 4 unless set. They queue in the lowest admission band, behind\nevery interactive request.", Nested: ""}, {Key: "retain_days", Type: "int", Doc: "RetainDays is how long a batch's answer and error files are kept;\n30 unless set. Uploaded files are kept until deleted.", Nested: ""}, }}, + {Package: "config", Name: "Cache", Title: "Cache", Doc: "", Fields: []Field{ + {Key: "max_bytes", Type: "int64", Doc: "MaxBytes bounds every project's answers together; 128 MB unless set.\nPast it, the answers used longest ago go first.", Nested: ""}, + {Key: "max_entry_bytes", Type: "int64", Doc: "MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB\nunless set.", Nested: ""}, + }}, {Package: "catalog", Name: "Canary", Title: "Canary", Doc: "", Fields: []Field{ {Key: "model", Type: "string", Doc: "Model is the candidate: a catalog model, usually this one at newer\nweights, which clients cannot name themselves.", Nested: ""}, {Key: "percent", Type: "int", Doc: "Percent of requests go to it, 0 to 100.", Nested: ""}, @@ -65,6 +69,7 @@ var Reference = []Block{ {Key: "port_range", Type: "[]int", Doc: "PortRange is the per-node range for engine HTTP and dist-init ports.", Nested: ""}, {Key: "mgmt_interface", Type: "string", Doc: "MgmtInterface is the ethernet interface NCCL bootstraps over.", Nested: ""}, {Key: "timezone", Type: "string", Doc: "Timezone is where models' scaling schedules are read, an IANA name\nlike Europe/Istanbul; empty is the control plane's own.", Nested: ""}, + {Key: "eval_dir", Type: "string", Doc: "EvalDir is where quality suites are read from by name, for `llmgw ctl\neval MODEL --suite NAME`, a model's eval gate and the console. The\nsuites the binary carries (arithmetic, json-format) are found when it\nhas none of the name; a suite here of the same name replaces one.", Nested: ""}, {Key: "enabled", Type: "bool", Doc: "Enabled runs the control plane even when configuration declares no\nmodels.\n\nWithout it, a deployment whose catalog lives entirely in the database\ncannot start: control plane mode is inferred from the config file, so an\nempty models block means \"router only\", the process refuses to start for\nwant of anything to serve, and the models it would have loaded are never\nread. That is a cluster that cannot be managed at runtime because it is\nmanaged at runtime. AgentListen cannot carry this signal — it has a\ndefault, so every deployment would become a control plane.", Nested: ""}, {Key: "lease_ttl", Type: "time.Duration", Doc: "LeaseTTL is how long this control plane's claim to be the one placing\nwork stands without being renewed.\n\nOnly meaningful with a shared store, where a second control plane can\nsee the claim. Long enough to survive a slow query or a brief network\nstall, short enough that a standby takes over before anyone has finished\nreading the alert. Zero takes the default.", Nested: ""}, }}, @@ -84,6 +89,11 @@ var Reference = []Block{ {Key: "labels", Type: "map[string]string", Doc: "Labels picks nodes carrying all of these.", Nested: ""}, {Key: "engines", Type: "map[string]string", Doc: "Engines are laid over the cluster's: a version, or \"\" for none.", Nested: ""}, }}, + {Package: "catalog", Name: "EvalGate", Title: "EvalGate", Doc: "", Fields: []Field{ + {Key: "suite", Type: "string", Doc: "Suite is a suite's name in control.eval_dir, or one the binary\ncarries (arithmetic, json-format).", Nested: ""}, + {Key: "min_score", Type: "float64", Doc: "MinScore is the score, 0 to 1, to pass at; zero is the suite's own\nthreshold.", Nested: ""}, + {Key: "judge", Type: "string", Doc: "Judge grades the suite's rubric cases, instead of the judge it names.", Nested: ""}, + }}, {Package: "catalog", Name: "External", Title: "External", Doc: "", Fields: []Field{ {Key: "url", Type: "string", Doc: "URL is the provider's base address, without /v1: https://api.openai.com.", Nested: ""}, {Key: "model", Type: "string", Doc: "Model is what the provider calls it; requests are rewritten to it.", Nested: ""}, @@ -104,6 +114,7 @@ var Reference = []Block{ {Key: "contact", Type: "string", Doc: "Contact is where someone without a key should go — an address, a URL, a\nchannel name. Rendered as a link when it is one, as text otherwise.", Nested: ""}, {Key: "notice", Type: "string", Doc: "Notice is an optional line of your own, for whatever the reader needs to\nknow that this page cannot guess.", Nested: ""}, {Key: "language", Type: "string", Doc: "Language is what the web portal speaks to someone who has not chosen\na language with the switcher at the foot of every page and whose\nbrowser asks for none it speaks: \"en\" or \"tr\". Empty is English.", Nested: ""}, + {Key: "docs_sign_in", Type: "bool", Doc: "DocsSignIn serves the docs portal at /docs only to someone signed in.\nOff, the portal is public like this page: its client guide is for the\nperson who has no account yet, and the operator documents describe\nllmgw in general — no address, model or key of this installation —\nwhich the project's repository publishes anyway. On, a visitor is sent\nto sign in, and without accounts the portal needs an API key.", Nested: ""}, }}, {Package: "engines", Name: "LlamaCppConfig", Title: "LlamaCppConfig", Doc: "", Fields: []Field{ {Key: "bin", Type: "string", Doc: "Bin is the llama-server binary. It is the server, not the CLI: the\ngateway talks OpenAI over HTTP and needs the one that serves it.\nEmpty means the agent installs llama.cpp itself, at the version the\ncontrol plane asks for. Setting it opts out of that.", Nested: ""}, @@ -130,6 +141,7 @@ var Reference = []Block{ {Key: "adapters", Type: "[]Adapter", Doc: "Adapters are LoRA adapters served on this model's replicas, each asked\nfor by its own name as though it were a model. Every replica loads\nevery adapter, applying one only to a request that names it.", Nested: ""}, {Key: "fallbacks", Type: "[]string", Doc: "", Nested: ""}, {Key: "canary", Type: "*Canary", Doc: "Canary sends a share of this model's requests to another catalog\nmodel — the same model at new weights — to compare the two before\nmoving to it (`llmgw ctl model canary`).", Nested: "catalog.Canary"}, + {Key: "eval", Type: "*EvalGate", Doc: "Eval is a quality suite new weights must pass before they replace\nthis model's: `llmgw ctl model update` and `model promote` run it\nagainst them first, and stop below its score.", Nested: "catalog.EvalGate"}, {Key: "routing_profile", Type: "string", Doc: "RoutingProfile selects how the router scores endpoints for this model.", Nested: ""}, {Key: "slo", Type: "SLO", Doc: "", Nested: "catalog.SLO"}, {Key: "context_length", Type: "int", Doc: "ContextLength is how many tokens one request may use, prompt and\ncompletion together, and MaxOutput bounds the completion alone. Zero\nmeans unknown, which is what every model was until now.\n\nThe gateway knew neither, so an over-long prompt was not refused: it was\nadmitted, given a replica, and failed at the engine, and a client sizing\nits prompts from /v1/models had nothing to read. Worse, the number is not\nthe one an operator writes in the engine's arguments — llama.cpp divides\n--ctx-size across its parallel slots, so a pool of 8192 across four slots\nis 2048 a request, and nothing said so.", Nested: ""}, @@ -249,6 +261,7 @@ var Reference = []Block{ {Key: "tag_labels", Type: "[]string", Doc: "TagLabels are the request tags (metadata, x-llmgw-tags) counted in\nllmgw_tagged_requests_total and llmgw_tagged_tokens_total, by value.\nOnly these: a tag is the client's to set, and every value of one is\na series. The first 100 values of each are counted apart, the rest as\n\"other\". Empty counts none; the usage records keep every tag anyway.", Nested: ""}, {Key: "max_body_bytes", Type: "int64", Doc: "MaxBodyBytes bounds the request body the router will read.", Nested: ""}, {Key: "batch", Type: "Batch", Doc: "Batch is the Files and Batch APIs (/v1/files, /v1/batches): a file of\nrequests served at the lowest priority, its answers written to\nanother. Needs the store.", Nested: "config.Batch"}, + {Key: "cache", Type: "Cache", Doc: "Cache bounds the answers kept for the projects that reuse them\n(llmgw keys cache). Which projects do, and for how long, is theirs\nto set; this is only how much memory it may take.", Nested: "config.Cache"}, {Key: "trusted_proxies", Type: "[]string", Doc: "TrustedProxies are the reverse proxies in front of the client port,\nas addresses or CIDR prefixes. Their X-Forwarded-For, -Proto and -Host\nare believed; anyone else's are not, since they decide the address\nwritten into the setup scripts and whose attempts are counted where.", Nested: ""}, {Key: "timeouts", Type: "Timeouts", Doc: "", Nested: "config.Timeouts"}, {Key: "scoring", Type: "Scoring", Doc: "", Nested: "config.Scoring"}, @@ -309,7 +322,7 @@ var Reference = []Block{ {Key: "debug_pprof", Type: "bool", Doc: "DebugPprof serves Go's profiler on the admin listener, behind the token.\nOff by default: a heap profile holds the API keys requests presented.", Nested: ""}, {Key: "agent_insecure", Type: "bool", Doc: "AgentInsecure lets a control plane run its agent port without\nagent_tls: plaintext, and any machine that reaches it may register as\nany node — be given replicas, and so be sent users' prompts — or, in\nthe other direction, a machine in the path may tell every agent what\nto run. Refused unless said here, and then the port listens on\nloopback unless agent_listen names an address. For one machine, a\ntest, or a network nothing untrusted reaches.", Nested: ""}, {Key: "monitoring", Type: "Monitoring", Doc: "Monitoring is where the dashboard reads its history from.", Nested: "config.Monitoring"}, - {Key: "tracing", Type: "Tracing", Doc: "Tracing sends a trace of each inference request to an OpenTelemetry\ncollector. Off unless an endpoint is set here or in\nOTEL_EXPORTER_OTLP_ENDPOINT.", Nested: "config.Tracing"}, + {Key: "tracing", Type: "obs.Tracing", Doc: "Tracing sends traces to an OpenTelemetry collector: each inference\nrequest, and on a control plane each placement, replica start and\nmodel job. Off unless an endpoint is set here or in\nOTEL_EXPORTER_OTLP_ENDPOINT.", Nested: "obs.Tracing"}, {Key: "mirror", Type: "Mirror", Doc: "Mirror points the control plane at a model mirror it can administer.\nEmpty means there is none, and /admin/mirror stays unmounted.", Nested: "config.Mirror"}, {Key: "engines", Type: "map[string]string", Doc: "Engines are the engines every node installs, and at which version. The\nagents install them themselves — the right build for their hardware,\nchecked against its publisher's checksum and seen to run — so a node is\nnever trusted to already have what somebody once put there.\n\nUnset keeps DefaultEngines. A version replaces the default; an empty\none leaves that engine to be installed by hand.", Nested: ""}, {Key: "engine_overrides", Type: "[]EngineOverride", Doc: "EngineOverrides give some nodes different engines from the rest: a\nvLLM built for CUDA 12 on the Volta cards CUDA 13 dropped, or none at\nall on nodes meant only for llama.cpp. Each picks nodes and lays its\nengines over the cluster's, in order, later ones last; an empty version\nremoves that engine from the nodes it picks.", Nested: ""}, @@ -330,6 +343,9 @@ var Reference = []Block{ {Package: "engines", Name: "SimConfig", Title: "SimConfig", Doc: "", Fields: []Field{ {Key: "bin", Type: "string", Doc: "Bin is the llmgw binary itself; the engine is one of its subcommands.\nEmpty means whatever started this agent, which is right almost always.", Nested: ""}, }}, + {Package: "engines", Name: "TRTLLMConfig", Title: "TRTLLMConfig", Doc: "", Fields: []Field{ + {Key: "python", Type: "string", Doc: "Python is the interpreter of the environment TensorRT-LLM is installed\nin: /bin/python for a virtual environment, python3 in NVIDIA's\ncontainer. Naming it is what makes this node offer the engine; empty\noffers nothing.\n\nThe interpreter rather than trtllm-serve, because llmgw starts the\nserver through a few lines of its own that make it demand the\ngateway's key, which trtllm-serve has no option for.", Nested: ""}, + }}, {Package: "config", Name: "Threads", Title: "Threads", Doc: "", Fields: []Field{ {Key: "store", Type: "string", Doc: "Store is the SQLite file. Empty means nothing is kept.", Nested: ""}, {Key: "retain_hours", Type: "int", Doc: "RetainHours is how long idle conversations and notes are kept. Required\nwith Store: a store with no expiry keeps everything anyone typed, by\nomission.", Nested: ""}, @@ -349,9 +365,9 @@ var Reference = []Block{ {Key: "max_calls_per_turn", Type: "int", Doc: "MaxCallsPerTurn stops an answer calling more tools than this.", Nested: ""}, {Key: "validate_arguments", Type: "bool", Doc: "ValidateArguments checks a call's arguments against the parameters\nschema the request declared for the tool.", Nested: ""}, }}, - {Package: "config", Name: "Tracing", Title: "Tracing", Doc: "", Fields: []Field{ + {Package: "obs", Name: "Tracing", Title: "Tracing", Doc: "", Fields: []Field{ {Key: "endpoint", Type: "string", Doc: "Endpoint is the collector, for example http://otel.internal:4318.\nEmpty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing.", Nested: ""}, - {Key: "sample_ratio", Type: "float64", Doc: "SampleRatio is the share of requests traced, 0 to 1, when the caller\ndid not decide it with a sampled traceparent of its own. Zero takes 1.", Nested: ""}, + {Key: "sample_ratio", Type: "float64", Doc: "SampleRatio is the share of traces kept, 0 to 1, of those nobody\nupstream already decided. Zero takes 1. A request with a sampled\ntraceparent of its own is kept, and an agent keeps every start the\ncontrol plane kept, whatever their own ratio says.", Nested: ""}, }}, {Package: "engines", Name: "VLLMConfig", Title: "VLLMConfig", Doc: "", Fields: []Field{ {Key: "bin", Type: "string", Doc: "Bin is the vllm executable.", Nested: ""}, @@ -409,6 +425,7 @@ var Reference = []Block{ {Key: "discovery", Type: "discovery.Options", Doc: "", Nested: "discovery.Options"}, {Key: "engines", Type: "engines.Config", Doc: "", Nested: "engines.Config"}, {Key: "supervisor", Type: "supervisor.Config", Doc: "", Nested: "supervisor.Config"}, + {Key: "tracing", Type: "obs.Tracing", Doc: "Tracing sends this node's part of each replica start — the weights\nfetched, the engine launched, the wait for it to answer — to an\nOpenTelemetry collector, inside the control plane's trace of that\nstart. Give it the collector server.yaml's tracing names. Off unless\nan endpoint is set here or in OTEL_EXPORTER_OTLP_ENDPOINT.", Nested: "obs.Tracing"}, }}, {Package: "config", Name: "Band", Title: "config.Band", Doc: "", Fields: []Field{ {Key: "name", Type: "string", Doc: "", Nested: ""}, @@ -424,6 +441,7 @@ var Reference = []Block{ {Key: "vllm", Type: "VLLMConfig", Doc: "", Nested: "engines.VLLMConfig"}, {Key: "sglang", Type: "SGLangConfig", Doc: "", Nested: "engines.SGLangConfig"}, {Key: "llamacpp", Type: "LlamaCppConfig", Doc: "", Nested: "engines.LlamaCppConfig"}, + {Key: "trtllm", Type: "TRTLLMConfig", Doc: "TRTLLM is TensorRT-LLM, which the agent does not install: a node\noffers it only when its interpreter is named here.", Nested: "engines.TRTLLMConfig"}, {Key: "sim", Type: "SimConfig", Doc: "", Nested: "engines.SimConfig"}, }}, {Package: "guard", Name: "Config", Title: "guard.Config", Doc: "", Fields: []Field{ diff --git a/internal/docgen/render.go b/internal/docgen/render.go index 89abf06..a686027 100644 --- a/internal/docgen/render.go +++ b/internal/docgen/render.go @@ -20,6 +20,7 @@ var Sources = []string{ "internal/agent/engines/llamacpp.go", "internal/agent/engines/vllm.go", "internal/agent/engines/sglang.go", + "internal/agent/engines/trtllm.go", "internal/agent/engines/sim.go", "internal/agent/supervisor/supervisor.go", "internal/tlsconf/tlsconf.go", @@ -31,6 +32,7 @@ var Sources = []string{ "internal/config/oidc.go", "internal/notify/notify.go", "internal/secrets/secrets.go", + "internal/obs/trace.go", } // notReference are files with koanf tags that an operator never writes. diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index a23533d..b32b4f4 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -17,6 +17,7 @@ import ( "path/filepath" "slices" "sort" + "strconv" "strings" "time" @@ -57,6 +58,10 @@ type Facts struct { GPUError string `json:"gpu_error,omitempty"` TopoRaw string `json:"topo_raw,omitempty"` TopoErr string `json:"topo_error,omitempty"` + // MIG is each MIG-capable card's mode and instances; MIGErr why it could + // not be read. + MIG []discovery.MIGCard `json:"mig,omitempty"` + MIGErr string `json:"mig_error,omitempty"` // NVSwitch is set when the machine has NVSwitch devices, which need the // fabric manager running before CUDA will start. NVSwitch bool `json:"nvswitch"` @@ -150,6 +155,16 @@ func Collect(ctx context.Context, o Options) Facts { } f.GPUs = gpus f.IMEX = discovery.DiscoverIMEX(ctx) + if slices.ContainsFunc(gpus, func(g discovery.GPU) bool { + cc, _ := strconv.ParseFloat(g.ComputeCapability, 64) + return g.IsMIG() || cc >= 8 + }) { + if mig, err := discovery.DiscoverMIG(ctx, smi); err != nil { + f.MIGErr = err.Error() + } else { + f.MIG = mig + } + } } if m, _ := filepath.Glob("/dev/nvidia-nvswitch*"); len(m) > 0 { f.NVSwitch = true @@ -367,17 +382,22 @@ func Evaluate(f Facts) []Finding { add(OK, "gpu", fmt.Sprintf("%d GPUs: %s", len(f.GPUs), gpuNames(f.GPUs)), "") for _, g := range f.GPUs { if !g.Health.OK() { - add(Warn, "gpu", fmt.Sprintf("GPU %d is kept out of placement: %s", g.Index, strings.Join(g.Health.Problems, "; ")), "") + add(Warn, "gpu", fmt.Sprintf("GPU %s is kept out of placement: %s", + discovery.DeviceLabel(g.Index), strings.Join(g.Health.Problems, "; ")), "") } } } + evaluateMIG(f, add) if f.NVSwitch && f.FabricManager != "active" { add(Fail, "nvlink", "this machine has NVSwitch but nvidia-fabricmanager is "+f.FabricManager+ ": CUDA will not initialise on its GPUs", "systemctl enable --now nvidia-fabricmanager (its version must match the driver's)") } - if len(f.GPUs) > 1 { - pairs, groups := nvlinkGroups(f.GPUs) + // NVLink is between whole cards: a MIG instance has none to report, and + // counting instances would call a node of MIG cards one without NVLink. + cards := slices.DeleteFunc(slices.Clone(f.GPUs), discovery.GPU.IsMIG) + if len(cards) > 1 { + pairs, groups := nvlinkGroups(cards) switch { case f.TopoErr != "": add(Warn, "nvlink", "could not read nvidia-smi topo -m: "+f.TopoErr+ @@ -393,7 +413,7 @@ func Evaluate(f Facts) []Finding { // --- multi-node NVLink --- domain := "" - for _, g := range f.GPUs { + for _, g := range cards { fb := g.Fabric if fb == nil || strings.EqualFold(fb.State, "Not Supported") { continue @@ -662,6 +682,64 @@ func rank(l Level) int { return 3 } +// evaluateMIG reports each MIG-capable card's mode and instances. Quiet for a +// card with MIG off, which is every card on most machines; what it warns of +// are the states in which a card is in MIG mode and nothing can run on it. +func evaluateMIG(f Facts, add func(Level, string, string, string)) { + if f.MIGErr != "" { + add(Warn, "mig", "could not read the cards' MIG state: "+f.MIGErr, "run nvidia-smi -q by hand") + return + } + for _, c := range f.MIG { + card := fmt.Sprintf("GPU %d (%s)", c.Index, c.Name) + if c.Enabled != c.PendingEnabled { + want := "off" + if c.PendingEnabled { + want = "on" + } + add(Warn, "mig", fmt.Sprintf("%s has MIG turning %s at its next reset, and until then runs as before", card, want), + fmt.Sprintf("reset the card when nothing runs on it: nvidia-smi -i %d -r (or reboot)", c.Index)) + } + if !c.Enabled { + continue + } + // A GPU instance with no compute instance is carved but not usable: + // it is not a device until one is made inside it. + var bare []string + for _, gi := range c.GPUInstances { + has := slices.ContainsFunc(c.ComputeInstances, func(ci discovery.MIGInstance) bool { + return ci.GPUInstance == gi.GPUInstance + }) + if !has { + bare = append(bare, fmt.Sprintf("%d (%s)", gi.GPUInstance, gi.Profile)) + } + } + switch { + case len(c.Instances) == 0 && len(c.GPUInstances) == 0: + add(Warn, "mig", card+" is in MIG mode with no instances created: nothing can run on it", + fmt.Sprintf("create instances, for example nvidia-smi mig -i %d -cgi 3g.40gb,3g.40gb -C "+ + "(nvidia-smi mig -i %d -lgip lists the profiles), or turn MIG off: nvidia-smi -i %d -mig 0", + c.Index, c.Index, c.Index)) + case len(c.Instances) == 0: + add(Warn, "mig", fmt.Sprintf("%s is in MIG mode with GPU instances %s but no compute instances: nothing can run on it", + card, strings.Join(bare, ", ")), + fmt.Sprintf("create them: nvidia-smi mig -i %d -cci", c.Index)) + default: + var names []string + for _, in := range c.Instances { + names = append(names, fmt.Sprintf("%d: %s %s", in.Device, in.Profile, in.UUID)) + } + add(OK, "mig", fmt.Sprintf("%s is in MIG mode with %d instance(s), each a device of its own: %s", + card, len(c.Instances), strings.Join(names, "; ")), "") + if len(bare) > 0 { + add(Warn, "mig", fmt.Sprintf("%s has GPU instances %s with no compute instance, which are not devices yet", + card, strings.Join(bare, ", ")), + fmt.Sprintf("create them: nvidia-smi mig -i %d -cci, or destroy the GPU instances", c.Index)) + } + } + } +} + func gpuNames(gpus []discovery.GPU) string { count := map[string]int{} var order []string diff --git a/internal/doctor/mig_test.go b/internal/doctor/mig_test.go new file mode 100644 index 0000000..3ceba6d --- /dev/null +++ b/internal/doctor/mig_test.go @@ -0,0 +1,80 @@ +package doctor + +import ( + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" +) + +// migFacts is the healthy node with card 0 in MIG mode, as given. +func migFacts(card discovery.MIGCard) Facts { + f := healthy() + f.MIG = []discovery.MIGCard{card} + return f +} + +func TestTheDoctorListsACardsMIGInstances(t *testing.T) { + fs := Evaluate(migFacts(discovery.MIGCard{Index: 0, Name: "NVIDIA A100-SXM4-80GB", Enabled: true, PendingEnabled: true, + Instances: []discovery.MIGListed{{Device: 0, Profile: "3g.40gb", UUID: "MIG-aaa"}, {Device: 1, Profile: "3g.40gb", UUID: "MIG-bbb"}}, + GPUInstances: []discovery.MIGInstance{{GPU: 0, Profile: "3g.40gb", GPUInstance: 1, ComputeInstance: -1}, + {GPU: 0, Profile: "3g.40gb", GPUInstance: 2, ComputeInstance: -1}}, + ComputeInstances: []discovery.MIGInstance{{GPU: 0, Profile: "3g.40gb", GPUInstance: 1}, + {GPU: 0, Profile: "3g.40gb", GPUInstance: 2}}, + })) + has(t, fs, OK, "GPU 0 (NVIDIA A100-SXM4-80GB) is in MIG mode with 2 instance(s)") + has(t, fs, OK, "1: 3g.40gb MIG-bbb") + for _, f := range fs { + if f.Area == "mig" && f.Level != OK { + t.Errorf("a card with its instances made was warned of: %+v", f) + } + } +} + +func TestTheDoctorWarnsOfMIGEnabledWithNoInstances(t *testing.T) { + // The state a card is left in by nvidia-smi -mig 1 alone: in MIG mode, + // so CUDA will not open it whole, and nothing carved to open instead. + fs := Evaluate(migFacts(discovery.MIGCard{Index: 0, Name: "NVIDIA A100-SXM4-80GB", Enabled: true, PendingEnabled: true})) + has(t, fs, Warn, "is in MIG mode with no instances created") + for _, f := range fs { + if f.Area == "mig" && f.Level == Warn && !strings.Contains(f.Fix, "nvidia-smi mig -i 0 -cgi") { + t.Errorf("the fix does not say how to create them: %q", f.Fix) + } + } + + // GPU instances with no compute instance in them are not devices either. + fs = Evaluate(migFacts(discovery.MIGCard{Index: 0, Name: "A100", Enabled: true, PendingEnabled: true, + GPUInstances: []discovery.MIGInstance{{GPU: 0, Profile: "7g.80gb", GPUInstance: 0, ComputeInstance: -1}}})) + has(t, fs, Warn, "GPU instances 0 (7g.80gb) but no compute instances") + + // A mode change waiting for a reset. + fs = Evaluate(migFacts(discovery.MIGCard{Index: 0, Name: "A100", Enabled: false, PendingEnabled: true})) + has(t, fs, Warn, "MIG turning on at its next reset") +} + +func TestACardWithMIGOffIsNotMentioned(t *testing.T) { + fs := Evaluate(migFacts(discovery.MIGCard{Index: 0, Name: "A100"})) + for _, f := range fs { + if f.Area == "mig" { + t.Errorf("a card with MIG off was reported: %+v", f) + } + } +} + +func TestNVLinkIsJudgedBetweenWholeCardsOnly(t *testing.T) { + // Every card of the node in MIG mode: the instances have no NVLink, and + // that is not a node "without NVLink" to warn about. + f := healthy() + f.GPUs = nil + for card := range 2 { + for dev := range 2 { + f.GPUs = append(f.GPUs, discovery.GPU{Index: discovery.MIGIndex(card, dev), Name: "A100 MIG 3g.40gb", + MIG: &discovery.MIGDevice{ParentIndex: card, Device: dev, Profile: "3g.40gb"}}) + } + } + for _, x := range Evaluate(f) { + if x.Area == "nvlink" && x.Level != OK { + t.Errorf("MIG instances were judged for NVLink: %+v", x) + } + } +} diff --git a/internal/mirror/import.go b/internal/mirror/import.go new file mode 100644 index 0000000..f49cd63 --- /dev/null +++ b/internal/mirror/import.go @@ -0,0 +1,131 @@ +package mirror + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Import copies a file on this machine into the mirror at model/version/file: +// weights somebody already has — a GGUF they converted, Ollama's blob of +// one — put where every node can fetch them, with the checksum nodes check +// them against. +// +// The same discipline as Add: written under a temporary name and renamed +// when whole, the checksum and the source beside it first, and an existing +// version never replaced. wantSHA256, when given, is what the file must hash +// to — Ollama names each blob by its digest — and a file that does not is +// refused rather than copied to every node. +// +// source is what is recorded as where the file came from. +func Import(ctx context.Context, root, src string, e Entry, wantSHA256, source string, + progress func(done, total int64), +) (Added, error) { + if e.Model == "" || e.Version == "" || e.File == "" { + return Added{}, fmt.Errorf("a model, a version and a filename are all required") + } + for _, n := range []string{e.Model, e.Version, e.File} { + if err := ValidName(n); err != nil { + return Added{}, err + } + } + in, err := os.Open(src) //nolint:gosec // a file the operator named + if err != nil { + return Added{}, err + } + defer in.Close() + st, err := in.Stat() + if err != nil { + return Added{}, err + } + if !st.Mode().IsRegular() { + return Added{}, fmt.Errorf("%s is not a file", src) + } + if strings.HasSuffix(strings.ToLower(e.File), ".gguf") { + if err := isGGUF(in, src); err != nil { + return Added{}, err + } + } + + final := e.Path(root) + if _, err := os.Stat(final); err == nil { + return Added{}, fmt.Errorf("%s/%s/%s: %w", e.Model, e.Version, e.File, ErrExists) + } + if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil { //nolint:gosec // served to every node + return Added{}, err + } + + part := final + partSuffix + // Truncated rather than refused when there: a .part is what an import cut + // short left, and the next attempt is the one that should finish it. + out, err := os.OpenFile(part, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) //nolint:gosec // inside the mirror root + if err != nil { + return Added{}, err + } + // Hashed on the way through: a forty gigabyte file is read once, not + // copied and then read again. + h := sha256.New() + written, err := copyWithProgress(io.MultiWriter(out, h), ctxReader{ctx, in}, st.Size(), progress) + closeErr := out.Close() + if err == nil { + err = closeErr + } + if err == nil && written != st.Size() { + err = fmt.Errorf("%s: copied %d bytes of %d; it changed while being read", src, written, st.Size()) + } + sum := hex.EncodeToString(h.Sum(nil)) + if err == nil && wantSHA256 != "" && !strings.EqualFold(sum, wantSHA256) { + err = fmt.Errorf("%s is damaged: it should hash to sha256 %s and hashes to %s; "+ + "nothing was added", src, wantSHA256, sum) + } + if err == nil { + err = writeChecksum(final, sum) + } + if err == nil { + err = writeSource(final, source) + } + if err == nil { + err = os.Rename(part, final) + } + if err != nil { + _ = os.Remove(part) + return Added{}, err + } + e.Bytes = written + return Added{Entry: e, SHA256: sum}, nil +} + +// ggufMagic opens every GGUF file. +const ggufMagic = "GGUF" + +// isGGUF checks a file named .gguf is one, and leaves it read from the start. +// A file that is not — an HTML error page saved under the name, Ollama's +// newer safetensors-based layers — would otherwise reach every node and fail +// there, in llama.cpp's words. +func isGGUF(f *os.File, name string) error { + head := make([]byte, len(ggufMagic)) + if _, err := io.ReadFull(f, head); err != nil || string(head) != ggufMagic { + return fmt.Errorf("%s is not a GGUF file: it does not begin with %q", name, ggufMagic) + } + _, err := f.Seek(0, io.SeekStart) + return err +} + +// ctxReader stops a copy when its context ends: an operator's ^C on a long +// import leaves no partial file behind. +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (c ctxReader) Read(p []byte) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.Read(p) +} diff --git a/internal/mirror/ollama.go b/internal/mirror/ollama.go new file mode 100644 index 0000000..77984b4 --- /dev/null +++ b/internal/mirror/ollama.go @@ -0,0 +1,352 @@ +package mirror + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Ollama keeps a model as a manifest and the blobs it names. The manifest is +// at manifests//// under its models directory — +// registry.ollama.ai/library/llama3.2/3b for "llama3.2:3b" — and each layer +// is a file blobs/sha256-, named by its own digest. The layer whose media +// type is application/vnd.ollama.image.model is the weights as a GGUF, which +// is what llama.cpp runs; a vision model has its projector in a second GGUF. +// +// What an operator already has in Ollama is weights they chose and have +// tried. Importing them puts that file in the mirror, checked against the +// digest Ollama named it by, rather than downloading it again from somewhere +// that may not be the same file. + +// Ollama's defaults for the parts of a name left out. +const ( + ollamaHost = "registry.ollama.ai" + ollamaNamespace = "library" + ollamaTag = "latest" +) + +// The layer media types an import reads, or says it leaves. +const ( + ollamaModelLayer = "application/vnd.ollama.image.model" + ollamaProjectorLayer = "application/vnd.ollama.image.projector" + ollamaParamsLayer = "application/vnd.ollama.image.params" + ollamaTensorLayer = "application/vnd.ollama.image.tensor" +) + +// OllamaName is a model as Ollama names it. +type OllamaName struct { + Host, Namespace, Model, Tag string +} + +// ParseOllamaName reads a name as Ollama does: model, namespace/model or +// host/namespace/model, with an optional :tag, and its defaults for whatever +// is left out — "llama3.2" is registry.ollama.ai/library/llama3.2:latest. +func ParseOllamaName(s string) (OllamaName, error) { + s = strings.TrimSpace(s) + var n OllamaName + // A digest pins a name in Ollama; the manifest on disk is by tag. + s, _, _ = strings.Cut(s, "@") + // "/" cannot be in a tag, so a colon after the last slash is the tag's. + if i := strings.LastIndex(s, ":"); i > strings.LastIndex(s, "/") { + s, n.Tag = s[:i], s[i+1:] + } + parts := strings.Split(s, "/") + switch len(parts) { + case 1: + n.Model = parts[0] + case 2: + n.Namespace, n.Model = parts[0], parts[1] + case 3: + n.Host, n.Namespace, n.Model = parts[0], parts[1], parts[2] + default: + return OllamaName{}, fmt.Errorf("%q is not an Ollama model name: model[:tag], namespace/model[:tag] "+ + "or host/namespace/model[:tag]", s) + } + if n.Host == "" { + n.Host = ollamaHost + } + if n.Namespace == "" { + n.Namespace = ollamaNamespace + } + if n.Tag == "" { + n.Tag = ollamaTag + } + for _, p := range []string{n.Host, n.Namespace, n.Model, n.Tag} { + // Each part is a directory of Ollama's; one that is not a single path + // element would read a manifest from somewhere else. + if p == "" || p == "." || p == ".." || strings.ContainsAny(p, `\`+"\x00") { + return OllamaName{}, fmt.Errorf("%q is not an Ollama model name", s) + } + } + return n, nil +} + +// String is the name the way Ollama shows it: without the parts that are its +// defaults. +func (n OllamaName) String() string { + name := n.Model + ":" + n.Tag + switch { + case !strings.EqualFold(n.Host, ollamaHost): + return n.Host + "/" + n.Namespace + "/" + name + case !strings.EqualFold(n.Namespace, ollamaNamespace): + return n.Namespace + "/" + name + } + return name +} + +func (n OllamaName) manifestPath(dir string) string { + return filepath.Join(dir, "manifests", n.Host, n.Namespace, n.Model, n.Tag) +} + +// OllamaDir finds Ollama's models directory: the one given (or its models +// subdirectory, for a ~/.ollama given whole), else OLLAMA_MODELS, else the +// places Ollama keeps it — this user's ~/.ollama/models, and on Linux the +// home of the ollama user its installer makes. +func OllamaDir(given string) (string, error) { + var candidates []string + if given != "" { + candidates = []string{given, filepath.Join(given, "models")} + } else { + if env := os.Getenv("OLLAMA_MODELS"); env != "" { + candidates = append(candidates, env) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".ollama", "models")) + } + candidates = append(candidates, + "/usr/share/ollama/.ollama/models", // Ollama's Linux installer + "/var/lib/ollama/.ollama/models", // some distributions' packages + ) + } + for _, c := range candidates { + if st, err := os.Stat(filepath.Join(c, "manifests")); err == nil && st.IsDir() { + return c, nil + } + } + if given != "" { + return "", fmt.Errorf("%s holds no Ollama models: there is no manifests directory in it or in its models", given) + } + return "", fmt.Errorf("no Ollama models found in %s; name the directory with --ollama-dir", + strings.Join(candidates, ", ")) +} + +// ListOllama lists the models in an Ollama models directory. +func ListOllama(dir string) ([]OllamaName, error) { + root := filepath.Join(dir, "manifests") + var out []OllamaName + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 4 || strings.HasPrefix(parts[3], ".") { + return nil // not a manifest: something else left in Ollama's directory + } + out = append(out, OllamaName{Host: parts[0], Namespace: parts[1], Model: parts[2], Tag: parts[3]}) + return nil + }) + if err != nil { + return nil, fmt.Errorf("reading Ollama's models in %s: %w", dir, err) + } + sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) + return out, nil +} + +// OllamaLayer is one layer of an Ollama manifest. +type OllamaLayer struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// ollamaManifest is the manifest's shape: Docker's image manifest, with +// Ollama's media types for the layers. +type ollamaManifest struct { + SchemaVersion int `json:"schemaVersion"` + Layers []OllamaLayer `json:"layers"` +} + +// OllamaModel is what an import takes from one of Ollama's models. +type OllamaModel struct { + Name OllamaName + // Model is the weights, a GGUF. + Model OllamaLayer + // Projectors are a vision model's image encoders, GGUFs beside it. + Projectors []OllamaLayer + // ContextLength is the num_ctx Ollama was told to run it with, 0 when + // it was not: worth carrying over, since llama.cpp's own default differs. + ContextLength int + // Left says what of the Ollama model an import does not carry. + Left []string +} + +var digestPattern = regexp.MustCompile(`^sha256:([0-9a-f]{64})$`) + +// ollamaBlob is where a layer's file is, and the sha256 its name promises. +func ollamaBlob(dir, digest string) (path, sum string, err error) { + m := digestPattern.FindStringSubmatch(digest) + if m == nil { + return "", "", fmt.Errorf("the manifest names a layer by %q, which is not a sha256 digest", digest) + } + return filepath.Join(dir, "blobs", "sha256-"+m[1]), m[1], nil +} + +// ReadOllama reads one model's manifest and the layers an import takes. +func ReadOllama(dir string, name OllamaName) (OllamaModel, error) { + b, err := os.ReadFile(name.manifestPath(dir)) //nolint:gosec // a manifest under Ollama's directory + if errors.Is(err, os.ErrNotExist) { + return OllamaModel{}, fmt.Errorf("no %s among the Ollama models in %s; `llmgw mirror import-ollama` with no name lists them", + name, dir) + } + if err != nil { + return OllamaModel{}, err + } + var mf ollamaManifest + if err := json.Unmarshal(b, &mf); err != nil { + return OllamaModel{}, fmt.Errorf("%s's manifest cannot be read: %w", name, err) + } + + m := OllamaModel{Name: name} + var models []OllamaLayer + for _, l := range mf.Layers { + switch l.MediaType { + case ollamaModelLayer: + models = append(models, l) + case ollamaProjectorLayer: + m.Projectors = append(m.Projectors, l) + case ollamaTensorLayer: + return OllamaModel{}, fmt.Errorf("%s is kept by Ollama as separate tensors rather than a GGUF; "+ + "llama.cpp cannot run that form: mirror the model's GGUF from its publisher instead", name) + case ollamaParamsLayer: + m.ContextLength = ollamaContext(dir, l) + case "application/vnd.ollama.image.template", "application/vnd.ollama.image.prompt": + m.Left = append(m.Left, "its prompt template: llama.cpp renders the chat template inside the GGUF") + case "application/vnd.ollama.image.system": + m.Left = append(m.Left, "its system prompt") + case "application/vnd.ollama.image.adapter": + m.Left = append(m.Left, "a LoRA adapter: give it to the model as one of its adapters") + case "application/vnd.ollama.image.draft": + m.Left = append(m.Left, "its draft model for speculative decoding") + } + } + switch len(models) { + case 0: + return OllamaModel{}, fmt.Errorf("%s has no GGUF layer (%s) to import", name, ollamaModelLayer) + case 1: + m.Model = models[0] + default: + return OllamaModel{}, fmt.Errorf("%s is made of %d weight layers, which an import does not join yet; "+ + "mirror the model's GGUF from its publisher instead", name, len(models)) + } + return m, nil +} + +// ollamaContext is the num_ctx in a params layer, 0 when there is none or it +// cannot be read: a hint, not worth failing an import over. +func ollamaContext(dir string, l OllamaLayer) int { + path, _, err := ollamaBlob(dir, l.Digest) + if err != nil { + return 0 + } + b, err := os.ReadFile(path) //nolint:gosec // a blob under Ollama's directory + if err != nil { + return 0 + } + var p struct { + NumCtx int `json:"num_ctx"` + } + _ = json.Unmarshal(b, &p) + return p.NumCtx +} + +// OllamaEntries are where an Ollama model's files go in the mirror, by +// default: the model's own name, and a version that is its tag with the start +// of its digest — a tag such as "latest" moves in Ollama, and a mirror +// version must not. +func OllamaEntries(m OllamaModel, model, version string) (weights Entry, projectors []Entry) { + if model == "" { + model = m.Name.Model + } + _, sum, _ := ollamaBlob("", m.Model.Digest) + if version == "" { + version = m.Name.Tag + "-" + sum[:min(12, len(sum))] + } + base := m.Name.Model + "-" + m.Name.Tag + weights = Entry{Model: model, Version: version, File: base + ".gguf"} + for i := range m.Projectors { + // "mmproj" in the name is what llama.cpp's engine knows a projector by, + // beside the model in one directory. + file := "mmproj-" + base + ".gguf" + if i > 0 { + file = fmt.Sprintf("mmproj-%s-%d.gguf", base, i+1) + } + projectors = append(projectors, Entry{Model: model, Version: version, File: file}) + } + return weights, projectors +} + +// ImportOllama copies an Ollama model's GGUF, and its projectors, into one +// version of the mirror, each checked against the digest Ollama named it by. +// progress is told which file it is about. +// +// All or nothing: a version left holding a projector and no model is one a +// node would fetch and fail to start, so the files already copied are taken +// out again when a later one fails. +func ImportOllama(ctx context.Context, root, dir string, m OllamaModel, model, version string, + progress func(file string, done, total int64), +) ([]Added, error) { + weights, projectors := OllamaEntries(m, model, version) + type job struct { + layer OllamaLayer + entry Entry + } + jobs := []job{{m.Model, weights}} + for i, l := range m.Projectors { + jobs = append(jobs, job{l, projectors[i]}) + } + // Refused before anything is copied, rather than after the first file. + if _, err := os.Stat(filepath.Join(root, weights.Model, weights.Version)); err == nil { + return nil, fmt.Errorf("%s/%s: %w", weights.Model, weights.Version, ErrExists) + } + + source := "ollama://" + m.Name.Host + "/" + m.Name.Namespace + "/" + m.Name.Model + ":" + m.Name.Tag + var done []Added + for _, j := range jobs { + a, err := importLayer(ctx, root, dir, j.layer, j.entry, source, progress) + if err != nil { + for _, prev := range done { + _ = Remove(root, prev.Entry) + } + return nil, fmt.Errorf("%s: %w", m.Name, err) + } + done = append(done, a) + } + return done, nil +} + +func importLayer(ctx context.Context, root, dir string, l OllamaLayer, e Entry, source string, + progress func(file string, done, total int64), +) (Added, error) { + src, sum, err := ollamaBlob(dir, l.Digest) + if err != nil { + return Added{}, err + } + if _, err := os.Stat(src); err != nil { + return Added{}, fmt.Errorf("its layer %s is not in %s: %w", l.Digest, filepath.Join(dir, "blobs"), err) + } + var report func(int64, int64) + if progress != nil { + report = func(d, t int64) { progress(e.File, d, t) } + } + return Import(ctx, root, src, e, sum, source, report) +} diff --git a/internal/mirror/ollama_test.go b/internal/mirror/ollama_test.go new file mode 100644 index 0000000..79fac6f --- /dev/null +++ b/internal/mirror/ollama_test.go @@ -0,0 +1,369 @@ +package mirror + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines" +) + +// ollamaFixture is an Ollama models directory laid out as Ollama writes it: +// blobs/sha256- named by their content's digest, and a manifest per +// model and tag under manifests////. +type ollamaFixture struct { + t *testing.T + dir string +} + +func newOllamaFixture(t *testing.T) *ollamaFixture { + t.Helper() + dir := filepath.Join(t.TempDir(), ".ollama", "models") + for _, d := range []string{"blobs", "manifests"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + return &ollamaFixture{t: t, dir: dir} +} + +// blob stores content as Ollama does and returns the layer naming it. +func (f *ollamaFixture) blob(mediaType string, content []byte) OllamaLayer { + f.t.Helper() + sum := sha256.Sum256(content) + hexsum := hex.EncodeToString(sum[:]) + if err := os.WriteFile(filepath.Join(f.dir, "blobs", "sha256-"+hexsum), content, 0o644); err != nil { + f.t.Fatal(err) + } + return OllamaLayer{MediaType: mediaType, Digest: "sha256:" + hexsum, Size: int64(len(content))} +} + +func (f *ollamaFixture) manifest(name string, layers ...OllamaLayer) { + f.t.Helper() + n, err := ParseOllamaName(name) + if err != nil { + f.t.Fatal(err) + } + config := f.blob("application/vnd.docker.container.image.v1+json", + []byte(`{"model_format":"gguf","model_family":"llama","file_type":"Q4_K_M"}`)) + b, _ := json.Marshal(map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": config, + "layers": layers, + }) + p := n.manifestPath(f.dir) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(p, b, 0o644); err != nil { + f.t.Fatal(err) + } +} + +// gguf is a small file that begins as every GGUF does. +func gguf(body string) []byte { return []byte("GGUF\x03\x00\x00\x00" + body) } + +// llama32 is llama3.2:3b as Ollama pulls it: the weights, a template, a +// license and parameters. +func (f *ollamaFixture) llama32() OllamaLayer { + weights := f.blob(ollamaModelLayer, gguf("llama 3.2 3b weights")) + f.manifest("llama3.2:3b", + weights, + f.blob("application/vnd.ollama.image.template", []byte(`<|start_header_id|>{{ .Prompt }}`)), + f.blob("application/vnd.ollama.image.license", []byte("LLAMA 3.2 COMMUNITY LICENSE")), + f.blob(ollamaParamsLayer, []byte(`{"num_ctx":8192,"stop":["<|eot_id|>"]}`)), + ) + return weights +} + +func TestOllamaNamesAreReadAsOllamaReadsThem(t *testing.T) { + for in, want := range map[string]OllamaName{ + "llama3.2": {ollamaHost, ollamaNamespace, "llama3.2", "latest"}, + "llama3.2:3b": {ollamaHost, ollamaNamespace, "llama3.2", "3b"}, + "jmorgan/llava:7b-q4": {ollamaHost, "jmorgan", "llava", "7b-q4"}, + "hf.co/bartowski/Qwen-GGUF:Q4": {"hf.co", "bartowski", "Qwen-GGUF", "Q4"}, + "localhost:5000/me/m:v1": {"localhost:5000", "me", "m", "v1"}, + "qwen3:8b@sha256:abcd": {ollamaHost, ollamaNamespace, "qwen3", "8b"}, + } { + got, err := ParseOllamaName(in) + if err != nil || got != want { + t.Errorf("%q: got %+v, %v; want %+v", in, got, err, want) + } + } + for _, bad := range []string{"", "a/b/c/d", "../etc:passwd", "x:.."} { + if _, err := ParseOllamaName(bad); err == nil { + t.Errorf("%q was taken for a name", bad) + } + } + // Shown the way Ollama shows them: without its defaults. + for in, want := range map[string]string{ + "llama3.2": "llama3.2:latest", "jmorgan/llava:7b": "jmorgan/llava:7b", + "hf.co/bartowski/Qwen-GGUF:Q4": "hf.co/bartowski/Qwen-GGUF:Q4", + } { + n, _ := ParseOllamaName(in) + if n.String() != want { + t.Errorf("%q shows as %q", in, n.String()) + } + } +} + +func TestOllamasDirectoryIsFoundWhereOllamaKeepsIt(t *testing.T) { + f := newOllamaFixture(t) + // Given as the models directory, or as the ~/.ollama it is in. + for _, given := range []string{f.dir, filepath.Dir(f.dir)} { + if got, err := OllamaDir(given); err != nil || got != f.dir { + t.Errorf("%s: got %q, %v", given, got, err) + } + } + t.Setenv("OLLAMA_MODELS", f.dir) + if got, err := OllamaDir(""); err != nil || got != f.dir { + t.Errorf("OLLAMA_MODELS: got %q, %v", got, err) + } + if _, err := OllamaDir(t.TempDir()); err == nil { + t.Error("a directory with no manifests was taken for Ollama's") + } +} + +func TestOllamasModelsAreListedFromItsManifests(t *testing.T) { + f := newOllamaFixture(t) + f.llama32() + f.manifest("hf.co/bartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_M", f.blob(ollamaModelLayer, gguf("qwen"))) + // Ollama's own leftovers are not models. + _ = os.WriteFile(filepath.Join(f.dir, "manifests", ".DS_Store"), nil, 0o644) + + got, err := ListOllama(f.dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, n := range got { + names = append(names, n.String()) + } + if !slices.Equal(names, []string{"hf.co/bartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_M", "llama3.2:3b"}) { + t.Errorf("listed %v", names) + } +} + +func TestAnOllamaManifestYieldsItsGGUFAndWhatIsLeftBehind(t *testing.T) { + f := newOllamaFixture(t) + weights := f.llama32() + m, err := ReadOllama(f.dir, mustName(t, "llama3.2:3b")) + if err != nil { + t.Fatal(err) + } + if m.Model != weights { + t.Errorf("model layer %+v, want %+v", m.Model, weights) + } + // What Ollama ran it with, to carry over as --ctx-size. + if m.ContextLength != 8192 { + t.Errorf("num_ctx=%d", m.ContextLength) + } + // The template is Ollama's own language; saying it is left is the honest + // part, since llama.cpp will use the GGUF's. + if len(m.Left) != 1 || !strings.Contains(m.Left[0], "template") { + t.Errorf("left: %v", m.Left) + } +} + +func TestOllamaModelsLlamaCppCannotRunAreRefused(t *testing.T) { + f := newOllamaFixture(t) + // Ollama's newer layout keeps some models as separate tensors. + f.manifest("gpt-oss:20b", f.blob(ollamaTensorLayer, []byte("tensor")), f.blob(ollamaTensorLayer, []byte("other"))) + // Two weight layers make a model this import does not join. + f.manifest("split:1", f.blob(ollamaModelLayer, gguf("a")), f.blob(ollamaModelLayer, gguf("b"))) + f.manifest("empty:1", f.blob("application/vnd.ollama.image.license", []byte("MIT"))) + + for name, want := range map[string]string{ + "gpt-oss:20b": "separate tensors", "split:1": "2 weight layers", "empty:1": "no GGUF layer", + "absent:1": "no absent:1", + } { + _, err := ReadOllama(f.dir, mustName(t, name)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: expected %q, got %v", name, want, err) + } + } +} + +func TestAnOllamaModelIsImportedIntoTheMirrorWithItsChecksum(t *testing.T) { + f := newOllamaFixture(t) + weights := f.llama32() + root := t.TempDir() + m, err := ReadOllama(f.dir, mustName(t, "llama3.2:3b")) + if err != nil { + t.Fatal(err) + } + + var reported []string + added, err := ImportOllama(t.Context(), root, f.dir, m, "", "", func(file string, _, _ int64) { + reported = append(reported, file) + }) + if err != nil { + t.Fatal(err) + } + // The tag alone moves in Ollama ("latest" is whatever was pulled last); + // the start of the digest is what pins the version. + sum := strings.TrimPrefix(weights.Digest, "sha256:") + want := Entry{Model: "llama3.2", Version: "3b-" + sum[:12], File: "llama3.2-3b.gguf", Bytes: weights.Size} + if len(added) != 1 || added[0].Entry != want { + t.Fatalf("added %+v, want %+v", added, want) + } + if added[0].SHA256 != sum { + t.Errorf("sha256 %s, want the digest Ollama named it by, %s", added[0].SHA256, sum) + } + if len(reported) == 0 || reported[0] != "llama3.2-3b.gguf" { + t.Errorf("progress said %v", reported) + } + + // What a node is given: the file, with the checksum it checks it against. + mf, err := ManifestFor(root, want.Model, want.Version) + if err != nil { + t.Fatal(err) + } + if len(mf.Files) != 1 || mf.Files[0].SHA256 != sum { + t.Errorf("manifest %+v", mf) + } + b, _ := os.ReadFile(want.Path(root)) + if string(b) != string(gguf("llama 3.2 3b weights")) { + t.Error("the mirrored file is not the blob") + } + if got := readSource(want.Path(root)); got != "ollama://registry.ollama.ai/library/llama3.2:3b" { + t.Errorf("source recorded as %q", got) + } + + // The same model again is the same version, and the mirror never + // replaces one. + if _, err := ImportOllama(t.Context(), root, f.dir, m, "", "", nil); !errors.Is(err, ErrExists) { + t.Errorf("a second import was not refused: %v", err) + } + // Under names the operator chose, it goes where they say. + added, err = ImportOllama(t.Context(), root, f.dir, m, "llama-3.2-3b-instruct", "2026-10", nil) + if err != nil || added[0].Entry.Path(root) != filepath.Join(root, "llama-3.2-3b-instruct", "2026-10", "llama3.2-3b.gguf") { + t.Errorf("named import: %+v, %v", added, err) + } +} + +func TestAVisionModelsProjectorGoesBesideItForLlamaCpp(t *testing.T) { + f := newOllamaFixture(t) + f.manifest("llava:7b", + f.blob(ollamaModelLayer, gguf("llava weights")), + f.blob(ollamaProjectorLayer, gguf("clip projector")), + ) + root := t.TempDir() + m, err := ReadOllama(f.dir, mustName(t, "llava:7b")) + if err != nil { + t.Fatal(err) + } + added, err := ImportOllama(t.Context(), root, f.dir, m, "", "v1", nil) + if err != nil { + t.Fatal(err) + } + if len(added) != 2 || added[1].Entry.File != "mmproj-llava-7b.gguf" { + t.Fatalf("added %+v", added) + } + + // The version directory is the model's source, and llama.cpp's engine + // finds in it the model to load and the projector to load beside it. + dir := filepath.Join(root, "llava", "v1") + s := engines.ShardSpec{HTTPPort: 20000, Profile: engines.Profile{Name: "cpu", Engine: "llamacpp", ModelPath: dir}} + cmds, err := (&engines.LlamaCpp{}).Commands(s) + if err != nil { + t.Fatal(err) + } + args := cmds[0].Args + if got := args[slices.Index(args, "--model")+1]; got != filepath.Join(dir, "llava-7b.gguf") { + t.Errorf("--model %s", got) + } + if got := args[slices.Index(args, "--mmproj")+1]; got != filepath.Join(dir, "mmproj-llava-7b.gguf") { + t.Errorf("--mmproj %s", got) + } +} + +func TestADamagedOllamaBlobIsNotMirrored(t *testing.T) { + f := newOllamaFixture(t) + f.manifest("llava:7b", + f.blob(ollamaModelLayer, gguf("llava weights")), + f.blob(ollamaProjectorLayer, gguf("clip projector")), + ) + m, err := ReadOllama(f.dir, mustName(t, "llava:7b")) + if err != nil { + t.Fatal(err) + } + // A bit gone wrong on Ollama's disk: the projector no longer hashes to + // the name it was stored under. + path, _, _ := ollamaBlob(f.dir, m.Projectors[0].Digest) + if err := os.WriteFile(path, gguf("clip projectoR"), 0o644); err != nil { + t.Fatal(err) + } + + root := t.TempDir() + _, err = ImportOllama(t.Context(), root, f.dir, m, "", "v1", nil) + if err == nil || !strings.Contains(err.Error(), "damaged") { + t.Fatalf("expected the damage to be found, got %v", err) + } + // All or nothing: the weights copied before it are taken out again, so + // no node is offered a vision model without its projector. + if entries, _ := List(root); len(entries) != 0 { + t.Errorf("left in the mirror: %+v", entries) + } + if _, err := os.Stat(filepath.Join(root, "llava")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the version's directory was left behind: %v", err) + } + + // A blob gone altogether says which. + _ = os.Remove(path) + if _, err := ImportOllama(t.Context(), root, f.dir, m, "", "v1", nil); err == nil || + !strings.Contains(err.Error(), m.Projectors[0].Digest) { + t.Errorf("a missing blob: %v", err) + } +} + +func TestOnlyAGGUFIsImportedAsOne(t *testing.T) { + // An HTML error page saved as a .gguf would reach every node otherwise. + src := filepath.Join(t.TempDir(), "model.gguf") + _ = os.WriteFile(src, []byte("rate limited"), 0o644) + _, err := Import(t.Context(), t.TempDir(), src, Entry{Model: "m", Version: "v", File: "model.gguf"}, "", "file://"+src, nil) + if err == nil || !strings.Contains(err.Error(), "not a GGUF") { + t.Errorf("expected a refusal, got %v", err) + } +} + +func TestALocalFileIsImportedWithItsChecksum(t *testing.T) { + src := filepath.Join(t.TempDir(), "q4_k_m.gguf") + content := gguf("converted by hand") + _ = os.WriteFile(src, content, 0o644) + root := t.TempDir() + e := Entry{Model: "qwen", Version: "2026-10-04", File: "q4_k_m.gguf"} + + a, err := Import(t.Context(), root, src, e, "", "file://"+src, nil) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + if a.SHA256 != hex.EncodeToString(sum[:]) || readChecksum(e.Path(root)) != a.SHA256 { + t.Errorf("checksum %s, recorded %s", a.SHA256, readChecksum(e.Path(root))) + } + // A checksum the operator was given is checked too. + e.Version = "again" + if _, err := Import(t.Context(), root, src, e, strings.Repeat("0", 64), "file://"+src, nil); err == nil { + t.Error("a file that does not hash to the sum given was imported") + } + if _, err := os.Stat(e.Path(root) + partSuffix); !errors.Is(err, os.ErrNotExist) { + t.Error("a refused import left its partial file") + } +} + +func mustName(t *testing.T, s string) OllamaName { + t.Helper() + n, err := ParseOllamaName(s) + if err != nil { + t.Fatal(err) + } + return n +} diff --git a/internal/modelplan/fit.go b/internal/modelplan/fit.go index 8a652b2..3dacb53 100644 --- a/internal/modelplan/fit.go +++ b/internal/modelplan/fit.go @@ -132,7 +132,14 @@ func fitsOn(m Model, h Hardware, d Demand, p Precision, context int) []Fit { // Powers of two only. Tensor parallelism divides attention heads across // ranks, and an engine that cannot divide them evenly refuses the profile — // so offering tp=3 would produce a plan that fails at launch. - for width := 1; width <= h.GPUsPerNode*h.Nodes; width *= 2 { + // + // One wide on MIG instances, however many there are: a process opens + // only one, and they have no NVLink between them. + widest := h.GPUsPerNode * h.Nodes + if isMIG(h.Class) { + widest = min(widest, 1) + } + for width := 1; width <= widest; width *= 2 { nodes := 1 perNode := width if width > h.GPUsPerNode { diff --git a/internal/modelplan/mig_test.go b/internal/modelplan/mig_test.go new file mode 100644 index 0000000..289449e --- /dev/null +++ b/internal/modelplan/mig_test.go @@ -0,0 +1,69 @@ +package modelplan + +import ( + "strings" + "testing" +) + +const mig3g = "a100-80g-mig-3g.40gb" + +func TestAMIGInstanceIsSizedByItsProfileNotItsCard(t *testing.T) { + for class, want := range map[string]int{ + mig3g: 40, "h100-80g-mig-1g.10gb": 10, "h100-80g-mig-1g.10gb-me": 10, "a100-80g": 80, "a100-80g-mig-": 0, + } { + if got := MemFromClass(class); got != want { + t.Errorf("MemFromClass(%q) = %d, want %d", class, got, want) + } + } +} + +func TestAModelThatFitsOneMIGInstanceIsPlannedOnOne(t *testing.T) { + // Four instances free on each of two nodes on a fabric: a 3B fits one, + // and a 70B, which no single instance holds, is not spread over several + // — a replica cannot use more than one. + hw := []Hardware{{Class: mig3g, GPUMemGB: 40, GPUsPerNode: 4, Nodes: 2, Fabric: "ib-a"}} + fits := Fits(coder3B, hw, 32768, []Precision{BF16}) + if len(fits) == 0 { + t.Fatal("a 3B does not fit a 40 GB MIG instance") + } + for _, f := range fits { + if f.TP != 1 || f.Nodes != 1 { + t.Errorf("planned across MIG instances: %s", f.Describe()) + } + } + if fits := Fits(llama70B, hw, 8192, []Precision{BF16}); len(fits) != 0 { + t.Errorf("a 70B was planned on MIG instances: %s", fits[0].Describe()) + } +} + +func TestANodeWithACardInMIGModeOffersEachOfItsClasses(t *testing.T) { + // Card 0 cut into two 3g.40gb instances, card 1 whole: two classes on + // one node, each with its own count. + nodes := []PoolNode{{ID: "a100", Class: mig3g, GPUs: 3, FreeGPUs: 3, HostMemGB: 512, HostFreeGB: 400, + Kinds: []PoolKind{ + {Class: mig3g, GPUMemGB: 40, GPUs: 2, FreeGPUs: 2}, + {Class: "a100-80g", GPUMemGB: 80, GPUs: 1, FreeGPUs: 1}, + }}} + arts := []Artifact{ + {Name: "q4.gguf", Format: "gguf", Bytes: 9 * gib}, + {Name: "f16.gguf", Format: "gguf", Bytes: 29 * gib}, + {Name: "big.gguf", Format: "gguf", Bytes: 60 * gib}, + } + opts := Options(arts, shape14B, nodes, 16384) + got := strings.Join(labels(opts), "; ") + for _, want := range []string{"q4.gguf 1 × " + mig3g, "f16.gguf 1 × " + mig3g, "big.gguf 1 × a100-80g"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } + // Sixty gigabytes is more than an instance holds, and two instances are + // not one bigger device. + if strings.Contains(got, "big.gguf 2 × "+mig3g) { + t.Errorf("planned across two MIG instances: %s", got) + } + for _, o := range opts { + if o.Class == mig3g && (o.Hosts != 1 || !o.FreeNow) { + t.Errorf("the MIG option counts %d hosts, free %v", o.Hosts, o.FreeNow) + } + } +} diff --git a/internal/modelplan/options.go b/internal/modelplan/options.go index 0265d22..bb1217d 100644 --- a/internal/modelplan/options.go +++ b/internal/modelplan/options.go @@ -46,8 +46,35 @@ type PoolNode struct { // HostMemGB is its RAM, and HostFreeGB what is unused now. HostMemGB int HostFreeGB int + // Kinds are the node's devices by class, for a node with more than one: + // a card in MIG mode is one or several classes of instance, and a whole + // card beside it another. Empty means Class, GPUMemGB, GPUs and FreeGPUs + // say it all. + Kinds []PoolKind } +// PoolKind is one class of a node's devices. +type PoolKind struct { + Class string + GPUMemGB int + GPUs int + FreeGPUs int +} + +// kinds is the node's devices by class. +func (n PoolNode) kinds() []PoolKind { + if len(n.Kinds) > 0 { + return n.Kinds + } + return []PoolKind{{Class: n.Class, GPUMemGB: n.GPUMemGB, GPUs: n.GPUs, FreeGPUs: n.FreeGPUs}} +} + +// isMIG reports whether a class names MIG instances: "a100-80g-mig-3g.40gb". +// A replica runs on exactly one, never a group of them, so a plan offers one +// instance and nothing wider. Read from the class label, as the card's memory +// is, which keeps this package free of the agent's. +func isMIG(class string) bool { return strings.Contains(class, "-mig-") } + // Option is one way to run one artifact. type Option struct { Artifact string `json:"artifact"` @@ -149,11 +176,13 @@ func ggufOptions(a Artifact, kv int64, nodes []PoolNode, bpw float64) []Option { } o := Option{Artifact: a.Name, Format: a.Format, Engine: "llamacpp", Class: c.class, GPUsPerNode: width, Nodes: 1, NeedGB: GB(per), HaveGB: budget, BitsPerWeight: bpw} - for _, n := range nodes { - if n.Class == c.class && n.GPUs >= width { - o.Hosts++ - if n.FreeGPUs >= width { - o.FreeNow = true + for _, fab := range c.fabrics { + for _, n := range fab.nodes { + if n.GPUs >= width { + o.Hosts++ + if n.FreeGPUs >= width { + o.FreeNow = true + } } } } @@ -196,7 +225,9 @@ func vllmOptions(a Artifact, kv int64, sh Shape, nodes []PoolNode, bpw float64) single = true break } - if single { + // A MIG instance does not join others, on its node or across a + // fabric: what does not fit one does not fit the class. + if single || isMIG(c.class) { continue } // Several nodes, a node's full width on each, pipeline-parallel @@ -251,32 +282,42 @@ type fabricPool struct { } // classes groups GPU nodes by card, and each by fabric: a replica spans -// nodes only within one. +// nodes only within one. A node with devices of several classes is in each +// class's pool, as the node it is for that class: its devices of that class +// and no others. func classes(nodes []PoolNode) []classPool { by := map[string]*classPool{} var order []string - for _, n := range nodes { - if n.Class == "" || n.GPUs == 0 || n.GPUMemGB == 0 { - continue - } - c := by[n.Class] - if c == nil { - c = &classPool{class: n.Class, mem: n.GPUMemGB} - by[n.Class] = c - order = append(order, n.Class) - } - c.maxGPUs = max(c.maxGPUs, n.GPUs) - i := -1 - for j := range c.fabrics { - if c.fabrics[j].name == n.Fabric { - i = j + for _, node := range nodes { + for _, k := range node.kinds() { + if k.Class == "" || k.GPUs == 0 || k.GPUMemGB == 0 { + continue } + n := node + n.Class, n.GPUMemGB, n.GPUs, n.FreeGPUs, n.Kinds = k.Class, k.GPUMemGB, k.GPUs, k.FreeGPUs, nil + if isMIG(n.Class) { + // One instance a replica, however many the node has. + n.GPUs, n.FreeGPUs = min(n.GPUs, 1), min(n.FreeGPUs, 1) + } + c := by[n.Class] + if c == nil { + c = &classPool{class: n.Class, mem: n.GPUMemGB} + by[n.Class] = c + order = append(order, n.Class) + } + c.maxGPUs = max(c.maxGPUs, n.GPUs) + i := -1 + for j := range c.fabrics { + if c.fabrics[j].name == n.Fabric { + i = j + } + } + if i < 0 { + c.fabrics = append(c.fabrics, fabricPool{name: n.Fabric}) + i = len(c.fabrics) - 1 + } + c.fabrics[i].nodes = append(c.fabrics[i].nodes, n) } - if i < 0 { - c.fabrics = append(c.fabrics, fabricPool{name: n.Fabric}) - i = len(c.fabrics) - 1 - } - c.fabrics[i].nodes = append(c.fabrics[i].nodes, n) } sort.Strings(order) out := make([]classPool, 0, len(order)) @@ -363,8 +404,17 @@ func QuantOf(name string) string { return strings.ToUpper(m[len(m)-1]) } -// MemFromClass reads the card size out of a class label such as "h100-80g". +// MemFromClass reads the card size out of a class label such as "h100-80g", +// and a MIG instance's out of its profile: 40 for "a100-80g-mig-3g.40gb", +// whose card is 80 but whose instance holds 40 of it. func MemFromClass(class string) int { + if _, profile, ok := strings.Cut(class, "-mig-"); ok { + var compute, gb int + if _, err := fmt.Sscanf(profile, "%dg.%dgb", &compute, &gb); err != nil { + return 0 + } + return gb + } i := strings.LastIndex(class, "-") if i < 0 || !strings.HasSuffix(class, "g") { return 0 diff --git a/internal/obs/obstest/obstest.go b/internal/obs/obstest/obstest.go new file mode 100644 index 0000000..6c59b9e --- /dev/null +++ b/internal/obs/obstest/obstest.go @@ -0,0 +1,98 @@ +// Package obstest records spans for tests that assert what was traced. +package obstest + +import ( + "testing" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +// Recorder is every span ended while a test runs. +type Recorder struct{ *tracetest.SpanRecorder } + +// Record installs a provider that keeps every span, and the W3C propagator, +// for the length of the test, putting back what was there after. +// +// Global, as the code under test finds it, so no test that records may run +// in parallel with another that traces. +func Record(t *testing.T) *Recorder { + t.Helper() + rec := tracetest.NewSpanRecorder() + prevTP, prevProp := otel.GetTracerProvider(), otel.GetTextMapPropagator() + otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))) + otel.SetTextMapPropagator(propagation.TraceContext{}) + t.Cleanup(func() { otel.SetTracerProvider(prevTP); otel.SetTextMapPropagator(prevProp) }) + return &Recorder{rec} +} + +// Named is the ended spans called name, oldest first. +func (r *Recorder) Named(name string) []sdktrace.ReadOnlySpan { + var out []sdktrace.ReadOnlySpan + for _, s := range r.Ended() { + if s.Name() == name { + out = append(out, s) + } + } + return out +} + +// One is the single ended span called name in trace tid, failing the test +// when there is not exactly one. A zero tid is any trace. +func (r *Recorder) One(t *testing.T, tid trace.TraceID, name string) sdktrace.ReadOnlySpan { + t.Helper() + var found []sdktrace.ReadOnlySpan + for _, s := range r.Named(name) { + if !tid.IsValid() || s.SpanContext().TraceID() == tid { + found = append(found, s) + } + } + if len(found) != 1 { + t.Fatalf("%d spans called %q in the trace; ended: %v", len(found), name, r.names()) + } + return found[0] +} + +// Await is One, waiting up to limit for the span to end: the code that +// ends it may run after whatever the test waited on. +func (r *Recorder) Await(t *testing.T, tid trace.TraceID, name string, limit time.Duration) sdktrace.ReadOnlySpan { + t.Helper() + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + for _, s := range r.Named(name) { + if s.SpanContext().TraceID() == tid { + return r.One(t, tid, name) + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("no span called %q ended within %v; ended: %v", name, limit, r.names()) + return nil +} + +func (r *Recorder) names() []string { + var out []string + for _, s := range r.Ended() { + out = append(out, s.Name()) + } + return out +} + +// Attrs is a span's attributes, as strings. +func Attrs(s sdktrace.ReadOnlySpan) map[string]string { + out := map[string]string{} + for _, a := range s.Attributes() { + out[string(a.Key)] = a.Value.Emit() + } + return out +} + +// ChildOf reports whether child's parent is parent. +func ChildOf(child, parent sdktrace.ReadOnlySpan) bool { + return child.Parent().SpanID() == parent.SpanContext().SpanID() && + child.SpanContext().TraceID() == parent.SpanContext().TraceID() +} diff --git a/internal/obs/trace.go b/internal/obs/trace.go index 334e9be..df91dde 100644 --- a/internal/obs/trace.go +++ b/internal/obs/trace.go @@ -9,18 +9,47 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" ) +// Tracing is where traces go, over OTLP/HTTP. +// +// In server.yaml: one span per inference request, with admission, scheduling +// and each attempt at an engine beneath it, and the trace carried on to the +// engine in a traceparent header — the request's X-Request-Id is on the span, +// so a trace and `llmgw ctl request` name the same request. A control plane +// also traces every reconcile pass that does something, each replica from +// its placement until it serves, and each job `ctl model serve` and `ctl +// model update` start. +// +// In agent.yaml: the node's part of starting a replica — fetching the +// weights, launching the engine, waiting for it to answer — inside the +// control plane's trace of that start. Point both at the same collector. +type Tracing struct { + // Endpoint is the collector, for example http://otel.internal:4318. + // Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing. + Endpoint string `koanf:"endpoint"` + // SampleRatio is the share of traces kept, 0 to 1, of those nobody + // upstream already decided. Zero takes 1. A request with a sampled + // traceparent of its own is kept, and an agent keeps every start the + // control plane kept, whatever their own ratio says. + SampleRatio float64 `koanf:"sample_ratio"` +} + // TracingOptions says where traces go. type TracingOptions struct { Endpoint string // http(s)://host:4318; empty reads OTEL_EXPORTER_OTLP_ENDPOINT SampleRatio float64 // of traces the caller did not already decide; zero takes 1 Service string Version string + // Instance tells apart the processes of one service: the node, for an + // agent. Empty leaves it out. + Instance string } // SetupTracing installs the global tracer provider and the W3C propagator, @@ -55,13 +84,16 @@ func SetupTracing(ctx context.Context, o TracingOptions) (func(context.Context) if ratio <= 0 || ratio > 1 { ratio = 1 } - res := resource.NewSchemaless( + attrs := []attribute.KeyValue{ attribute.String("service.name", o.Service), attribute.String("service.version", o.Version), - ) + } + if o.Instance != "" { + attrs = append(attrs, attribute.String("service.instance.id", o.Instance)) + } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)), - sdktrace.WithResource(res), + sdktrace.WithResource(resource.NewSchemaless(attrs...)), // A caller that sent a sampled traceparent has already decided; the // ratio is for requests that arrive without one. sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), @@ -70,3 +102,42 @@ func SetupTracing(ctx context.Context, o TracingOptions) (func(context.Context) otel.SetTextMapPropagator(propagation.TraceContext{}) return tp.Shutdown, nil } + +// Tracer is the named tracer of whatever provider is installed when it is +// called. Looked up per use rather than kept, so a provider installed after a +// component was built — as tests do — is the one its spans go to. With none +// installed it is the no-op tracer, and a span costs a function call. +func Tracer(name string) trace.Tracer { return otel.Tracer(name) } + +// End closes a span, marking it failed when err is set. +func End(span trace.Span, err error) { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + span.End() +} + +// Inject is the trace context of ctx as a message carries it: traceparent +// and, when there is one, tracestate. Nil when ctx holds no span, which is +// every context while tracing is off, so a message then carries nothing. +func Inject(ctx context.Context) map[string]string { + if !trace.SpanContextFromContext(ctx).IsValid() { + return nil + } + m := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, m) + if len(m) == 0 { + return nil + } + return m +} + +// Extract is ctx continuing the trace a message carried, as Inject wrote it. +// A message that carried none leaves ctx as it was. +func Extract(ctx context.Context, carried map[string]string) context.Context { + if len(carried) == 0 { + return ctx + } + return otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(carried)) +} diff --git a/internal/obs/trace_test.go b/internal/obs/trace_test.go index ea0da69..658e27a 100644 --- a/internal/obs/trace_test.go +++ b/internal/obs/trace_test.go @@ -2,7 +2,16 @@ package obs import ( "context" + "errors" "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" ) func TestTracingIsOffUntilGivenSomewhereToSend(t *testing.T) { @@ -17,3 +26,57 @@ func TestTracingIsOffUntilGivenSomewhereToSend(t *testing.T) { } } } + +func TestATraceCrossesAMessageAndContinuesOnTheOtherSide(t *testing.T) { + rec := tracetest.NewSpanRecorder() + prevTP, prevProp := otel.GetTracerProvider(), otel.GetTextMapPropagator() + otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))) + otel.SetTextMapPropagator(propagation.TraceContext{}) + t.Cleanup(func() { otel.SetTracerProvider(prevTP); otel.SetTextMapPropagator(prevProp) }) + + if m := Inject(context.Background()); m != nil { + t.Errorf("a context with no span carried %v", m) + } + if ctx := Extract(context.Background(), nil); trace.SpanContextFromContext(ctx).IsValid() { + t.Error("nothing was carried, yet a trace continued") + } + + ctx, sent := Tracer("t").Start(context.Background(), "send") + carried := Inject(ctx) + sent.End() + if carried["traceparent"] == "" { + t.Fatalf("no traceparent carried: %v", carried) + } + _, got := Tracer("t").Start(Extract(context.Background(), carried), "receive") + End(got, errors.New("it broke")) + + spans := rec.Ended() + if len(spans) != 2 { + t.Fatalf("%d spans", len(spans)) + } + recv := spans[1] + if recv.Parent().SpanID() != sent.SpanContext().SpanID() || recv.SpanContext().TraceID() != sent.SpanContext().TraceID() { + t.Error("the receiving span is not the sending one's child") + } + if recv.Status().Code != codes.Error || recv.Status().Description != "it broke" { + t.Errorf("a failure was not recorded: %+v", recv.Status()) + } +} + +func TestWithTracingOffAMessageCarriesNothing(t *testing.T) { + // What a process that never called SetupTracing has: spans that are not + // recorded and contexts with nothing to propagate. + prevTP, prevProp := otel.GetTracerProvider(), otel.GetTextMapPropagator() + otel.SetTracerProvider(noop.NewTracerProvider()) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator()) + t.Cleanup(func() { otel.SetTracerProvider(prevTP); otel.SetTextMapPropagator(prevProp) }) + + ctx, span := Tracer("t").Start(context.Background(), "send") + defer span.End() + if span.IsRecording() { + t.Error("a span is recorded with tracing off") + } + if m := Inject(ctx); m != nil { + t.Errorf("a message carries %v with tracing off", m) + } +} diff --git a/internal/router/cache.go b/internal/router/cache.go new file mode 100644 index 0000000..aab55a5 --- /dev/null +++ b/internal/router/cache.go @@ -0,0 +1,525 @@ +package router + +import ( + "bytes" + "container/list" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/guard" + "gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy" + storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// Reusing answers, for a project that asks: the same request to the same +// model, from the same project and within its TTL, is answered with what +// the model said the first time rather than asking it again. +// +// Only an answer that would come out the same is reused, unless the project +// says otherwise: a chat or a completion asked at temperature 0 or with a +// seed, and an embedding, a rerank or a score, which have no sampling at +// all. A request sampled above temperature 0 is asked for a new answer, and +// handing it an old one would quietly turn variety into repetition. +// +// The answers are held in this process's memory and nowhere else. A shared +// cache in the store would write every answer of every project that turned +// this on to the database — the very thing the request log is opt-in for — +// and add a round trip to each request to save one; with two control planes +// behind a balancer each fills its own, which costs some hits and no +// correctness. + +// cacheHeader says what the cache did for a request, hit, miss or bypass, +// and is how a client asks it to stay out of the way: "no". +const cacheHeader = "x-llmgw-cache" + +// Defaults for router.cache. +const ( + defaultCacheBytes = 128 << 20 + defaultCacheEntryBytes = 1 << 20 +) + +// cacheEndpoints are those whose answers can be reused. The chat and +// completion endpoints only when the answer is reproducible; the others +// always, as they sample nothing. Audio is a form, not JSON, and is not +// among them. +var cacheEndpoints = map[string]bool{ + "/v1/chat/completions": true, + "/v1/completions": true, + "/v1/embeddings": true, + "/v1/rerank": true, + "/v1/score": true, +} + +// sampledEndpoints are those whose answer depends on sampling. +var sampledEndpoints = map[string]bool{ + "/v1/chat/completions": true, + "/v1/completions": true, +} + +// cacheState is where a request's answer is kept, for one whose answer +// may be reused and was not found. +type cacheState struct { + // key is the answer's place in the cache; empty when it is not kept. + key string + // project is whose answer it is, and ttl how long it is reused. + project string + ttl time.Duration +} + +// cachedAnswer is one reusable answer. +type cachedAnswer struct { + key, project string + // model is the model that answered, by its catalog name. + model string + path string + // body is the answer as one JSON object — a chat.completion, a + // text_completion, a list of embeddings — even when it was streamed. + body []byte + at time.Time + // expires is when the TTL in force when it was kept runs out. The TTL + // in force when it is asked for is checked too, so shortening a + // project's TTL shortens the life of what it already has. + expires time.Time + size int64 +} + +// answerCache is a least-recently-used cache of answers, bounded in bytes. +type answerCache struct { + maxBytes, maxEntry int64 + + mu sync.Mutex + lru *list.List // of *cachedAnswer, most recently used at the front + by map[string]*list.Element + bytes int64 + // swept is when expired answers were last cleared out. + swept time.Time + // size reports bytes and answers held, after each change. + size func(bytes int64, n int) +} + +func newAnswerCache(maxBytes, maxEntry int64, size func(int64, int)) *answerCache { + if maxBytes <= 0 { + maxBytes = defaultCacheBytes + } + if maxEntry <= 0 { + maxEntry = defaultCacheEntryBytes + } + maxEntry = min(maxEntry, maxBytes) + if size == nil { + size = func(int64, int) {} + } + return &answerCache{maxBytes: maxBytes, maxEntry: maxEntry, lru: list.New(), + by: map[string]*list.Element{}, size: size} +} + +// get is the answer kept under key, when it is younger than ttl and has +// not expired. +func (c *answerCache) get(key string, ttl time.Duration, now time.Time) *cachedAnswer { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.by[key] + if !ok { + return nil + } + a := el.Value.(*cachedAnswer) + if now.After(a.expires) || now.Sub(a.at) >= ttl { + c.remove(el) + c.size(c.bytes, len(c.by)) + return nil + } + c.lru.MoveToFront(el) + return a +} + +// put keeps an answer, making room for it by dropping those used longest +// ago. One larger than an entry may be is not kept. +func (c *answerCache) put(a *cachedAnswer, now time.Time) bool { + // What the entry costs besides its body: the key twice, the names, and + // the bookkeeping, roughly. + a.size = int64(len(a.body) + 2*len(a.key) + len(a.project) + len(a.model) + len(a.path) + 160) + if a.size > c.maxEntry { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.by[a.key]; ok { + c.remove(el) + } + // Now and then, those past their time, so a quiet project's answers + // do not sit in memory until pushed out by others. + if now.Sub(c.swept) > time.Minute { + c.swept = now + for el := c.lru.Back(); el != nil; { + prev := el.Prev() + if now.After(el.Value.(*cachedAnswer).expires) { + c.remove(el) + } + el = prev + } + } + for c.bytes+a.size > c.maxBytes && c.lru.Len() > 0 { + c.remove(c.lru.Back()) + } + c.by[a.key] = c.lru.PushFront(a) + c.bytes += a.size + c.size(c.bytes, len(c.by)) + return true +} + +func (c *answerCache) remove(el *list.Element) { + a := el.Value.(*cachedAnswer) + c.lru.Remove(el) + delete(c.by, a.key) + c.bytes -= a.size +} + +// answerCacheOf is a project's setting, read at most every requestLogTTL +// with its other settings. Unreadable, it reuses nothing: asking the model +// again is the safe way to be wrong. +func (r *Router) answerCacheOf(ctx context.Context, project string) storepkg.AnswerCache { + if r.store == nil || project == "" { + return storepkg.AnswerCache{} + } + r.reqLogs.mu.Lock() + c, ok := r.reqLogs.by[project] + r.reqLogs.mu.Unlock() + if ok && time.Since(c.at) < requestLogTTL { + return c.cache + } + c, err := r.readProjectPolicy(ctx, project) + if err != nil { + r.log.Warn("could not read whether a project reuses answers", "project", project, "err", err) + } + return c.cache +} + +// cacheLookup decides whether a request's answer may come from its +// project's cache, says what it decided in x-llmgw-cache, and returns the +// answer when there is one. Run after the guardrails have judged the +// request, so a cached answer reaches only a request that would have been +// sent to the model, and the request it is looked up by is the one the +// model would have been sent. +func (r *Router) cacheLookup(ctx context.Context, w http.ResponseWriter, req *parsedRequest) *cachedAnswer { + if r.answers == nil || req.internal || req.ProjectID == "" { + return nil + } + set := r.answerCacheOf(ctx, req.ProjectID) + if set.TTL <= 0 { + // Says nothing: there is no cache here to have bypassed. + return nil + } + result := "bypass" + defer func() { + w.Header().Set(cacheHeader, result) + r.m.cacheRequests.WithLabelValues(r.metricModel(req.Model), result).Inc() + }() + if !reusable(req, set.Any) { + return nil + } + model, _ := r.resolve(req.Model) + key, ok := answerKey(req.ProjectID, req.Path, model, req.UpstreamBody) + if !ok { + return nil + } + now := time.Now() + if a := r.answers.get(key, set.TTL, now); a != nil && a.project == req.ProjectID { + result = "hit" + // Age is HTTP's own way of saying how old a reused answer is. + w.Header().Set("Age", strconv.Itoa(int(now.Sub(a.at).Seconds()))) + return a + } + result = "miss" + req.cache = cacheState{key: key, project: req.ProjectID, ttl: set.TTL} + return nil +} + +// reusable says whether a request's answer may be reused at all: not one +// the client asked to be answered afresh, not one continuing a stored +// conversation (each turn is new, and is saved), not one under a policy +// that keeps nothing — a cached answer is a kept one — and, for chat and +// completions, only one asked at temperature 0 or with a seed unless the +// project reuses any answer. +func reusable(req *parsedRequest, anyAnswer bool) bool { + if req.noStore || req.ThreadID != "" || req.ContentType != "" || !cacheEndpoints[req.Path] { + return false + } + if v := strings.ToLower(strings.TrimSpace(req.Header.Get(cacheHeader))); v == "no" || v == "off" || v == "bypass" { + return false + } + for _, v := range req.Header.Values("Cache-Control") { + v = strings.ToLower(v) + if strings.Contains(v, "no-cache") || strings.Contains(v, "no-store") { + return false + } + } + if !sampledEndpoints[req.Path] || anyAnswer { + return true + } + var b struct { + Temperature *float64 `json:"temperature"` + Seed json.RawMessage `json:"seed"` + } + if json.Unmarshal(req.UpstreamBody, &b) != nil { + return false + } + seeded := len(b.Seed) > 0 && string(b.Seed) != "null" + return seeded || b.Temperature != nil && *b.Temperature == 0 +} + +// answerFields are taken out of a request before it is looked up: they +// change how an answer is delivered or recorded, never what it says. +var answerFields = []string{"model", "stream", "stream_options", "user", "metadata", "store", "service_tier"} + +// answerKey is where a request's answer is kept: the project, the endpoint, +// the model by its catalog name, and the request with what does not change +// the answer taken out and its keys sorted. The project is spelled out +// rather than only hashed, so no answer is ever looked up across projects. +func answerKey(project, path, model string, body []byte) (string, bool) { + d := json.NewDecoder(bytes.NewReader(body)) + d.UseNumber() + var m map[string]any + if err := d.Decode(&m); err != nil || m == nil { + return "", false + } + for _, f := range answerFields { + delete(m, f) + } + // encoding/json writes map keys sorted, at every depth. + norm, err := json.Marshal(canonicalNumbers(m)) + if err != nil { + return "", false + } + h := sha256.New() + for _, part := range [][]byte{[]byte(path), []byte(model), norm} { + h.Write(part) + h.Write([]byte{0}) + } + return project + "\x00" + hex.EncodeToString(h.Sum(nil)), true +} + +// canonicalNumbers writes each number one way, so 0, 0.0 and 0e0 are the +// same temperature. Whole numbers are left as written: a seed above 2^53 +// would lose its last digits as a float, and two seeds become one. +func canonicalNumbers(v any) any { + switch x := v.(type) { + case map[string]any: + for k, e := range x { + x[k] = canonicalNumbers(e) + } + case []any: + for i, e := range x { + x[i] = canonicalNumbers(e) + } + case json.Number: + s := string(x) + if _, err := strconv.ParseInt(s, 10, 64); err == nil { + return x + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + if f == float64(int64(f)) && f > -1<<53 && f < 1<<53 { + return json.Number(strconv.FormatInt(int64(f), 10)) + } + return json.Number(strconv.FormatFloat(f, 'g', -1, 64)) + } + } + return v +} + +// cacheWriter keeps a copy of what is written to the client, up to a +// bound, so an answer can be kept once it is known to be a good one. +type cacheWriter struct { + http.ResponseWriter + status int + buf bytes.Buffer + limit int + over bool +} + +func (c *cacheWriter) WriteHeader(code int) { + if c.status == 0 { + c.status = code + } + c.ResponseWriter.WriteHeader(code) +} + +func (c *cacheWriter) Write(p []byte) (int, error) { + if c.status == 0 { + c.status = http.StatusOK + } + if !c.over { + if c.buf.Len()+len(p) > c.limit { + c.over = true + c.buf = bytes.Buffer{} + } else { + c.buf.Write(p) + } + } + return c.ResponseWriter.Write(p) +} + +// Flush passes through, which the proxy needs to stream. +func (c *cacheWriter) Flush() { + if f, ok := c.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (c *cacheWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter } + +// cacheWriterFor wraps w when a request's answer may be kept. +func (r *Router) cacheWriterFor(w http.ResponseWriter, req *parsedRequest) (http.ResponseWriter, *cacheWriter) { + if req.cache.key == "" { + return w, nil + } + cw := &cacheWriter{ResponseWriter: w, limit: int(r.answers.maxEntry)} + return cw, cw +} + +// cacheKeep keeps an answer that may be reused: a whole 200 from the model +// asked for — not a fallback, which is another model, nor a canary, which +// is weights on trial — that no guard changed or stopped. +func (r *Router) cacheKeep(req *parsedRequest, res dispatchResult, status int, cw *cacheWriter) { + if cw == nil || cw.over || status != http.StatusOK || cw.status != http.StatusOK || res.err != nil || + res.outcome.Status != http.StatusOK || res.outcome.Stopped || res.fallback || res.onCanary { + return + } + if res.judged && res.guard.Action != guard.Allow && !res.guard.Shadow { + return + } + var body []byte + captured := bytes.TrimSpace(cw.buf.Bytes()) + if strings.Contains(cw.Header().Get("Content-Type"), "text/event-stream") || + bytes.HasPrefix(captured, []byte("data:")) { + b, ok := assembleStream(req.Path, captured, res.outcome.Usage) + if !ok { + return + } + body = b + } else { + if len(captured) == 0 || captured[0] != '{' || !json.Valid(captured) { + return + } + body = bytes.Clone(captured) + } + model := res.model + if model == "" { + model, _ = r.resolve(req.Model) + } + now := time.Now() + r.answers.put(&cachedAnswer{key: req.cache.key, project: req.cache.project, model: model, + path: req.Path, body: body, at: now, expires: now.Add(req.cache.ttl)}, now) +} + +// serveCached answers a request from the cache, as the model would have: +// as one JSON object, or as a stream of chunks to a client that asked for +// one. The guardrails' output policy judges it as it judges any answer — +// the policy in force now, not the one when it was first given — and the +// usage is recorded as a request that generated nothing: no tokens, no +// cost, marked as a cache hit. +func (r *Router) serveCached(ctx context.Context, w http.ResponseWriter, req *parsedRequest, a *cachedAnswer, start time.Time) { + body := a.body + res := dispatchResult{servedModel: servedModel{model: a.model}, cached: true} + if r.guard != nil { + if f := r.guard.Output(ctx, r.subject(req, a.model)); f != nil { + // A copy: the kept answer is shared by every request reading it, + // and a filter is free to rewrite what it is given. + body = f.Buffered(bytes.Clone(body)) + res.guard, res.judged = f.Verdict(), true + if res.guard.Stricter(req.guardIn) { + setGuardHeaders(w.Header(), res.guard) + } + } + } + var werr error + if req.Stream && sampledEndpoints[req.Path] { + werr = writeCachedStream(w, req, body) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, werr = w.Write(body) + } + status := http.StatusOK + if werr != nil { + status = 499 + } + res.outcome = proxy.Outcome{Status: http.StatusOK, Wrote: true, Usage: &proxy.Usage{}, + Reply: replyText(body)} + took := time.Since(start) + r.m.observeRequest(r.metricModel(req.Model), "cache", status, took, nil, 0) + r.account(req, res, status, took) + r.accessLog(req, res, status, took) + r.observeOutput(req, res, res.judged) + if req.logRetain > 0 { + go r.keepRequest(context.WithoutCancel(ctx), req, res, status) + } +} + +// writeCachedStream sends a kept answer as the chunks a model streams, with +// the usage chunk only to a client that asked for one. +func writeCachedStream(w http.ResponseWriter, req *parsedRequest, body []byte) error { + events, err := streamFromAnswer(req.Path, body, clientWantsUsage(req)) + if err != nil { + // Not an answer that can be streamed — a guard's replacement, say. + // Sent whole, which a client reads as a model that does not stream. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(body) + return err + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + for _, e := range append(events, "[DONE]") { + if _, err := io.WriteString(w, "data: "+e+"\n\n"); err != nil { + return err + } + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil +} + +// clientWantsUsage says whether the client asked for the usage chunk +// itself, rather than the gateway asking for it on its behalf. +func clientWantsUsage(req *parsedRequest) bool { + if req.SuppressUsageChunk { + return false + } + var b struct { + StreamOptions *struct { + IncludeUsage bool `json:"include_usage"` + } `json:"stream_options"` + } + return json.Unmarshal(req.UpstreamBody, &b) == nil && b.StreamOptions != nil && b.StreamOptions.IncludeUsage +} + +// replyText is an answer's first choice as text, for the request log. +func replyText(body []byte) string { + var c struct { + Choices []struct { + Message struct { + Content *string `json:"content"` + } `json:"message"` + Text *string `json:"text"` + } `json:"choices"` + } + if json.Unmarshal(body, &c) != nil || len(c.Choices) == 0 { + return "" + } + if s := c.Choices[0].Message.Content; s != nil { + return *s + } + if s := c.Choices[0].Text; s != nil { + return *s + } + return "" +} diff --git a/internal/router/cache_test.go b/internal/router/cache_test.go new file mode 100644 index 0000000..56369dd --- /dev/null +++ b/internal/router/cache_test.go @@ -0,0 +1,615 @@ +package router + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + + "gitea.mynodes.xyz/ilker/llmgw/internal/guard" + "gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// cachingRouter is pricedRouter with its project reusing answers for an +// hour, reproducible ones only unless anyAnswer. +func cachingRouter(t *testing.T, engineURL string, anyAnswer bool) (*Router, string) { + t.Helper() + rt, secret := pricedRouter(t, engineURL) + if err := rt.store.SetAnswerCache(t.Context(), mustProjectOf(t, rt, "key-1"), time.Hour, anyAnswer); err != nil { + t.Fatal(err) + } + return rt, secret +} + +func ask(temperature any, text string) map[string]any { + b := map[string]any{"model": "q14", "messages": []any{map[string]any{"role": "user", "content": text}}} + if temperature != nil { + b["temperature"] = temperature + } + return b +} + +// usageOf waits for the record of one request. +func usageOf(t *testing.T, rt *Router, id string) store.Usage { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if u, ok, err := rt.store.UsageByRequest(t.Context(), id); err == nil && ok { + return u + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("no usage was recorded for %s", id) + return store.Usage{} +} + +func TestAProjectThatReusesAnswersAnswersTheSameRequestWithoutTheModel(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, false) + h := rt.Handler() + auth := func(id string) map[string]string { + return map[string]string{"Authorization": "Bearer " + secret, "X-Request-Id": id} + } + + first := post(t, h, ask(0, "what is 2+2"), auth("first")) + if first.Code != 200 || first.Header().Get(cacheHeader) != "miss" { + t.Fatalf("first: %d %q %s", first.Code, first.Header().Get(cacheHeader), first.Body.String()) + } + // The same request, said differently: keys in another order, another + // end user, the temperature written as a float, by the canonical name. + again := map[string]any{"messages": []any{map[string]any{"content": "what is 2+2", "role": "user"}}, + "temperature": 0.0, "model": "qwen2.5-14b-instruct", "user": "someone-else"} + second := post(t, h, again, auth("second")) + if second.Code != 200 || second.Header().Get(cacheHeader) != "hit" { + t.Fatalf("second: %d %q %s", second.Code, second.Header().Get(cacheHeader), second.Body.String()) + } + if e.calls.Load() != 1 { + t.Errorf("the engine was asked %d times; the second answer should have been reused", e.calls.Load()) + } + if second.Body.String() != first.Body.String() { + t.Errorf("reused %s, first was %s", second.Body.String(), first.Body.String()) + } + if second.Header().Get("Age") == "" { + t.Error("a reused answer did not say its age") + } + + // Recorded, as a request that generated nothing. + miss, hit := usageOf(t, rt, "first"), usageOf(t, rt, "second") + if miss.CacheHit || miss.CostMicros == 0 || miss.PromptTokens == 0 { + t.Errorf("the answer the model gave: %+v", miss) + } + if !hit.CacheHit || hit.CostMicros != 0 || hit.PromptTokens != 0 || hit.CompletionTokens != 0 || + hit.Status != 200 || hit.ServedModel != "qwen2.5-14b-instruct" { + t.Errorf("the reused answer: %+v", hit) + } + + // A sampled answer is asked for each time, and says so. + for i := range 2 { + rec := post(t, h, ask(0.7, "write a poem"), auth(fmt.Sprintf("poem-%d", i))) + if rec.Header().Get(cacheHeader) != "bypass" { + t.Errorf("a sampled request: %q", rec.Header().Get(cacheHeader)) + } + } + if e.calls.Load() != 3 { + t.Errorf("the engine was asked %d times, expected each sampled request to reach it", e.calls.Load()) + } + // A seed makes it reproducible. + seeded := ask(0.7, "write a poem") + seeded["seed"] = 42 + post(t, h, seeded, auth("seed-1")) + if rec := post(t, h, seeded, auth("seed-2")); rec.Header().Get(cacheHeader) != "hit" { + t.Errorf("a seeded request: %q", rec.Header().Get(cacheHeader)) + } + // A client that asks for a fresh answer gets one. + hdr := auth("fresh") + hdr["Cache-Control"] = "no-cache" + if rec := post(t, h, ask(0, "what is 2+2"), hdr); rec.Header().Get(cacheHeader) != "bypass" { + t.Errorf("Cache-Control: no-cache: %q", rec.Header().Get(cacheHeader)) + } + hdr = auth("fresh-2") + hdr[cacheHeader] = "no" + if rec := post(t, h, ask(0, "what is 2+2"), hdr); rec.Header().Get(cacheHeader) != "bypass" { + t.Errorf("x-llmgw-cache: no: %q", rec.Header().Get(cacheHeader)) + } + + if n := testutil.ToFloat64(rt.m.cacheRequests.WithLabelValues("qwen2.5-14b-instruct", "hit")); n != 2 { + t.Errorf("%v hits counted", n) + } + if n := testutil.ToFloat64(rt.m.cacheRequests.WithLabelValues("qwen2.5-14b-instruct", "miss")); n != 2 { + t.Errorf("%v misses counted", n) + } + if n := testutil.ToFloat64(rt.m.cacheBytes); n <= 0 { + t.Errorf("the cache holds %v bytes", n) + } + + // Turned off, nothing is reused, and nothing is said about it. + _ = rt.store.SetAnswerCache(t.Context(), mustProjectOf(t, rt, "key-1"), 0, false) + rt.reqLogs.by = nil + calls := e.calls.Load() + if rec := post(t, h, ask(0, "what is 2+2"), auth("off")); rec.Header().Get(cacheHeader) != "" || e.calls.Load() != calls+1 { + t.Errorf("a project that reuses nothing: %q, %d calls", rec.Header().Get(cacheHeader), e.calls.Load()-calls) + } +} + +func TestAProjectThatReusesAnyAnswerReusesSampledOnes(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, true) + auth := map[string]string{"Authorization": "Bearer " + secret} + post(t, rt.Handler(), ask(nil, "write a poem"), auth) + if rec := post(t, rt.Handler(), ask(nil, "write a poem"), auth); rec.Header().Get(cacheHeader) != "hit" { + t.Errorf("%q", rec.Header().Get(cacheHeader)) + } +} + +func TestKeysOfOneProjectNeverSeeAnotherProjectsAnswers(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, false) + team, err := rt.store.CreateTeam(t.Context(), mustOrgOf(t, rt), "other") + if err != nil { + t.Fatal(err) + } + other, err := rt.store.CreateProject(t.Context(), team, "elsewhere") + if err != nil { + t.Fatal(err) + } + _ = rt.store.SetAnswerCache(t.Context(), other, time.Hour, true) + secret2 := store.KeyPrefix + strings.Repeat("b", 43) + sum := sha256.Sum256([]byte(secret2)) + if err := rt.store.CreateKey(t.Context(), store.Key{ID: "key-2", Hash: sum[:], Prefix: "bbbb", Name: "other", + ProjectID: other, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + + post(t, rt.Handler(), ask(0, "the secret plan"), map[string]string{"Authorization": "Bearer " + secret}) + rec := post(t, rt.Handler(), ask(0, "the secret plan"), map[string]string{"Authorization": "Bearer " + secret2}) + if rec.Header().Get(cacheHeader) != "miss" || e.calls.Load() != 2 { + t.Errorf("another project's key: %q, the engine asked %d times", rec.Header().Get(cacheHeader), e.calls.Load()) + } +} + +func mustOrgOf(t *testing.T, rt *Router) string { + t.Helper() + orgs, err := rt.store.Tree(t.Context()) + if err != nil || len(orgs) == 0 { + t.Fatalf("no organization: %v", err) + } + return orgs[0].ID +} + +func TestAFailedAnswerIsNotReused(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, false) + auth := map[string]string{"Authorization": "Bearer " + secret} + e.status.Store(http.StatusBadRequest) + if rec := post(t, rt.Handler(), ask(0, "hello"), auth); rec.Code == 200 { + t.Fatalf("%d", rec.Code) + } + e.status.Store(0) + if rec := post(t, rt.Handler(), ask(0, "hello"), auth); rec.Code != 200 || rec.Header().Get(cacheHeader) != "miss" { + t.Errorf("after a failure: %d %q", rec.Code, rec.Header().Get(cacheHeader)) + } +} + +// switchGuard blocks every request, or puts every one under no_store, when +// told to. +type switchGuard struct { + block, noStore atomic.Bool + // answers, when set, stops answers saying "hi". + answers atomic.Bool +} + +func (g *switchGuard) Input(context.Context, guard.Subject, []byte) (guard.Verdict, error) { + if g.block.Load() { + return guard.Verdict{Action: guard.Block, Policy: "nothing"}, nil + } + return guard.Verdict{}, nil +} + +func (g *switchGuard) Output(context.Context, guard.Subject) guard.OutputFilter { + if !g.answers.Load() { + return nil + } + return &stopHi{} +} + +func (g *switchGuard) Directives(guard.Subject) guard.Directives { + return guard.Directives{NoStore: g.noStore.Load()} +} + +func TestTheGuardrailsStillDecideAboutAnAnswerFromTheCache(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, false) + g := &switchGuard{} + rt.SetGuard(g) + auth := map[string]string{"Authorization": "Bearer " + secret} + if rec := post(t, rt.Handler(), ask(0, "hello"), auth); rec.Header().Get(cacheHeader) != "miss" { + t.Fatalf("%q", rec.Header().Get(cacheHeader)) + } + + // A policy that now refuses the request refuses it, answer kept or not. + g.block.Store(true) + if rec := post(t, rt.Handler(), ask(0, "hello"), auth); rec.Code != http.StatusBadRequest || rec.Header().Get(cacheHeader) != "" { + t.Errorf("a blocked request: %d %q %s", rec.Code, rec.Header().Get(cacheHeader), rec.Body.String()) + } + g.block.Store(false) + + // A policy that keeps nothing neither reads the cache nor fills it. + g.noStore.Store(true) + if rec := post(t, rt.Handler(), ask(0, "hello"), auth); rec.Code != 200 || rec.Header().Get(cacheHeader) != "bypass" { + t.Errorf("under no_store: %d %q", rec.Code, rec.Header().Get(cacheHeader)) + } + if rec := post(t, rt.Handler(), ask(0, "under no_store"), auth); rec.Header().Get(cacheHeader) != "bypass" { + t.Errorf("under no_store: %q", rec.Header().Get(cacheHeader)) + } + g.noStore.Store(false) + if rec := post(t, rt.Handler(), ask(0, "under no_store"), auth); rec.Header().Get(cacheHeader) != "miss" { + t.Errorf("an answer given under no_store was kept: %q", rec.Header().Get(cacheHeader)) + } + + // An output policy added since judges the reused answer too. + g.answers.Store(true) + rec := post(t, rt.Handler(), ask(0, "hello"), auth) + if rec.Header().Get(cacheHeader) != "hit" || !strings.Contains(rec.Body.String(), "content_filter") || + rec.Header().Get(guardHeader) != "blocked" { + t.Errorf("an answer the output policy stops: %q %q %s", rec.Header().Get(cacheHeader), + rec.Header().Get(guardHeader), rec.Body.String()) + } + // And an answer it stopped is not kept. + if rec := post(t, rt.Handler(), ask(0, "new question"), auth); rec.Header().Get(cacheHeader) != "miss" { + t.Fatalf("%q", rec.Header().Get(cacheHeader)) + } + g.answers.Store(false) + if rec := post(t, rt.Handler(), ask(0, "new question"), auth); rec.Header().Get(cacheHeader) != "miss" { + t.Errorf("an answer a guard stopped was kept: %q", rec.Header().Get(cacheHeader)) + } +} + +// greetingEngine streams "Hello world" to a request that asks for a +// stream, with usage when asked, and answers whole otherwise. +func greetingEngine(t *testing.T) (*httptest.Server, *atomic.Int64) { + t.Helper() + var calls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/metrics" { + return + } + calls.Add(1) + var b struct { + Stream bool `json:"stream"` + StreamOptions struct { + IncludeUsage bool `json:"include_usage"` + } `json:"stream_options"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &b) + if !b.Stream { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"c1","object":"chat.completion","created":1,"model":"qwen2.5-14b-instruct",`+ + `"choices":[{"index":0,"message":{"role":"assistant","content":"Hello world"},"finish_reason":"stop"}],`+ + `"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + head := `{"id":"c1","object":"chat.completion.chunk","created":1,"model":"qwen2.5-14b-instruct","choices":` + for _, c := range []string{ + `[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`, + `[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}`, + `[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}`, + `[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + } { + fmt.Fprintf(w, "data: %s%s\n\n", head, c) + } + if b.StreamOptions.IncludeUsage { + fmt.Fprintf(w, "data: %s[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n", head) + } + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +// streamedText is the content of a streamed chat answer, and whether it +// carried usage and ended with [DONE]. +func streamedText(t *testing.T, sse string) (text string, usage, done bool) { + t.Helper() + for _, line := range strings.Split(sse, "\n") { + data, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + if data == "[DONE]" { + done = true + continue + } + var c struct { + Object string `json:"object"` + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Usage *struct { + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal([]byte(data), &c); err != nil || c.Object != "chat.completion.chunk" { + t.Fatalf("not a chunk: %q", data) + } + for _, ch := range c.Choices { + text += ch.Delta.Content + } + if c.Usage != nil && c.Usage.CompletionTokens == 2 { + usage = true + } + } + return text, usage, done +} + +func TestAStreamedAnswerIsReusedForClientsThatStreamAndThatDoNot(t *testing.T) { + srv, calls := greetingEngine(t) + rt, secret := cachingRouter(t, srv.URL, false) + auth := map[string]string{"Authorization": "Bearer " + secret} + streamed := func(includeUsage bool) map[string]any { + b := ask(0, "greet me") + b["stream"] = true + if includeUsage { + b["stream_options"] = map[string]any{"include_usage": true} + } + return b + } + + first := post(t, rt.Handler(), streamed(false), auth) + if first.Header().Get(cacheHeader) != "miss" { + t.Fatalf("%q %s", first.Header().Get(cacheHeader), first.Body.String()) + } + if text, _, _ := streamedText(t, first.Body.String()); text != "Hello world" { + t.Fatalf("streamed %q", text) + } + + // Whole, to a client that does not stream. + whole := post(t, rt.Handler(), ask(0, "greet me"), auth) + var c struct { + Object string `json:"object"` + Choices []struct { + Message struct { + Role, Content string + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens, CompletionTokens int + } `json:"usage"` + } + if err := json.Unmarshal(whole.Body.Bytes(), &c); err != nil || whole.Header().Get(cacheHeader) != "hit" { + t.Fatalf("%q %s %v", whole.Header().Get(cacheHeader), whole.Body.String(), err) + } + if c.Object != "chat.completion" || len(c.Choices) != 1 || c.Choices[0].Message.Content != "Hello world" || + c.Choices[0].Message.Role != "assistant" || c.Choices[0].FinishReason != "stop" { + t.Errorf("put back together as %s", whole.Body.String()) + } + + // Streamed again, with the usage only to a client that asks for it. + for _, withUsage := range []bool{false, true} { + rec := post(t, rt.Handler(), streamed(withUsage), auth) + text, usage, done := streamedText(t, rec.Body.String()) + if rec.Header().Get(cacheHeader) != "hit" || text != "Hello world" || !done || usage != withUsage || + !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/event-stream") { + t.Errorf("streamed from the cache (usage %v): %q %q usage=%v done=%v\n%s", withUsage, + rec.Header().Get(cacheHeader), text, usage, done, rec.Body.String()) + } + } + if calls.Load() != 1 { + t.Errorf("the engine was asked %d times", calls.Load()) + } +} + +func TestManyClientsReadingAndFillingTheCacheAtOnce(t *testing.T) { + e := newFakeEngine(t, "qwen2.5-14b-instruct") + rt, secret := cachingRouter(t, e.srv.URL, false) + h := rt.Handler() + auth := map[string]string{"Authorization": "Bearer " + secret} + var wg sync.WaitGroup + for i := range 40 { + wg.Go(func() { + rec := post(t, h, ask(0, fmt.Sprintf("question %d", i%5)), auth) + if rec.Code != 200 { + t.Errorf("%d %s", rec.Code, rec.Body.String()) + } + }) + } + wg.Wait() + for i := range 5 { + if rec := post(t, h, ask(0, fmt.Sprintf("question %d", i)), auth); rec.Header().Get(cacheHeader) != "hit" { + t.Errorf("question %d: %q", i, rec.Header().Get(cacheHeader)) + } + } +} + +func TestTheAnswerCacheDropsWhatWasUsedLongestAgoPastItsBudget(t *testing.T) { + var held int64 + c := newAnswerCache(1000, 400, func(b int64, _ int) { held = b }) + now := time.Now() + put := func(key string) bool { + return c.put(&cachedAnswer{key: key, project: "p", body: bytes.Repeat([]byte("x"), 100), + at: now, expires: now.Add(time.Hour)}, now) + } + for _, k := range []string{"a", "b", "c"} { + put(k) + } + c.get("a", time.Hour, now) // a is now the most recently used + put("d") + put("e") + if c.get("b", time.Hour, now) != nil { + t.Error("b, used longest ago, was kept past the budget") + } + if c.get("a", time.Hour, now) == nil { + t.Error("a, used recently, was dropped") + } + if held > 1000 { + t.Errorf("holding %d bytes of 1000", held) + } + if c.put(&cachedAnswer{key: "big", body: bytes.Repeat([]byte("x"), 500)}, now) { + t.Error("an answer larger than an entry may be was kept") + } + // Past its TTL, or past a TTL shortened since, it is gone. + if c.get("a", time.Hour, now.Add(2*time.Hour)) != nil { + t.Error("an expired answer was returned") + } + if c.get("e", time.Minute, now.Add(2*time.Minute)) != nil { + t.Error("an answer older than the project's TTL now was returned") + } +} + +func TestAnAnswerIsLookedUpByWhatChangesIt(t *testing.T) { + key := func(project, model, body string) string { + k, ok := answerKey(project, "/v1/chat/completions", model, []byte(body)) + if !ok { + t.Fatalf("no key for %s", body) + } + return k + } + base := key("p", "m", `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0}`) + same := []string{ + `{"messages":[{"content":"hi","role":"user"}],"temperature":0.0,"model":"alias"}`, + `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0,"stream":true,"stream_options":{"include_usage":true}}`, + `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0e0,"user":"u","metadata":{"a":"b"}}`, + } + for _, b := range same { + if key("p", "m", b) != base { + t.Errorf("%s was looked up apart", b) + } + } + different := []string{ + `{"model":"m","messages":[{"role":"user","content":"hi!"}],"temperature":0}`, + `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0,"max_tokens":5}`, + `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0,"seed":9007199254740993}`, + } + for _, b := range different { + if key("p", "m", b) == base { + t.Errorf("%s was looked up as the same", b) + } + } + if key("p", "m", `{"seed":9007199254740993}`) == key("p", "m", `{"seed":9007199254740992}`) { + t.Error("two large seeds were taken for one") + } + if key("other", "m", `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0}`) == base { + t.Error("another project's request was looked up as the same") + } + if key("p", "other", `{"model":"m","messages":[{"role":"user","content":"hi"}],"temperature":0}`) == base { + t.Error("another model's request was looked up as the same") + } + if _, ok := answerKey("p", "/v1/chat/completions", "m", []byte(`not json`)); ok { + t.Error("a body that is not JSON was given a key") + } +} + +func TestAStreamedAnswerIsPutBackTogetherAndTakenApartAgain(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"c","object":"chat.completion.chunk","created":7,"model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + `data: {"id":"c","object":"chat.completion.chunk","created":7,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"c","object":"chat.completion.chunk","created":7,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}`, + `data: {"id":"c","object":"chat.completion.chunk","created":7,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Paris\"}"}}]},"finish_reason":null}]}`, + `data: {"id":"c","object":"chat.completion.chunk","created":7,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + }, "\n\n") + "\n\n" + body, ok := assembleStream("/v1/chat/completions", []byte(sse), &proxy.Usage{PromptTokens: 3, CompletionTokens: 4}) + if !ok { + t.Fatal("not put back together") + } + var c struct { + Choices []struct { + Message struct { + Content *string `json:"content"` + ToolCalls []toolCall `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(body, &c); err != nil { + t.Fatal(err) + } + if len(c.Choices) != 1 || c.Choices[0].Message.Content != nil || len(c.Choices[0].Message.ToolCalls) != 1 || + c.Choices[0].Message.ToolCalls[0].Function.Arguments != `{"city":"Paris"}` || + c.Choices[0].Message.ToolCalls[0].ID != "call_1" || c.Choices[0].FinishReason != "tool_calls" || + c.Usage.TotalTokens != 7 { + t.Errorf("put back together as %s", body) + } + + // Taken apart, and put back together again, it is the same answer. + events, err := streamFromAnswer("/v1/chat/completions", body, false) + if err != nil { + t.Fatal(err) + } + again, ok := assembleStream("/v1/chat/completions", + []byte("data: "+strings.Join(append(events, "[DONE]"), "\n\ndata: ")+"\n\n"), &proxy.Usage{PromptTokens: 3, CompletionTokens: 4}) + if !ok || !bytes.Equal(again, body) { + t.Errorf("round trip:\n%s\n%s", body, again) + } + + // What it does not understand, and a stream cut short, are not kept. + for _, bad := range []string{ + strings.Replace(sse, `"delta":{}`, `"delta":{"audio":{"id":"a"}}`, 1), + strings.Replace(sse, `"finish_reason":null}]}`, `"finish_reason":null,"logprobs":{"content":[]}}]}`, 1), + strings.Replace(sse, "data: [DONE]", "", 1), + } { + if _, ok := assembleStream("/v1/chat/completions", []byte(bad), nil); ok { + t.Errorf("kept: %s", bad) + } + } + + // A completion, too. + text := `data: {"id":"t","object":"text_completion","created":1,"model":"m","choices":[{"index":0,"text":"Hel","finish_reason":null}]}` + "\n\n" + + `data: {"id":"t","object":"text_completion","created":1,"model":"m","choices":[{"index":0,"text":"lo","finish_reason":"length"}]}` + "\n\n" + + "data: [DONE]\n\n" + tb, ok := assembleStream("/v1/completions", []byte(text), nil) + if !ok || !strings.Contains(string(tb), `"text":"Hello"`) || !strings.Contains(string(tb), `"object":"text_completion"`) { + t.Errorf("a completion: %s", tb) + } +} + +func TestAnAnswerFromTheCacheReachesAnthropicClientsInTheirShape(t *testing.T) { + srv, calls := greetingEngine(t) + rt, secret := cachingRouter(t, srv.URL, false) + send := func(stream bool) *httptest.ResponseRecorder { + b, _ := json.Marshal(map[string]any{"model": "q14", "max_tokens": 50, "temperature": 0, "stream": stream, + "messages": []any{map[string]any{"role": "user", "content": "greet me"}}}) + req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", secret) + rec := httptest.NewRecorder() + rt.Handler().ServeHTTP(rec, req) + return rec + } + first := send(true) + if first.Code != 200 || !strings.Contains(first.Body.String(), "Hello") { + t.Fatalf("%d %s", first.Code, first.Body.String()) + } + whole := send(false) + if whole.Code != 200 || !strings.Contains(whole.Body.String(), `"text":"Hello world"`) { + t.Errorf("whole: %d %s", whole.Code, whole.Body.String()) + } + streamed := send(true) + if !strings.Contains(streamed.Body.String(), "content_block_delta") || + !strings.Contains(streamed.Body.String(), "Hello world") || + !strings.Contains(streamed.Body.String(), "message_stop") { + t.Errorf("streamed: %s", streamed.Body.String()) + } + if calls.Load() != 1 { + t.Errorf("the engine was asked %d times", calls.Load()) + } +} diff --git a/internal/router/cachestream.go b/internal/router/cachestream.go new file mode 100644 index 0000000..0b05c63 --- /dev/null +++ b/internal/router/cachestream.go @@ -0,0 +1,364 @@ +package router + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "strings" + + "gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy" +) + +// A cached answer is kept as one JSON object whichever way it was asked +// for, so a client that streams and one that does not share it. A streamed +// answer is put back together into the object a model would have sent +// whole, and an answer is taken apart into chunks for a client that +// streams. + +// streamChunk is one streamed chunk of a chat or a completion. +type streamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint string `json:"system_fingerprint"` + Choices []struct { + Index int `json:"index"` + // Delta is a chat chunk's, as fields: those not understood here + // leave the answer unkept rather than kept without them. + Delta map[string]json.RawMessage `json:"delta"` + Text *string `json:"text"` + FinishReason *string `json:"finish_reason"` + Logprobs json.RawMessage `json:"logprobs"` + } `json:"choices"` +} + +// toolCallDelta is part of one tool call, as streamed. +type toolCallDelta struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type toolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// assembledChoice is one choice being put back together. +type assembledChoice struct { + role string + content, reasoning, text strings.Builder + hasContent, hasReasoning bool + tools []*toolCall + finish *string +} + +// assembleStream puts a streamed chat or completion back together as the +// object the model would have answered without streaming, with usage as the +// engine reported it. ok is false for anything it does not fully +// understand — logprobs, audio, a chunk that is not JSON — which is then +// not kept: an answer kept with something missing would be served short. +func assembleStream(path string, sse []byte, usage *proxy.Usage) ([]byte, bool) { + chat := path == "/v1/chat/completions" + if !chat && path != "/v1/completions" { + return nil, false + } + var head streamChunk + choices := map[int]*assembledChoice{} + maxIndex := -1 + done := false + sc := bufio.NewScanner(bytes.NewReader(sse)) + sc.Buffer(make([]byte, 64<<10), len(sse)+1) + for sc.Scan() { + data, ok := strings.CutPrefix(sc.Text(), "data:") + if !ok { + continue + } + data = strings.TrimSpace(data) + if data == "[DONE]" { + done = true + continue + } + var c streamChunk + if json.Unmarshal([]byte(data), &c) != nil { + return nil, false + } + if head.ID == "" { + head.ID, head.Created, head.Model = c.ID, c.Created, c.Model + } + if c.SystemFingerprint != "" { + head.SystemFingerprint = c.SystemFingerprint + } + for _, ch := range c.Choices { + if len(ch.Logprobs) > 0 && string(ch.Logprobs) != "null" { + return nil, false + } + a := choices[ch.Index] + if a == nil { + a = &assembledChoice{} + choices[ch.Index] = a + maxIndex = max(maxIndex, ch.Index) + } + if ch.FinishReason != nil { + a.finish = ch.FinishReason + } + if ch.Text != nil { + a.text.WriteString(*ch.Text) + } + if !addDelta(a, ch.Delta) { + return nil, false + } + } + } + if sc.Err() != nil || !done || len(choices) == 0 || len(choices) != maxIndex+1 { + // A stream that never said it was done may have been cut short. + return nil, false + } + out := map[string]any{"id": head.ID, "created": head.Created, "model": head.Model} + if head.SystemFingerprint != "" { + out["system_fingerprint"] = head.SystemFingerprint + } + list := make([]map[string]any, 0, len(choices)) + for i := 0; i <= maxIndex; i++ { + a := choices[i] + if a.finish == nil { + return nil, false + } + c := map[string]any{"index": i, "finish_reason": *a.finish, "logprobs": nil} + if chat { + role := a.role + if role == "" { + role = "assistant" + } + msg := map[string]any{"role": role, "content": nil} + if a.hasContent || len(a.tools) == 0 { + msg["content"] = a.content.String() + } + if a.hasReasoning { + msg["reasoning_content"] = a.reasoning.String() + } + if len(a.tools) > 0 { + msg["tool_calls"] = a.tools + } + c["message"] = msg + } else { + c["text"] = a.text.String() + } + list = append(list, c) + } + out["choices"] = list + if chat { + out["object"] = "chat.completion" + } else { + out["object"] = "text_completion" + } + if usage != nil { + out["usage"] = map[string]int{"prompt_tokens": usage.PromptTokens, + "completion_tokens": usage.CompletionTokens, + "total_tokens": usage.PromptTokens + usage.CompletionTokens} + } + b, err := json.Marshal(out) + return b, err == nil +} + +// addDelta adds one chat chunk's delta to its choice, and says whether it +// understood every field of it. +func addDelta(a *assembledChoice, delta map[string]json.RawMessage) bool { + for k, raw := range delta { + if string(raw) == "null" { + continue + } + switch k { + case "role": + if json.Unmarshal(raw, &a.role) != nil { + return false + } + case "content", "reasoning_content": + var s string + if json.Unmarshal(raw, &s) != nil { + return false + } + // Empty says nothing: the first chunk carries the role with an + // empty content even when the answer is a tool call. + if s == "" { + continue + } + if k == "content" { + a.content.WriteString(s) + a.hasContent = true + } else { + a.reasoning.WriteString(s) + a.hasReasoning = true + } + case "refusal": + // An empty refusal is a field some engines always send. + var s string + if json.Unmarshal(raw, &s) != nil || s != "" { + return false + } + case "tool_calls": + var parts []toolCallDelta + if json.Unmarshal(raw, &parts) != nil { + return false + } + for _, p := range parts { + for len(a.tools) <= p.Index { + a.tools = append(a.tools, &toolCall{}) + } + t := a.tools[p.Index] + if p.ID != "" { + t.ID = p.ID + } + if p.Type != "" { + t.Type = p.Type + } + t.Function.Name += p.Function.Name + t.Function.Arguments += p.Function.Arguments + } + default: + return false + } + } + return true +} + +// streamFromAnswer is a kept chat or completion as the chunks a model would +// have streamed: per choice, its whole text in one chunk and then its +// finish reason, and the usage last for a client that asks for it. +func streamFromAnswer(path string, body []byte, withUsage bool) ([]string, error) { + var a struct { + ID string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint string `json:"system_fingerprint"` + Usage json.RawMessage `json:"usage"` + Choices []struct { + Index int `json:"index"` + FinishReason json.RawMessage `json:"finish_reason"` + Logprobs json.RawMessage `json:"logprobs"` + Text *string `json:"text"` + Message *struct { + Role string `json:"role"` + Content *string `json:"content"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []json.RawMessage `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(body, &a); err != nil { + return nil, err + } + if len(a.Choices) == 0 { + return nil, errors.New("an answer with no choices") + } + chat := path == "/v1/chat/completions" + object := "text_completion" + if chat { + object = "chat.completion.chunk" + } + chunk := func(choices []map[string]any) (string, error) { + c := map[string]any{"id": a.ID, "object": object, "created": a.Created, "model": a.Model, + "choices": choices} + if a.SystemFingerprint != "" { + c["system_fingerprint"] = a.SystemFingerprint + } + b, err := json.Marshal(c) + return string(b), err + } + var events []string + add := func(choices []map[string]any) error { + e, err := chunk(choices) + events = append(events, e) + return err + } + for _, ch := range a.Choices { + var logprobs any + if len(ch.Logprobs) > 0 { + logprobs = ch.Logprobs + } + finish := any(json.RawMessage("null")) + if len(ch.FinishReason) > 0 { + finish = ch.FinishReason + } + if !chat { + if ch.Text == nil { + return nil, errors.New("a completion with no text") + } + if err := add([]map[string]any{{"index": ch.Index, "text": *ch.Text, "logprobs": logprobs, + "finish_reason": nil}}); err != nil { + return nil, err + } + if err := add([]map[string]any{{"index": ch.Index, "text": "", "finish_reason": finish}}); err != nil { + return nil, err + } + continue + } + m := ch.Message + if m == nil { + return nil, errors.New("a chat answer with no message") + } + role := m.Role + if role == "" { + role = "assistant" + } + if err := add([]map[string]any{{"index": ch.Index, "delta": map[string]any{"role": role, "content": ""}, + "finish_reason": nil}}); err != nil { + return nil, err + } + if m.ReasoningContent != nil && *m.ReasoningContent != "" { + if err := add([]map[string]any{{"index": ch.Index, + "delta": map[string]any{"reasoning_content": *m.ReasoningContent}, "finish_reason": nil}}); err != nil { + return nil, err + } + } + if m.Content != nil && *m.Content != "" || logprobs != nil { + content := "" + if m.Content != nil { + content = *m.Content + } + if err := add([]map[string]any{{"index": ch.Index, "delta": map[string]any{"content": content}, + "logprobs": logprobs, "finish_reason": nil}}); err != nil { + return nil, err + } + } + if len(m.ToolCalls) > 0 { + calls := make([]map[string]any, 0, len(m.ToolCalls)) + for i, raw := range m.ToolCalls { + var tc map[string]any + if err := json.Unmarshal(raw, &tc); err != nil { + return nil, err + } + tc["index"] = i + calls = append(calls, tc) + } + if err := add([]map[string]any{{"index": ch.Index, "delta": map[string]any{"tool_calls": calls}, + "finish_reason": nil}}); err != nil { + return nil, err + } + } + if err := add([]map[string]any{{"index": ch.Index, "delta": map[string]any{}, + "finish_reason": finish}}); err != nil { + return nil, err + } + } + if withUsage && len(a.Usage) > 0 && string(a.Usage) != "null" { + c := map[string]any{"id": a.ID, "object": object, "created": a.Created, "model": a.Model, + "choices": []any{}, "usage": a.Usage} + b, err := json.Marshal(c) + if err != nil { + return nil, err + } + events = append(events, string(b)) + } + return events, nil +} diff --git a/internal/router/docs_test.go b/internal/router/docs_test.go new file mode 100644 index 0000000..a8c23e2 --- /dev/null +++ b/internal/router/docs_test.go @@ -0,0 +1,48 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/config" +) + +func docsRouter(t *testing.T, signIn bool) http.Handler { + t.Helper() + cfg := baseConfig(config.Endpoint{ID: "a", URL: "http://127.0.0.1:1", Models: []string{"m"}}) + cfg.Auth.Enabled = true + cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db") + cfg.Landing.DocsSignIn = signIn + return newTestRouter(t, cfg).Handler() +} + +// The docs are public by default on a gateway with keys: the client guide is +// for the person who has none yet, so asking for one first defeats it. +func TestTheDocsAreServedWithoutAKey(t *testing.T) { + h := docsRouter(t, false) + for _, path := range []string{"/docs", "/docs/clients"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "llmgw docs") { + t.Errorf("%s: %d %.200s", path, w.Code, w.Body.String()) + } + } +} + +func TestDocsSignInSendsAVisitorToSignIn(t *testing.T) { + h := docsRouter(t, true) + for _, path := range []string{"/docs", "/docs/install"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + // The session guard, not the key check: a browser has no key. + if strings.Contains(w.Body.String(), "api key") { + t.Errorf("%s asked a browser for an API key", path) + } + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" { + t.Errorf("%s: %d to %q, want a redirect to /login", path, w.Code, w.Header().Get("Location")) + } + } +} diff --git a/internal/router/handlers.go b/internal/router/handlers.go index d8e3bc2..f1ed618 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -234,6 +234,11 @@ func (r *Router) Handler() http.Handler { pages.RegisterAdminGuard(mux, r.store, r.guardLibrary) } pages.Register(mux) + // The docs portal, with the menu of whoever is signed in, and only to + // them when the site asked for that. + docs := pages.DocsPage(web.Docs(version.Version, true), r.landing.DocsSignIn) + mux.HandleFunc("GET /docs", docs) + mux.HandleFunc("GET /docs/{doc}", docs) r.registerChat(mux, pages) // An organization's own page, for the people who manage it. pages.RegisterOrgPages(mux, r.store, r.acct) @@ -285,18 +290,29 @@ func (r *Router) Handler() http.Handler { Catalog: r.catalogAdmin, Fleet: r.clusterView, }) } + } else { + // No accounts, so nobody to show a menu to or to send to sign in. + docs := web.Docs(version.Version, false) + mux.HandleFunc("GET /docs", docs) + mux.HandleFunc("GET /docs/{doc}", docs) } if r.auth == nil { return mux } + exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", + "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", + "/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/"} + // The docs are public unless the site said otherwise, and then guarded by + // the session check in front of them — or, on a gateway with keys and no + // accounts to sign in to, by the key like any other path. + if !r.landing.DocsSignIn || r.accounts != nil { + exempt = append(exempt, "/docs", "/docs/") + } // Wrapping the whole mux rather than checking inside each handler: adding a // route must not be able to add an unauthenticated one by omission. Health // stays open because a load balancer has no key. - return auth.Middleware(r.auth, r.log, - "/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", - "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", - "/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/")(mux) + return auth.Middleware(r.auth, r.log, exempt...)(mux) } // parsedRequest is what the router needs from a request. Everything else in the @@ -369,6 +385,9 @@ type parsedRequest struct { // internal is a request the gateway makes of itself — a judge's — which // no guard filters. internal bool + // cache is where its answer is kept, when its project reuses answers + // and this one may be. + cache cacheState } // inferenceBody covers the fields the router reads across all inference @@ -534,8 +553,17 @@ func (r *Router) handleInference(path string) http.HandlerFunc { } r.decideRequestLog(req.Context(), parsed) - res := r.dispatch(req.Context(), w, parsed) + // After the guardrails, so a policy still decides whether a request is + // answered, and the request looked up is the one the model would be + // sent. + if a := r.cacheLookup(req.Context(), w, parsed); a != nil { + r.serveCached(req.Context(), w, parsed, a, start) + return + } + dw, cw := r.cacheWriterFor(w, parsed) + res := r.dispatch(req.Context(), dw, parsed) status := r.finish(w, parsed, res, start) + r.cacheKeep(parsed, res, status, cw) r.observeCanary(parsed, res, status, time.Since(start)) if parsed.logRetain > 0 { go r.keepRequest(context.WithoutCancel(req.Context()), parsed, res, status) @@ -648,6 +676,9 @@ func (r *Router) account(req *parsedRequest, res dispatchResult, status int, too EndUser: req.EndUser, Tags: req.Tags, } guardFields(&u, req.guardIn, res.guard) + // An answer reused generates nothing: its usage is the zero the hit + // carries, and it costs nothing against a budget or a token limit. + u.CacheHit = res.cached if u.ServedModel == "" { u.ServedModel = req.Model } @@ -1172,6 +1203,9 @@ func (r *Router) accessLog(req *parsedRequest, res dispatchResult, status int, t if res.outcome.Stopped { attrs = append(attrs, "stopped_by_guard", true) } + if res.cached { + attrs = append(attrs, "cache", "hit") + } r.log.Info("request", attrs...) } diff --git a/internal/router/metrics.go b/internal/router/metrics.go index 0d583d2..cd31eb7 100644 --- a/internal/router/metrics.go +++ b/internal/router/metrics.go @@ -31,6 +31,12 @@ type metrics struct { prefixDecisions *prometheus.CounterVec + // cacheRequests counts what the answer cache did, cacheBytes and + // cacheEntries what it holds. + cacheRequests *prometheus.CounterVec + cacheBytes prometheus.Gauge + cacheEntries prometheus.Gauge + tenant *tenantMetrics guard *guardMetrics token *tokenMetrics @@ -91,6 +97,20 @@ func newMetrics() *metrics { Name: "llmgw_admission_rejected_total", Help: "Requests refused by flow control, by reason.", }, []string{"band", "reason"}), + cacheRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "llmgw_cache_requests_total", + Help: "Requests of projects that reuse answers, by what the answer cache did: " + + "hit (answered from it), miss (asked the model, and kept the answer if it could), " + + "bypass (not one whose answer is reused).", + }, []string{"model", "result"}), + cacheBytes: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "llmgw_cache_bytes", + Help: "Memory the answers kept for reuse take in this process, roughly.", + }), + cacheEntries: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "llmgw_cache_entries", + Help: "Answers kept for reuse in this process.", + }), tenant: newTenantMetrics(), guard: newGuardMetrics(), token: newTokenMetrics(), @@ -103,6 +123,7 @@ func (m *metrics) collectors() []prometheus.Collector { m.requests, m.duration, m.ttft, m.tokens, m.scorerValue, m.filtered, m.considered, m.admissionWait, m.admissionRejected, m.prefixDecisions, + m.cacheRequests, m.cacheBytes, m.cacheEntries, }, slices.Concat(m.tenant.collectors(), m.guard.collectors(), m.token.collectors())...) } diff --git a/internal/router/quality_test.go b/internal/router/quality_test.go new file mode 100644 index 0000000..8893cd4 --- /dev/null +++ b/internal/router/quality_test.go @@ -0,0 +1,131 @@ +package router + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "testing/fstest" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval" + "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// answeringEngine is an engine that answers a few questions right, one +// wrong, and grades as a judge would. +func answeringEngine(t *testing.T, served string) (*httptest.Server, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "# TYPE vllm:num_requests_running gauge\nvllm:num_requests_running 0\n") + }) + mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var req struct { + Model string `json:"model"` + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + Stream bool `json:"stream"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Stream || len(req.Messages) == 0 { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + q := req.Messages[len(req.Messages)-1].Content + answer := "I am not sure." + switch { + case served == "grader" && strings.Contains(q, "Answer:\nParis"): + answer = `{"score": 1, "reason": "names Paris"}` + case served == "grader": + answer = `{"score": 0, "reason": "does not name Paris"}` + case strings.Contains(q, "17 + 25"): + answer = "17 and 2542" + case strings.Contains(q, "6 * 7"): + answer = "6 * 7 = 43" + case strings.Contains(q, "JSON"): + answer = "```json\n{\"ok\": true}\n```" + case strings.Contains(q, "capital"): + answer = "Paris" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"model": req.Model, "choices": []any{map[string]any{ + "message": map[string]any{"role": "assistant", "content": answer}, "finish_reason": "stop"}}, + "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 3}}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, &calls +} + +const qualitySuite = ` +name: smoke +threshold: 0.7 +judge: grader +cases: + - id: add + prompt: What is 17 + 25? + number: 42 + - id: mul + prompt: What is 6 * 7? + number: 42 + - id: json + prompt: Give a JSON object with ok true. + json_schema: {type: object, required: [ok], properties: {ok: {const: true}}} + - id: capital + prompt: What is the capital of France? + judge: It names Paris. + weight: 2 +` + +func TestASuiteIsAskedThroughTheRouterAndScoredAndKept(t *testing.T) { + model, modelCalls := answeringEngine(t, "qwen") + grader, graderCalls := answeringEngine(t, "grader") + rt := newTestRouter(t, baseConfig( + config.Endpoint{ID: "a", URL: model.URL, Models: []string{"qwen"}}, + config.Endpoint{ID: "j", URL: grader.URL, Models: []string{"grader"}}, + )) + db, err := store.OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "store.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + svc := eval.NewService(eval.Deps{Call: rt.Replay, Store: db, Log: slog.New(slog.DiscardHandler), + Builtin: fstest.MapFS{"smoke.yaml": {Data: []byte(qualitySuite)}}}) + + live, err := svc.Start("qwen", eval.Request{Suite: "smoke", Concurrency: 2}, "tester") + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for live.State == eval.Running && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + live, _ = svc.Get(t.Context(), live.ID) + } + // add 1, mul 0, json 1, capital 1 at weight 2: 4 of 5. + if live.State != eval.Done || live.Score != 0.8 || !live.Passed || live.Below != 1 || live.Errors != 0 { + t.Fatalf("%+v", live) + } + if modelCalls.Load() != 4 || graderCalls.Load() != 1 { + t.Errorf("the model was asked %d times and the judge %d", modelCalls.Load(), graderCalls.Load()) + } + kept, err := db.EvalRunByID(t.Context(), live.ID) + if err != nil || len(kept.Results) != 4 || kept.Results[0].Answer != "42" || + !strings.Contains(kept.Results[1].Detail, "read 43") || kept.Results[3].Detail != "the judge: names Paris" { + t.Fatalf("kept %+v %v", kept, err) + } + + // A model nothing serves scores nothing, and says why. + run, err := svc.Run(t.Context(), "nothing", eval.Request{Suite: "smoke"}, "tester", "") + if err != nil || run.Passed || run.Errors != 4 || !strings.Contains(run.Error, "no case was answered") { + t.Fatalf("%+v %v", run, err) + } +} diff --git a/internal/router/requestlog.go b/internal/router/requestlog.go index 1cf126a..a1d253c 100644 --- a/internal/router/requestlog.go +++ b/internal/router/requestlog.go @@ -28,7 +28,9 @@ type requestLogs struct { type cachedLog struct { l storepkg.RequestLog inCluster bool - at time.Time + // cache is how the project reuses answers. + cache storepkg.AnswerCache + at time.Time } // requestLogOf is a project's setting, read at most every requestLogTTL. @@ -49,7 +51,8 @@ func (r *Router) requestLogOf(ctx context.Context, project string) storepkg.Requ return c.l } -// readProjectPolicy reads a project's keeping and residency, and caches them. +// readProjectPolicy reads a project's keeping, residency and reuse of +// answers, and caches them. func (r *Router) readProjectPolicy(ctx context.Context, project string) (cachedLog, error) { l, err := r.store.RequestLogOf(ctx, project) if err != nil { @@ -59,7 +62,11 @@ func (r *Router) readProjectPolicy(ctx context.Context, project string) (cachedL if err != nil { return cachedLog{}, err } - c := cachedLog{l: l, inCluster: in, at: time.Now()} + ac, err := r.store.AnswerCacheOf(ctx, project) + if err != nil { + return cachedLog{}, err + } + c := cachedLog{l: l, inCluster: in, cache: ac, at: time.Now()} r.reqLogs.mu.Lock() if r.reqLogs.by == nil { r.reqLogs.by = map[string]cachedLog{} diff --git a/internal/router/router.go b/internal/router/router.go index d2c658e..1ffe7bf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -74,6 +74,8 @@ type Router struct { batchWake chan struct{} // reqLogs is each project's keeping of requests, as last read. reqLogs requestLogs + // answers are the answers projects reuse; nil reuses none. + answers *answerCache // extraMetrics are collectors other components publish on /metrics. extraMetrics []prometheus.Collector @@ -271,6 +273,11 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) { MaxResponseBytes: cfg.Router.MaxBodyBytes, }), } + r.answers = newAnswerCache(cfg.Router.Cache.MaxBytes, cfg.Router.Cache.MaxEntryBytes, + func(bytes int64, n int) { + metrics.cacheBytes.Set(float64(bytes)) + metrics.cacheEntries.Set(float64(n)) + }) r.poller = scrape.NewPoller(reg, scrape.Config{ Interval: cfg.Router.ScrapeInterval, Timeout: cfg.Router.ScrapeTimeout, @@ -772,6 +779,9 @@ type dispatchResult struct { // the candidate, and onCanary whether the candidate served it. canaryOf, canary string onCanary bool + // cached is an answer from the project's answer cache, which no model + // was asked for. + cached bool } type servedModel struct { diff --git a/internal/router/scrape/parse.go b/internal/router/scrape/parse.go index 379bba8..ec35a65 100644 --- a/internal/router/scrape/parse.go +++ b/internal/router/scrape/parse.go @@ -28,6 +28,11 @@ type Mapping struct { PromptTokens string GenerationTokens string SuccessTotal string + + // Path is where the engine serves these metrics, when it is not the + // poller's configured path: trtllm-serve's /metrics is JSON, and its + // Prometheus text is under /prometheus/metrics. + Path string } // MappingFor returns the metric names for an engine. @@ -41,6 +46,8 @@ func MappingFor(engine string) Mapping { switch engine { case "llamacpp": return LlamaCppMapping() + case "trtllm": + return TRTLLMMapping() default: return VLLMMapping() } @@ -72,6 +79,33 @@ func LlamaCppMapping() Mapping { } } +// TRTLLMMapping returns TensorRT-LLM's metric names, as trtllm-serve +// publishes them under /prometheus/metrics. +// +// They are there only when the server runs with return_perf_metrics, and +// running and waiting move only with enable_iter_perf_stats; llmgw's launcher +// turns both on. The gauges are set from each iteration's statistics, so +// they say what the engine was doing when it last iterated: an engine that +// has gone idle keeps showing its last batch until the next request moves it. +// Running and waiting first appeared in the 1.3 release candidates; an older +// server serves none of the three and is reported as breaking the contract, +// which is what it does. +// +// KV cache utilization is used blocks over the pool, a fraction like vLLM's. +// The cache's configuration is published under other label names than the +// block counts read from vLLM, so it is left unread. +func TRTLLMMapping() Mapping { + return Mapping{ + Running: "trtllm_num_requests_running", + Waiting: "trtllm_num_requests_waiting", + KVCacheUsage: "trtllm_kv_cache_utilization", + PromptTokens: "trtllm_prompt_tokens_total", + GenerationTokens: "trtllm_generation_tokens_total", + SuccessTotal: "trtllm_request_success_total", + Path: "/prometheus/metrics", + } +} + // VLLMMapping returns vLLM's current metric names. func VLLMMapping() Mapping { return Mapping{ diff --git a/internal/router/scrape/poller.go b/internal/router/scrape/poller.go index 096c023..e0fb2f2 100644 --- a/internal/router/scrape/poller.go +++ b/internal/router/scrape/poller.go @@ -208,11 +208,16 @@ func (p *Poller) fetch(ctx context.Context, base, apiKey string, mapping Mapping ctx, cancel := context.WithTimeout(ctx, p.cfg.Timeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+p.cfg.Path, nil) + path := p.cfg.Path + if mapping.Path != "" { + path = mapping.Path + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil) if err != nil { return endpoint.Stats{}, err } - // llama.cpp guards /metrics with its key; vLLM does not, and ignores one. + // llama.cpp guards /metrics with its key, and llmgw's TensorRT-LLM + // launcher guards everything but /health; vLLM does not, and ignores one. if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } diff --git a/internal/router/scrape/trtllm_test.go b/internal/router/scrape/trtllm_test.go new file mode 100644 index 0000000..deb44b4 --- /dev/null +++ b/internal/router/scrape/trtllm_test.go @@ -0,0 +1,133 @@ +package scrape + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint" +) + +// trtllmMetrics is what trtllm-serve's /prometheus/metrics serves, in the +// shape prometheus_client's multiprocess collector writes it: every family +// labelled with the model and the backend, gauges with the pid of the +// process that set them, counters named with their _total. Trimmed to the +// families that matter and one histogram, which the parser has to step over. +const trtllmMetrics = `# HELP trtllm_request_success_total Count of successfully processed requests. +# TYPE trtllm_request_success_total counter +trtllm_request_success_total{engine_type="pytorch",finished_reason="stop",model_name="llama-3.1-70b"} 40.0 +trtllm_request_success_total{engine_type="pytorch",finished_reason="length",model_name="llama-3.1-70b"} 2.0 +# HELP trtllm_e2e_request_latency_seconds Histogram of end to end request latency in seconds. +# TYPE trtllm_e2e_request_latency_seconds histogram +trtllm_e2e_request_latency_seconds_bucket{engine_type="pytorch",le="0.5",model_name="llama-3.1-70b"} 10.0 +trtllm_e2e_request_latency_seconds_bucket{engine_type="pytorch",le="+Inf",model_name="llama-3.1-70b"} 42.0 +trtllm_e2e_request_latency_seconds_count{engine_type="pytorch",model_name="llama-3.1-70b"} 42.0 +trtllm_e2e_request_latency_seconds_sum{engine_type="pytorch",model_name="llama-3.1-70b"} 61.5 +# HELP trtllm_prompt_tokens_total Number of prefill tokens processed. +# TYPE trtllm_prompt_tokens_total counter +trtllm_prompt_tokens_total{engine_type="pytorch",model_name="llama-3.1-70b"} 18234.0 +# HELP trtllm_generation_tokens_total Number of generation tokens processed. +# TYPE trtllm_generation_tokens_total counter +trtllm_generation_tokens_total{engine_type="pytorch",model_name="llama-3.1-70b"} 9120.0 +# HELP trtllm_kv_cache_utilization KV cache utilization +# TYPE trtllm_kv_cache_utilization gauge +trtllm_kv_cache_utilization{engine_type="pytorch",model_name="llama-3.1-70b",pid="4121"} 0.375 +# HELP trtllm_kv_cache_config_info KV cache configuration info +# TYPE trtllm_kv_cache_config_info gauge +trtllm_kv_cache_config_info{enable_block_reuse="True",engine_type="pytorch",model_name="llama-3.1-70b",page_size="32",pid="4121"} 1.0 +# HELP trtllm_num_requests_running Number of active requests +# TYPE trtllm_num_requests_running gauge +trtllm_num_requests_running{engine_type="pytorch",model_name="llama-3.1-70b",pid="4121"} 5.0 +# HELP trtllm_num_requests_waiting Number of queued requests +# TYPE trtllm_num_requests_waiting gauge +trtllm_num_requests_waiting{engine_type="pytorch",model_name="llama-3.1-70b",pid="4121"} 2.0 +` + +func TestTRTLLMMetricsSatisfyTheContract(t *testing.T) { + s, err := Parse(strings.NewReader(trtllmMetrics), MappingFor("trtllm")) + if err != nil { + t.Fatal(err) + } + if s.Running != 5 || s.Waiting != 2 { + t.Errorf("running=%d waiting=%d", s.Running, s.Waiting) + } + // A fraction of the pool, as vLLM's is: the scorer compares the two. + if s.KVCacheUsage != 0.375 { + t.Errorf("kv=%v", s.KVCacheUsage) + } + if s.PromptTokens != 18234 || s.GenerationTokens != 9120 { + t.Errorf("tokens=%v/%v", s.PromptTokens, s.GenerationTokens) + } + // Split by finished_reason, summed. + if s.SuccessTotal != 42 { + t.Errorf("success=%v", s.SuccessTotal) + } + // Its cache configuration carries no block counts to read. + if s.BlockSize != 0 || s.NumGPUBlocks != 0 { + t.Errorf("block size %d and blocks %d read from labels that do not hold them", s.BlockSize, s.NumGPUBlocks) + } +} + +func TestATRTLLMWithoutItsQueueGaugesIsReportedNotIdle(t *testing.T) { + // TensorRT-LLM 1.2 publishes the cache's utilization and none of the queue + // gauges. Read as zeros, it would look like the emptiest endpoint in the + // cluster and take every request. + only := `# TYPE trtllm_kv_cache_utilization gauge +trtllm_kv_cache_utilization{engine_type="pytorch",model_name="m"} 0.1 +` + var missing *MissingError + _, err := Parse(strings.NewReader(only), TRTLLMMapping()) + if !errors.As(err, &missing) || len(missing.Missing) != 2 { + t.Fatalf("expected running and waiting reported missing, got %v", err) + } +} + +func TestTRTLLMIsScrapedWhereItPublishesPrometheusText(t *testing.T) { + // trtllm-serve's /metrics answers JSON — iteration statistics, drained by + // each read — and the launcher puts every path but /health behind the key. + var gotKey atomic.Value + gotKey.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/prometheus/metrics": + gotKey.Store(r.Header.Get("Authorization")) + if r.Header.Get("Authorization") != "Bearer k1" { + http.Error(w, "a valid API key is required", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(trtllmMetrics)) + case "/metrics": + _, _ = w.Write([]byte(`[{"iter": 154, "numActiveRequests": 1}]`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + reg := endpoint.NewRegistry(endpoint.Config{}) + reg.Replace([]endpoint.Info{{ID: "t1", URL: srv.URL, Engine: "trtllm", APIKey: "k1", Models: []string{"m"}}}) + p := NewPoller(reg, Config{Interval: 20 * time.Millisecond, Timeout: 2 * time.Second}, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p.Sync(ctx) + defer p.Stop() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for _, snap := range reg.Snapshots("m") { + if snap.ID == "t1" && snap.Stats.Running == 5 { + if snap.Stats.Waiting != 2 || snap.Stats.KVCacheUsage != 0.375 { + t.Errorf("stats read wrong: %+v", snap.Stats) + } + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("the endpoint was never measured (key sent: %q)", gotKey.Load()) +} diff --git a/internal/store/answercache.go b/internal/store/answercache.go new file mode 100644 index 0000000..38fd454 --- /dev/null +++ b/internal/store/answercache.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// AnswerCache is how a project reuses answers: for how long one is served +// again to the same request, and whether that is any request's answer or +// only one that would come out the same every time. Zero TTL reuses none. +// +// Only the setting lives in the store. The answers are held in each +// gateway's memory, so turning this on writes no text anywhere. +type AnswerCache struct { + TTL time.Duration + // Any reuses answers to requests sampled at a temperature above 0 + // with no seed, which would otherwise each be asked again. + Any bool +} + +// SetAnswerCache turns reusing answers on for a project, or off with ttl 0. +// The TTL is kept in whole seconds, at least one. +func (s *DB) SetAnswerCache(ctx context.Context, projectID string, ttl time.Duration, anyAnswer bool) error { + var secs any + a := 0 + if ttl > 0 { + secs = max(int64(ttl/time.Second), 1) + if anyAnswer { + a = 1 + } + } + res, err := s.db.ExecContext(ctx, + `UPDATE projects SET cache_ttl_seconds = ?, cache_any = ? WHERE id = ?`, secs, a, projectID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("store: no project %q", projectID) + } + return nil +} + +// AnswerCacheOf is a project's setting; zero when it reuses none. +func (s *DB) AnswerCacheOf(ctx context.Context, projectID string) (AnswerCache, error) { + var secs sql.NullInt64 + var a int + err := s.db.QueryRowContext(ctx, + `SELECT cache_ttl_seconds, cache_any FROM projects WHERE id = ?`, projectID).Scan(&secs, &a) + if errors.Is(err, sql.ErrNoRows) || err == nil && (!secs.Valid || secs.Int64 <= 0) { + return AnswerCache{}, nil + } + if err != nil { + return AnswerCache{}, err + } + return AnswerCache{TTL: time.Duration(secs.Int64) * time.Second, Any: a == 1}, nil +} diff --git a/internal/store/answercache_test.go b/internal/store/answercache_test.go new file mode 100644 index 0000000..f447509 --- /dev/null +++ b/internal/store/answercache_test.go @@ -0,0 +1,57 @@ +package store + +import ( + "testing" + "time" +) + +// answerCacheRoundTrip is the same on both backends: a project's setting +// and a hit's mark on its usage record. +func answerCacheRoundTrip(t *testing.T, s *DB) { + t.Helper() + ctx := t.Context() + proj := tree(t, s) + _, k := mustKey(t, s, "k1", proj) + + if c, err := s.AnswerCacheOf(ctx, proj); err != nil || c.TTL != 0 || c.Any { + t.Fatalf("a new project reuses answers: %+v %v", c, err) + } + if err := s.SetAnswerCache(ctx, proj, 90*time.Minute, true); err != nil { + t.Fatal(err) + } + if c, _ := s.AnswerCacheOf(ctx, proj); c.TTL != 90*time.Minute || !c.Any { + t.Errorf("set: %+v", c) + } + if err := s.SetAnswerCache(ctx, proj, 0, true); err != nil { + t.Fatal(err) + } + if c, _ := s.AnswerCacheOf(ctx, proj); c.TTL != 0 || c.Any { + t.Errorf("off: %+v", c) + } + if err := s.SetAnswerCache(ctx, "no-such-project", time.Hour, false); err == nil { + t.Error("a project that does not exist was set") + } + + now := time.Now() + if err := s.WriteUsage(ctx, []Usage{ + {At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m", Status: 200, RequestID: "hit", CacheHit: true}, + {At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m", Status: 200, RequestID: "miss", + PromptTokens: 3, CompletionTokens: 4, CostMicros: 10}, + }); err != nil { + t.Fatal(err) + } + if u, ok, err := s.UsageByRequest(ctx, "hit"); err != nil || !ok || !u.CacheHit { + t.Errorf("a hit: %+v %v", u, err) + } + if u, ok, err := s.UsageByRequest(ctx, "miss"); err != nil || !ok || u.CacheHit { + t.Errorf("a miss: %+v %v", u, err) + } +} + +func TestAProjectsReuseOfAnswersIsKeptAndAHitIsMarked(t *testing.T) { + answerCacheRoundTrip(t, capacityStore(t)) +} + +func TestPostgresKeepsAProjectsReuseOfAnswers(t *testing.T) { + answerCacheRoundTrip(t, openPG(t)) +} diff --git a/internal/store/copy.go b/internal/store/copy.go index 535ca89..f56e9d5 100644 --- a/internal/store/copy.go +++ b/internal/store/copy.go @@ -62,6 +62,9 @@ var copyOrder = []string{ "batches", "batch_results", "request_bodies", + // Runs before their cases, which point at them. + "eval_runs", + "eval_cases", } // notCopied are tables deliberately left behind. diff --git a/internal/store/eval.go b/internal/store/eval.go new file mode 100644 index 0000000..911bf8e --- /dev/null +++ b/internal/store/eval.go @@ -0,0 +1,136 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// EvalRun is one quality suite run against a model, as `llmgw ctl eval` +// and the gate on an update keep it. +type EvalRun struct { + ID string `json:"id"` + // Model is the model the run is about; Target the catalog model that + // answered, when that was another — its canary, or the new weights an + // update tried before moving to them. + Model string `json:"model"` + Target string `json:"target,omitempty"` + // Repo and Revision are where the weights that answered came from, + // when that is known. + Repo string `json:"repo,omitempty"` + Revision string `json:"revision,omitempty"` + // Suite is its name, SuiteHash the sha256 of its text. + Suite string `json:"suite"` + SuiteHash string `json:"suite_hash"` + At time.Time `json:"at"` + TookMs int64 `json:"took_ms"` + // Score is the cases' scores weighed, 0 to 1; Passed whether it + // reached Threshold. + Score float64 `json:"score"` + Threshold float64 `json:"threshold"` + Passed bool `json:"passed"` + // Cases were asked; Below scored under half; Errors got no answer. + Cases int `json:"cases"` + Below int `json:"below"` + Errors int `json:"errors"` + Judge string `json:"judge,omitempty"` + By string `json:"by,omitempty"` + // Job is the update or promotion the run gated, if one did. + Job string `json:"job,omitempty"` + // Error is why the run could not be scored at all. + Error string `json:"error,omitempty"` + Results []EvalCase `json:"results,omitempty"` +} + +// EvalCase is one case of a run: how its answer scored, and why. +type EvalCase struct { + ID string `json:"id"` + Kind string `json:"kind"` + Weight float64 `json:"weight"` + Score float64 `json:"score"` + Passed bool `json:"passed"` + Answer string `json:"answer,omitempty"` + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` + TookMs int64 `json:"took_ms"` +} + +// PutEvalRun keeps a finished run and its cases. +func (s *DB) PutEvalRun(ctx context.Context, r EvalRun) error { + return s.inTx(ctx, func(q conn) error { + if _, err := q.ExecContext(ctx, `INSERT INTO eval_runs (id, model, target, repo, revision, suite, + suite_hash, started_at, took_ms, score, threshold, passed, cases, below, errors, judge, actor, job, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.Model, r.Target, r.Repo, r.Revision, r.Suite, r.SuiteHash, millis(r.At), r.TookMs, + r.Score, r.Threshold, boolInt(r.Passed), r.Cases, r.Below, r.Errors, r.Judge, r.By, r.Job, r.Error); err != nil { + return err + } + for i, c := range r.Results { + if _, err := q.ExecContext(ctx, `INSERT INTO eval_cases (run_id, idx, case_id, kind, weight, score, + passed, answer, detail, error, took_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, i, c.ID, c.Kind, c.Weight, c.Score, boolInt(c.Passed), c.Answer, c.Detail, c.Error, + c.TookMs); err != nil { + return err + } + } + return nil + }) +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +const evalRunColumns = `id, model, target, repo, revision, suite, suite_hash, started_at, took_ms, score, + threshold, passed, cases, below, errors, judge, actor, job, error` + +func scanEvalRun(row interface{ Scan(...any) error }) (EvalRun, error) { + var r EvalRun + var at int64 + var passed int + err := row.Scan(&r.ID, &r.Model, &r.Target, &r.Repo, &r.Revision, &r.Suite, &r.SuiteHash, &at, &r.TookMs, + &r.Score, &r.Threshold, &passed, &r.Cases, &r.Below, &r.Errors, &r.Judge, &r.By, &r.Job, &r.Error) + r.At, r.Passed = fromMillis(at), passed == 1 + return r, err +} + +// EvalRuns are a model's runs, newest first and without their cases; every +// model's for an empty name. +func (s *DB) EvalRuns(ctx context.Context, model string, limit int) ([]EvalRun, error) { + if limit <= 0 || limit > 1000 { + limit = 20 + } + q := `SELECT ` + evalRunColumns + ` FROM eval_runs` + var args []any + if model != "" { + q += ` WHERE model = ? OR target = ?` + args = append(args, model, model) + } + q += ` ORDER BY started_at DESC, id DESC LIMIT ?` + args = append(args, limit) + return queryAll(ctx, s.db, q, args, func(rows *sql.Rows) (EvalRun, error) { return scanEvalRun(rows) }) +} + +// EvalRunByID is one run with its cases; ErrNotFound when there is none. +func (s *DB) EvalRunByID(ctx context.Context, id string) (EvalRun, error) { + r, err := scanEvalRun(s.db.QueryRowContext(ctx, `SELECT `+evalRunColumns+` FROM eval_runs WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return EvalRun{}, ErrNotFound + } + if err != nil { + return EvalRun{}, err + } + r.Results, err = queryAll(ctx, s.db, `SELECT case_id, kind, weight, score, passed, answer, detail, error, took_ms + FROM eval_cases WHERE run_id = ? ORDER BY idx`, []any{id}, func(rows *sql.Rows) (EvalCase, error) { + var c EvalCase + var passed int + err := rows.Scan(&c.ID, &c.Kind, &c.Weight, &c.Score, &passed, &c.Answer, &c.Detail, &c.Error, &c.TookMs) + c.Passed = passed == 1 + return c, err + }) + return r, err +} diff --git a/internal/store/eval_test.go b/internal/store/eval_test.go new file mode 100644 index 0000000..a9a5401 --- /dev/null +++ b/internal/store/eval_test.go @@ -0,0 +1,59 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestEvalRunsAreKeptWithTheirCases(t *testing.T) { + checkEvalRuns(t, capacityStore(t)) +} + +func TestPostgresEvalRunsAreKeptWithTheirCases(t *testing.T) { + checkEvalRuns(t, openPG(t)) +} + +func checkEvalRuns(t *testing.T, db *DB) { + ctx := t.Context() + now := time.Now().UTC().Truncate(time.Millisecond) + first := EvalRun{ID: "eval-1", Model: "qwen", Repo: "o/r", Revision: "abc", Suite: "arith", SuiteHash: "h1", + At: now.Add(-time.Hour), TookMs: 1200, Score: 0.9, Threshold: 0.8, Passed: true, Cases: 2, Below: 0, + By: "ilker", Results: []EvalCase{ + {ID: "add", Kind: "number", Weight: 1, Score: 1, Passed: true, Answer: "42", Detail: "read 42", TookMs: 300}, + {ID: "json", Kind: "json_schema", Weight: 2, Score: 0.85, Passed: true, Answer: "{}", TookMs: 900}, + }} + second := EvalRun{ID: "eval-2", Model: "qwen", Target: "qwen-canary", Revision: "def", Suite: "arith", + SuiteHash: "h1", At: now, Score: 0.5, Threshold: 0.8, Cases: 1, Below: 1, Errors: 1, Job: "promote-1", + Results: []EvalCase{{ID: "add", Kind: "number", Weight: 1, Error: "qwen-canary answered 503"}}} + other := EvalRun{ID: "eval-3", Model: "llama", Suite: "fmt", SuiteHash: "h2", At: now, Score: 1, Threshold: 0.8, + Passed: true, Cases: 0} + for _, r := range []EvalRun{first, second, other} { + if err := db.PutEvalRun(ctx, r); err != nil { + t.Fatal(err) + } + } + runs, err := db.EvalRuns(ctx, "qwen", 10) + if err != nil || len(runs) != 2 || runs[0].ID != "eval-2" || runs[1].ID != "eval-1" { + t.Fatalf("%+v %v", runs, err) + } + if r := runs[0]; r.Target != "qwen-canary" || r.Passed || r.Job != "promote-1" || r.Errors != 1 || + !r.At.Equal(now) || r.Results != nil { + t.Errorf("%+v", r) + } + // Asked by the canary's name, its runs are found too. + if got, _ := db.EvalRuns(ctx, "qwen-canary", 10); len(got) != 1 || got[0].ID != "eval-2" { + t.Errorf("by target: %+v", got) + } + if all, _ := db.EvalRuns(ctx, "", 2); len(all) != 2 { + t.Errorf("limit: %d", len(all)) + } + got, err := db.EvalRunByID(ctx, "eval-1") + if err != nil || got.Score != 0.9 || !got.Passed || got.Repo != "o/r" || got.By != "ilker" || len(got.Results) != 2 || + got.Results[1].ID != "json" || got.Results[1].Weight != 2 || got.Results[0].Answer != "42" || !got.Results[0].Passed { + t.Fatalf("%+v %v", got, err) + } + if _, err := db.EvalRunByID(ctx, "eval-9"); !errors.Is(err, ErrNotFound) { + t.Errorf("%v", err) + } +} diff --git a/internal/store/migrations/postgres/0027_answer_cache.sql b/internal/store/migrations/postgres/0027_answer_cache.sql new file mode 100644 index 0000000..99c4a4e --- /dev/null +++ b/internal/store/migrations/postgres/0027_answer_cache.sql @@ -0,0 +1,11 @@ +-- Ported from the SQLite migration of the same number. +-- Reusing answers, for a project that asks: how long one is reused, and +-- whether any request's answer is, or only those that come out the same +-- every time (temperature 0, or a seed). Off for every project until set. +-- The answers themselves are held in each gateway's memory, never here. +ALTER TABLE projects ADD COLUMN cache_ttl_seconds BIGINT; +ALTER TABLE projects ADD COLUMN cache_any BIGINT NOT NULL DEFAULT 0; + +-- A request answered from that cache: nothing was generated for it, so it +-- carries no tokens and costs nothing, and this says why. +ALTER TABLE usage ADD COLUMN cache_hit BIGINT NOT NULL DEFAULT 0; diff --git a/internal/store/migrations/postgres/0028_eval_runs.sql b/internal/store/migrations/postgres/0028_eval_runs.sql new file mode 100644 index 0000000..1f621ba --- /dev/null +++ b/internal/store/migrations/postgres/0028_eval_runs.sql @@ -0,0 +1,43 @@ +-- Ported from the SQLite migration of the same number. +-- Quality suites run against a model (`llmgw ctl eval`, and the gate on +-- `model update` and `model promote`): one row a run, filed under the model +-- it is about, with the revision its weights came from when that is known, +-- and the suite's hash, so two runs compare only when they asked the same +-- questions. Each case's score and answer beside it. +CREATE TABLE eval_runs ( + id TEXT PRIMARY KEY, + model TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + repo TEXT NOT NULL DEFAULT '', + revision TEXT NOT NULL DEFAULT '', + suite TEXT NOT NULL, + suite_hash TEXT NOT NULL, + started_at BIGINT NOT NULL, + took_ms BIGINT NOT NULL DEFAULT 0, + score DOUBLE PRECISION NOT NULL, + threshold DOUBLE PRECISION NOT NULL, + passed BIGINT NOT NULL, + cases BIGINT NOT NULL, + below BIGINT NOT NULL DEFAULT 0, + errors BIGINT NOT NULL DEFAULT 0, + judge TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT '', + job TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '' +); +CREATE INDEX eval_runs_model ON eval_runs (model, started_at); + +CREATE TABLE eval_cases ( + run_id TEXT NOT NULL REFERENCES eval_runs (id) ON DELETE CASCADE, + idx BIGINT NOT NULL, + case_id TEXT NOT NULL, + kind TEXT NOT NULL, + weight DOUBLE PRECISION NOT NULL, + score DOUBLE PRECISION NOT NULL, + passed BIGINT NOT NULL, + answer TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + took_ms BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, idx) +); diff --git a/internal/store/migrations/sqlite/0027_answer_cache.sql b/internal/store/migrations/sqlite/0027_answer_cache.sql new file mode 100644 index 0000000..dc06470 --- /dev/null +++ b/internal/store/migrations/sqlite/0027_answer_cache.sql @@ -0,0 +1,10 @@ +-- Reusing answers, for a project that asks: how long one is reused, and +-- whether any request's answer is, or only those that come out the same +-- every time (temperature 0, or a seed). Off for every project until set. +-- The answers themselves are held in each gateway's memory, never here. +ALTER TABLE projects ADD COLUMN cache_ttl_seconds INTEGER; +ALTER TABLE projects ADD COLUMN cache_any INTEGER NOT NULL DEFAULT 0; + +-- A request answered from that cache: nothing was generated for it, so it +-- carries no tokens and costs nothing, and this says why. +ALTER TABLE usage ADD COLUMN cache_hit INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/migrations/sqlite/0028_eval_runs.sql b/internal/store/migrations/sqlite/0028_eval_runs.sql new file mode 100644 index 0000000..701843f --- /dev/null +++ b/internal/store/migrations/sqlite/0028_eval_runs.sql @@ -0,0 +1,42 @@ +-- Quality suites run against a model (`llmgw ctl eval`, and the gate on +-- `model update` and `model promote`): one row a run, filed under the model +-- it is about, with the revision its weights came from when that is known, +-- and the suite's hash, so two runs compare only when they asked the same +-- questions. Each case's score and answer beside it. +CREATE TABLE eval_runs ( + id TEXT PRIMARY KEY, + model TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + repo TEXT NOT NULL DEFAULT '', + revision TEXT NOT NULL DEFAULT '', + suite TEXT NOT NULL, + suite_hash TEXT NOT NULL, + started_at INTEGER NOT NULL, + took_ms INTEGER NOT NULL DEFAULT 0, + score REAL NOT NULL, + threshold REAL NOT NULL, + passed INTEGER NOT NULL, + cases INTEGER NOT NULL, + below INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + judge TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT '', + job TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '' +); +CREATE INDEX eval_runs_model ON eval_runs (model, started_at); + +CREATE TABLE eval_cases ( + run_id TEXT NOT NULL REFERENCES eval_runs (id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + case_id TEXT NOT NULL, + kind TEXT NOT NULL, + weight REAL NOT NULL, + score REAL NOT NULL, + passed INTEGER NOT NULL, + answer TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + took_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, idx) +); diff --git a/internal/store/postgres_test.go b/internal/store/postgres_test.go index 010df30..c78b7e2 100644 --- a/internal/store/postgres_test.go +++ b/internal/store/postgres_test.go @@ -43,7 +43,8 @@ func openPG(t *testing.T) *DB { // every subtest gets a fresh file, and failed only here. `TRUNCATE thread_messages, threads, memory_notes, usage, usage_5m, usage_1h, usage_1d, api_keys, memberships, key_claims, limits, models, sessions, users, projects, teams, - organizations, leadership, control_planes, files, batches, batch_results, request_bodies RESTART IDENTITY CASCADE`) + organizations, leadership, control_planes, files, batches, batch_results, request_bodies, + eval_runs, eval_cases RESTART IDENTITY CASCADE`) if err != nil { t.Fatalf("clearing the test database: %v", err) } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index babd8ae..da9484b 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -435,18 +435,22 @@ func (s *DB) WriteUsage(ctx context.Context, records []Usage) error { `INSERT INTO usage (ts, key_id, project_id, model, served_model, prompt_tokens, completion_tokens, cost_micros, status, latency_ms, user_id, request_id, replica, queue_ms, ttft_ms, - guard_action, guard_policy, guard_category, guard_ms, end_user, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } defer stmt.Close() for _, r := range records { + hit := 0 + if r.CacheHit { + hit = 1 + } if _, err := stmt.ExecContext(ctx, millis(r.At), r.KeyID, r.ProjectID, r.Model, r.ServedModel, r.PromptTokens, r.CompletionTokens, r.CostMicros, r.Status, r.LatencyMs, r.UserID, r.RequestID, r.Replica, r.QueueMs, r.TTFTMs, - r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardMs, r.EndUser, tagsJSON(r.Tags)); err != nil { + r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil { return err } } @@ -856,15 +860,16 @@ func (s *DB) UsageByRequest(ctx context.Context, requestID string) (Usage, bool, var u Usage var ts int64 var tags string + var hit int err := s.db.QueryRowContext(ctx, `SELECT ts, key_id, project_id, COALESCE(user_id, ''), model, served_model, prompt_tokens, completion_tokens, cost_micros, status, latency_ms, request_id, replica, queue_ms, ttft_ms, - guard_action, guard_policy, guard_category, guard_ms, end_user, tags + guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit FROM usage WHERE request_id = ? ORDER BY ts DESC LIMIT 1`, requestID). Scan(&ts, &u.KeyID, &u.ProjectID, &u.UserID, &u.Model, &u.ServedModel, &u.PromptTokens, &u.CompletionTokens, &u.CostMicros, &u.Status, &u.LatencyMs, &u.RequestID, &u.Replica, &u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardMs, - &u.EndUser, &tags) + &u.EndUser, &tags, &hit) if errors.Is(err, sql.ErrNoRows) { return Usage{}, false, nil } @@ -873,5 +878,6 @@ func (s *DB) UsageByRequest(ctx context.Context, requestID string) (Usage, bool, } u.At = time.UnixMilli(ts) u.Tags = parseTags(tags) + u.CacheHit = hit == 1 return u, true, nil } diff --git a/internal/store/store.go b/internal/store/store.go index d1d338d..d974c35 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -269,6 +269,10 @@ type Usage struct { // and x-llmgw-tags. Both for attributing spend inside a project. EndUser string Tags map[string]string + + // CacheHit says the answer came from the project's answer cache: + // nothing was generated, so the record carries no tokens and no cost. + CacheHit bool } // tagsJSON is how tags are kept: a JSON object, keys sorted, or empty. diff --git a/internal/web/admin_serve.go b/internal/web/admin_serve.go index f064c03..dc21159 100644 --- a/internal/web/admin_serve.go +++ b/internal/web/admin_serve.go @@ -546,7 +546,7 @@ func describeServe(l *Lang, m catalog.Model) string { return "" } p := m.Profiles[0] - engine := map[string]string{"llamacpp": "llama.cpp", "vllm": "vLLM", "sglang": "SGLang"}[p.Engine] + engine := map[string]string{"llamacpp": "llama.cpp", "vllm": "vLLM", "sglang": "SGLang", "trtllm": "TensorRT-LLM"}[p.Engine] if engine == "" { engine = p.Engine } diff --git a/internal/web/docs.go b/internal/web/docs.go new file mode 100644 index 0000000..c48b25c --- /dev/null +++ b/internal/web/docs.go @@ -0,0 +1,258 @@ +package web + +import ( + "bytes" + "html/template" + "net/http" + "net/url" + "path" + "strings" + "sync" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" +) + +// The docs portal: the manual the binary carries, the pages `llmgw docs` and +// the console show, rendered as a website at /docs on the client port. +// +// Public by default, like the landing page and for the same reason: the +// person who needs the client guide is the one who does not have an account +// yet. The operator documents describe how llmgw is deployed in general, not +// this deployment — no address, no model, no key — so publishing them tells a +// stranger what the project's own repository already does. A site that would +// rather not say even that sets landing.docs_sign_in. +// +// Only the embedded documents are rendered, never anything a visitor sent, and +// in goldmark's safe mode all the same: raw HTML in a document is dropped +// rather than trusted, so a page here can never carry a script for the +// Content-Security-Policy to have to refuse. + +// docLink is one entry of the portal's sidebar. +type docLink struct{ Key, Title string } + +// docSection is a second-level heading, offered under the open document. +type docSection struct{ ID, Title string } + +// renderedDoc is a document turned into HTML once, when first asked for. +type renderedDoc struct { + docLink + Sections []docSection + Body template.HTML +} + +// docsData is what the docs template is rendered with. +type docsData struct { + Version string + // Doc is the open document; nil on the index. + Doc *renderedDoc + Docs []docLink + // Clients is the client developer's guide, which the index leads with. + Clients *docLink + // Nav is the menu of whoever is signed in, and SignedIn whether anyone is. + Nav Nav + SignedIn bool + // SignIn offers a sign-in link to someone who is not. + SignIn bool +} + +// manualOnce renders every document the first time the portal is opened: +// the configuration reference alone is two thousand lines, and rendering it +// again on every request would be work for nothing — the documents change +// only when the binary does. +var ( + manualOnce sync.Once + manualDocs []renderedDoc +) + +func manual() []renderedDoc { + manualOnce.Do(func() { + docs := llmgw.Manual() + paths := map[string]string{} + for _, d := range docs { + paths[d.Path] = d.Key + } + for _, d := range docs { + body, sections := renderDoc(d.Source(), d.Path, paths) + manualDocs = append(manualDocs, renderedDoc{ + docLink: docLink{Key: d.Key, Title: d.Title}, Sections: sections, Body: body, + }) + } + }) + return manualDocs +} + +// renderDoc turns one embedded document into HTML. from is its path in the +// embed, which its relative links are resolved against; paths are the paths +// of every document the portal has, by key. +func renderDoc(src, from string, paths map[string]string) (template.HTML, []docSection) { + t := &docTransform{from: from, paths: paths} + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions( + // The same ids GitHub gives headings, which is what the documents' + // own #links were written against. + parser.WithAutoHeadingID(), + parser.WithASTTransformers(util.Prioritized(t, 100))), + ) + var out bytes.Buffer + if err := md.Convert([]byte(src), &out); err != nil { + return template.HTML("

This document could not be rendered.

"), nil + } + // goldmark in its default, safe mode over documents compiled into the + // binary, with links rewritten below: not something to escape again. + return template.HTML(out.String()), t.sections //nolint:gosec +} + +// docTransform rewrites a document's links for the portal, gives every +// heading a link to itself, and collects the sections for the sidebar. +type docTransform struct { + from string + paths map[string]string + sections []docSection +} + +func (t *docTransform) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { + src := reader.Source() + var headings []*ast.Heading + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch n := n.(type) { + case *ast.Link: + n.Destination = []byte(t.rewrite(string(n.Destination))) + case *ast.Heading: + headings = append(headings, n) + } + return ast.WalkContinue, nil + }) + // After the walk, so the links added here are not themselves rewritten. + for _, h := range headings { + id, ok := h.AttributeString("id") + if !ok { + continue + } + idStr := string(id.([]byte)) + if h.Level == 2 { + t.sections = append(t.sections, docSection{ID: idStr, Title: plainText(h, src)}) + } + a := ast.NewLink() + a.Destination = []byte("#" + idStr) + a.Title = []byte("Link to this section") + a.SetAttributeString("class", []byte("anchor")) + a.AppendChild(a, ast.NewString([]byte("#"))) + h.AppendChild(h, a) + } +} + +// rewrite points a link at where its target is served from. +// +// A link to another document becomes its page under /docs, keeping the +// section it names. A link to anything else in the repository — deploy/, +// the backlog, the design plan — is not in the binary, so it goes to the +// repository itself rather than to a 404 here. Absolute links and links +// within the page are left alone. +func (t *docTransform) rewrite(dest string) string { + u, err := url.Parse(dest) + if err != nil || u.Scheme != "" || u.Host != "" || u.Path == "" || strings.HasPrefix(u.Path, "/") { + return dest + } + target := path.Clean(path.Join(path.Dir(t.from), u.Path)) + frag := "" + if u.Fragment != "" { + frag = "#" + u.EscapedFragment() + } + if key, ok := t.paths[target]; ok { + return "/docs/" + key + frag + } + if strings.HasPrefix(target, "../") { + return dest // outside the repository: nothing better to point it at + } + if strings.HasSuffix(u.Path, "/") { + target += "/" + } + return repoURL + "/src/branch/main/" + target + frag +} + +// plainText is a heading's words without their markup: "`name`" is "name". +func plainText(n ast.Node, src []byte) string { + var b strings.Builder + _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch c := c.(type) { + case *ast.Text: + b.Write(c.Segment.Value(src)) + if c.SoftLineBreak() { + b.WriteByte(' ') + } + case *ast.String: + b.Write(c.Value) + } + return ast.WalkContinue, nil + }) + return b.String() +} + +// Docs serves the portal: the index at /docs and a document at +// /docs/{doc}. signIn is whether this gateway has accounts to sign in to, +// which decides whether the page offers it. +func Docs(version string, signIn bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + docs := manual() + d := docsData{Version: version, SignIn: signIn} + if n, ok := r.Context().Value(navKey{}).(Nav); ok { + n.Current = "docs" + d.Nav, d.SignedIn = n, true + } + for i := range docs { + d.Docs = append(d.Docs, docs[i].docLink) + if docs[i].Key == "clients" { + d.Clients = &docs[i].docLink + } + } + key := strings.TrimSuffix(strings.ToLower(r.PathValue("doc")), ".md") + if key == "" { + render(w, r, "docs", d) + return + } + for i := range docs { + if docs[i].Key == key { + d.Doc = &docs[i] + render(w, r, "docs", d) + return + } + } + http.NotFound(w, r) + } +} + +// DocsPage serves the docs portal with the menu of whoever is signed in and, +// when required is set, to nobody else: someone not signed in is sent to sign +// in, as every other page that needs a session does. +func (p *AccountPages) DocsPage(docs http.HandlerFunc, required bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, user, ok := p.session(r) + if !ok { + if required { + noStore(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + docs(w, r) + return + } + // The page carries this person's menu, so no cache may keep it for + // the next. + noStore(w) + docs(w, p.withNav(r, user)) + } +} diff --git a/internal/web/docs_test.go b/internal/web/docs_test.go new file mode 100644 index 0000000..eb85daf --- /dev/null +++ b/internal/web/docs_test.go @@ -0,0 +1,174 @@ +package web + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// docsMux mounts the portal the way the router does. +func docsMux(h http.HandlerFunc) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /docs", h) + mux.HandleFunc("GET /docs/{doc}", h) + return mux +} + +func TestTheDocsPortalRendersADocumentWithItsHeadingsAndCode(t *testing.T) { + code, body := get(t, docsMux(Docs("v1", false)), "/docs/clients", nil) + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + for _, want := range []string{ + `

Using llmgw from code`, + // A heading links to itself, for someone sending a section to someone. + `

Streaming`, + `client.chat.completions.create(`, + // Tables are GitHub's. + ``, + // The sidebar offers every document and marks the open one. + `Using llmgw from code`, + ``, + // And this document's sections beneath it. + `The Anthropic SDK`, + } { + if !strings.Contains(body, want) { + t.Errorf("the page lacks %q", want) + } + } +} + +func TestEveryDocumentInTheManualIsAPage(t *testing.T) { + h := docsMux(Docs("v1", false)) + for _, d := range llmgw.Manual() { + if code, _ := get(t, h, "/docs/"+d.Key, nil); code != http.StatusOK { + t.Errorf("/docs/%s: %d", d.Key, code) + } + } +} + +func TestTheIndexLeadsWithTheClientGuide(t *testing.T) { + code, body := get(t, docsMux(Docs("v1", true)), "/docs", nil) + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + lead := strings.Index(body, `Using llmgw from code shows`) + every := strings.Index(body, "Every document") + if lead < 0 || every < 0 || lead > every { + t.Errorf("the client guide is not offered first:\n%s", body) + } + if !strings.Contains(body, `Sign in`) { + t.Error("a gateway with accounts does not offer to sign in") + } +} + +func TestLinksBetweenDocumentsStayInThePortal(t *testing.T) { + paths := map[string]string{"docs/install.md": "install", "docs/guardrails.md": "guardrails", + "README.md": "readme", "CHANGELOG.md": "changes"} + src := "See [install](install.md), [a section](install.md#2-the-control-plane), " + + "[here](#local), [the readme](../README.md), [proxies](../deploy/proxy/), " + + "[the plan](llmgw-plan.md) and [elsewhere](https://example.com/x.md).\n" + body, _ := renderDoc(src, "docs/guardrails.md", paths) + for _, want := range []string{ + `href="/docs/install"`, + `href="/docs/install#2-the-control-plane"`, + `href="#local"`, + `href="/docs/readme"`, + // Not in the binary: the repository has it. + `href="` + repoURL + `/src/branch/main/deploy/proxy/"`, + `href="` + repoURL + `/src/branch/main/docs/llmgw-plan.md"`, + `href="https://example.com/x.md"`, + } { + if !strings.Contains(string(body), want) { + t.Errorf("rendered without %s:\n%s", want, body) + } + } + // The README links into docs/ from the repository's root. + body, _ = renderDoc("[guardrails](docs/guardrails.md)\n", "README.md", paths) + if !strings.Contains(string(body), `href="/docs/guardrails"`) { + t.Errorf("a link from the README: %s", body) + } +} + +func TestNoEmbeddedDocumentLinksToAPageThePortalLacks(t *testing.T) { + // Every link a document makes into /docs/ must land on a page, or the + // portal ships its own 404s. + keys := map[string]bool{} + for _, d := range manual() { + keys[d.Key] = true + } + for _, d := range manual() { + for _, part := range strings.Split(string(d.Body), `href="/docs/`)[1:] { + key := part[:strings.IndexAny(part, `"#`)] + if !keys[key] { + t.Errorf("%s links to /docs/%s, which is not a page", d.Key, key) + } + } + } +} + +func TestAnUnknownDocumentIsNotFound(t *testing.T) { + h := docsMux(Docs("v1", false)) + for _, path := range []string{"/docs/no-such-doc", "/docs/..%2fgo.mod", "/docs/BACKLOG"} { + if code, _ := get(t, h, path, nil); code != http.StatusNotFound { + t.Errorf("%s: %d, want 404", path, code) + } + } + // The file name works as well as the key: it is what the documents say. + if code, _ := get(t, h, "/docs/install.md", nil); code != http.StatusOK { + t.Errorf("/docs/install.md: %d", code) + } +} + +func TestTheDocsCarryThePolicyAndNoScript(t *testing.T) { + for _, path := range []string{"/docs", "/docs/clients", "/docs/reference", "/docs/readme"} { + rec := httptest.NewRecorder() + docsMux(Docs("v1", false)).ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + csp := rec.Header().Get("Content-Security-Policy") + if !strings.Contains(csp, "script-src 'self';") || strings.Contains(csp, "sha256-") { + t.Errorf("%s: policy %q, want this server's scripts and no inline one", path, csp) + } + if strings.Contains(strings.ToLower(rec.Body.String()), "Docs<`) || + !strings.Contains(menu, `"/logout"`) { + t.Errorf("the signed-in menu:\n%s", menu) + } + if !strings.Contains(signedIn.Header().Get("Cache-Control"), "no-store") { + t.Error("a page with someone's menu on it may be cached") + } + + public := docsMux(p.DocsPage(Docs("v0", true), false)) + rec = httptest.NewRecorder() + public.ServeHTTP(rec, httptest.NewRequest("GET", "/docs/install", nil)) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"/logout"`) { + t.Errorf("public, without a session: %d", rec.Code) + } +} diff --git a/internal/web/i18n_test.go b/internal/web/i18n_test.go index 979795a..77dc872 100644 --- a/internal/web/i18n_test.go +++ b/internal/web/i18n_test.go @@ -457,3 +457,24 @@ func TestACatalogueWithAKeyTwiceIsRefused(t *testing.T) { t.Errorf("%v %v", m, err) } } + +// The docs portal's own pages are translated; the documents themselves are +// English, and a Turkish reader is told so rather than left to find out. +func TestTheDocsPortalSpeaksTurkishAroundEnglishDocuments(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/docs", nil) + r.Header.Set("Accept-Language", "tr") + w := httptest.NewRecorder() + Docs("v1", true)(w, r) + body := w.Body.String() + for _, want := range []string{``, "llmgw belgeleri", "Tüm belgeler", "yalnızca İngilizcedir", `href="/_lang/en"`} { + if !strings.Contains(body, want) { + t.Errorf("the Turkish docs index lacks %q", want) + } + } + r = httptest.NewRequest(http.MethodGet, "/docs", nil) + w = httptest.NewRecorder() + Docs("v1", true)(w, r) + if strings.Contains(w.Body.String(), "İngilizce") || !strings.Contains(w.Body.String(), "Every document") { + t.Error("the English docs index is not as it was") + } +} diff --git a/internal/web/locales/en/account.json b/internal/web/locales/en/account.json index a4e0827..f055e6a 100644 --- a/internal/web/locales/en/account.json +++ b/internal/web/locales/en/account.json @@ -147,5 +147,6 @@ "profile.err.try_again": "something went wrong; try again", "profile.err.not_member": "you are not a member of that project", "profile.err.wrong_password": "that is not your current password", - "profile.err.same_password": "that is the password you already have" + "profile.err.same_password": "that is the password you already have", + "connect.own_code": "From your own code: the official OpenAI and Anthropic SDKs work as they are —\nthe client guide shows how, in Python, TypeScript, Go and curl." } diff --git a/internal/web/locales/en/docs.json b/internal/web/locales/en/docs.json new file mode 100644 index 0000000..a88ecc2 --- /dev/null +++ b/internal/web/locales/en/docs.json @@ -0,0 +1,14 @@ +{ + "docs.title": "llmgw docs", + "docs.description": "How to use and run llmgw: the client guide, installation, operation and the configuration reference.", + "docs.this_gateway": "This gateway", + "docs.about": "About llmgw", + "docs.documents": "Documents", + "docs.sub": "The manual this gateway's software carries — the same pages\nllmgw docs prints and the console's Manual shows.", + "docs.from_code": "Using the gateway from code", + "docs.from_code_text": "The official OpenAI and Anthropic SDKs work as they are: only the base URL\n and the key change. %s shows Python,\n TypeScript, Go and curl for chat, streaming, tools, embeddings, the Responses\n API, batches, and what llmgw adds — stored conversations, tags, sessions and\n memory notes.", + "docs.every": "Every document", + "docs.general": "The documents describe llmgw in general, not this installation:\nthey name no address, model or key of it.", + "docs.english_only": "", + "docs.footer": "docs" +} diff --git a/internal/web/locales/en/landing.json b/internal/web/locales/en/landing.json index 4a0faf1..4caa9ed 100644 --- a/internal/web/locales/en/landing.json +++ b/internal/web/locales/en/landing.json @@ -30,5 +30,6 @@ "landing.anything_works": "Anything that speaks the chat-completions API works, whether or not it\nis listed here — there is nothing llmgw-specific to install.", "landing.once_you_have_key": "Once you have a key", "landing.models_endpoint": "what your key may use", - "landing.me_endpoint": "its limits, and what it has spent" + "landing.me_endpoint": "its limits, and what it has spent", + "landing.client_guide": "The client guide has more: streaming, tools, embeddings and the Anthropic SDK." } diff --git a/internal/web/locales/en/nav.json b/internal/web/locales/en/nav.json index 14100d7..2c625db 100644 --- a/internal/web/locales/en/nav.json +++ b/internal/web/locales/en/nav.json @@ -9,5 +9,6 @@ "nav.guard": "Guardrails", "nav.chat": "Chat", "nav.account": "Account", - "nav.sign_out": "Sign out" + "nav.sign_out": "Sign out", + "nav.docs": "Docs" } diff --git a/internal/web/locales/tr/account.json b/internal/web/locales/tr/account.json index 783af3d..edb4576 100644 --- a/internal/web/locales/tr/account.json +++ b/internal/web/locales/tr/account.json @@ -147,5 +147,6 @@ "profile.err.try_again": "bir sorun oluştu; yeniden deneyin", "profile.err.not_member": "o projenin üyesi değilsiniz", "profile.err.wrong_password": "mevcut parolanız bu değil", - "profile.err.same_password": "bu zaten kullandığınız parola" + "profile.err.same_password": "bu zaten kullandığınız parola", + "connect.own_code": "Kendi kodunuzdan: resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır;\nnasıl yapılacağını istemci kılavuzu Python, TypeScript, Go ve curl ile gösteriyor." } diff --git a/internal/web/locales/tr/docs.json b/internal/web/locales/tr/docs.json new file mode 100644 index 0000000..71f0479 --- /dev/null +++ b/internal/web/locales/tr/docs.json @@ -0,0 +1,14 @@ +{ + "docs.title": "llmgw belgeleri", + "docs.description": "llmgw'nin kullanımı ve işletimi: istemci kılavuzu, kurulum, işletim ve yapılandırma başvurusu.", + "docs.this_gateway": "Bu ağ geçidi", + "docs.about": "llmgw hakkında", + "docs.documents": "Belgeler", + "docs.sub": "Bu ağ geçidinin yazılımıyla birlikte gelen kılavuz: llmgw docs komutunun yazdırdığı\nve konsoldaki Kılavuz ekranının gösterdiği sayfaların aynısı.", + "docs.from_code": "Ağ geçidini koddan kullanmak", + "docs.from_code_text": "Resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır: yalnızca temel URL ile anahtar değişir.\n %s; sohbet, akış, araçlar, gömmeler, Responses API'si, toplu işler ve\n llmgw'nin eklediklerini (saklanan sohbetler, etiketler, oturumlar ve bellek notları)\n Python, TypeScript, Go ve curl örnekleriyle gösterir.", + "docs.every": "Tüm belgeler", + "docs.general": "Belgeler bu kurulumu değil, genel olarak llmgw'yi anlatır:\nbu kuruluma ait hiçbir adres, model ya da anahtar içermez.", + "docs.english_only": "Belgelerin kendisi yalnızca İngilizcedir.", + "docs.footer": "belgeler" +} diff --git a/internal/web/locales/tr/landing.json b/internal/web/locales/tr/landing.json index 17e4afb..779589a 100644 --- a/internal/web/locales/tr/landing.json +++ b/internal/web/locales/tr/landing.json @@ -30,5 +30,6 @@ "landing.anything_works": "Chat completions API'sini konuşan her şey çalışır, burada listelenmiş olsun ya da olmasın;\nllmgw'ye özel kurulacak hiçbir şey yoktur.", "landing.once_you_have_key": "Anahtarınızı aldıktan sonra", "landing.models_endpoint": "anahtarınızın kullanabildikleri", - "landing.me_endpoint": "sınırları ve şimdiye kadarki harcaması" + "landing.me_endpoint": "sınırları ve şimdiye kadarki harcaması", + "landing.client_guide": "Daha fazlası istemci kılavuzunda: akış, araçlar, gömmeler (embeddings) ve Anthropic SDK." } diff --git a/internal/web/locales/tr/nav.json b/internal/web/locales/tr/nav.json index adc07a2..8b70a3e 100644 --- a/internal/web/locales/tr/nav.json +++ b/internal/web/locales/tr/nav.json @@ -9,5 +9,6 @@ "nav.guard": "Koruma kuralları", "nav.chat": "Sohbet", "nav.account": "Hesap", - "nav.sign_out": "Çıkış yap" + "nav.sign_out": "Çıkış yap", + "nav.docs": "Belgeler" } diff --git a/internal/web/static/docs.css b/internal/web/static/docs.css new file mode 100644 index 0000000..62d7c1a --- /dev/null +++ b/internal/web/static/docs.css @@ -0,0 +1,50 @@ +/* The docs portal, over base.css: a sidebar of the documents beside the one + being read. Its own file so the pages that have no use for it do not carry + it. */ +.docs-head { margin-bottom: 1.5rem; } +.docs-brand { margin: 0; font-weight: 600; font-size: 1.05rem; } +.docs-brand a { color: var(--fg); text-decoration: none; } + +.docs { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: 2.5rem; align-items: start; } +.docs-side { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; + font-size: .88rem; padding-right: .25rem; } +.docs-side ul { list-style: none; padding: 0; margin: 0; } +.docs-side li { margin: 0; } +.docs-side a { display: block; padding: .22rem .55rem; border-radius: 5px; + color: var(--muted); text-decoration: none; } +.docs-side a:hover { color: var(--fg); } +.docs-side a[aria-current="page"] { color: var(--fg); background: var(--card); + font-weight: 600; border: 1px solid var(--line); } +.docs-side .sections { margin: .25rem 0 .6rem .7rem; padding-left: .3rem; + border-left: 1px solid var(--line); font-size: .82rem; } +.docs-side .sections a { padding: .12rem .45rem; } + +.doc { line-height: 1.65; min-width: 0; } +.doc h1 { font-size: 1.7rem; margin: 0 0 1rem; } +.doc h2 { font-size: 1.2rem; margin: 2.6rem 0 .8rem; padding-top: 1rem; border-top: 1px solid var(--line); } +.doc h3 { font-size: 1rem; margin: 1.8rem 0 .5rem; } +.doc h4 { font-size: .93rem; margin: 1.4rem 0 .4rem; } +.doc :is(h1, h2, h3, h4) { scroll-margin-top: 1rem; } +.doc .anchor { margin-left: .4rem; color: var(--muted); text-decoration: none; + font-weight: 400; opacity: 0; } +.doc :is(h1, h2, h3, h4):hover .anchor, .doc .anchor:focus-visible { opacity: 1; } +.doc pre { line-height: 1.45; } +.doc :not(pre) > code { background: var(--card); border: 1px solid var(--line); + border-radius: 4px; padding: .05rem .3rem; } +.doc table { display: block; overflow-x: auto; margin: .8rem 0 1.4rem; width: auto; max-width: 100%; } +.doc th { text-transform: none; letter-spacing: 0; font-size: .82rem; } +.doc blockquote { margin: 1rem 0; padding: .2rem 1rem; border-left: 3px solid var(--accent); + background: var(--card); border-radius: 0 6px 6px 0; } +.doc img { max-width: 100%; } +.doc li > p { margin: .2rem 0; } + +.docs-lead { background: var(--card); border: 1px solid var(--line); border-radius: 8px; + padding: .2rem 1.1rem .9rem; margin: 1.5rem 0; } +.docs-lead h2 { border-top: none; padding-top: 0; margin-top: 1rem; } +.docs-index { columns: 2 16rem; } + +@media (max-width: 760px) { + .docs { grid-template-columns: 1fr; gap: 1rem; } + .docs-side { position: static; max-height: none; border-bottom: 1px solid var(--line); + padding-bottom: 1rem; } +} diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html index 9ba6c6c..83c7c74 100644 --- a/internal/web/templates/account.html +++ b/internal/web/templates/account.html @@ -99,6 +99,7 @@

{{t "connect.heading"}}

{{t "connect.intro"}}

+

{{th "connect.own_code"}}

diff --git a/internal/web/templates/docs.html b/internal/web/templates/docs.html new file mode 100644 index 0000000..1dc87ae --- /dev/null +++ b/internal/web/templates/docs.html @@ -0,0 +1,62 @@ +{{/* The docs portal: the manual the binary carries, as a website. No script + at all — a sidebar of links and a rendered document need none, and the + policy then has nothing to allow. */}} +{{define "docs"}} + + +{{if .Doc}}{{.Doc.Title}} · {{end}}{{t "docs.title"}} + + + +
+
+

{{t "docs.title"}}

+ {{if .SignedIn}}{{template "nav" .Nav}}{{else}} + {{end}} +
+ +
+ + +
+{{if .Doc}} +{{.Doc.Body}} +{{else}} +

{{t "docs.title"}}

+

{{th "docs.sub"}}

+ +{{with .Clients}} +
+

{{t "docs.from_code"}}

+

{{th "docs.from_code_text" .Key .Title}}

+
+{{end}} + +

{{t "docs.every"}}

+ +

{{t "docs.general"}}

+{{with t "docs.english_only"}}

{{.}}

{{end}} +{{end}} +
+
+ + +
{{end}} diff --git a/internal/web/templates/landing.html b/internal/web/templates/landing.html index f6ede8f..741b8ab 100644 --- a/internal/web/templates/landing.html +++ b/internal/web/templates/landing.html @@ -163,7 +163,7 @@ print(reply.choices[0].message.content) }
-

{{t "landing.anything_works"}}

+

{{t "landing.anything_works"}} {{th "landing.client_guide"}}

{{t "landing.once_you_have_key"}}

    diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 7e22c71..74d07b1 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -26,6 +26,7 @@ {{- end}} {{t "nav.chat"}} {{t "nav.account"}} + {{t "nav.docs"}} diff --git a/notes/c17.md b/notes/c17.md new file mode 100644 index 0000000..75558d8 --- /dev/null +++ b/notes/c17.md @@ -0,0 +1,55 @@ +## CHANGELOG + +### Reusing answers + +- **A response cache, per project, off until asked for.** `llmgw keys cache + --project P --ttl 1h` has the same request — same project, endpoint, + model by its catalog name, and body with `stream`, `stream_options`, + `user`, `metadata`, `store` and `service_tier` left out and its keys in + any order — answered from the gateway's memory instead of the model, for + the TTL. `--off` stops it; without either it says how it stands. The + console has it as **Cache answers…** on a project. +- **Only answers that come out the same are reused:** chat and completions + asked at temperature 0 or with a seed, and embeddings, rerank and score. + `--any` reuses sampled answers too. Only a whole 200 from the model asked + for is kept — not an error, a fallback's or a canary's answer, one a + guardrail changed or stopped, or one over `router.cache.max_entry_bytes`. +- **Streams both ways:** a streamed answer is kept put back together into + one completion (tool calls and reasoning included), and a kept answer is + streamed as chunks — with the usage chunk if the client asked — to a + client that streams. `/v1/messages` and `/v1/responses` get it too. +- **Headers:** `x-llmgw-cache: hit|miss|bypass` on every answer of a + project that reuses answers, `Age` on a hit. `Cache-Control: no-cache` + or `no-store`, or `x-llmgw-cache: no`, asks the model anew. +- **Policies still apply:** limits and the input guardrails run before the + cache is looked in, the output policy judges a reused answer as it would + a new one, and a request under `no_store` or continuing a stored + conversation is never served from it nor kept in it. A project's answers + are never looked up by another project's keys. +- **A hit costs nothing:** recorded as a 200 with no tokens and no cost, + marked `cache_hit` on the usage record (migration 0027), and shown by + `llmgw ctl request`; it counts towards the requests-per-minute limit only. +- **In memory only:** `router.cache.max_bytes` (128 MB) for all projects + together in each gateway process, least recently used dropped first; + nothing is written to the store. `llmgw_cache_requests_total{model,result}`, + `llmgw_cache_bytes` and `llmgw_cache_entries`. + +## BACKLOG + +**Done (branch `features-c2`):** exact-match answer cache per project +(`keys cache --ttl/--any/--off`, console **Cache answers…**, `projects` +columns, 30-second setting cache), reproducible answers only unless +`--any`, LRU in memory bounded by `router.cache.max_bytes` and +`max_entry_bytes`, streamed answers kept and replayed as chunks, +`x-llmgw-cache` and `Cache-Control: no-cache`, guardrails before a hit and +the output policy on it, `no_store` and threads bypass, a hit recorded as +`cache_hit` with no tokens and no cost (migration 0027), metrics, docs. +Left: semantic (embedding-similarity) caching through the embeddings +models; a cache shared across control planes (each process keeps its own, +deliberately: a shared one would write every answer to the store); a hit +still waits its turn in admission, because the guardrails judge the request +after admission and the key is the request they let through; logprobs in a +streamed answer leave it unkept; `users forget` does not reach into the +cache (answers age out by the TTL); no listing or flushing of what is +cached (`--off` stops it being served at once, the memory is freed by TTL +and LRU). diff --git a/notes/c19.md b/notes/c19.md new file mode 100644 index 0000000..76e95f3 --- /dev/null +++ b/notes/c19.md @@ -0,0 +1,57 @@ +# C19: OpenAPI for the admin API, and a Terraform provider + +## CHANGELOG + +### The admin API described, and a Terraform provider + +- **The admin API in OpenAPI 3.1.** Every endpoint under `/api/v1`, its path + and query parameters, and the schema of each request and answer with the + meaning of each field, in + [docs/admin-api.openapi.json](docs/admin-api.openapi.json) and served by + every control plane at `GET /api/v1/openapi.json` (with the token, like the + rest). The bearer token, the `X-Llmgw-Actor` header and the error shape are + in it. Generated by `go generate ./internal/docgen` from a route table in + `internal/control/adminapi/routes.go` and the Go types the handlers use, with + the fields' comments as descriptions; a test fails when an endpoint is + served and not in the table, another when the committed document is stale. + Checked with openapi-spec-validator and openapi-generator-cli's validate. +- **[docs/admin-api.md](docs/admin-api.md)**: how to reach the API (loopback, + an SSH tunnel, `admin_remote`), the token at `/var/lib/llmgw/admin-token`, + what a request must carry — an address in `Host`, `application/json` on + every change, even a `DELETE` — and some calls with `curl`. +- **`GET /api/v1/models/{name}`**: one model's whole definition as it is in + force, with the defaults the catalog filled in. The listing has only its + state and its profiles' names. +- **A Terraform provider**, in `terraform-provider-llmgw/`, a module of its + own so the gateway does not carry its dependencies. `llmgw_model` (a model + added at runtime: scaling, class, owner and adapters change in place, the + rest replaces it), `llmgw_quota`, `llmgw_guard_attachment`, and the + `llmgw_models` and `llmgw_nodes` data sources. Its client is checked + against the OpenAPI document, endpoint by endpoint and field by field, so a + change to the admin API that breaks it fails its tests; CI runs them. + Built and installed by hand for now: see its + [README](terraform-provider-llmgw/README.md). + +## BACKLOG + +**Done (branch `features-c2`):** `adminapi.Routes` and +`internal/docgen/openapi` generating `docs/admin-api.openapi.json` (3.1, +bearer security, every route, query parameters, schemas from the Go types +with their comments), served embedded at `GET /api/v1/openapi.json`, kept true +by a route-table test and a staleness test; validated with +openapi-spec-validator and openapi-generator-cli. `GET /api/v1/models/{name}`. +`docs/admin-api.md`. `terraform-provider-llmgw/` (terraform-plugin-framework): +`llmgw_model`, `llmgw_quota`, `llmgw_guard_attachment`, data sources +`llmgw_models` and `llmgw_nodes`; client tests against a fake admin API and +the OpenAPI document; acceptance tests with Terraform and OpenTofu against the +fake; one apply, empty plan, in-place change and destroy against a real +`llmgw server` run by hand. Left: organizations, teams, projects, people and +keys are not in the admin API (they are `llmgw org`/`users`/`keys` against the +store), so not in the provider; a model's definition beyond scaling, class, +owner and adapters is changed by replacing it (the API has no edit, and +`resources` is not used yet); `canary`, `slo`, `scaling.schedule` and +`prewarm`, `guard` role and `origin` are not in `llmgw_model`; models cannot be +imported; the provider is not published to a registry and has no release +build; the acceptance tests are not run in CI (they need Terraform); the +document's schemas mark nothing required and give no enums; `catalog.SLO`'s +fields have no JSON tags, so they are `TTFTp99` and `TPOTp99` on the wire. diff --git a/notes/c20.md b/notes/c20.md new file mode 100644 index 0000000..0e76094 --- /dev/null +++ b/notes/c20.md @@ -0,0 +1,60 @@ +## CHANGELOG + +### Tracing beyond the router + +- **The control plane traces what it does to the fleet.** A reconcile pass + that does something is a `reconcile` trace — passes with nothing to do are + not traced at all — with what it did beneath it: `scale up` with `place` + (the profile, engine, GPU class, pool, fabric and nodes chosen, or every + candidate turned down and why, as `ctl explain-placement` would print it), + `scale down`, `drain replica` and `stop replica` each with its reason, + `evict`, `preempt` with its victims, `reap replica` with the error and + when the model is tried again, `restart replica`, `restart refused`, + `node unreachable` and `node lost`. +- **A replica's start is one trace, down to the node.** `replica start` + stays open from placement until the replica serves, fails or is stopped, + with each shard's progress as an event and how long it took. The command + to each node is a `send start` span whose context travels in the command + (a new `trace` field on the agent protocol's `ControlMessage`), and the + agent continues the same trace: `start shard`, with `fetch weights` + (local, cached, downloaded, or downloaded by another shard; a `download` + with its bytes and where a resume began, the source's credentials and + query left out), `launch engine` (and `wait for rendezvous` on a worker + of a multi-node replica), and `wait for engine` with how often the engine + was asked and its last answer. "Why did qwen take four minutes to start" + is that one trace. +- **Stops, drains and adoptions on a node** are in the control plane's + trace too: `stop shard` and `stop engine`, saying whether the engine had + to be killed; `drain shard`; and `adopt` when a registering node reports + engines already running, with how many nobody claimed. An engine install + is an `install engine` trace on the node. +- **Model jobs are traces.** `ctl model serve` and `ctl model update` are + `serve model` and `update model`, with a span per step — registry, + mirror, check, catalog, placement, smoke test, replicas — and from the + placement step a link to the replica's own start. +- **The agent has a `tracing` block** in `agent.yaml`, the same as + `server.yaml`'s (`endpoint`, `sample_ratio`; `OTEL_EXPORTER_OTLP_ENDPOINT` + when unset). Point both at one collector. A node follows the control + plane's sampling for every start it is sent. Traces from a node carry its + id as `service.instance.id`, and a control plane's its host name. +- With no endpoint nothing changes: every span is the no-op one, and a + command carries no trace context. + +## BACKLOG + +**Done (branch `features-c2`):** spans for the reconcile pass (begun only +when a pass does something), placement with its answer or rejections, +scale-up/down, drain, stop, eviction, preemption, reaping, restarts and +lost nodes; a `replica start` span from placement to serving whose context +travels to the agent in the start command (`ControlMessage.trace`), where +weights fetch, engine launch, rendezvous and readiness wait continue the +trace; stops, drains, adoption and engine installs; modelserve jobs as a +trace with a span per step linked to the replica's start; an agent +`tracing` block; docs in install.md's "Tracing". Left: the router's span +for a request does not link to the replica it was sent to's start; +heartbeats, fabric scans, logs and agent upgrades are not traced; the +supervisor has no restart span because it never restarts an engine itself +(a restart is the reconciler's `restart replica` and reaches a node as a +stop and a start); canary and promote jobs, which landed on `features-c2` +after this was branched, are traced only through the shared step recording +(as ` model`), unchecked by a test. diff --git a/notes/c21.md b/notes/c21.md new file mode 100644 index 0000000..baa30a3 --- /dev/null +++ b/notes/c21.md @@ -0,0 +1,62 @@ +## CHANGELOG + +### Quality suites, and new weights held to one + +- **`llmgw ctl eval MODEL --suite FILE|NAME`** asks a model a quality suite + and scores its answers: per case, then the cases weighed into one score, 0 + to 1, against the suite's `threshold` (or `--min-score`); it exits non-zero + below it. `--concurrency`, `--judge`, `--detach`, `--json`. + - **Suites** are YAML or JSON lines: a prompt or a conversation, a weight, + and one way to score it — `exact`, `contains`, `regex`, `number` within + a `tolerance` (the last number in the answer), `json_schema` (the answer + read as JSON, in a code fence or a sentence or not, held to a schema), or + `judge`: a rubric another model on the cluster grades 0 to 1. A + reasoning model's `` is not part of its answer. + - **Run on the control plane**, through the router as its own requests, + as a replay is: past admission, quotas, accounting and the guardrails. + `--suite` is a file sent whole, or a suite's name in `control.eval_dir` + (`/etc/llmgw/eval`) or built in: `arithmetic` and `json-format`, in + `deploy/eval/`, sized for small models. `--suites` lists them. + - **Kept** in the store (migration 0028): every run with its model, the + repository and revision of the weights that answered, the suite's name + and hash, its score and each case's answer and reason. + `llmgw ctl eval --history [MODEL]` lists them, `--run ID` shows one. +- **Updates and promotions held to a suite.** `llmgw ctl model update` and + `model promote` take `--eval SUITE --min-score N`, or a model carries one + (`llmgw ctl model gate MODEL --eval SUITE`, `eval:` in its definition). + A promotion asks the canary first and leaves it in place below the score; + an update runs the new weights first as a canary given none of the + model's requests, asks it, and promotes it or takes it out again, so the + old weights serve until the new have passed. The job's `eval` step says + the score; `--skip-eval` moves once without the model's suite. +- **The console** has **Ask it a quality suite…** (the score counted in as it + comes), **Its quality runs** and **Hold its updates to a quality suite…** + on a model's menu. +- **Fixed:** a change to the admin API with a body over 64 KiB reached its + handler cut off at 64 KiB, by the audit log's reading of it. + +## BACKLOG + +**Done (branch `features-c2`):** `internal/bench/eval` (suites in YAML or +JSON lines; exact, contains, regex, number, a JSON Schema subset, and a judge +model with a rubric; weights and a threshold), run on the control plane +through `Router.Replay`; `llmgw ctl eval MODEL --suite FILE|NAME`, `--history`, +`--run`, `--suites`; runs and their cases kept (migration 0028, `eval_runs` +and `eval_cases`); `model update`/`promote --eval SUITE --min-score N`, a +model's standing gate (`ctl model gate`, `eval:` in the catalog) — a promote +asks the canary first, an update tries the new weights as a 0% canary and +promotes or removes it; the console's model menu; `arithmetic` and +`json-format` in `deploy/eval/`, carried in the binary. Left: the suite of a +model's standing gate is read by name on the control plane at the time of +the update, so two control planes need the same `control.eval_dir`; a gated +update needs room for one more replica while it runs (there is no in-place +gate, and a declared model in server.yaml cannot be updated at all); no +comparison against the old weights' score (the gate is an absolute +threshold, not "no worse than before"); a run is not stopped once started +(Esc in the console only stops watching it), and the in-memory progress of a +run lives on the control plane that started it; runs are never pruned; the +JSON Schema support is a subset (no `$ref`, `oneOf`, `not`, `uniqueItems`); +judge cases go through the same concurrency as the model's, and a judge's +answers are not kept, only its score and reason; a gateway without a +control plane cannot run suites (there is no mode that asks through the +client API with a key). diff --git a/notes/c22.md b/notes/c22.md new file mode 100644 index 0000000..0907520 --- /dev/null +++ b/notes/c22.md @@ -0,0 +1,54 @@ +# C22: MIG + +## CHANGELOG + +### MIG instances as GPUs of their own class + +- **A card in MIG mode is placed as its instances.** Discovery reports a card + in MIG mode as its MIG instances instead of the whole card, each a device of + its own with its UUID (`MIG-…`), its own memory, and a class named from the + card's and the profile: two `3g.40gb` instances of an A100 80GB are two + devices of class `a100-80g-mig-3g.40gb`. A profile names that class like + any other, and placement puts a replica on a free instance of it. Both + discovery paths do it: nvidia-smi (instances from `nvidia-smi -L`, their + memory and ids from `nvidia-smi -q -x`) and NVML (`GetMigMode`, + `GetMaxMigDeviceCount`, `GetMigDeviceHandleByIndex`). llmgw does not create + instances; `docs/install.md` has a MIG section on making them. +- **One instance a replica.** A process opens only one MIG device, and + instances have no NVLink or peer-to-peer between them, so a profile on a + MIG class asking for more than one GPU, tp or pp above 1, or more than one + node is refused when the model is added, with that reason; placement + refuses it again. `ctl model plan` and `ctl model serve` plan an instance + for a model one holds and never several together. +- **Engines are given the instance by UUID.** Placement numbers card 0's + instance 2 as device 1002 (1000 + 100 × card + instance); the agent turns + that into `CUDA_VISIBLE_DEVICES=MIG-` for vLLM and llama.cpp, and + leaves `GGML_VK_VISIBLE_DEVICES` unset (Vulkan cannot see MIG). Every view + shows the device as `0/mig2`. +- **Memory per instance, no made-up utilisation.** Each instance's memory, + and the processes holding it, are its own — read per GPU and compute + instance from nvidia-smi's report, so a foreign process on one instance + keeps that instance out of placement and not its neighbours. Temperature, + power and wear are the card's. No driver reports utilisation per instance, + so none is published for one: the console says `n/a on MIG`, and + `llmgw_gpu_utilization_percent` has no series for it rather than a zero. +- **A card in MIG mode with nothing carved is kept out of placement**, with + the reason in `ctl nodes`, since CUDA will not open the whole card while MIG + is on. +- **`llmgw doctor` reports MIG**: each card's mode and instances, and warns + of a card in MIG mode with no instances, of GPU instances with no compute + instance, and of a mode change waiting for a reset, with the `nvidia-smi` + command for each. +- `ctl nodes` counts a node's devices by class when it has MIG instances or + more than one class (`a100-80g-mig-3g.40gb×2,a100-80g×1`); the console's + GPU list shows instances under their card with their own memory and the + card's heat and power, and a node's page lists its classes with counts. + `ctl status` and the plan count each device under its own class, where a + node's cards were all put under its first class before. +- The agent protocol's `GPU` gains `mig` (parent card, its memory, profile, + GPU and compute instance ids); the node API's GPUs gain `label`, `class`, + `card`, `mig_profile` and memory, and nodes gain `gpus_by_class`. + +## BACKLOG + +**Done (branch `features-c2`):** MIG instances discovered on both paths (nvidia-smi `-L` + `-q -x`; NVML through an interface with a faithful fake) and reported instead of their card, each with its MIG UUID, own memory and class `-mig-`; device numbers 1000 + 100 × card + instance, shown as `card/migN`; placement on MIG classes; tp/pp/gpus/nodes above 1 on a MIG class refused in catalog validation and again in placement; `CUDA_VISIBLE_DEVICES=MIG-` for vLLM and llama.cpp (no Vulkan device variable on MIG); per-instance memory and processes in heartbeats and the GPU check, card sensors under each instance, utilisation absent rather than zero; doctor MIG findings (`mig -lgi`/`-lci` parsed); `ctl nodes`, console, `ctl status`, `ctl model plan`/`serve` by class; docs/install.md MIG section. Written and tested against fakes only — untested on real MIG hardware. Left: run it on an A100/H100 in MIG mode (check `-L`/`-q -x`/`mig -lgi`/`-lci` and NVML MIG device names against the parsers, and that `CUDA_VISIBLE_DEVICES=MIG-…` reaches vLLM's workers through Ray); instances changed while the agent runs are only seen when it restarts (no rescan); llmgw does not create or reshape instances (no mig-parted integration); quotas count an instance as one GPU, the same as a whole card; SGLang on MIG not checked; device numbers assume fewer than 100 instances per card and that instance numbers stay put while shards run on them. diff --git a/notes/c23.md b/notes/c23.md new file mode 100644 index 0000000..01db2bd --- /dev/null +++ b/notes/c23.md @@ -0,0 +1,61 @@ +## CHANGELOG + +### Engines and the mirror + +- **TensorRT-LLM as an engine** (`engine: trtllm`), on nodes where you + installed it: name its interpreter in the node's `agent.yaml` + (`engines.trtllm.python`) and the node offers it while that interpreter + has the `tensorrt_llm` package. llmgw does not install it. The agent starts + `trtllm-serve serve` with the replica's host, port, `--tp_size`, + `--pp_size` and served name, through a few lines of its own that make it + demand the gateway's key on every path but `/health` (trtllm-serve has no + key option), turn on the Prometheus endpoint and iteration statistics, and + keep usage reports to NVIDIA off. The router reads it at + `/prometheus/metrics` — `trtllm_num_requests_running`, + `trtllm_num_requests_waiting`, `trtllm_kv_cache_utilization`, tokens and + successes. Needs TensorRT-LLM 1.3 (rc13 or later) and an Ampere or newer + GPU; one node per replica. Profiles may not give it an options file, + `--trust_remote_code`, `--grpc`, `--num_serve_frontends` or the flags the + agent sets; `--set PATH=VALUE` changes a setting instead. `llmgw ctl + engine-check trtllm --bin ` checks an installed one. Written + against TensorRT-LLM's documented command line, server and metrics and + tested with stand-ins for them; not yet run on a datacenter GPU. +- **`llmgw mirror import-ollama [NAME[:TAG]]`** puts a model you already + have in Ollama into the mirror for llama.cpp: its GGUF, and a vision + model's projector beside it, each checked against the sha256 Ollama named + its file by. With no name it lists Ollama's models; it finds Ollama's + directory itself (`OLLAMA_MODELS`, `~/.ollama/models`, Ollama's Linux + service) or takes `--ollama-dir`. The version is the tag and the start of + the digest, since a tag moves in Ollama. It prints a model definition to + add, with Ollama's `num_ctx` as `--ctx-size`. The console's **Mirror** + view does the same with **I**, for the mirror beside the control plane. +- **`llmgw mirror import FILE... --model NAME`** copies files already on the + mirror machine into it the way a download is kept: whole or not at all, the + checksum beside it, a version never replaced, a `.gguf` checked to be one, + and `--sha256` checked when given. + +## BACKLOG + +**Done (branch `features-c2`):** engine `trtllm` (`internal/agent/engines/trtllm.go`): +`trtllm-serve` through a launcher of llmgw's that demands the replica's key +(trtllm-serve has none) and writes the options file that turns on +`/prometheus/metrics` and the iteration statistics; the scrape contract for +it (`scrape.TRTLLMMapping`, with a per-engine metrics path); found, not +installed — `engines.trtllm.python` in `agent.yaml`, probed from the +package's metadata; catalog validation (GPU class, one node, no GGUF source, +the flags a profile may not give); `ctl engine-check trtllm`, which asks +`trtllm-serve serve --help` through its module. Ollama/GGUF import: `mirror +import-ollama` (manifest parsing, blob digest check, projector beside the +model, all or nothing, a model definition printed), `mirror import` for any +local file, and the console's **I** on the Mirror view. Left: TensorRT-LLM has +not run on real hardware — the first node with one should run `engine-check` +and serve a model before anyone relies on it; no installer for it; no +multi-node TensorRT-LLM replica (it needs an MPI or Slurm launch from one +node); Triton Inference Server as an engine is not done; trtllm's queue gauges +are set per iteration, so an idle engine shows its last batch until the next +request; `--num_serve_frontends` is refused because extra front ends would not +demand the key. The Ollama import runs on the mirror machine only — the +mirror's API takes no files, so an Ollama elsewhere means copying the blob or +running the import there; Ollama models kept as separate tensors or as several +weight layers, and its LoRA adapters, are refused or left with a sentence; +files an import run with `sudo` writes are root's, as `mirror add`'s are. diff --git a/notes/c24-docs.md b/notes/c24-docs.md new file mode 100644 index 0000000..8026661 --- /dev/null +++ b/notes/c24-docs.md @@ -0,0 +1,30 @@ +# C24: the SDK/docs portal + +## CHANGELOG + +### Documentation + +- **A docs portal at /docs.** The manual the binary carries — the pages + `llmgw docs` prints and the console's Manual shows, the configuration + reference among them — rendered on the client port in the portal's own + look: a sidebar of the documents in reading order, the open one's sections + under it, a link on every heading, and links between documents kept inside + the portal (links to files that are not in the binary, such as `deploy/`, + go to the repository). No script on the page, so the Content-Security-Policy + has nothing to allow. Public by default, like the landing page; set + `landing.docs_sign_in: true` to send visitors to sign in first. Signed in, + the page carries the usual menu, which now has a **Docs** link. +- **A client developer's guide, `docs/clients.md`.** How to use llmgw from + the official OpenAI and Anthropic SDKs — there is no llmgw SDK, and none is + needed — in Python, TypeScript, Go and curl: the base URL and key, + streaming, tools, embeddings, the Responses API, batches, the Anthropic SDK + against `/v1/messages`, and what llmgw adds as headers (`x-llmgw-thread`, + `x-session-id`, metadata and `x-llmgw-tags`, `x-llmgw-variant`, + `/v1/memory`, `/mcp`), with the statuses a client meets. The portal's index + leads with it, and the landing page and the account page link to it. +- The console's Manual and the portal read one list of documents in one + order (`llmgw.Manual`), so the two cannot drift. + +## BACKLOG + +- [x] 24 (SDK/docs portal): built — /docs renders the embedded manual (goldmark, GFM, heading anchors, links rewritten, no script; public, `landing.docs_sign_in` to require a session), and docs/clients.md shows the official OpenAI/Anthropic SDKs against llmgw. Left: the console's generated "Commands" page is not in the portal (it is built from the cobra tree, which internal/web cannot see); no search across documents; the clients.md sections on /v1/messages, batches, tags and x-llmgw-variant were written against the features-c2 commit messages and must be checked against that code when merging (exact `x-llmgw-tags` format, `canary` as the variant value, Anthropic-shaped errors on /v1/messages). diff --git a/notes/c24-i18n.md b/notes/c24-i18n.md index 3d37542..7902bba 100644 --- a/notes/c24-i18n.md +++ b/notes/c24-i18n.md @@ -5,7 +5,8 @@ - **The web portal speaks English and Turkish** — the landing page, sign-up and sign-in, the account page and its editor setup, the claim page, chat, the about page, and every administrators' page (cluster, nodes, NVLink and - fabrics, people, limits, usage, organizations, mirror, guardrails). Flash + fabrics, people, limits, usage, organizations, mirror, guardrails), and + the docs portal around its documents, which stay English. Flash messages and form errors built by the server are translated too. - **A switcher at the foot of every page** (English · Türkçe) remembers the choice in a cookie; without one the browser's Accept-Language decides, and @@ -26,4 +27,4 @@ ## BACKLOG -24 (portal i18n): built — English/Turkish catalogue with fallback, per-language templates, cookie/Accept-Language/`landing.language` choice and a footer switcher, Turkish numbers and dates, every template and handler message translated, tests that no template text stays English; left — errors built with fmt.Errorf in other packages (store, catalog, mirror, adminapi) still English on Turkish pages, the embedded Latin font subsets lack ğ/ş/İ (latin-ext subsets needed), percent sign placement is mixed (%50 on limits, 93% on cluster pages), RFC3339 footer timestamps and plain-text http.Error bodies untranslated, a per-person language column (cookie only today). +24 (portal i18n): built — English/Turkish catalogue with fallback, per-language templates, cookie/Accept-Language/`landing.language` choice and a footer switcher, Turkish numbers and dates, every template and handler message translated, tests that no template text stays English; left — errors built with fmt.Errorf in other packages (store, catalog, mirror, adminapi) still English on Turkish pages, the embedded Latin font subsets lack ğ/ş/İ (latin-ext subsets needed), percent sign placement is mixed (%50 on limits, 93% on cluster pages), RFC3339 footer timestamps and plain-text http.Error bodies untranslated, the /docs documents themselves English, a per-person language column (cookie only today). diff --git a/terraform-provider-llmgw/README.md b/terraform-provider-llmgw/README.md new file mode 100644 index 0000000..a46ca7e --- /dev/null +++ b/terraform-provider-llmgw/README.md @@ -0,0 +1,135 @@ +# terraform-provider-llmgw + +A Terraform (and OpenTofu) provider for llmgw: the models in a cluster's +catalog, capacity quotas on its teams and projects, and where its guardrail +policies apply, kept in configuration and reviewed like the rest of it. + +It talks to the control plane's admin API with the admin token, the same API +`llmgw ctl` uses; see [docs/admin-api.md](../docs/admin-api.md) for how that +API is reached and what it asks of a request. + +## What it manages + +| | | +|---|---| +| `llmgw_model` | A model added to the catalog at runtime, as `llmgw ctl model add` adds one. Scaling, class, owner and adapters change in place; any other change to the definition replaces the model, which stops its replicas while it is added again. | +| `llmgw_quota` | A capacity quota on an organization, team or project: a guarantee, a limit, or both, in GPUs, CPU cores and memory. | +| `llmgw_guard_attachment` | A guardrail policy attached to an organization, team, project, key or person. The policies are defined in `server.yaml`'s `guard:` section; this says where they apply. | +| `data.llmgw_models` | Every model in the catalog, with its replicas now. | +| `data.llmgw_nodes` | Every node, with its cards and state now. | + +Not here, because the admin API does not serve them: organizations, teams, +projects, people and API keys. Those live in the store and are managed with +`llmgw org`, `llmgw users` and `llmgw keys` on the control plane. A quota or +an attachment names a team that already exists, by its path or its ID. + +Models declared in the control plane's `server.yaml` are not managed here +either: the cluster refuses to remove or change them through the API, since +the configuration file would put them back on the next restart. + +## Configuring it + +```hcl +terraform { + required_providers { + llmgw = { source = "gitea.mynodes.xyz/ilker/llmgw" } + } +} + +provider "llmgw" { + endpoint = "http://127.0.0.1:9090" # or LLMGW_ADMIN_URL + # token = "..." # or LLMGW_ADMIN_TOKEN + token_file = "/var/lib/llmgw/admin-token" # read when no token is given + actor = "terraform" # who the audit log says made the change +} +``` + +All four are optional and default to what is shown. The admin listener is on +loopback, so run Terraform on the control plane — as a user that can read the +token file, which is root's — or from elsewhere through a tunnel: + +```sh +ssh -N -L 9090:127.0.0.1:9090 admin@control-plane & +export LLMGW_ADMIN_TOKEN=$(ssh admin@control-plane sudo cat /var/lib/llmgw/admin-token) +terraform apply +``` + +The endpoint must be an address, not a name: the admin listener refuses a +`Host` that is a DNS name. + +[examples/main.tf](examples/main.tf) has a model on CPUs, one on GPUs, a team's +quota, a policy attachment and the two data sources. + +## Installing it + +It is not in a public registry. Build it and point Terraform at it, either as +a development override: + +```sh +cd terraform-provider-llmgw +go build -o ~/.terraform.d/llmgw/ . +cat >> ~/.terraformrc <<'EOF' +provider_installation { + dev_overrides { + "gitea.mynodes.xyz/ilker/llmgw" = "/home/you/.terraform.d/llmgw" + } + direct {} +} +EOF +``` + +or in a filesystem mirror, which `terraform init` installs from like a +registry and records in the lock file: + +```sh +v=0.1.0 +d=~/.terraform.d/plugins/gitea.mynodes.xyz/ilker/llmgw/$v/$(go env GOOS)_$(go env GOARCH) +mkdir -p $d +go build -ldflags "-X main.version=$v" -o $d/terraform-provider-llmgw_v$v . +``` + +with `version = "0.1.0"` beside `source` in `required_providers`. + +## How the state behaves + +- **A model's definition is kept as written.** The catalog fills in what a + definition leaves out — `tp = 1`, `engine = "vllm"`, a 20-minute start-up + timeout — and the state does not take those values, so a plan does not show + them as differences. What can change in place is read back from the + cluster: a model scaled or reclassified from a terminal shows as a change to + put right on the next plan, and one removed from a terminal is added again. +- **`scaling.max` left out follows `min`**, as in the configuration file, and + is read back. Leaving the whole `scaling` block out means no replicas: the + model is in the catalog and does not run. +- **Durations are written as Go writes them**, `"30m"` or `"1h30m"`. +- **An owner or a quota's scope is kept as written** (`team:Acme/ml`) beside + the ID the cluster resolved it to (`owner_id`, and the quota's `id`), and is + found again by the ID, so renaming the team in the store does not lose it. +- **Import** works for quotas, by `team:ID` as `GET /api/v1/quotas` lists it + (the `scope` is then that ID; write it so in the configuration, or the next + plan replaces the quota with itself), and for attachments, by + `scope:SCOPE-ID:policy` as their `id` shows. Models cannot be imported yet. + +## Developing it + +A module of its own, so the gateway's binary does not carry Terraform's +dependencies; `go test ./...` at the repository's root does not include it. + +```sh +cd terraform-provider-llmgw +go vet ./... && go test ./... # the client, against an admin API in memory and the OpenAPI document +TF_ACC=1 go test ./internal/provider/ # Terraform itself, plan and apply, against the same fake +``` + +- `internal/client` is the HTTP client. Its `Endpoints` lists every endpoint it + calls and it refuses any other; a test checks each of them, and every field + of every body it sends and reads, against the gateway's generated + [OpenAPI document](../docs/admin-api.openapi.json). A change to the admin API + that breaks the provider fails here. +- `internal/fakeapi` is an admin API in memory: the token, the content type, + the catalog's defaults and the tenancy tree's names, enough to run the + provider without a cluster. +- The acceptance tests need `TF_ACC=1` and a `terraform` on `PATH` (or + `TF_ACC_TERRAFORM_PATH`); without one, terraform-plugin-testing downloads the + latest. They pass with OpenTofu too: + `TF_ACC_TERRAFORM_PATH=$(which tofu) TF_ACC_PROVIDER_HOST=registry.opentofu.org`. diff --git a/terraform-provider-llmgw/examples/main.tf b/terraform-provider-llmgw/examples/main.tf new file mode 100644 index 0000000..4132aae --- /dev/null +++ b/terraform-provider-llmgw/examples/main.tf @@ -0,0 +1,94 @@ +# A model, a team's quota and a guardrail policy, managed through the control +# plane's admin API. Run where the admin listener is reachable: on the control +# plane itself, or through `ssh -N -L 9090:127.0.0.1:9090 control-plane`. + +terraform { + required_providers { + llmgw = { + source = "gitea.mynodes.xyz/ilker/llmgw" + } + } +} + +provider "llmgw" { + # The defaults, written out. The token comes from LLMGW_ADMIN_TOKEN when it + # is set, and otherwise from token_file, which only root can read. + endpoint = "http://127.0.0.1:9090" + token_file = "/var/lib/llmgw/admin-token" + actor = "terraform" +} + +# A small model on CPUs, loaded when asked for and stopped after half an hour +# unused. +resource "llmgw_model" "qwen_small" { + name = "qwen-small" + description = "Qwen 2.5 1.5B instruct, for quick questions" + source = "/srv/models/qwen2.5-1.5b-instruct-q4_k_m.gguf" + aliases = ["qwen"] + + profiles = [{ + name = "cpu" + engine = "llamacpp" + min_memory_gb = 4 + cpu_cores = 4 + }] + + scaling = { + min = 0 + max = 2 + idle_timeout = "30m" + } + + class = "opportunistic" + owner = "team:Acme/ml" +} + +# A larger one on GPUs, always running. +resource "llmgw_model" "llama_70b" { + name = "llama-3.3-70b" + source = "meta-llama/Llama-3.3-70B-Instruct" + + profiles = [{ + name = "h100" + engine = "vllm" + gpu_class = "h100-80g" + tp = 4 + }] + + scaling = { + min = 1 + max = 3 + } + + capabilities = { + tools = true + } + fallbacks = [llmgw_model.qwen_small.name] +} + +# The ml team may hold at most eight GPUs, and is always given two. +resource "llmgw_quota" "ml" { + scope = "team:Acme/ml" + guarantee = { gpus = 2 } + limit = { gpus = 8 } +} + +# The finance policy, from server.yaml's guard.policies, on the ml team. +resource "llmgw_guard_attachment" "ml_finance" { + target = "team:Acme/ml" + policy = "finance" +} + +data "llmgw_nodes" "all" {} + +data "llmgw_models" "all" { + depends_on = [llmgw_model.qwen_small, llmgw_model.llama_70b] +} + +output "free_gpus" { + value = sum([for n in data.llmgw_nodes.all.nodes : n.gpu_free if n.state == "ready"]) +} + +output "models_not_ready" { + value = [for m in data.llmgw_models.all.models : m.name if m.ready_replicas < m.min_replicas] +} diff --git a/terraform-provider-llmgw/go.mod b/terraform-provider-llmgw/go.mod new file mode 100644 index 0000000..1f7f58a --- /dev/null +++ b/terraform-provider-llmgw/go.mod @@ -0,0 +1,62 @@ +module gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw + +go 1.26.6 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-go v0.31.0 + github.com/hashicorp/terraform-plugin-testing v1.16.0 +) + +require ( + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/hc-install v0.9.4 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.25.1 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.2.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.18.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/terraform-provider-llmgw/go.sum b/terraform-provider-llmgw/go.sum new file mode 100644 index 0000000..076fcd0 --- /dev/null +++ b/terraform-provider-llmgw/go.sum @@ -0,0 +1,242 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc= +github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.25.1 h1:PRutYRGM8pixV3B8812NYoBK5O+yuf3qcB/70KFKGiU= +github.com/hashicorp/terraform-exec v0.25.1/go.mod h1:+izOYrs9sKMQK4OYvGDnrSSJHY/pm4e4eXFqSL2Q5mA= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-plugin-testing v1.16.0 h1:GB97nGnJ1hESpDrCjqZig38RodSF0gdRzxlDupLXP38= +github.com/hashicorp/terraform-plugin-testing v1.16.0/go.mod h1:eQPYAy9xFMV7xtIFX8Y+wJGtUB++HBl329zCF6PBMZk= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= +github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= +github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform-provider-llmgw/internal/client/client.go b/terraform-provider-llmgw/internal/client/client.go new file mode 100644 index 0000000..2d3ff0e --- /dev/null +++ b/terraform-provider-llmgw/internal/client/client.go @@ -0,0 +1,438 @@ +// 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", +} + +// 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) +} diff --git a/terraform-provider-llmgw/internal/client/client_test.go b/terraform-provider-llmgw/internal/client/client_test.go new file mode 100644 index 0000000..005a349 --- /dev/null +++ b/terraform-provider-llmgw/internal/client/client_test.go @@ -0,0 +1,174 @@ +package client_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/fakeapi" +) + +func serve(t *testing.T) (*fakeapi.API, *client.Client) { + t.Helper() + api := fakeapi.New("secret", client.NodeView{ID: "gpu01", State: "ready", GPUTotal: 8, GPUFree: 6}) + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + c, err := client.New(srv.URL, "secret", "terraform") + if err != nil { + t.Fatal(err) + } + return api, c +} + +func TestAModelIsAddedReadScaledAndRemoved(t *testing.T) { + api, c := serve(t) + ctx := context.Background() + err := c.AddModel(ctx, client.Model{Name: "qwen", Source: "/m/qwen.gguf", + Profiles: []client.Profile{{Name: "cpu", Engine: "llamacpp", MinMemoryGB: 4}}, + Scaling: client.Scaling{Min: 1}}) + if err != nil { + t.Fatal(err) + } + m, err := c.Model(ctx, "qwen") + if err != nil { + t.Fatal(err) + } + // As the catalog filled it in. + if m.Scaling.Max != 1 || m.Profiles[0].TP != 1 || m.Profiles[0].StartupTimeout != 20*time.Minute { + t.Errorf("read back %+v", m) + } + if err := c.Scale(ctx, "qwen", client.ScaleRequest{Min: 0, Max: 2, IdleTimeout: 30 * time.Minute}); err != nil { + t.Fatal(err) + } + if m, _ := api.Model("qwen"); m.Scaling != (client.Scaling{Min: 0, Max: 2, IdleTimeout: 30 * time.Minute}) { + t.Errorf("scaled to %+v", m.Scaling) + } + if err := c.RemoveModel(ctx, "qwen"); err != nil { + t.Fatal(err) + } + if _, err := c.Model(ctx, "qwen"); !client.IsNotFound(err) { + t.Errorf("a removed model read as %v", err) + } +} + +func TestEveryChangeSaysItIsJSONAndWhoMadeIt(t *testing.T) { + // The admin listener refuses a change without application/json, a DELETE + // with no body included; and the audit log names whoever the change says + // it is from. + api, c := serve(t) + ctx := context.Background() + if err := c.AddModel(ctx, client.Model{Name: "m", Source: "/m"}); err != nil { + t.Fatal(err) + } + if err := c.RemoveModel(ctx, "m"); err != nil { + t.Fatalf("a DELETE with no body: %v", err) + } + if got := api.Actors(); !slices.Equal(got, []string{"terraform", "terraform"}) { + t.Errorf("actors %v", got) + } +} + +func TestARefusalCarriesWhatTheAPISaid(t *testing.T) { + _, c := serve(t) + ctx := context.Background() + _, err := c.SetQuota(ctx, client.QuotaRequest{Scope: "team:Nobody/x", Limit: client.Resources{GPUs: ptr(1)}}) + var e *client.Error + if err == nil || !strings.Contains(err.Error(), `there is no team "Nobody/x"`) { + t.Fatalf("got %v", err) + } + if !errors.As(err, &e) || e.Status != http.StatusBadRequest { + t.Errorf("not a 400: %#v", err) + } +} + +func TestAWrongTokenIsRefused(t *testing.T) { + api := fakeapi.New("secret") + srv := httptest.NewServer(api) + defer srv.Close() + c, err := client.New(srv.URL, "wrong", "") + if err != nil { + t.Fatal(err) + } + var e *client.Error + if _, err := c.Models(context.Background()); !errors.As(err, &e) || e.Status != http.StatusUnauthorized { + t.Errorf("got %v", err) + } +} + +func TestANameWithASlashIsOneSegment(t *testing.T) { + // A quota's scope is named by path, Acme/ml, and must not become two + // segments of the URL on its way to DELETE /api/v1/quotas/{scope}. + api, c := serve(t) + ctx := context.Background() + id, err := c.SetQuota(ctx, client.QuotaRequest{Scope: "team:Acme/ml", Guarantee: client.Resources{GPUs: ptr(2)}}) + if err != nil || id != "team:t1" { + t.Fatalf("set %q, %v", id, err) + } + if err := c.ClearQuota(ctx, "team:Acme/ml"); err != nil { + t.Fatal(err) + } + if _, ok := api.Quota("team:t1"); ok { + t.Error("the quota is still there") + } +} + +func TestAPolicyIsAttachedByPathAndDetachedByID(t *testing.T) { + api, c := serve(t) + ctx := context.Background() + a, err := c.Attach(ctx, "team:Acme/ml", "finance") + if err != nil { + t.Fatal(err) + } + if a != (client.Attachment{Scope: "team", ScopeID: "t1", Policy: "finance"}) { + t.Errorf("attached %+v", a) + } + g, err := c.Guard(ctx) + if err != nil || len(g.Attachments) != 1 || g.Attachments[0] != a { + t.Fatalf("guard %+v, %v", g, err) + } + if err := c.Detach(ctx, "team:t1", "finance"); err != nil { + t.Fatal(err) + } + if len(api.Attachments()) != 0 { + t.Error("still attached") + } + if err := c.Detach(ctx, "team:t1", "finance"); !client.IsNotFound(err) { + t.Errorf("detaching twice: %v", err) + } +} + +func TestTheNodesAreListed(t *testing.T) { + _, c := serve(t) + ns, err := c.Nodes(context.Background()) + if err != nil || len(ns) != 1 || ns[0].ID != "gpu01" || ns[0].GPUFree != 6 { + t.Errorf("nodes %+v, %v", ns, err) + } +} + +func TestAnEndpointNotListedIsNotCalled(t *testing.T) { + // Endpoints is what the contract test checks against the API's document; + // a call outside it would be one nothing checks. + if slices.Contains(client.Endpoints, "POST /api/v1/restart") { + t.Skip("restart is listed now") + } + if !strings.Contains(client.CallUnlisted(), "add it to Endpoints") { + t.Error("an unlisted endpoint was called") + } +} + +func TestABadEndpointOrNoTokenIsRefusedUpFront(t *testing.T) { + if _, err := client.New("127.0.0.1:9090", "t", ""); err == nil { + t.Error("an address without a scheme was taken") + } + if _, err := client.New("http://127.0.0.1:9090", "", ""); err == nil { + t.Error("no token was taken") + } +} + +func ptr(n int64) *int64 { return &n } diff --git a/terraform-provider-llmgw/internal/client/contract_test.go b/terraform-provider-llmgw/internal/client/contract_test.go new file mode 100644 index 0000000..3d61f5c --- /dev/null +++ b/terraform-provider-llmgw/internal/client/contract_test.go @@ -0,0 +1,201 @@ +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +// The gateway's OpenAPI document is generated from its route table and the +// Go types its handlers use, and a test there keeps it current. Checking +// this client against it is what keeps the two modules in step without the +// provider importing the gateway. + +type document struct { + Paths map[string]map[string]operation `json:"paths"` + Components struct { + Schemas map[string]schemaDoc `json:"schemas"` + } `json:"components"` +} + +type operation struct { + RequestBody *struct { + Content map[string]struct { + Schema schemaDoc `json:"schema"` + } `json:"content"` + } `json:"requestBody"` + Responses map[string]struct { + Content map[string]struct { + Schema schemaDoc `json:"schema"` + } `json:"content"` + } `json:"responses"` +} + +type schemaDoc struct { + Ref string `json:"$ref"` + Type string `json:"type"` + Items *schemaDoc `json:"items"` + Properties map[string]schemaDoc `json:"properties"` +} + +func (s schemaDoc) name() string { return strings.TrimPrefix(s.Ref, "#/components/schemas/") } + +func load(t *testing.T) document { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate this test") + } + p := filepath.Join(filepath.Dir(file), "..", "..", "..", "docs", "admin-api.openapi.json") + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("the gateway's OpenAPI document: %v", err) + } + var d document + if err := json.Unmarshal(b, &d); err != nil { + t.Fatal(err) + } + return d +} + +func (d document) op(t *testing.T, endpoint string) (operation, bool) { + method, p, _ := strings.Cut(endpoint, " ") + op, ok := d.Paths[p][strings.ToLower(method)] + return op, ok +} + +func TestEveryEndpointTheClientCallsIsInTheDocument(t *testing.T) { + d := load(t) + for _, e := range Endpoints { + if _, ok := d.op(t, e); !ok { + t.Errorf("the client calls %s, which the admin API does not serve", e) + } + } +} + +// bodies is each endpoint's request and answer: the client's type and the +// schema the document names for it. Empty is none, or one the client +// ignores. +var bodies = []struct { + endpoint string + request any + requestSchema string + answer any + answerSchema string +}{ + {"GET /api/v1/models", nil, "", ModelView{}, "ModelView"}, + {"POST /api/v1/models", Model{}, "catalog.Model", nil, ""}, + {"GET /api/v1/models/{name}", nil, "", Model{}, "catalog.Model"}, + {"POST /api/v1/models/{name}/scale", ScaleRequest{}, "ScaleRequest", nil, ""}, + {"POST /api/v1/models/{name}/class", ClassRequest{}, "ClassRequest", nil, ""}, + {"POST /api/v1/models/{name}/owner", OwnerRequest{}, "OwnerRequest", OwnerView{}, "OwnerView"}, + {"POST /api/v1/models/{name}/adapters", AdaptersRequest{}, "AdaptersRequest", nil, ""}, + {"GET /api/v1/nodes", nil, "", NodeView{}, "NodeView"}, + {"GET /api/v1/quotas", nil, "", QuotaView{}, "QuotaView"}, + {"PUT /api/v1/quotas", QuotaRequest{}, "QuotaRequest", QuotaSet{}, "QuotaSet"}, + {"GET /api/v1/guard", nil, "", GuardView{}, "GuardView"}, + {"POST /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", Attachment{}, "guard.Attachment"}, + {"DELETE /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", nil, ""}, +} + +// nested are the client's types inside those bodies, and their schemas. +var nested = map[reflect.Type]string{ + reflect.TypeOf(Profile{}): "catalog.Profile", + reflect.TypeOf(Scaling{}): "catalog.Scaling", + reflect.TypeOf(External{}): "catalog.External", + reflect.TypeOf(Adapter{}): "catalog.Adapter", + reflect.TypeOf(Capabilities{}): "catalog.Capabilities", + reflect.TypeOf(Resources{}): "store.Resources", + reflect.TypeOf(Attachment{}): "GuardAttachmentView", +} + +func TestEveryBodyTheClientSendsAndReadsMatchesTheDocument(t *testing.T) { + d := load(t) + for _, b := range bodies { + op, ok := d.op(t, b.endpoint) + if !ok { + t.Errorf("%s is not in the document", b.endpoint) + continue + } + if b.request != nil { + if op.RequestBody == nil { + t.Errorf("%s takes no body in the document", b.endpoint) + } else if got := op.RequestBody.Content["application/json"].Schema.name(); got != b.requestSchema { + t.Errorf("%s takes %s, not %s", b.endpoint, got, b.requestSchema) + } + checkType(t, d, reflect.TypeOf(b.request), b.requestSchema) + } + if b.answer != nil { + var s schemaDoc + for code, r := range op.Responses { + if strings.HasPrefix(code, "2") { + s = r.Content["application/json"].Schema + } + } + if s.Type == "array" && s.Items != nil { + s = *s.Items + } + if s.name() != b.answerSchema { + t.Errorf("%s answers with %s, not %s", b.endpoint, s.name(), b.answerSchema) + } + checkType(t, d, reflect.TypeOf(b.answer), b.answerSchema) + } + } + for typ, name := range nested { + checkType(t, d, typ, name) + } +} + +// checkType fails for each of a struct's JSON keys the schema does not have, +// or has as another kind of value. +func checkType(t *testing.T, d document, typ reflect.Type, schema string) { + t.Helper() + s, ok := d.Components.Schemas[schema] + if !ok { + t.Errorf("no schema %s in the document", schema) + return + } + for i := range typ.NumField() { + f := typ.Field(i) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" { + name = f.Name + } + p, ok := s.Properties[name] + if !ok { + t.Errorf("%s.%s is %q, which %s does not have", typ.Name(), f.Name, name, schema) + continue + } + if want := kindOf(f.Type); want != "" && p.Type != "" && p.Type != want { + t.Errorf("%s.%s is a %s here and a %s in %s", typ.Name(), f.Name, want, p.Type, schema) + } + } +} + +func kindOf(t reflect.Type) string { + if t == reflect.TypeOf(time.Duration(0)) { + return "integer" + } + switch t.Kind() { + case reflect.Pointer: + return kindOf(t.Elem()) + case reflect.String: + return "string" + case reflect.Bool: + return "boolean" + case reflect.Int, reflect.Int64: + return "integer" + case reflect.Float64: + return "number" + case reflect.Slice: + return "array" + case reflect.Map: + return "object" + } + return "" +} diff --git a/terraform-provider-llmgw/internal/client/export_test.go b/terraform-provider-llmgw/internal/client/export_test.go new file mode 100644 index 0000000..0ad837e --- /dev/null +++ b/terraform-provider-llmgw/internal/client/export_test.go @@ -0,0 +1,14 @@ +package client + +import "context" + +// CallUnlisted calls an endpoint that is not in Endpoints, and says what +// came of it. +func CallUnlisted() string { + c := &Client{} + err := c.do(context.Background(), "POST /api/v1/restart", nil, nil, nil) + if err == nil { + return "" + } + return err.Error() +} diff --git a/terraform-provider-llmgw/internal/fakeapi/fakeapi.go b/terraform-provider-llmgw/internal/fakeapi/fakeapi.go new file mode 100644 index 0000000..7718ebb --- /dev/null +++ b/terraform-provider-llmgw/internal/fakeapi/fakeapi.go @@ -0,0 +1,459 @@ +// Package fakeapi is an admin API in memory, for testing the client and the +// provider without a cluster. +// +// It does what the real one does at the edges the provider depends on: it +// demands the token, refuses a change not sent as JSON, fills in the defaults +// the catalog fills in, resolves a scope named by path to its ID, and answers +// with the same shapes and statuses. It is not the gateway; the OpenAPI +// contract test is what ties the client to that. +package fakeapi + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "sort" + "strings" + "sync" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// API is the fake. Its fields may be read by a test while it serves. +type API struct { + Token string + + mu sync.Mutex + models map[string]client.Model + order []string + quotas map[string]client.QuotaView + attachments []client.Attachment + nodes []client.NodeView + actors []string + // scopes maps scope:ID to the path people know it by. + scopes map[string]string +} + +// New builds a fake with a small tenancy tree — Acme, Acme/ml, +// Acme/ml/training — two guard policies and the nodes given. +func New(token string, nodes ...client.NodeView) *API { + return &API{ + Token: token, + models: map[string]client.Model{}, + quotas: map[string]client.QuotaView{}, + nodes: nodes, + scopes: map[string]string{ + "org:o1": "Acme", + "team:t1": "Acme / ml", + "project:p1": "Acme/ml/training", + }, + } +} + +// Actors are who each change said it was made by, in order. +func (a *API) Actors() []string { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.actors) +} + +// Model is a model as the fake holds it, for a test to look at. +func (a *API) Model(name string) (client.Model, bool) { + a.mu.Lock() + defer a.mu.Unlock() + m, ok := a.models[name] + return m, ok +} + +// Quota is a quota as the fake holds it, by scope:ID. +func (a *API) Quota(scope string) (client.QuotaView, bool) { + a.mu.Lock() + defer a.mu.Unlock() + q, ok := a.quotas[scope] + return q, ok +} + +// Attachments are the guard attachments as the fake holds them. +func (a *API) Attachments() []client.Attachment { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.attachments) +} + +// ScaleBehindTerraform changes a model's scaling as someone at a terminal +// would, for a test of drift. +func (a *API) ScaleBehindTerraform(name string, minimum, maximum int64) { + a.mu.Lock() + defer a.mu.Unlock() + m := a.models[name] + m.Scaling.Min, m.Scaling.Max = minimum, maximum + a.models[name] = m +} + +// RemoveBehindTerraform removes a model as someone at a terminal would. +func (a *API) RemoveBehindTerraform(name string) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.models, name) + a.order = slices.DeleteFunc(a.order, func(n string) bool { return n == name }) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// ServeHTTP checks what the admin listener checks, then routes. +func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+a.Token { + writeError(w, http.StatusUnauthorized, "the admin API needs its token") + return + } + if r.Method != http.MethodGet { + if ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";"); ct != "application/json" { + http.Error(w, "a change to the admin API must be sent as application/json", http.StatusUnsupportedMediaType) + return + } + a.mu.Lock() + a.actors = append(a.actors, r.Header.Get("X-Llmgw-Actor")) + a.mu.Unlock() + } + a.mux().ServeHTTP(w, r) +} + +func (a *API) mux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/models", a.listModels) + mux.HandleFunc("POST /api/v1/models", a.addModel) + mux.HandleFunc("GET /api/v1/models/{name}", a.getModel) + mux.HandleFunc("DELETE /api/v1/models/{name}", a.removeModel) + mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scale) + mux.HandleFunc("POST /api/v1/models/{name}/class", a.class) + mux.HandleFunc("POST /api/v1/models/{name}/owner", a.owner) + mux.HandleFunc("POST /api/v1/models/{name}/adapters", a.adapters) + mux.HandleFunc("GET /api/v1/nodes", func(w http.ResponseWriter, _ *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + out := slices.Clone(a.nodes) + if out == nil { + out = []client.NodeView{} + } + writeJSON(w, http.StatusOK, out) + }) + mux.HandleFunc("GET /api/v1/quotas", a.listQuotas) + mux.HandleFunc("PUT /api/v1/quotas", a.putQuota) + mux.HandleFunc("DELETE /api/v1/quotas/{scope}", a.deleteQuota) + mux.HandleFunc("GET /api/v1/guard", a.guard) + mux.HandleFunc("POST /api/v1/guard/attachments", a.attach(true)) + mux.HandleFunc("DELETE /api/v1/guard/attachments", a.attach(false)) + return mux +} + +// normalize fills in what the catalog fills in. +func normalize(m client.Model) client.Model { + if m.Scaling.Max == 0 && m.Scaling.Min > 0 { + m.Scaling.Max = m.Scaling.Min + } + for i := range m.Profiles { + p := &m.Profiles[i] + if p.Engine == "" { + p.Engine = "vllm" + } + if p.TP <= 0 { + p.TP = 1 + } + if p.Nodes <= 0 { + p.Nodes = 1 + } + if p.StartupTimeout <= 0 { + p.StartupTimeout = 20 * time.Minute + } + if p.Weight <= 0 { + p.Weight = 1 + } + } + return m +} + +func (a *API) listModels(w http.ResponseWriter, _ *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + out := []client.ModelView{} + for _, n := range a.order { + m := a.models[n] + v := client.ModelView{Name: m.Name, Description: m.Description, Aliases: m.Aliases, Source: m.Source, + Min: m.Scaling.Min, Max: m.Scaling.Max, Desired: m.Scaling.Min, Class: m.Class, Owner: m.Owner} + if v.Class == "" { + v.Class = "standard" + } + for _, p := range m.Profiles { + v.Profiles = append(v.Profiles, p.Name) + } + if m.External != nil { + v.External = m.External.URL + " · " + m.External.Model + } + out = append(out, v) + } + writeJSON(w, http.StatusOK, out) +} + +func (a *API) addModel(w http.ResponseWriter, r *http.Request) { + var m client.Model + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + writeError(w, http.StatusBadRequest, "the model definition is not valid JSON: "+err.Error()) + return + } + a.mu.Lock() + defer a.mu.Unlock() + if m.Name == "" || (m.Source == "" && m.External == nil) { + writeError(w, http.StatusBadRequest, "a model needs a name and a source") + return + } + if _, ok := a.models[m.Name]; ok { + writeError(w, http.StatusBadRequest, fmt.Sprintf("model %q is already in the catalog", m.Name)) + return + } + a.models[m.Name] = normalize(m) + a.order = append(a.order, m.Name) + writeJSON(w, http.StatusOK, map[string]string{"added": m.Name}) +} + +func (a *API) getModel(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + m, ok := a.models[r.PathValue("name")] + if !ok { + writeError(w, http.StatusNotFound, "no model named "+r.PathValue("name")+" is in the catalog") + return + } + writeJSON(w, http.StatusOK, m) +} + +func (a *API) removeModel(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + name := r.PathValue("name") + if _, ok := a.models[name]; !ok { + // The real API says this with a 400: the model was not added at + // runtime, or is not there at all. + writeError(w, http.StatusBadRequest, fmt.Sprintf("model %q was not added at runtime", name)) + return + } + delete(a.models, name) + a.order = slices.DeleteFunc(a.order, func(n string) bool { return n == name }) + writeJSON(w, http.StatusOK, map[string]string{"removed": name}) +} + +// change applies f to a model, answering 400 when there is none. +func (a *API) change(w http.ResponseWriter, r *http.Request, body any, f func(*client.Model) error) (client.Model, bool) { + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) + return client.Model{}, false + } + a.mu.Lock() + defer a.mu.Unlock() + m, ok := a.models[r.PathValue("name")] + if !ok { + writeError(w, http.StatusBadRequest, fmt.Sprintf("model %q was not added at runtime", r.PathValue("name"))) + return client.Model{}, false + } + if err := f(&m); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return client.Model{}, false + } + m = normalize(m) + a.models[m.Name] = m + return m, true +} + +func (a *API) scale(w http.ResponseWriter, r *http.Request) { + var req client.ScaleRequest + m, ok := a.change(w, r, &req, func(m *client.Model) error { + if req.Min < 0 || req.Max < 0 { + return fmt.Errorf("a replica count cannot be negative") + } + m.Scaling = client.Scaling{Min: req.Min, Max: req.Max, IdleTimeout: req.IdleTimeout} + return nil + }) + if ok { + writeJSON(w, http.StatusOK, map[string]any{"model": m.Name, "min": m.Scaling.Min, "max": m.Scaling.Max, + "idle_timeout": m.Scaling.IdleTimeout, "on_demand": m.Scaling.Min == 0 && m.Scaling.Max > 0}) + } +} + +func (a *API) class(w http.ResponseWriter, r *http.Request) { + var req client.ClassRequest + m, ok := a.change(w, r, &req, func(m *client.Model) error { + switch req.Class { + case "", "critical", "standard", "opportunistic": + m.Class = req.Class + return nil + } + return fmt.Errorf("class %q is not critical, standard or opportunistic", req.Class) + }) + if ok { + writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "class": m.Class}) + } +} + +func (a *API) owner(w http.ResponseWriter, r *http.Request) { + var req client.OwnerRequest + m, ok := a.change(w, r, &req, func(m *client.Model) error { + if req.Owner == "" { + m.Owner = "" + return nil + } + key, err := a.resolve(req.Owner) + m.Owner = key + return err + }) + if ok { + writeJSON(w, http.StatusOK, client.OwnerView{Model: m.Name, Owner: m.Owner}) + } +} + +func (a *API) adapters(w http.ResponseWriter, r *http.Request) { + var req client.AdaptersRequest + m, ok := a.change(w, r, &req, func(m *client.Model) error { + m.Adapters = req.Adapters + if len(m.Adapters) == 0 { + m.Adapters = nil + } + return nil + }) + if ok { + writeJSON(w, http.StatusOK, client.AdaptersRequest{Adapters: m.Adapters}) + } +} + +// resolve reads org:X, team:X or project:X, X an ID or a path, as scope:ID. +// Must be called holding mu. +func (a *API) resolve(s string) (string, error) { + kind, what, ok := strings.Cut(s, ":") + if !ok || what == "" { + return "", fmt.Errorf("%q is not org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", s) + } + if _, ok := a.scopes[s]; ok { + return s, nil + } + norm := func(p string) string { + parts := strings.Split(p, "/") + for i := range parts { + parts[i] = strings.ToLower(strings.TrimSpace(parts[i])) + } + return strings.Join(parts, "/") + } + for key, name := range a.scopes { + if strings.HasPrefix(key, kind+":") && norm(name) == norm(what) { + return key, nil + } + } + return "", fmt.Errorf("there is no %s %q", kind, what) +} + +func (a *API) listQuotas(w http.ResponseWriter, _ *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + out := []client.QuotaView{} + for _, q := range a.quotas { + out = append(out, q) + } + sort.Slice(out, func(i, j int) bool { return out[i].Scope < out[j].Scope }) + writeJSON(w, http.StatusOK, out) +} + +func (a *API) putQuota(w http.ResponseWriter, r *http.Request) { + var req client.QuotaRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) + return + } + a.mu.Lock() + defer a.mu.Unlock() + key, err := a.resolve(req.Scope) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + empty := func(r client.Resources) bool { return r.GPUs == nil && r.CPUCores == nil && r.MemoryGB == nil } + if empty(req.Guarantee) && empty(req.Limit) { + writeError(w, http.StatusBadRequest, "a quota needs a guarantee or a limit; to remove one, clear it") + return + } + a.quotas[key] = client.QuotaView{Scope: key, Name: a.scopes[key], Guarantee: req.Guarantee, Limit: req.Limit} + writeJSON(w, http.StatusOK, client.QuotaSet{Scope: key}) +} + +func (a *API) deleteQuota(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + key, err := a.resolve(r.PathValue("scope")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(a.quotas, key) + writeJSON(w, http.StatusOK, map[string]string{"removed": key}) +} + +func (a *API) guard(w http.ResponseWriter, _ *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + type view struct { + client.Attachment + Name string `json:"name,omitempty"` + } + atts := []view{} + for _, at := range a.attachments { + atts = append(atts, view{Attachment: at, Name: a.scopes[at.Scope+":"+at.ScopeID]}) + } + writeJSON(w, http.StatusOK, map[string]any{ + "policies": []string{"baseline", "finance"}, "default": []string{"baseline"}, "attachments": atts, + }) +} + +func (a *API) attach(on bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req client.GuardAttachmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + a.mu.Lock() + defer a.mu.Unlock() + kind, what, _ := strings.Cut(req.Target, ":") + at := client.Attachment{Scope: kind, ScopeID: what, Policy: req.Policy} + if kind == "org" || kind == "team" || kind == "project" { + key, err := a.resolve(req.Target) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + _, at.ScopeID, _ = strings.Cut(key, ":") + } else if kind != "key" && kind != "user" { + writeError(w, http.StatusBadRequest, fmt.Sprintf("%q: attach to org, team, project, key or user", kind)) + return + } + i := slices.Index(a.attachments, at) + switch { + case on && req.Policy != "baseline" && req.Policy != "finance": + writeError(w, http.StatusBadRequest, fmt.Sprintf("no policy %q in server.yaml's guard.policies", req.Policy)) + return + case on && i < 0: + a.attachments = append(a.attachments, at) + case !on && i < 0: + writeError(w, http.StatusNotFound, "no such attachment") + return + case !on: + a.attachments = slices.Delete(a.attachments, i, i+1) + } + writeJSON(w, http.StatusOK, at) + } +} diff --git a/terraform-provider-llmgw/internal/provider/data_sources.go b/terraform-provider-llmgw/internal/provider/data_sources.go new file mode 100644 index 0000000..d3228e9 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/data_sources.go @@ -0,0 +1,209 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// --- llmgw_models --- + +type modelsDataSource struct{ c *client.Client } + +func newModelsDataSource() datasource.DataSource { return &modelsDataSource{} } + +type modelsData struct { + Models []modelSummary `tfsdk:"models"` +} + +type modelSummary struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Source types.String `tfsdk:"source"` + Aliases []string `tfsdk:"aliases"` + Profiles []string `tfsdk:"profiles"` + Class types.String `tfsdk:"class"` + Owner types.String `tfsdk:"owner"` + External types.String `tfsdk:"external"` + MinReplicas types.Int64 `tfsdk:"min_replicas"` + MaxReplicas types.Int64 `tfsdk:"max_replicas"` + ReadyReplicas types.Int64 `tfsdk:"ready_replicas"` + TotalReplicas types.Int64 `tfsdk:"total_replicas"` + DesiredReplicas types.Int64 `tfsdk:"desired_replicas"` + Parked types.Bool `tfsdk:"parked"` + Reason types.String `tfsdk:"reason"` +} + +func (d *modelsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_models" +} + +func (d *modelsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + str := func(desc string) schema.StringAttribute { + return schema.StringAttribute{Computed: true, Description: desc} + } + num := func(desc string) schema.Int64Attribute { + return schema.Int64Attribute{Computed: true, Description: desc} + } + resp.Schema = schema.Schema{ + Description: "Every model in the catalog, from configuration and added at runtime, with its replicas now.", + Attributes: map[string]schema.Attribute{ + "models": schema.ListNestedAttribute{Computed: true, Description: "The models, in the catalog's order.", + NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{ + "name": str("The name clients ask for."), + "description": str("What it is for."), + "source": str("Where its weights are."), + "aliases": schema.ListAttribute{Computed: true, ElementType: types.StringType, Description: "Other names it answers to."}, + "profiles": schema.ListAttribute{Computed: true, ElementType: types.StringType, Description: "Its profiles' names."}, + "class": str("Its capacity class."), + "owner": str("Its owner, scope:ID; empty is shared."), + "external": str("Where an external model is served: the provider's host and its name for it."), + "min_replicas": num("Replicas kept running."), + "max_replicas": num("The most replicas."), + "ready_replicas": num("Replicas serving now."), + "total_replicas": num("Replicas in any state."), + "desired_replicas": num("Replicas it should have now."), + "parked": schema.BoolAttribute{Computed: true, Description: "It failed to start too often and is no longer retried."}, + "reason": str("The last error, when parked."), + }}, + }, + }, + } +} + +func (d *modelsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { + if c, ok := clientFrom(req.ProviderData); ok { + d.c = c + } +} + +func (d *modelsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + ms, err := d.c.Models(ctx) + if err != nil { + resp.Diagnostics.AddError("Listing the models", err.Error()) + return + } + out := modelsData{Models: []modelSummary{}} + for _, m := range ms { + out.Models = append(out.Models, modelSummary{ + Name: types.StringValue(m.Name), Description: types.StringValue(m.Description), Source: types.StringValue(m.Source), + Aliases: nonNil(m.Aliases), Profiles: nonNil(m.Profiles), Class: types.StringValue(m.Class), + Owner: types.StringValue(m.Owner), External: types.StringValue(m.External), + MinReplicas: types.Int64Value(m.Min), MaxReplicas: types.Int64Value(m.Max), ReadyReplicas: types.Int64Value(m.Ready), + TotalReplicas: types.Int64Value(m.Total), DesiredReplicas: types.Int64Value(m.Desired), + Parked: types.BoolValue(m.Parked), Reason: types.StringValue(m.Reason), + }) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &out)...) +} + +// nonNil is an empty list for none, which reads better in an expression than +// a null one: length() of it is 0 rather than an error. +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// --- llmgw_nodes --- + +type nodesDataSource struct{ c *client.Client } + +func newNodesDataSource() datasource.DataSource { return &nodesDataSource{} } + +type nodesData struct { + Nodes []nodeSummary `tfsdk:"nodes"` +} + +type nodeSummary struct { + ID types.String `tfsdk:"id"` + Hostname types.String `tfsdk:"hostname"` + Address types.String `tfsdk:"address"` + State types.String `tfsdk:"state"` + Labels map[string]string `tfsdk:"labels"` + GPUTotal types.Int64 `tfsdk:"gpu_total"` + GPUFree types.Int64 `tfsdk:"gpu_free"` + GPUClasses []string `tfsdk:"gpu_classes"` + MemoryGB types.Int64 `tfsdk:"memory_gb"` + CPUCount types.Int64 `tfsdk:"cpu_count"` + Engines []string `tfsdk:"engines"` + Fabrics []string `tfsdk:"fabrics"` + OS types.String `tfsdk:"os"` + AgentVersion types.String `tfsdk:"agent_version"` + Replicas types.Int64 `tfsdk:"replicas"` +} + +func (d *nodesDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_nodes" +} + +func (d *nodesDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + str := func(desc string) schema.StringAttribute { + return schema.StringAttribute{Computed: true, Description: desc} + } + num := func(desc string) schema.Int64Attribute { + return schema.Int64Attribute{Computed: true, Description: desc} + } + list := func(desc string) schema.ListAttribute { + return schema.ListAttribute{Computed: true, ElementType: types.StringType, Description: desc} + } + resp.Schema = schema.Schema{ + Description: "Every node the control plane knows, with its cards and state now.", + Attributes: map[string]schema.Attribute{ + "nodes": schema.ListNestedAttribute{Computed: true, Description: "The nodes.", + NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{ + "id": str("The node's ID."), + "hostname": str("Its hostname."), + "address": str("Its management address."), + "state": str("ready, cordoned, draining, unreachable or lost."), + "labels": schema.MapAttribute{Computed: true, ElementType: types.StringType, Description: "Its labels."}, + "gpu_total": num("Its GPUs."), + "gpu_free": num("GPUs not allocated."), + "gpu_classes": list("The classes of its GPUs, h100-80g."), + "memory_gb": num("Its memory, 0 when not reported."), + "cpu_count": num("Its CPUs, 0 when not reported."), + "engines": list("The inference engines installed."), + "fabrics": list("The fabrics its adapters are on."), + "os": str("Its operating system and architecture."), + "agent_version": str("The agent's version."), + "replicas": num("Replicas with a shard on it."), + }}, + }, + }, + } +} + +func (d *nodesDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { + if c, ok := clientFrom(req.ProviderData); ok { + d.c = c + } +} + +func (d *nodesDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + ns, err := d.c.Nodes(ctx) + if err != nil { + resp.Diagnostics.AddError("Listing the nodes", err.Error()) + return + } + out := nodesData{Nodes: []nodeSummary{}} + for _, n := range ns { + labels := n.Labels + if labels == nil { + labels = map[string]string{} + } + out.Nodes = append(out.Nodes, nodeSummary{ + ID: types.StringValue(n.ID), Hostname: types.StringValue(n.Hostname), Address: types.StringValue(n.MgmtAddr), + State: types.StringValue(n.State), Labels: labels, + GPUTotal: types.Int64Value(n.GPUTotal), GPUFree: types.Int64Value(n.GPUFree), GPUClasses: nonNil(n.Classes), + MemoryGB: types.Int64Value(n.MemoryGB), CPUCount: types.Int64Value(n.CPUCount), + Engines: nonNil(n.Engines), Fabrics: nonNil(n.Fabrics), OS: types.StringValue(n.OS), + AgentVersion: types.StringValue(n.AgentVersion), Replicas: types.Int64Value(n.Replicas), + }) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &out)...) +} diff --git a/terraform-provider-llmgw/internal/provider/provider.go b/terraform-provider-llmgw/internal/provider/provider.go new file mode 100644 index 0000000..77f2752 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/provider.go @@ -0,0 +1,119 @@ +// Package provider is the llmgw Terraform provider: models, quotas and +// guardrail attachments managed through a control plane's admin API. +package provider + +import ( + "context" + "os" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// DefaultEndpoint is the admin listener's default address. +const DefaultEndpoint = "http://127.0.0.1:9090" + +// DefaultTokenFile is where the server writes the admin token under systemd. +const DefaultTokenFile = "/var/lib/llmgw/admin-token" + +type llmgwProvider struct{ version string } + +// New returns the provider, for providerserver. +func New(version string) func() provider.Provider { + return func() provider.Provider { return &llmgwProvider{version: version} } +} + +type providerConfig struct { + Endpoint types.String `tfsdk:"endpoint"` + Token types.String `tfsdk:"token"` + TokenFile types.String `tfsdk:"token_file"` + Actor types.String `tfsdk:"actor"` +} + +func (p *llmgwProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "llmgw" + resp.Version = p.version +} + +func (p *llmgwProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages an llmgw cluster through its control plane's admin API.", + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{ + Optional: true, + Description: "The admin listener, " + DefaultEndpoint + " by default, or LLMGW_ADMIN_URL. " + + "It answers to an address, not a name; reach a remote one through an SSH tunnel.", + }, + "token": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "The admin token, or LLMGW_ADMIN_TOKEN. When neither is set it is read from token_file.", + }, + "token_file": schema.StringAttribute{ + Optional: true, + Description: "A file holding the admin token; " + DefaultTokenFile + " by default.", + }, + "actor": schema.StringAttribute{ + Optional: true, + Description: "Who changes are recorded as made by in the audit log; terraform by default.", + }, + }, + } +} + +func (p *llmgwProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var cfg providerConfig + resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...) + if resp.Diagnostics.HasError() { + return + } + pick := func(v types.String, env, def string) string { + if !v.IsNull() && !v.IsUnknown() && v.ValueString() != "" { + return v.ValueString() + } + if s := os.Getenv(env); env != "" && s != "" { + return s + } + return def + } + endpoint := pick(cfg.Endpoint, "LLMGW_ADMIN_URL", DefaultEndpoint) + token := pick(cfg.Token, "LLMGW_ADMIN_TOKEN", "") + if token == "" { + file := pick(cfg.TokenFile, "", DefaultTokenFile) + b, err := os.ReadFile(file) //nolint:gosec // the file the configuration names + if err != nil { + resp.Diagnostics.AddError("No admin token", + "Set token or LLMGW_ADMIN_TOKEN, or make "+file+" readable (it is root's): "+err.Error()) + return + } + token = strings.TrimSpace(string(b)) + } + c, err := client.New(endpoint, token, pick(cfg.Actor, "", "terraform")) + if err != nil { + resp.Diagnostics.AddError("Cannot reach the admin API", err.Error()) + return + } + resp.ResourceData = c + resp.DataSourceData = c +} + +func (p *llmgwProvider) Resources(context.Context) []func() resource.Resource { + return []func() resource.Resource{newModelResource, newQuotaResource, newGuardAttachmentResource} +} + +func (p *llmgwProvider) DataSources(context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{newModelsDataSource, newNodesDataSource} +} + +// clientFrom takes the client Configure built, which is absent while +// Terraform validates a configuration before the provider is configured. +func clientFrom(data any) (*client.Client, bool) { + c, ok := data.(*client.Client) + return c, ok && c != nil +} diff --git a/terraform-provider-llmgw/internal/provider/provider_test.go b/terraform-provider-llmgw/internal/provider/provider_test.go new file mode 100644 index 0000000..8e4dad3 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/provider_test.go @@ -0,0 +1,307 @@ +package provider + +import ( + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/fakeapi" +) + +// These run Terraform itself against the provider and an admin API in +// memory, and so need TF_ACC=1 and a terraform binary (on PATH, or +// TF_ACC_TERRAFORM_PATH); without TF_ACC they are skipped. Each step's apply +// is followed by a plan that must be empty, which is what shows the state +// read back does not differ from the configuration on every run. + +var factories = map[string]func() (tfprotov6.ProviderServer, error){ + "llmgw": providerserver.NewProtocol6WithError(New("test")()), +} + +func fake(t *testing.T) (*fakeapi.API, string) { + t.Helper() + api := fakeapi.New("secret", + client.NodeView{ID: "gpu01", State: "ready", GPUTotal: 8, GPUFree: 8, Classes: []string{"h100-80g"}}, + client.NodeView{ID: "cpu01", State: "cordoned", CPUCount: 32, MemoryGB: 128}) + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + return api, fmt.Sprintf(` +provider "llmgw" { + endpoint = %q + token = "secret" +} +`, srv.URL) +} + +const modelV1 = ` +resource "llmgw_model" "qwen" { + name = "qwen-small" + source = "/srv/models/qwen.gguf" + aliases = ["qwen"] + profiles = [{ + name = "cpu" + engine = "llamacpp" + min_memory_gb = 4 + cpu_cores = 4 + }] + scaling = { + min = 1 + idle_timeout = "30m" + } +} +` + +// In place: scaling, class and owner. +const modelV2 = ` +resource "llmgw_model" "qwen" { + name = "qwen-small" + source = "/srv/models/qwen.gguf" + aliases = ["qwen"] + profiles = [{ + name = "cpu" + engine = "llamacpp" + min_memory_gb = 4 + cpu_cores = 4 + }] + scaling = { + min = 0 + max = 2 + idle_timeout = "30m" + } + class = "opportunistic" + owner = "team:Acme/ml" +} +` + +// A profile changed: the model is replaced. +const modelV3 = ` +resource "llmgw_model" "qwen" { + name = "qwen-small" + source = "/srv/models/qwen.gguf" + aliases = ["qwen"] + profiles = [{ + name = "cpu" + engine = "llamacpp" + min_memory_gb = 6 + cpu_cores = 4 + }] + scaling = { + min = 0 + max = 2 + idle_timeout = "30m" + } + class = "opportunistic" + owner = "team:Acme/ml" +} +` + +func TestAccAModelIsAddedChangedInPlaceReplacedAndRemoved(t *testing.T) { + api, cfg := fake(t) + inCluster := func(check func(client.Model) error) resource.TestCheckFunc { + return func(*terraform.State) error { + m, ok := api.Model("qwen-small") + if !ok { + return fmt.Errorf("qwen-small is not in the catalog") + } + return check(m) + } + } + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + CheckDestroy: func(*terraform.State) error { + if _, ok := api.Model("qwen-small"); ok { + return fmt.Errorf("qwen-small is still in the catalog") + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg + modelV1, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("llmgw_model.qwen", "id", "qwen-small"), + // Filled in by the catalog: a max not written follows min. + resource.TestCheckResourceAttr("llmgw_model.qwen", "scaling.max", "1"), + // As written, not as the catalog defaulted it. + resource.TestCheckNoResourceAttr("llmgw_model.qwen", "profiles.0.tp"), + inCluster(func(m client.Model) error { + if m.Scaling.IdleTimeout != 30*time.Minute || m.Profiles[0].MinMemoryGB != 4 { + return fmt.Errorf("added as %+v", m) + } + return nil + }), + ), + }, + { + Config: cfg + modelV2, + ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("llmgw_model.qwen", plancheck.ResourceActionUpdate), + }}, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("llmgw_model.qwen", "owner", "team:Acme/ml"), + resource.TestCheckResourceAttr("llmgw_model.qwen", "owner_id", "team:t1"), + inCluster(func(m client.Model) error { + if m.Scaling.Min != 0 || m.Scaling.Max != 2 || m.Class != "opportunistic" || m.Owner != "team:t1" { + return fmt.Errorf("changed to %+v", m) + } + return nil + }), + ), + }, + { + // Scaled from a terminal: the next plan puts it back, in place. + PreConfig: func() { api.ScaleBehindTerraform("qwen-small", 3, 3) }, + Config: cfg + modelV2, + ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("llmgw_model.qwen", plancheck.ResourceActionUpdate), + }}, + Check: inCluster(func(m client.Model) error { + if m.Scaling.Min != 0 || m.Scaling.Max != 2 { + return fmt.Errorf("not put back: %+v", m.Scaling) + } + return nil + }), + }, + { + Config: cfg + modelV3, + ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("llmgw_model.qwen", plancheck.ResourceActionDestroyBeforeCreate), + }}, + Check: inCluster(func(m client.Model) error { + if m.Profiles[0].MinMemoryGB != 6 || m.Owner != "team:t1" { + return fmt.Errorf("replaced as %+v", m) + } + return nil + }), + }, + { + // Removed from a terminal: planned again. + PreConfig: func() { api.RemoveBehindTerraform("qwen-small") }, + Config: cfg + modelV3, + ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("llmgw_model.qwen", plancheck.ResourceActionCreate), + }}, + }, + }, + }) +} + +func TestAccAQuotaIsSetChangedImportedAndCleared(t *testing.T) { + api, cfg := fake(t) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + CheckDestroy: func(*terraform.State) error { + if _, ok := api.Quota("team:t1"); ok { + return fmt.Errorf("the quota is still set") + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg + ` +resource "llmgw_quota" "ml" { + scope = "team:Acme/ml" + limit = { gpus = 8 } +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("llmgw_quota.ml", "id", "team:t1"), + resource.TestCheckResourceAttr("llmgw_quota.ml", "name", "Acme / ml"), + resource.TestCheckResourceAttr("llmgw_quota.ml", "limit.gpus", "8"), + ), + }, + { + Config: cfg + ` +resource "llmgw_quota" "ml" { + scope = "team:Acme/ml" + guarantee = { gpus = 2 } + limit = { gpus = 8, memory_gb = 256 } +} +`, + ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("llmgw_quota.ml", plancheck.ResourceActionUpdate), + }}, + Check: func(*terraform.State) error { + q, ok := api.Quota("team:t1") + if !ok || q.Guarantee.GPUs == nil || *q.Guarantee.GPUs != 2 || q.Limit.MemoryGB == nil || *q.Limit.MemoryGB != 256 { + return fmt.Errorf("quota %+v", q) + } + return nil + }, + }, + { + ResourceName: "llmgw_quota.ml", + ImportState: true, + ImportStateId: "team:t1", + ImportStateVerify: true, + // Imported by its ID, which is then the only name it has. + ImportStateVerifyIgnore: []string{"scope"}, + }, + }, + }) +} + +func TestAccAPolicyIsAttachedImportedAndDetached(t *testing.T) { + api, cfg := fake(t) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + CheckDestroy: func(*terraform.State) error { + if n := len(api.Attachments()); n != 0 { + return fmt.Errorf("%d attachments left", n) + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg + ` +resource "llmgw_guard_attachment" "ml" { + target = "team:Acme/ml" + policy = "finance" +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("llmgw_guard_attachment.ml", "id", "team:t1:finance"), + resource.TestCheckResourceAttr("llmgw_guard_attachment.ml", "scope_id", "t1"), + ), + }, + { + ResourceName: "llmgw_guard_attachment.ml", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"target"}, + }, + }, + }) +} + +func TestAccTheModelsAndNodesAreRead(t *testing.T) { + _, cfg := fake(t) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: cfg + modelV1 + ` +data "llmgw_nodes" "all" {} +data "llmgw_models" "all" { + depends_on = [llmgw_model.qwen] +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.llmgw_nodes.all", "nodes.#", "2"), + resource.TestCheckResourceAttr("data.llmgw_nodes.all", "nodes.0.gpu_classes.0", "h100-80g"), + resource.TestCheckResourceAttr("data.llmgw_nodes.all", "nodes.1.state", "cordoned"), + resource.TestCheckResourceAttr("data.llmgw_models.all", "models.#", "1"), + resource.TestCheckResourceAttr("data.llmgw_models.all", "models.0.name", "qwen-small"), + resource.TestCheckResourceAttr("data.llmgw_models.all", "models.0.profiles.0", "cpu"), + ), + }, + }, + }) +} diff --git a/terraform-provider-llmgw/internal/provider/resource_guard.go b/terraform-provider-llmgw/internal/provider/resource_guard.go new file mode 100644 index 0000000..904d804 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/resource_guard.go @@ -0,0 +1,139 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// The guard attachment resource is a guardrail policy attached to a part of +// the tenancy tree, a key or a person. The policies themselves are defined +// in server.yaml's guard section, not here: what this manages is where they +// apply. + +type guardAttachmentResource struct{ c *client.Client } + +func newGuardAttachmentResource() resource.Resource { return &guardAttachmentResource{} } + +type guardAttachmentModel struct { + ID types.String `tfsdk:"id"` + Target types.String `tfsdk:"target"` + Policy types.String `tfsdk:"policy"` + Scope types.String `tfsdk:"scope"` + ScopeID types.String `tfsdk:"scope_id"` +} + +func (r *guardAttachmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_guard_attachment" +} + +func (r *guardAttachmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + replace := []planmodifier.String{stringplanmodifier.RequiresReplace()} + keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()} + resp.Schema = schema.Schema{ + Description: "A guardrail policy from server.yaml's guard.policies, attached to an organization, team, project, key or person.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: keep, + Description: "scope:SCOPE-ID:policy. Import by it."}, + "target": schema.StringAttribute{Required: true, PlanModifiers: replace, + Description: "org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID after the colon), key:KEY-ID or user:USER-ID."}, + "policy": schema.StringAttribute{Required: true, PlanModifiers: replace, + Description: "The policy's name in server.yaml's guard.policies."}, + "scope": schema.StringAttribute{Computed: true, PlanModifiers: keep, + Description: "org, team, project, key or user."}, + "scope_id": schema.StringAttribute{Computed: true, PlanModifiers: keep, + Description: "The ID the target resolved to."}, + }, + } +} + +func (r *guardAttachmentResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if c, ok := clientFrom(req.ProviderData); ok { + r.c = c + } +} + +func attachmentID(a client.Attachment) string { return a.Scope + ":" + a.ScopeID + ":" + a.Policy } + +func (r *guardAttachmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan guardAttachmentModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + a, err := r.c.Attach(ctx, plan.Target.ValueString(), plan.Policy.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Attaching "+plan.Policy.ValueString()+" to "+plan.Target.ValueString(), err.Error()) + return + } + plan.ID = types.StringValue(attachmentID(a)) + plan.Scope = types.StringValue(a.Scope) + plan.ScopeID = types.StringValue(a.ScopeID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *guardAttachmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var st guardAttachmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + g, err := r.c.Guard(ctx) + if err != nil { + resp.Diagnostics.AddError("Reading the guard attachments", err.Error()) + return + } + for _, a := range g.Attachments { + if attachmentID(a) == st.ID.ValueString() { + resp.Diagnostics.Append(resp.State.Set(ctx, &st)...) + return + } + } + resp.State.RemoveResource(ctx) +} + +// Update is never called: every attribute replaces the attachment. +func (r *guardAttachmentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan guardAttachmentModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *guardAttachmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var st guardAttachmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + // By the ID it resolved to, which still names it if the team has since + // been renamed. + err := r.c.Detach(ctx, st.Scope.ValueString()+":"+st.ScopeID.ValueString(), st.Policy.ValueString()) + if err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Detaching "+st.Policy.ValueString()+" from "+st.Target.ValueString(), err.Error()) + } +} + +func (r *guardAttachmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // scope:SCOPE-ID:policy. The policy is after the last colon and the scope + // before the first; an ID between them is whatever is left. + scope, rest, ok1 := strings.Cut(req.ID, ":") + i := strings.LastIndex(rest, ":") + if !ok1 || i <= 0 || i == len(rest)-1 { + resp.Diagnostics.AddError("Not an attachment ID", fmt.Sprintf("%q is not scope:SCOPE-ID:policy, as id shows it", req.ID)) + return + } + id, policy := rest[:i], rest[i+1:] + st := guardAttachmentModel{ + ID: types.StringValue(req.ID), Target: types.StringValue(scope + ":" + id), Policy: types.StringValue(policy), + Scope: types.StringValue(scope), ScopeID: types.StringValue(id), + } + resp.Diagnostics.Append(resp.State.Set(ctx, &st)...) +} diff --git a/terraform-provider-llmgw/internal/provider/resource_model.go b/terraform-provider-llmgw/internal/provider/resource_model.go new file mode 100644 index 0000000..55f0b05 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/resource_model.go @@ -0,0 +1,424 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// The model resource is a catalog entry added at runtime, as `llmgw ctl +// model add` adds one. +// +// What the admin API can change in place — scaling, class, owner, adapters — +// is changed in place; anything else in the definition replaces the model, +// because the API has no way to edit it and removing and adding is what an +// operator would do by hand. A replacement stops the model's replicas. +// +// The state keeps the definition as it was written rather than as the +// catalog filled it in, so a profile written without tp does not differ on +// every plan from one the catalog gave tp 1. Only what can change in place is +// read back from the cluster, where a change made from a terminal shows as a +// difference to put right. A model removed from the cluster is planned again. + +type modelResource struct{ c *client.Client } + +func newModelResource() resource.Resource { return &modelResource{} } + +type modelResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Source types.String `tfsdk:"source"` + Description types.String `tfsdk:"description"` + Aliases types.List `tfsdk:"aliases"` + Fallbacks types.List `tfsdk:"fallbacks"` + RoutingProfile types.String `tfsdk:"routing_profile"` + ContextLength types.Int64 `tfsdk:"context_length"` + MaxOutput types.Int64 `tfsdk:"max_output"` + Class types.String `tfsdk:"class"` + Owner types.String `tfsdk:"owner"` + OwnerID types.String `tfsdk:"owner_id"` + Capabilities *capabilitiesModel `tfsdk:"capabilities"` + External *externalModel `tfsdk:"external"` + Profiles []profileModel `tfsdk:"profiles"` + Scaling *scalingModel `tfsdk:"scaling"` + Adapters []adapterModel `tfsdk:"adapters"` +} + +type capabilitiesModel struct { + Tools types.Bool `tfsdk:"tools"` + Vision types.Bool `tfsdk:"vision"` + Embeddings types.Bool `tfsdk:"embeddings"` +} + +type externalModel struct { + URL types.String `tfsdk:"url"` + Model types.String `tfsdk:"model"` + APIKeyFile types.String `tfsdk:"api_key_file"` +} + +type profileModel struct { + Name types.String `tfsdk:"name"` + Engine types.String `tfsdk:"engine"` + GPUClass types.String `tfsdk:"gpu_class"` + TP types.Int64 `tfsdk:"tp"` + PP types.Int64 `tfsdk:"pp"` + GPUsPerNode types.Int64 `tfsdk:"gpus_per_node"` + Nodes types.Int64 `tfsdk:"nodes"` + Args types.List `tfsdk:"args"` + Env types.Map `tfsdk:"env"` + MinMemoryGB types.Int64 `tfsdk:"min_memory_gb"` + CPUCores types.Int64 `tfsdk:"cpu_cores"` + MinDiskGB types.Int64 `tfsdk:"min_disk_gb"` + NodeSelector types.Map `tfsdk:"node_selector"` + Weight types.Float64 `tfsdk:"weight"` + Priority types.Int64 `tfsdk:"priority"` + StartupTimeout types.String `tfsdk:"startup_timeout"` +} + +type scalingModel struct { + Min types.Int64 `tfsdk:"min"` + Max types.Int64 `tfsdk:"max"` + IdleTimeout types.String `tfsdk:"idle_timeout"` +} + +type adapterModel struct { + Name types.String `tfsdk:"name"` + Source types.String `tfsdk:"source"` + Scale types.Float64 `tfsdk:"scale"` +} + +func (r *modelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_model" +} + +func (r *modelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + replace := []planmodifier.String{stringplanmodifier.RequiresReplace()} + replaceInt := []planmodifier.Int64{int64planmodifier.RequiresReplace()} + replaceList := []planmodifier.List{listplanmodifier.RequiresReplace()} + replaceObject := []planmodifier.Object{objectplanmodifier.RequiresReplace()} + str := func(desc string) schema.StringAttribute { + return schema.StringAttribute{Optional: true, Description: desc} + } + num := func(desc string) schema.Int64Attribute { + return schema.Int64Attribute{Optional: true, Description: desc} + } + strMap := func(desc string) schema.MapAttribute { + return schema.MapAttribute{Optional: true, ElementType: types.StringType, Description: desc} + } + + resp.Schema = schema.Schema{ + Description: "A model in the catalog, added at runtime and kept in the store, as `llmgw ctl model add` adds one. " + + "Scaling, class, owner and adapters change in place; any other change replaces the model, stopping its replicas. " + + "A model declared in the control plane's configuration cannot be managed here.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, Description: "The model's name.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true, PlanModifiers: replace, + Description: "The name clients ask for."}, + "source": schema.StringAttribute{Optional: true, PlanModifiers: replace, + Description: "Where the weights are: a path on the nodes, an https URL, or a registry repository. Absent for an external model."}, + "description": schema.StringAttribute{Optional: true, PlanModifiers: replace, + Description: "What the model is for."}, + "aliases": schema.ListAttribute{Optional: true, ElementType: types.StringType, PlanModifiers: replaceList, + Description: "Other names it answers to."}, + "fallbacks": schema.ListAttribute{Optional: true, ElementType: types.StringType, PlanModifiers: replaceList, + Description: "Models tried in order when this one cannot answer."}, + "routing_profile": schema.StringAttribute{Optional: true, PlanModifiers: replace, + Description: "The routing profile, from the router's configuration."}, + "context_length": schema.Int64Attribute{Optional: true, PlanModifiers: replaceInt, + Description: "The context window, in tokens."}, + "max_output": schema.Int64Attribute{Optional: true, PlanModifiers: replaceInt, + Description: "The most tokens one answer may have."}, + "class": schema.StringAttribute{Optional: true, + Description: "The capacity class: critical, standard or opportunistic. Absent is what the scaling implies."}, + "owner": schema.StringAttribute{Optional: true, + Description: "The part of the tenancy tree the model's capacity counts against: org:NAME, team:ORG/TEAM, " + + "project:ORG/TEAM/PROJECT, or their IDs. Absent is shared."}, + "owner_id": schema.StringAttribute{Computed: true, + Description: "The owner as the cluster records it, scope:ID."}, + "capabilities": schema.SingleNestedAttribute{Optional: true, PlanModifiers: replaceObject, + Description: "What the model can do beyond chat.", + Attributes: map[string]schema.Attribute{ + "tools": schema.BoolAttribute{Optional: true, Description: "It calls tools."}, + "vision": schema.BoolAttribute{Optional: true, Description: "It reads images."}, + "embeddings": schema.BoolAttribute{Optional: true, Description: "It answers /v1/embeddings."}, + }}, + "external": schema.SingleNestedAttribute{Optional: true, PlanModifiers: replaceObject, + Description: "A model served by a provider outside the cluster, in place of source and profiles.", + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{Required: true, Description: "The provider's API, https://api.openai.com/v1."}, + "model": schema.StringAttribute{Required: true, Description: "The provider's name for the model."}, + "api_key_file": schema.StringAttribute{Optional: true, Description: "The provider's key: a file on the control plane, or an env: or vault: reference."}, + }}, + "profiles": schema.ListNestedAttribute{Optional: true, PlanModifiers: replaceList, + Description: "How the model runs: one profile per kind of node, tried in order. " + + "What is left out takes the catalog's default, as in server.yaml.", + NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{ + "name": str("The profile's name."), + "engine": str("vllm, sglang or llamacpp; vllm when absent."), + "gpu_class": str("The GPU class it runs on, h100-80g; absent runs on CPUs."), + "tp": num("Tensor parallelism."), + "pp": num("Pipeline parallelism."), + "gpus_per_node": num("GPUs on each node."), + "nodes": num("Nodes one replica spans."), + "args": schema.ListAttribute{Optional: true, ElementType: types.StringType, Description: "Arguments added to the engine's."}, + "env": strMap("Environment for the engine."), + "min_memory_gb": num("Host memory one replica reserves, in GB."), + "cpu_cores": num("CPU cores one replica without a GPU computes on."), + "min_disk_gb": num("Free disk a node needs for the weights, in GB."), + "node_selector": strMap("Labels a node must have."), + "weight": schema.Float64Attribute{Optional: true, Description: "The share of requests this profile's replicas take."}, + "priority": num("Placement order among profiles."), + "startup_timeout": str("How long a replica may take to start, 20m when absent."), + }}, + }, + "scaling": schema.SingleNestedAttribute{Optional: true, + Description: "How many replicas run. Absent is none: the model is in the catalog and not running. " + + "Changes in place.", + Attributes: map[string]schema.Attribute{ + "min": schema.Int64Attribute{Optional: true, Computed: true, Description: "Replicas kept running; 0 is on demand."}, + "max": schema.Int64Attribute{Optional: true, Computed: true, Description: "The most replicas; min when absent."}, + "idle_timeout": schema.StringAttribute{Optional: true, Computed: true, + Description: "How long an on-demand model may go unasked-for before it is stopped, 30m; the cluster's default when absent."}, + }}, + "adapters": schema.ListNestedAttribute{Optional: true, + Description: "LoRA adapters it serves, each a name clients ask for. Changes in place; each new file is checked first.", + NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{Required: true, Description: "The name clients ask for."}, + "source": schema.StringAttribute{Required: true, Description: "Where the adapter's file is."}, + "scale": schema.Float64Attribute{Optional: true, Description: "How strongly it applies, 1 when absent."}, + }}, + }, + }, + } +} + +func (r *modelResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if c, ok := clientFrom(req.ProviderData); ok { + r.c = c + } +} + +// definition is the model to add, from a plan. The owner is left out: it is +// set afterwards through its own endpoint, which resolves a path to an ID. +func definition(ctx context.Context, p modelResourceModel, diags *diag.Diagnostics) client.Model { + m := client.Model{ + Name: p.Name.ValueString(), Source: p.Source.ValueString(), Description: p.Description.ValueString(), + Aliases: listOf(ctx, p.Aliases, diags), Fallbacks: listOf(ctx, p.Fallbacks, diags), + RoutingProfile: p.RoutingProfile.ValueString(), ContextLength: p.ContextLength.ValueInt64(), + MaxOutput: p.MaxOutput.ValueInt64(), Class: p.Class.ValueString(), + Adapters: adapters(p.Adapters), + } + if c := p.Capabilities; c != nil { + m.Capabilities = &client.Capabilities{Tools: c.Tools.ValueBool(), Vision: c.Vision.ValueBool(), Embeddings: c.Embeddings.ValueBool()} + } + if e := p.External; e != nil { + m.External = &client.External{URL: e.URL.ValueString(), Model: e.Model.ValueString(), APIKeyFile: e.APIKeyFile.ValueString()} + } + for _, pr := range p.Profiles { + m.Profiles = append(m.Profiles, client.Profile{ + Name: pr.Name.ValueString(), Engine: pr.Engine.ValueString(), GPUClass: pr.GPUClass.ValueString(), + TP: pr.TP.ValueInt64(), PP: pr.PP.ValueInt64(), GPUsPerNode: pr.GPUsPerNode.ValueInt64(), Nodes: pr.Nodes.ValueInt64(), + Args: listOf(ctx, pr.Args, diags), Env: mapOf(ctx, pr.Env, diags), + MinMemoryGB: pr.MinMemoryGB.ValueInt64(), CPUCores: pr.CPUCores.ValueInt64(), MinDiskGB: pr.MinDiskGB.ValueInt64(), + NodeSelector: mapOf(ctx, pr.NodeSelector, diags), Weight: pr.Weight.ValueFloat64(), Priority: pr.Priority.ValueInt64(), + StartupTimeout: duration(pr.StartupTimeout, "startup_timeout", diags), + }) + } + m.Scaling = client.Scaling(scaleRequest(p.Scaling, diags)) + return m +} + +func adapters(in []adapterModel) []client.Adapter { + var out []client.Adapter + for _, a := range in { + out = append(out, client.Adapter{Name: a.Name.ValueString(), Source: a.Source.ValueString(), Scale: a.Scale.ValueFloat64()}) + } + return out +} + +// scaleRequest is the scaling a plan asks for. What is unknown is left for +// the cluster to fill in: a max not written follows min. +func scaleRequest(s *scalingModel, diags *diag.Diagnostics) client.ScaleRequest { + if s == nil { + return client.ScaleRequest{} + } + return client.ScaleRequest{Min: s.Min.ValueInt64(), Max: s.Max.ValueInt64(), + IdleTimeout: duration(s.IdleTimeout, "scaling.idle_timeout", diags)} +} + +// refresh takes what can change in place from the cluster's copy. adopt is +// set when reading: a scaling block the configuration left out is taken +// from the cluster when the cluster has one, so a model scaled from a +// terminal shows the difference. +func refresh(m client.Model, st *modelResourceModel, adopt bool) { + st.ID = types.StringValue(m.Name) + switch { + case st.Scaling != nil: + st.Scaling.Min = types.Int64Value(m.Scaling.Min) + st.Scaling.Max = types.Int64Value(m.Scaling.Max) + st.Scaling.IdleTimeout = keepDuration(st.Scaling.IdleTimeout, m.Scaling.IdleTimeout) + case adopt && m.Scaling != (client.Scaling{}): + st.Scaling = &scalingModel{Min: types.Int64Value(m.Scaling.Min), Max: types.Int64Value(m.Scaling.Max), + IdleTimeout: types.StringValue(m.Scaling.IdleTimeout.String())} + } + st.Class = strOrNull(m.Class) + if m.Owner != st.OwnerID.ValueString() { + st.Owner = strOrNull(m.Owner) + } + st.OwnerID = strOrNull(m.Owner) + switch { + case len(m.Adapters) > 0: + st.Adapters = nil + for _, a := range m.Adapters { + st.Adapters = append(st.Adapters, adapterModel{Name: types.StringValue(a.Name), + Source: types.StringValue(a.Source), Scale: floatOrNull(a.Scale)}) + } + case st.Adapters != nil: + st.Adapters = []adapterModel{} + } +} + +func (r *modelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan modelResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + m := definition(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + name := m.Name + if err := r.c.AddModel(ctx, m); err != nil { + resp.Diagnostics.AddError("Adding the model "+name, err.Error()) + return + } + plan.OwnerID = types.StringNull() + if owner := plan.Owner.ValueString(); owner != "" { + id, err := r.c.SetOwner(ctx, name, owner) + if err != nil { + // Taken out again rather than left half made: Terraform would + // not know it exists, and the next apply would fail on the name. + _ = r.c.RemoveModel(ctx, name) + resp.Diagnostics.AddError("Setting the owner of "+name, err.Error()) + return + } + plan.OwnerID = strOrNull(id) + } + got, err := r.c.Model(ctx, name) + if err != nil { + resp.Diagnostics.AddError("Reading the model "+name+" back", err.Error()) + return + } + refresh(got, &plan, false) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *modelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var st modelResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + m, err := r.c.Model(ctx, st.Name.ValueString()) + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Reading the model "+st.Name.ValueString(), err.Error()) + return + } + refresh(m, &st, true) + resp.Diagnostics.Append(resp.State.Set(ctx, &st)...) +} + +func (r *modelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, st modelResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + name := plan.Name.ValueString() + + want, had := scaleRequest(plan.Scaling, &resp.Diagnostics), scaleRequest(st.Scaling, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if want != had || (plan.Scaling == nil) != (st.Scaling == nil) { + if err := r.c.Scale(ctx, name, want); err != nil { + resp.Diagnostics.AddError("Scaling "+name, err.Error()) + return + } + } + if !plan.Class.Equal(st.Class) { + if err := r.c.SetClass(ctx, name, plan.Class.ValueString()); err != nil { + resp.Diagnostics.AddError("Setting the class of "+name, err.Error()) + return + } + } + plan.OwnerID = st.OwnerID + if !plan.Owner.Equal(st.Owner) { + id, err := r.c.SetOwner(ctx, name, plan.Owner.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Setting the owner of "+name, err.Error()) + return + } + plan.OwnerID = strOrNull(id) + } + if !sameAdapters(plan.Adapters, st.Adapters) { + if err := r.c.SetAdapters(ctx, name, adapters(plan.Adapters)); err != nil { + resp.Diagnostics.AddError("Setting the adapters of "+name, err.Error()) + return + } + } + got, err := r.c.Model(ctx, name) + if err != nil { + resp.Diagnostics.AddError("Reading the model "+name+" back", err.Error()) + return + } + refresh(got, &plan, false) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func sameAdapters(a, b []adapterModel) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !a[i].Name.Equal(b[i].Name) || !a[i].Source.Equal(b[i].Source) || a[i].Scale.ValueFloat64() != b[i].Scale.ValueFloat64() { + return false + } + } + return true +} + +func (r *modelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var st modelResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + name := st.Name.ValueString() + if err := r.c.RemoveModel(ctx, name); err != nil { + // The API refuses removing a model that is not there with the same + // status as one it may not remove; asking tells them apart, and one + // already gone is what deleting wanted. + if _, gerr := r.c.Model(ctx, name); client.IsNotFound(gerr) { + return + } + resp.Diagnostics.AddError("Removing the model "+name, err.Error()) + } +} diff --git a/terraform-provider-llmgw/internal/provider/resource_quota.go b/terraform-provider-llmgw/internal/provider/resource_quota.go new file mode 100644 index 0000000..26a5260 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/resource_quota.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client" +) + +// The quota resource is one scope's capacity quota: what an organization, +// team or project is guaranteed of the cluster and the most it may hold. +// +// The scope is kept as written — team:Acme/ml — and beside it, as id, the +// scope the cluster resolved it to, team:ID. The quota is found again by the +// ID, so renaming the team does not lose it. + +type quotaResource struct{ c *client.Client } + +func newQuotaResource() resource.Resource { return "aResource{} } + +type quotaModel struct { + ID types.String `tfsdk:"id"` + Scope types.String `tfsdk:"scope"` + Name types.String `tfsdk:"name"` + Guarantee *resourcesModel `tfsdk:"guarantee"` + Limit *resourcesModel `tfsdk:"limit"` +} + +type resourcesModel struct { + GPUs types.Int64 `tfsdk:"gpus"` + CPUCores types.Int64 `tfsdk:"cpu_cores"` + MemoryGB types.Int64 `tfsdk:"memory_gb"` +} + +func (r *quotaResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_quota" +} + +func (r *quotaResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + amount := func(desc string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{Optional: true, Description: desc, + Attributes: map[string]schema.Attribute{ + "gpus": schema.Int64Attribute{Optional: true, Description: "GPUs; absent is not governed."}, + "cpu_cores": schema.Int64Attribute{Optional: true, Description: "CPU cores of replicas without a GPU; absent is not governed."}, + "memory_gb": schema.Int64Attribute{Optional: true, Description: "Host memory of those replicas, in GB; absent is not governed."}, + }} + } + resp.Schema = schema.Schema{ + Description: "A capacity quota on an organization, team or project. At least one of guarantee and limit must be set.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, + Description: "The scope as the cluster records it, team:ID. Import by it.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "scope": schema.StringAttribute{Required: true, + Description: "org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the ID after the colon.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}}, + "name": schema.StringAttribute{Computed: true, Description: "The scope's path, Acme / ml.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "guarantee": amount("What is held for the scope: while it holds less, what others borrowed beyond their own guarantees is taken back for it."), + "limit": amount("The most the scope may hold."), + }, + } +} + +func (r *quotaResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if c, ok := clientFrom(req.ProviderData); ok { + r.c = c + } +} + +func amountOf(m *resourcesModel) client.Resources { + if m == nil { + return client.Resources{} + } + return client.Resources{GPUs: ptrOf(m.GPUs), CPUCores: ptrOf(m.CPUCores), MemoryGB: ptrOf(m.MemoryGB)} +} + +// amountModel is the cluster's amount for the state: a block left out stays +// out while the cluster governs nothing in it. +func amountModel(had *resourcesModel, r client.Resources) *resourcesModel { + if had == nil && r.GPUs == nil && r.CPUCores == nil && r.MemoryGB == nil { + return nil + } + return &resourcesModel{GPUs: ptrOrNull(r.GPUs), CPUCores: ptrOrNull(r.CPUCores), MemoryGB: ptrOrNull(r.MemoryGB)} +} + +func (r *quotaResource) put(ctx context.Context, plan *quotaModel) error { + id, err := r.c.SetQuota(ctx, client.QuotaRequest{Scope: plan.Scope.ValueString(), + Guarantee: amountOf(plan.Guarantee), Limit: amountOf(plan.Limit)}) + if err != nil { + return err + } + plan.ID = types.StringValue(id) + plan.Name = types.StringNull() + qs, err := r.c.Quotas(ctx) + if err != nil { + return err + } + for _, q := range qs { + if q.Scope == id { + plan.Name = strOrNull(q.Name) + } + } + return nil +} + +func (r *quotaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan quotaModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.put(ctx, &plan); err != nil { + resp.Diagnostics.AddError("Setting the quota on "+plan.Scope.ValueString(), err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *quotaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var st quotaModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + qs, err := r.c.Quotas(ctx) + if err != nil { + resp.Diagnostics.AddError("Reading the quotas", err.Error()) + return + } + for _, q := range qs { + if q.Scope != st.ID.ValueString() { + continue + } + if st.Scope.IsNull() { + // Imported: the ID is the only name there is. + st.Scope = types.StringValue(q.Scope) + } + st.Name = strOrNull(q.Name) + st.Guarantee = amountModel(st.Guarantee, q.Guarantee) + st.Limit = amountModel(st.Limit, q.Limit) + resp.Diagnostics.Append(resp.State.Set(ctx, &st)...) + return + } + resp.State.RemoveResource(ctx) +} + +func (r *quotaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan quotaModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.put(ctx, &plan); err != nil { + resp.Diagnostics.AddError("Setting the quota on "+plan.Scope.ValueString(), err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *quotaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var st quotaModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.c.ClearQuota(ctx, st.ID.ValueString()); err != nil { + resp.Diagnostics.AddError("Removing the quota on "+st.Scope.ValueString(), err.Error()) + } +} + +func (r *quotaResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/terraform-provider-llmgw/internal/provider/values.go b/terraform-provider-llmgw/internal/provider/values.go new file mode 100644 index 0000000..517672c --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/values.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Conversions between Terraform's values and the client's. + +// strOrNull is null for empty: the API leaves an unset field out, and a +// configuration that does not set it holds null, not "". +func strOrNull(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} + +func intOrNull(n int64) types.Int64 { + if n == 0 { + return types.Int64Null() + } + return types.Int64Value(n) +} + +func floatOrNull(f float64) types.Float64 { + if f == 0 { + return types.Float64Null() + } + return types.Float64Value(f) +} + +func ptrOrNull(p *int64) types.Int64 { + if p == nil { + return types.Int64Null() + } + return types.Int64Value(*p) +} + +func ptrOf(v types.Int64) *int64 { + if v.IsNull() || v.IsUnknown() { + return nil + } + n := v.ValueInt64() + return &n +} + +func listOf(ctx context.Context, l types.List, diags *diag.Diagnostics) []string { + if l.IsNull() || l.IsUnknown() { + return nil + } + var out []string + diags.Append(l.ElementsAs(ctx, &out, false)...) + return out +} + +func mapOf(ctx context.Context, m types.Map, diags *diag.Diagnostics) map[string]string { + if m.IsNull() || m.IsUnknown() { + return nil + } + out := map[string]string{} + diags.Append(m.ElementsAs(ctx, &out, false)...) + return out +} + +// duration reads a duration written as Go writes them, "30m" or "1h30m". +func duration(v types.String, what string, diags *diag.Diagnostics) time.Duration { + if v.IsNull() || v.IsUnknown() || v.ValueString() == "" { + return 0 + } + d, err := time.ParseDuration(v.ValueString()) + if err != nil || d < 0 { + diags.AddError("Not a duration", fmt.Sprintf("%s is %q: write a duration like 30m or 1h30m", what, v.ValueString())) + } + return d +} + +// keepDuration is the API's duration as the configuration wrote it when they +// mean the same: "30m" and "30m0s" are one value, and showing a difference +// between them on every plan would be noise. +func keepDuration(had types.String, d time.Duration) types.String { + if !had.IsNull() && !had.IsUnknown() { + if was, err := time.ParseDuration(had.ValueString()); err == nil && was == d { + return had + } + } + return types.StringValue(d.String()) +} diff --git a/terraform-provider-llmgw/main.go b/terraform-provider-llmgw/main.go new file mode 100644 index 0000000..b43dfe6 --- /dev/null +++ b/terraform-provider-llmgw/main.go @@ -0,0 +1,38 @@ +// Command terraform-provider-llmgw is the llmgw Terraform provider: models, +// capacity quotas and guardrail attachments managed through a control +// plane's admin API. +// +// A module of its own, so the gateway's binary does not carry Terraform's +// dependencies. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/provider" +) + +// version is set when a release is built: -ldflags "-X main.version=v0.1.0". +var version = "dev" + +// Address is the provider's source address, as a configuration's +// required_providers names it. +const Address = "gitea.mynodes.xyz/ilker/llmgw" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "run under a debugger, printing what TF_REATTACH_PROVIDERS needs") + flag.Parse() + + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + Address: Address, + Debug: debug, + }) + if err != nil { + log.Fatal(err) + } +}