Code/AutoRig/Vast/VastOffers.cs

Types for interacting with vast.ai search responses. VastOffer is a data record for a single rentable GPU offer with an EstimateCost helper. VastOffers builds the JSON body for a search request and parses a search response into a list of VastOffer, tolerating missing fields.

Http Calls
using System.Text.Json;

namespace AutoRig.Vast;

/// <summary>One rentable GPU offer from vast.ai's search (api/v0/search/asks/).</summary>
public sealed class VastOffer
{
    public required long Id { get; init; }
    public required string GpuName { get; init; }
    public required int NumGpus { get; init; }
    public required float GpuRamMb { get; init; }
    public required float DollarsPerHour { get; init; }
    public required float Reliability { get; init; }
    public required float InetDownMbps { get; init; }
    public required float DiskSpaceGb { get; init; }

    /// <summary>Worst-case cost for a bounded session, rounded up to the cent.</summary>
    public float EstimateCost( int minutes )
        => MathF.Ceiling( DollarsPerHour * minutes / 60f * 100f ) / 100f;
}

/// <summary>
/// Pure request/response shapes for vast.ai offer search — no IO here so the
/// editor client stays a thin transport and everything is unit-testable.
/// </summary>
public static class VastOffers
{
    /// <summary>PUT body for api/v0/search/asks/: verified machines with enough
    /// GPU RAM for the model, on-demand (ask) pricing, cheapest first. The whole
    /// query lives under "q" (the endpoint rejects a bare query, and rejects the
    /// old select_cols:"*"), with order/limit inside it.</summary>
    public static string BuildSearchBody( int minGpuRamMb, int minDiskGb )
        => JsonSerializer.Serialize( new Dictionary<string, object>
        {
            ["q"] = new Dictionary<string, object>
            {
                ["gpu_ram"] = new Dictionary<string, object> { ["gte"] = minGpuRamMb },
                ["disk_space"] = new Dictionary<string, object> { ["gte"] = minDiskGb },
                ["rentable"] = new Dictionary<string, object> { ["eq"] = true },
                ["verified"] = new Dictionary<string, object> { ["eq"] = true },
                ["num_gpus"] = new Dictionary<string, object> { ["eq"] = 1 },
                ["order"] = new object[] { new object[] { "dph_total", "asc" } },
                ["limit"] = 32,
            },
        } );

    /// <summary>Parses the search response ({"offers":[...]}), tolerating
    /// missing fields — vast's schema drifts and a partial row is still useful.</summary>
    public static List<VastOffer> ParseOffers( string json )
    {
        ArgumentNullException.ThrowIfNull( json );
        using var doc = JsonDocument.Parse( json );
        if ( !doc.RootElement.TryGetProperty( "offers", out var offers )
            || offers.ValueKind != JsonValueKind.Array )
            throw new FormatException( "vast.ai search response has no 'offers' array." );

        var result = new List<VastOffer>();
        foreach ( var offer in offers.EnumerateArray() )
        {
            if ( !offer.TryGetProperty( "id", out var id ) )
                continue;
            result.Add( new VastOffer
            {
                Id = id.GetInt64(),
                GpuName = Str( offer, "gpu_name" ) ?? "GPU",
                NumGpus = (int)Num( offer, "num_gpus", 1 ),
                GpuRamMb = Num( offer, "gpu_ram", 0 ),
                DollarsPerHour = Num( offer, "dph_total", 0 ),
                Reliability = Num( offer, "reliability2", Num( offer, "reliability", 0 ) ),
                InetDownMbps = Num( offer, "inet_down", 0 ),
                DiskSpaceGb = Num( offer, "disk_space", 0 ),
            } );
        }
        return result;
    }

    static string Str( JsonElement e, string name )
        => e.TryGetProperty( name, out var v ) && v.ValueKind == JsonValueKind.String
            ? v.GetString()
            : null;

    static float Num( JsonElement e, string name, float fallback )
        => e.TryGetProperty( name, out var v ) && v.ValueKind == JsonValueKind.Number
            ? v.GetSingle()
            : fallback;
}