HTTP client for the vast.ai REST API used by the Editor AutoRig tooling. It wraps authenticated calls to search offers, create (rent) an instance, list and query instances, and delete an instance with verification and retries.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using AutoRig.Vast;
namespace Editor.AutoRig.Vast;
/// <summary>
/// Thin transport for the vast.ai REST API (console.vast.ai, Bearer key). Each
/// call names its own API version because the migration is selective: the
/// /instances family is v1 (v0 returns 410 Gone), while offer search and rental
/// creation are still v0. All request/response shapes live in whitelist-safe
/// Code/AutoRig/Vast so this class stays dumb and the logic stays unit-tested.
/// </summary>
public sealed class VastClient : IDisposable
{
public const string BaseUrl = "https://console.vast.ai/";
readonly HttpClient _http;
public VastClient( string apiKey, HttpMessageHandler handler = null )
{
if ( string.IsNullOrWhiteSpace( apiKey ) )
throw new ArgumentException( "vast.ai API key is required.", nameof( apiKey ) );
_http = handler is null ? new HttpClient() : new HttpClient( handler );
_http.BaseAddress = new Uri( BaseUrl );
_http.Timeout = TimeSpan.FromSeconds( 60 );
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue( "Bearer", apiKey.Trim() );
}
public async Task<List<VastOffer>> SearchOffers( int minGpuRamMb, int minDiskGb )
{
var body = new StringContent(
VastOffers.BuildSearchBody( minGpuRamMb, minDiskGb ),
Encoding.UTF8, "application/json" );
// Offer search is v0 /search/asks/ (PUT). v0 /bundles/ is gone.
var response = await _http.PutAsync( "api/v0/search/asks/", body );
var json = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
throw new FormatException( $"vast.ai offer search failed ({(int)response.StatusCode}): {Truncate( json )}" );
return VastOffers.ParseOffers( json );
}
/// <summary>Rents the offer. The caller MUST persist the returned id to the
/// ownership ledger before doing anything else with it.</summary>
public async Task<long> CreateInstance( long offerId, string image, string onstart, int diskGb )
{
var payload = System.Text.Json.JsonSerializer.Serialize( new Dictionary<string, object>
{
["client_id"] = "me",
["image"] = image,
["disk"] = diskGb,
["onstart"] = onstart,
["runtype"] = "ssh",
} );
// Rental creation is v0 /asks/{offerId}/ (PUT) - not an /instances path.
var response = await _http.PutAsync( $"api/v0/asks/{offerId}/",
new StringContent( payload, Encoding.UTF8, "application/json" ) );
var json = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
throw new FormatException( $"vast.ai rental failed ({(int)response.StatusCode}): {Truncate( json )}" );
return VastInstances.ParseCreateResponse( json );
}
/// <summary>Lists the user's current instances (read-only) so a rig can be
/// OFFLOADED to one already running. This only enumerates for display - the
/// destroy path still takes its id from the ledger alone, so listing here
/// can never lead to touching an instance we did not create.</summary>
public async Task<List<VastInstances.InstanceSummary>> ListInstances()
{
var response = await _http.GetAsync( "api/v1/instances/" );
var json = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
throw new FormatException( $"vast.ai instance list failed ({(int)response.StatusCode}): {Truncate( json )}" );
return VastInstances.ParseInstanceList( json );
}
/// <summary>Null when the instance no longer exists (verified-destroy signal).</summary>
public async Task<VastInstances.InstanceState> GetInstance( long id )
{
// Instances are v1 (v0 /instances/ returns 410 Gone). owner=me matches
// the official CLI - harmless and accepted by v1.
var response = await _http.GetAsync( $"api/v1/instances/{id}/?owner=me" );
if ( response.StatusCode == System.Net.HttpStatusCode.NotFound )
return null;
var json = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
throw new FormatException( $"vast.ai instance query failed ({(int)response.StatusCode}): {Truncate( json )}" );
return VastInstances.ParseInstance( json );
}
/// <summary>DELETE exactly this id - never enumerates, never touches others.
/// Verified by re-GET and retried; returns true only when the instance is
/// confirmed gone.</summary>
public async Task<bool> DestroyVerified( long id, int attempts = 5 )
{
for ( var attempt = 0; attempt < attempts; attempt++ )
{
try
{
// Instances are v1; the official CLI sends an empty JSON body.
using var request = new HttpRequestMessage( HttpMethod.Delete, $"api/v1/instances/{id}/" )
{
Content = new StringContent( "{}", Encoding.UTF8, "application/json" ),
};
await _http.SendAsync( request );
await Task.Delay( TimeSpan.FromSeconds( 3 + attempt * 3 ) );
if ( await GetInstance( id ) is null )
return true;
}
catch
{
await Task.Delay( TimeSpan.FromSeconds( 5 ) );
}
}
return await GetInstance( id ) is null;
}
static string Truncate( string s )
=> s is null ? "" : s.Length > 300 ? s[..300] : s;
public void Dispose() => _http.Dispose();
}