diff --git a/lib/octaspace.rb b/lib/octaspace.rb index de16fd4..490d771 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -1,29 +1,29 @@ # frozen_string_literal: true require "octaspace/version" +require "octaspace/configuration" require "octaspace/errors" -require "octaspace/payload_helpers" require "octaspace/response" require "octaspace/json_null" +require "octaspace/money" require "octaspace/model" -require "octaspace/configuration" require "octaspace/middleware/url_rotator" require "octaspace/transport/base" require "octaspace/transport/faraday_transport" -require "octaspace/types/account" -require "octaspace/types/balance" -require "octaspace/types/node" require "octaspace/types/machines" require "octaspace/types/session" +require "octaspace/types/account" +require "octaspace/types/network" +require "octaspace/types/hosting" +require "octaspace/types/stats" require "octaspace/resources/base" require "octaspace/resources/accounts" -require "octaspace/resources/nodes" -require "octaspace/resources/sessions" -require "octaspace/resources/apps" require "octaspace/resources/network" +require "octaspace/resources/stats" require "octaspace/resources/compute" require "octaspace/resources/render" -require "octaspace/resources/idle_jobs" +require "octaspace/resources/sessions" +require "octaspace/resources/hosting" require "octaspace/client" # OctaSpace Ruby SDK diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 64f40e0..07bd046 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -35,8 +35,20 @@ module OctaSpace # on_response: ->(resp) { puts "← #{resp.status}" } # ) class Client - attr_reader :accounts, :nodes, :sessions, :apps, - :network, :idle_jobs, :compute, :render + # @return [Resources::Accounts] balance + attr_reader :accounts + # @return [Resources::Compute] machines for rent: list, start, start_batch, restart + attr_reader :compute + # @return [Resources::Hosting] nodes of the account and their prices + attr_reader :hosting + # @return [Resources::Network] public platform statistics + attr_reader :network + # @return [Resources::Render] render nodes: list, start, start_batch + attr_reader :render + # @return [Resources::Sessions] running sessions: list, get, logs, stop, wait_ready, wait_stopped + attr_reader :sessions + # @return [Resources::Stats] public GPU market statistics + attr_reader :stats # @param api_key [String, nil] API key for authentication (optional for public endpoints) # @param opts [Hash] Per-instance configuration overrides. @@ -45,13 +57,12 @@ def initialize(api_key: nil, transport: nil, **opts) @config = build_config(api_key, opts) @transport = transport || build_transport @accounts = Resources::Accounts.new(@transport) - @nodes = Resources::Nodes.new(@transport) - @sessions = Resources::Sessions.new(@transport) - @apps = Resources::Apps.new(@transport) - @network = Resources::Network.new(@transport) - @idle_jobs = Resources::IdleJobs.new(@transport) @compute = Resources::Compute.new(@transport) + @hosting = Resources::Hosting.new(@transport) + @network = Resources::Network.new(@transport) @render = Resources::Render.new(@transport) + @sessions = Resources::Sessions.new(@transport) + @stats = Resources::Stats.new(@transport) end # Shut down persistent connections (only relevant in keep_alive mode) diff --git a/lib/octaspace/money.rb b/lib/octaspace/money.rb new file mode 100644 index 0000000..e0f551b --- /dev/null +++ b/lib/octaspace/money.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module OctaSpace + # Conversions for micro-USD, the integer unit of every price, charge and + # balance in the API (1 micro-USD = $0.000001). + # + # @example + # OctaSpace::Money.format(100) # => "$0.000100" + # OctaSpace::Money.to_usd(1_250_000) # => (5/4) + # OctaSpace::Money.from_usd("0.25") # => 250000 + module Money + # Number of micro-USD in one dollar + MICRO_USD_PER_USD = 1_000_000 + + module_function + + # Converts an integer amount to dollars, exactly. + # @param amount [Integer] micro-USD (or units of 10^-decimals) + # @param decimals [Integer] decimal places of the unit (6 for micro-USD) + # @return [Rational] + def to_usd(amount, decimals: 6) + Rational(integer!(amount), 10**decimals) + end + + # Converts dollars to micro-USD, rounded to the nearest integer + # (e.g. for +hosting.set_prices+). + # @param usd [Integer, Rational, String, Float] a String like "0.25" is + # exact; a Float goes through its shortest decimal form + # @return [Integer] + def from_usd(usd) + value = case usd + when Integer, Rational then usd + when Float + raise ArgumentError, "not a finite dollar amount: #{usd}" unless usd.finite? + + Rational(usd.to_s) + when String + text = usd.strip.delete_prefix("$") + raise ArgumentError, "not a dollar amount: #{usd.inspect}" unless text.match?(/\A-?\d+(\.\d+)?\z/) + + Rational(text) + else + raise ArgumentError, "not a dollar amount: #{usd.inspect}" + end + (value * MICRO_USD_PER_USD).round + end + + # Formats an integer amount as dollars with all decimal places, using + # integer arithmetic: 100 → "$0.000100", -1_250_000 → "-$1.250000". + # @param amount [Integer] micro-USD (or units of 10^-decimals) + # @param decimals [Integer] + # @return [String] + def format(amount, decimals: 6) + amount = integer!(amount) + whole, fraction = amount.abs.divmod(10**decimals) + sign = amount.negative? ? "-" : "" + return "#{sign}$#{whole}" if decimals.zero? + + "#{sign}$#{whole}.#{fraction.to_s.rjust(decimals, "0")}" + end + + def integer!(amount) + raise ArgumentError, "not an integer amount: #{amount.inspect}" unless amount.is_a?(Integer) + + amount + end + private_class_method :integer! + end +end diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb deleted file mode 100644 index b944b88..0000000 --- a/lib/octaspace/payload_helpers.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module OctaSpace - module PayloadHelpers - module_function - - def parse_port_list(value) - case value - when nil - [] - when Array - value - when String - parse_stringified_port_list(value) - else - Array(value) - end - end - - def parse_stringified_port_list(value) - stripped = value.strip - return [] if stripped.empty? - - parsed = JSON.parse(stripped) - parsed.is_a?(Array) ? parsed : [] - rescue JSON::ParserError - [] - end - private_class_method :parse_stringified_port_list - end -end diff --git a/lib/octaspace/resources/accounts.rb b/lib/octaspace/resources/accounts.rb index 52b5579..3b2b39a 100644 --- a/lib/octaspace/resources/accounts.rb +++ b/lib/octaspace/resources/accounts.rb @@ -2,31 +2,20 @@ module OctaSpace module Resources - # Account-related API endpoints + # The account the API key belongs to # # @example - # client.accounts.profile - # client.accounts.balance + # client.accounts.balance.formatted # => "$2.147661" class Accounts < Base - # Fetch the authenticated user's profile - # GET /accounts - # @return [OctaSpace::Response] - def profile - get("/accounts") - end - - # Fetch the authenticated user's balance + # Account balance. # GET /accounts/balance - # @return [OctaSpace::Response] + # + # The API has no "who am I" endpoint, so this is also the way to check + # that an API key is valid. It is read from a replica, so a recent charge + # or top-up may show up with a delay. + # @return [OctaSpace::Types::Balance] def balance - get("/accounts/balance") - end - - # Generate / create a new wallet for the authenticated user - # POST /accounts - # @return [OctaSpace::Response] - def generate_wallet - post("/accounts") + Types::Balance.new(get("/accounts/balance").data) end end end diff --git a/lib/octaspace/resources/apps.rb b/lib/octaspace/resources/apps.rb deleted file mode 100644 index 3729172..0000000 --- a/lib/octaspace/resources/apps.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Apps API endpoint - # - # @example - # client.apps.list - class Apps < Base - # List available apps - # GET /apps - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/apps", params:) - end - end - end -end diff --git a/lib/octaspace/resources/hosting.rb b/lib/octaspace/resources/hosting.rb new file mode 100644 index 0000000..d8cbcf9 --- /dev/null +++ b/lib/octaspace/resources/hosting.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Nodes the account hosts + # + # @example + # client.hosting.nodes.each { |node| puts "#{node.number} #{node.status}" } + # client.hosting.set_prices("M95S-WZG8-7", base: OctaSpace::Money.from_usd("0.25")) + class Hosting < Base + PRICE_FIELDS = %i[base storage traffic min_rent_minutes].freeze + + # GPU fields nodes may send as strings in +system.gpus+ + HOSTING_GPU_FIELDS = (GPU_INTEGER_FIELDS + %w[mem_free_mb fan_speed gpu_temperature gpu_utilization power_draw_watt]).freeze + + # Online nodes of the account with their state, hardware and prices. + # GET /hosting/nodes + # + # Offline nodes are not listed, and there is no per-node endpoint. + # @return [Array] + def nodes + data = JsonNull.null_strings!(Array(get("/hosting/nodes").data)) + JsonNull.gpu_numbers!(data.map { |node| node.is_a?(Hash) ? node["system"] : nil }.compact, HOSTING_GPU_FIELDS) + Types::HostingNode.wrap_all(data) + end + + # Updates some prices of a node. + # PATCH /hosting/nodes/{number}/prices (→ 204) + # + # Only the given fields are sent; at least one is required. Amounts are + # integer micro-USD (convert dollars with {OctaSpace::Money.from_usd}). + # Ranges: base and traffic 100..1_000_000_000, storage 0..1_000_000_000, + # min_rent_minutes 0..60. The API refuses other values with + # {OctaSpace::ValidationError}, and an unknown node with + # {OctaSpace::NotFoundError}. + # + # 204 means the prices are stored; listings (+hosting.nodes+, + # +compute.list+) show them after replication, usually within seconds. + # + # @param number [String] node number; case and dashes are ignored + # @param base [Integer, nil] + # @param storage [Integer, nil] + # @param traffic [Integer, nil] + # @param min_rent_minutes [Integer, nil] + # @return [nil] + def set_prices(number, **prices) + body = compact_params(prices, PRICE_FIELDS) + raise ArgumentError, "set_prices needs at least one of #{PRICE_FIELDS.join(", ")}" if body.empty? + + body.each do |field, value| + raise ArgumentError, "#{field} must be an Integer (micro-USD or minutes), got #{value.inspect}" unless value.is_a?(Integer) + end + patch("/hosting/nodes/#{encode(number)}/prices", body: body) + nil + end + end + end +end diff --git a/lib/octaspace/resources/idle_jobs.rb b/lib/octaspace/resources/idle_jobs.rb deleted file mode 100644 index f19e1cb..0000000 --- a/lib/octaspace/resources/idle_jobs.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Idle Jobs API endpoints - # - # Each idle job is identified by both a node ID and a job ID. - # - # @example - # client.idle_jobs.find(node_id: 69, job_id: 42) - # client.idle_jobs.logs(node_id: 69, job_id: 42) - class IdleJobs < Base - # Fetch a single idle job status - # GET /idle_jobs/:node_id/:job_id - # @param node_id [Integer, String] - # @param job_id [Integer, String] - # @return [OctaSpace::Response] - def find(node_id:, job_id:) - get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}") - end - - # Fetch idle job logs - # GET /idle_jobs/:node_id/:job_id/logs - # @param node_id [Integer, String] - # @param job_id [Integer, String] - # @return [OctaSpace::Response] - def logs(node_id:, job_id:) - get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}/logs") - end - end - end -end diff --git a/lib/octaspace/resources/network.rb b/lib/octaspace/resources/network.rb index 61e6365..173a4f2 100644 --- a/lib/octaspace/resources/network.rb +++ b/lib/octaspace/resources/network.rb @@ -2,16 +2,18 @@ module OctaSpace module Resources - # Network information endpoint + # Public platform statistics; no API key needed # # @example - # client.network.info + # client.network.info.nodes.online class Network < Base - # Fetch combined network information (blockchain, market, nodes, power, etc.) + # Platform statistics snapshot. # GET /network - # @return [OctaSpace::Response] + # + # Raises {OctaSpace::ServiceUnavailableError} while it is unavailable. + # @return [OctaSpace::Types::Network] def info - get("/network", auth: :none) + Types::Network.new(get("/network", auth: :none).data) end end end diff --git a/lib/octaspace/resources/nodes.rb b/lib/octaspace/resources/nodes.rb deleted file mode 100644 index e9595a2..0000000 --- a/lib/octaspace/resources/nodes.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Node-related API endpoints - # - # @example - # client.nodes.list - # client.nodes.find(123) - # client.nodes.reboot(123) - # client.nodes.update_prices(123, gpu_hour: 0.5, cpu_hour: 0.1) - class Nodes < Base - # List all nodes - # GET /nodes - # @param params [Hash] optional filter/pagination params - # @return [OctaSpace::Response] - def list(**params) - get("/nodes", params:) - end - - # Fetch a single node by ID - # GET /nodes/:id - # @param id [Integer, String] - # @return [OctaSpace::Response] - def find(id) - get("/nodes/#{encode(id)}") - end - - # Download node identity file (binary response) - # GET /nodes/:id/ident - # @param id [Integer, String] - # @return [OctaSpace::Response] - def download_ident(id) - get("/nodes/#{encode(id)}/ident") - end - - # Download node logs (binary response) - # GET /nodes/:id/logs - # @param id [Integer, String] - # @return [OctaSpace::Response] - def download_logs(id) - get("/nodes/#{encode(id)}/logs") - end - - # Update node pricing - # PATCH /nodes/:id/prices - # @param id [Integer, String] - # @param prices [Hash] e.g. { gpu_hour: 0.5, cpu_hour: 0.1 } - # @return [OctaSpace::Response] - def update_prices(id, **prices) - patch("/nodes/#{encode(id)}/prices", body: prices) - end - - # Reboot a node - # GET /nodes/:id/reboot - # @param id [Integer, String] - # @return [OctaSpace::Response] - def reboot(id) - get("/nodes/#{encode(id)}/reboot") - end - end - end -end diff --git a/lib/octaspace/resources/stats.rb b/lib/octaspace/resources/stats.rb new file mode 100644 index 0000000..82bd497 --- /dev/null +++ b/lib/octaspace/resources/stats.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Public GPU market statistics; no API key needed + # + # @example + # client.stats.gpu_current.gpus.first.rented_price.p50 + # client.stats.gpu_history(gpu_name: "NVIDIA GeForce RTX 3090", from: Time.now - 7 * 86_400) + # .fetch("NVIDIA GeForce RTX 3090").points + class Stats < Base + # GPU models on the market now, most common first. + # GET /stats/gpu/current + # @param verified [String, Symbol, nil] "all" (default), "yes" or "no" + # @return [OctaSpace::Types::GpuMarket] + def gpu_current(verified: nil) + Types::GpuMarket.new(fetch("/stats/gpu/current", verified: verified)) + end + + # GPU market over time, by model. Defaults to the last day with hourly + # points. Invalid ranges (step < 300, too many points, from after to) + # raise {OctaSpace::ValidationError}. + # GET /stats/gpu/history + # + # @param gpu_name [String, Array, nil] model name(s), or "all" + # @param verified [String, Symbol, nil] "all", "yes" or "no" + # @param from [Time, Integer, nil] range start (Unix seconds); the API's +start+ + # @param to [Time, Integer, nil] range end (Unix seconds); the API's +end+ + # @param step [Integer, nil] seconds between points, at least 300 (default 3600) + # @return [Hash{String=>OctaSpace::Types::GpuHistory}] by model name + def gpu_history(gpu_name: nil, verified: nil, from: nil, to: nil, step: nil) + data = fetch("/stats/gpu/history", + gpu_name: gpu_name.is_a?(Array) ? gpu_name.join(",") : gpu_name, + verified: verified, start: unix(from), end: unix(to), step: step) + data.to_h.transform_values { |history| Types::GpuHistory.wrap(history) } + end + + # GPUs by city and model (GeoIP), without per-node detail. + # GET /stats/gpu/locations + # @return [Array] + def gpu_locations + Types::GpuLocation.wrap_all(fetch("/stats/gpu/locations")) + end + + private + + def fetch(path, **params) + JsonNull.null_strings!(get(path, params: params, auth: :none).data) + end + + def unix(value) = value.respond_to?(:to_time) ? value.to_time.to_i : value + end + end +end diff --git a/lib/octaspace/types/account.rb b/lib/octaspace/types/account.rb index 0ae11e5..3d98508 100644 --- a/lib/octaspace/types/account.rb +++ b/lib/octaspace/types/account.rb @@ -2,10 +2,23 @@ module OctaSpace module Types - # Value object representing an OctaSpace account - Account = Data.define(:account_uuid, :email, :avatar, :balance) do - # @return [String] - def to_s = "OctaSpace Account <#{email}>" + # Account balance (+GET /accounts/balance+). It can be negative. + class Balance < Model + # @!attribute [r] balance + # @return [Integer] amount in units of 10^-decimals of {#currency} + # @!attribute [r] currency + # @return [String] "USD" + # @!attribute [r] decimals + # @return [Integer] decimal places of {#balance} (6: micro-USD) + attribute :balance, :currency, :decimals + + # @return [Rational] the balance in currency units, exactly + def to_usd = Money.to_usd(balance, decimals: decimals) + + # @return [String] e.g. "$2.147661" + def formatted = Money.format(balance, decimals: decimals) + + def negative? = balance.negative? end end end diff --git a/lib/octaspace/types/balance.rb b/lib/octaspace/types/balance.rb deleted file mode 100644 index 2d6f315..0000000 --- a/lib/octaspace/types/balance.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Types - # Value object representing an account balance - Balance = Data.define(:amount, :currency) do - # @return [String] - def to_s = "#{amount} #{currency}" - end - end -end diff --git a/lib/octaspace/types/hosting.rb b/lib/octaspace/types/hosting.rb new file mode 100644 index 0000000..9e33679 --- /dev/null +++ b/lib/octaspace/types/hosting.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "time" + +module OctaSpace + module Types + # Prices of a hosted node, integer micro-USD + class NodePrices < Model + # @!attribute [r] base + # @return [Integer] per hour + # @!attribute [r] storage + # @return [Integer] per GB of disk per hour + # @!attribute [r] traffic + # @return [Integer] per GB of traffic + # @!attribute [r] min_rent_minutes + # @return [Integer] minimum rent, 0..60 (0 = none) + attribute :base, :storage, :traffic, :min_rent_minutes + end + + # Whether a node is offered for a service, and why not + class ServiceOffer < Model + # @!attribute [r] reason + # @return [String, nil] e.g. "ports_closed" + attribute :offered, :reason + + def offered? = offered == true + end + + # A verification or benchmark that is running on a node + class NodeActivity < Model + # @!attribute [r] kind + # @return [String] "verification" or "benchmark" + # @!attribute [r] started_at + # @return [Integer, nil] Unix seconds + attribute :kind, :step, :started_at + + # @return [Time, nil] + def started_time = started_at.is_a?(Numeric) ? Time.at(started_at) : nil + end + + # Size and use of memory or disk, bytes + class ResourceUsage < Model + attribute :size, :free, :used, :used_percent + end + + # A GPU as the node reports it; fields beyond these via #[] + class HostingGpu < Gpu + attribute :mem_free_mb, :fan_speed, :gpu_temperature, :gpu_utilization, :power_draw_watt, :pstate + end + + # Hardware and OS of a hosted node + class HostingSystem < Model + attribute :cpu_cores, :cpu_model_name, :cpu_load_percent, :os_version + attribute :memory, :disk, model: ResourceUsage + attribute :gpus, model: HostingGpu, many: true + end + + # Location of a node (GeoIP) + class Location < Model + attribute :city, :country, :latitude, :longitude + end + + # An online node of the account (+GET /hosting/nodes+) + class HostingNode < Model + # @!attribute [r] number + # @return [String] node number, e.g. "M95S-WZG8-7" (the one renters see) + # @!attribute [r] status + # @return [String] "idle", "rented", "verification", "benchmark" or "unknown" + # @!attribute [r] verified + # @return [Boolean, nil] passed verification; nil while unknown + # @!attribute [r] osn + # @return [String] node software version + # @!attribute [r] online_at + # @return [String] ISO 8601; see {#online_time} + # @!attribute [r] uptime + # @return [Integer] seconds since the machine booted + # @!attribute [r] uptime_percent + # @return [Float] online over the last 30 days (97 and above show as 100) + attribute :number, :status, :maintenance, :verified, :ip, :osn, :online_at, :uptime, :uptime_percent + attribute :activity, model: NodeActivity + attribute :prices, model: NodePrices + attribute :system, model: HostingSystem + attribute :location, model: Location + + def maintenance? = maintenance == true + def verified? = verified == true + def idle? = status == "idle" + def rented? = status == "rented" + + # @return [Hash{String=>ServiceOffer}] by service ("compute", "render", …) + def services + @services ||= self["services"].to_h.transform_values { |offer| ServiceOffer.wrap(offer) } + end + + # @param service [String, Symbol] + # @return [Boolean] whether the node is offered for this service + def offered?(service) = services[service.to_s]&.offered? || false + + # @return [Time, nil] + def online_time + online_at.is_a?(String) ? Time.iso8601(online_at) : nil + rescue ArgumentError + nil + end + end + end +end diff --git a/lib/octaspace/types/network.rb b/lib/octaspace/types/network.rb new file mode 100644 index 0000000..f784727 --- /dev/null +++ b/lib/octaspace/types/network.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # Platform statistics snapshot (+GET /network+), refreshed every few minutes + class Network < Model + # Node counts + class Nodes < Model + attribute :online, :available, :rented, :countries + end + + # Total hardware of online nodes; +memory+ and +disk+ in bytes + class Power < Model + attribute :cpu_cores, :disk, :gpus, :memory, :tflops + end + + # Rendered frames of one week + class WeekFrames < Model + attribute :week, :frames + end + + # Render farm counters + class Render < Model + attribute :frames_24h, :frames_total + attribute :frames_by_week, model: WeekFrames, many: true + end + + # Session counters + class Sessions < Model + attribute :running, :total, :last_24h + end + + # User counters + class Users < Model + attribute :total, :last_24h + end + + # VPN node counters + class Vpn < Model + attribute :nodes, :countries + end + + # @!attribute [r] at + # @return [Integer] snapshot time, Unix seconds; see {#time} + attribute :at + attribute :nodes, model: Nodes + attribute :power, model: Power + attribute :render, model: Render + attribute :sessions, model: Sessions + attribute :users, model: Users + attribute :vpn, model: Vpn + + # @return [Time, nil] + def time = at.is_a?(Numeric) ? Time.at(at) : nil + end + end +end diff --git a/lib/octaspace/types/node.rb b/lib/octaspace/types/node.rb deleted file mode 100644 index 91647b8..0000000 --- a/lib/octaspace/types/node.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Types - # Value object representing a compute node - Node = Data.define(:id, :ip, :state, :location, :prices, :system) do - # @return [Boolean] - def online? = state == "online" - def offline? = !online? - end - end -end diff --git a/lib/octaspace/types/stats.rb b/lib/octaspace/types/stats.rb new file mode 100644 index 0000000..55120c5 --- /dev/null +++ b/lib/octaspace/types/stats.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # Price percentiles, micro-USD per GPU-hour; nil where there is no data + class PricePercentiles < Model + attribute :p10, :p25, :p50, :p75, :p90 + end + + # One GPU model on the market now + class GpuMarketModel < Model + # @!attribute [r] total + # @return [Integer] GPUs (not nodes) of this model + # @!attribute [r] usage + # @return [Float] percent rented now + # @!attribute [r] usage_30d + # @return [Float, nil] average percent rented over 30 days + attribute :model, :total, :available, :rented, :usage, :usage_30d, :ai_score, :tflops, :tflops_per_dollar + attribute :available_price, :rented_price, model: PricePercentiles + end + + # GPU market now (+GET /stats/gpu/current+), most common models first + class GpuMarket < Model + # @!attribute [r] at + # @return [Integer] snapshot time, Unix seconds + attribute :at + attribute :gpus, model: GpuMarketModel, many: true + + # @return [Time, nil] + def time = at.is_a?(Numeric) ? Time.at(at) : nil + end + + # Parallel arrays of GPU counts + class SupplyDemand < Model + attribute :total, :available, :rented + end + + # Parallel arrays of price percentiles + class PriceSeries < Model + attribute :p10, :p25, :p50, :p75, :p90 + end + + # One point of a model's history, see {GpuHistory#points} + GpuHistoryPoint = Data.define(:ts, :total, :available, :rented, :available_price, :rented_price) do + # @return [Time] + def time = Time.at(ts) + end + + # History of one GPU model (+GET /stats/gpu/history+): parallel arrays, + # one value per entry of {#timestamps} + class GpuHistory < Model + # @!attribute [r] timestamps + # @return [Array] Unix seconds + attribute :timestamps, :tflops + attribute :supply_demand, model: SupplyDemand + attribute :available_price, :rented_price, model: PriceSeries + + # Turns the parallel arrays into one point per timestamp, e.g. for a chart. + # @return [Array] + def points + Array(timestamps).each_with_index.map do |ts, i| + GpuHistoryPoint.new( + ts: ts, + total: series_at(supply_demand, :total, i), + available: series_at(supply_demand, :available, i), + rented: series_at(supply_demand, :rented, i), + available_price: percentiles_at(available_price, i), + rented_price: percentiles_at(rented_price, i) + ) + end + end + + private + + def series_at(model, key, i) = model&.public_send(key)&.at(i) + + def percentiles_at(series, i) + PricePercentiles.new(%w[p10 p25 p50 p75 p90].to_h { |p| [p, series_at(series, p, i)] }) + end + end + + # GPUs of one model in one city (+GET /stats/gpu/locations+) + class GpuLocation < Model + # @!attribute [r] gpus + # @return [Integer] GPUs of this model in the city + # @!attribute [r] verified + # @return [Integer] of them on verified nodes + attribute :city, :country, :latitude, :longitude, :model, :gpus, :rented, :verified + end + end +end diff --git a/test/fixtures/accounts/balance.json b/test/fixtures/accounts/balance.json deleted file mode 100644 index afbd637..0000000 --- a/test/fixtures/accounts/balance.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "balance": "1000000000000000000", - "currency": "OCTA" -} diff --git a/test/fixtures/accounts/show.json b/test/fixtures/accounts/show.json deleted file mode 100644 index 603f897..0000000 --- a/test/fixtures/accounts/show.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "account_uuid": "acc-123-abc", - "email": "user@example.com", - "avatar": "https://example.com/avatar.png", - "balance": "1000000000000000000" -} diff --git a/test/fixtures/apps/index.json b/test/fixtures/apps/index.json deleted file mode 100644 index ab250f0..0000000 --- a/test/fixtures/apps/index.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "id": 1, - "uuid": "249b4cb3-3db1-4c06-98a4-772ba88cd81c", - "name": "Stable Diffusion", - "version": "2.1", - "category": "AI", - "image": "ubuntu:24.04", - "docker_image": "octaspace/stable-diffusion:2.1", - "min_vram_gb": 8, - "ports": "[5000,6000]", - "http_ports": "[8080]" - }, - { - "id": 2, - "uuid": "a7f5f354-5d7a-4f4e-b927-a4f18f3eb241", - "name": "ComfyUI", - "version": "1.0", - "category": "AI", - "image": "ubuntu:22.04", - "docker_image": "octaspace/comfyui:latest", - "min_vram_gb": 6, - "ports": "[]", - "http_ports": "[]" - } -] diff --git a/test/fixtures/idle_jobs/index.json b/test/fixtures/idle_jobs/index.json deleted file mode 100644 index 62d7c5d..0000000 --- a/test/fixtures/idle_jobs/index.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "id": 10, - "node_id": 3, - "command": "python train.py --epochs 10", - "status": "completed", - "created_at": "2026-04-10T12:00:00Z", - "finished_at": "2026-04-10T14:30:00Z" - }, - { - "id": 11, - "node_id": 4, - "command": "bash preprocess.sh", - "status": "running", - "created_at": "2026-04-11T07:00:00Z", - "finished_at": null - } -] diff --git a/test/fixtures/idle_jobs/logs.json b/test/fixtures/idle_jobs/logs.json deleted file mode 100644 index 09872d1..0000000 --- a/test/fixtures/idle_jobs/logs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "data": [ - {"line": 1, "text": "Epoch 1/10 - loss: 0.842"}, - {"line": 2, "text": "Epoch 2/10 - loss: 0.731"}, - {"line": 3, "text": "Training complete."} - ] -} diff --git a/test/fixtures/idle_jobs/show.json b/test/fixtures/idle_jobs/show.json deleted file mode 100644 index b796a5e..0000000 --- a/test/fixtures/idle_jobs/show.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 42, - "node_id": 69, - "status": "completed", - "is_up": false, - "created_at": "2026-04-10T12:00:00Z", - "finished_at": "2026-04-10T14:30:00Z" -} diff --git a/test/fixtures/network/index.json b/test/fixtures/network/index.json deleted file mode 100644 index e78ca57..0000000 --- a/test/fixtures/network/index.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "blockchain": { - "blocktime": 12.77, - "circulating_supply": 42115431, - "era": "Vega", - "height": 9299385 - }, - "market_price": 0.104, - "marketplace": { - "gpus": { - "NVIDIA GeForce RTX 3090": { "avg_price": 0.09, "count": 61 }, - "NVIDIA GeForce RTX 4090": { "avg_price": 0.17, "count": 31 } - } - }, - "nodes": { - "online": 98, - "total": 120 - }, - "power": { - "total_tflops": 5100.5, - "utilized_tflops": 3200.0 - }, - "platform": { - "total_jobs": 412 - } -} diff --git a/test/fixtures/network/power.json b/test/fixtures/network/power.json deleted file mode 100644 index 6e10704..0000000 --- a/test/fixtures/network/power.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "total_tflops": 12500.5, - "available_tflops": 1840.0, - "utilization_pct": 85.3 -} diff --git a/test/fixtures/network/stats.json b/test/fixtures/network/stats.json deleted file mode 100644 index 7001b07..0000000 --- a/test/fixtures/network/stats.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "total_nodes": 120, - "online_nodes": 98, - "total_gpu_count": 310, - "available_gpu_count": 45, - "regions": ["EU", "US", "APAC"] -} diff --git a/test/fixtures/next/account_balance.json b/test/fixtures/next/account_balance.json new file mode 100644 index 0000000..9526195 --- /dev/null +++ b/test/fixtures/next/account_balance.json @@ -0,0 +1,5 @@ +{ + "decimals": 6, + "balance": 1234567, + "currency": "USD" +} \ No newline at end of file diff --git a/test/fixtures/next/hosting_nodes.json b/test/fixtures/next/hosting_nodes.json new file mode 100644 index 0000000..e4d97b0 --- /dev/null +++ b/test/fixtures/next/hosting_nodes.json @@ -0,0 +1,248 @@ +[ + { + "status": "idle", + "system": { + "memory": { + "free": 128846458880, + "size": 132114866176, + "used": 3268407296, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 47, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 39.27, + "power_limit": 390, + "pstate": "P8" + }, + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 35, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 24.47, + "power_limit": 390, + "pstate": "P8" + }, + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 35, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 29.33, + "power_limit": 390, + "pstate": "P8" + } + ], + "disk": { + "free": 1193761476608, + "size": 1999420977152, + "used": 805659500544, + "used_percent": 41 + }, + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.10", + "number": "M95S-WZG8-7", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "render": { + "reason": null, + "offered": true + }, + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 170542, + "verified": true, + "uptime_percent": 96.2168089037879, + "prices": { + "base": 100, + "storage": 100, + "min_rent_minutes": 0, + "traffic": 100 + }, + "osn": "1.0.0-pre35", + "online_at": "2026-11-01T18:43:45Z", + "maintenance": false, + "activity": null + }, + { + "status": "idle", + "system": { + "memory": { + "free": 63785484288, + "size": 65173594112, + "used": 1388109824, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 33, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24123, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_draw_watt": 23.06, + "power_limit": 390, + "pstate": "P8" + } + ], + "disk": { + "free": 773940592640, + "size": 792034541568, + "used": 18093948928, + "used_percent": 3 + }, + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.11", + "number": "7R2S-S05E-S", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "render": { + "reason": null, + "offered": true + }, + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 258040, + "verified": true, + "uptime_percent": 92.03747157535116, + "prices": { + "base": 100000, + "storage": 0, + "min_rent_minutes": 10, + "traffic": 100 + }, + "osn": "1.0.0-pre34", + "online_at": "2026-11-01T18:43:44Z", + "maintenance": false, + "activity": null + }, + { + "status": "idle", + "system": { + "memory": { + "free": 96366718976, + "size": 97915236352, + "used": 1548517376, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2850, + "fan_speed": "null", + "gpu_temperature": 27, + "gpu_utilization": 0, + "mem_clock": "null", + "mem_free_mb": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 8, + "power_draw_watt": "null", + "power_limit": "null" + }, + { + "core_clock": 2850, + "fan_speed": "null", + "gpu_temperature": 51, + "gpu_utilization": 0, + "mem_clock": "null", + "mem_free_mb": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 4, + "power_draw_watt": "null", + "power_limit": "null" + } + ], + "disk": { + "free": 1439272214528, + "size": 1479966982144, + "used": 40694767616, + "used_percent": 3 + }, + "cpu_model_name": "AMD Ryzen 7 7700 8-Core Processor", + "cpu_cores": 16, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.12", + "number": "WJFM-MYWR-9", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 32390, + "verified": true, + "uptime_percent": 83.78341416810808, + "prices": { + "base": 100, + "storage": 100, + "min_rent_minutes": 0, + "traffic": 100 + }, + "osn": "1.0.0-pre34", + "online_at": "2026-11-01T18:43:44Z", + "maintenance": false, + "activity": null + } +] \ No newline at end of file diff --git a/test/fixtures/next/network.json b/test/fixtures/next/network.json new file mode 100644 index 0000000..b77d742 --- /dev/null +++ b/test/fixtures/next/network.json @@ -0,0 +1,34 @@ +{ + "nodes": { + "available": 2, + "rented": 1, + "countries": 1, + "online": 3 + }, + "at": 1793566134, + "users": { + "total": 1, + "last_24h": 0 + }, + "render": { + "frames_24h": 0, + "frames_by_week": [], + "frames_total": 6588996 + }, + "vpn": { + "nodes": 0, + "countries": 0 + }, + "sessions": { + "running": 1, + "total": 87, + "last_24h": 4 + }, + "power": { + "memory": 295203696640, + "gpus": 6, + "disk": 4271422500864, + "cpu_cores": 76, + "tflops": 142.4 + } +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_current.json b/test/fixtures/next/stats_gpu_current.json new file mode 100644 index 0000000..9183ed8 --- /dev/null +++ b/test/fixtures/next/stats_gpu_current.json @@ -0,0 +1,55 @@ +{ + "at": 1793566134, + "gpus": [ + { + "total": 4, + "usage": 25.0, + "available": 3, + "ai_score": 36621, + "rented": 1, + "model": "NVIDIA GeForce RTX 3090", + "available_price": { + "p10": 33, + "p25": 33, + "p50": 33, + "p75": 33, + "p90": 33 + }, + "rented_price": { + "p10": 100000, + "p25": 100000, + "p50": 100000, + "p75": 100000, + "p90": 100000 + }, + "tflops": 35.6, + "tflops_per_dollar": 1078787.88, + "usage_30d": 11.9 + }, + { + "total": 2, + "usage": 0.0, + "available": 2, + "ai_score": 0, + "rented": 0, + "model": "Arc B580", + "available_price": { + "p10": 50, + "p25": 50, + "p50": 50, + "p75": 50, + "p90": 50 + }, + "rented_price": { + "p10": "null", + "p25": "null", + "p50": "null", + "p75": "null", + "p90": "null" + }, + "tflops": "null", + "tflops_per_dollar": "null", + "usage_30d": 31.39 + } + ] +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_history.json b/test/fixtures/next/stats_gpu_history.json new file mode 100644 index 0000000..6797de5 --- /dev/null +++ b/test/fixtures/next/stats_gpu_history.json @@ -0,0 +1,160 @@ +{ + "Arc B580": { + "available_price": { + "p10": [ + 50, + 50, + 50 + ], + "p25": [ + 50, + 50, + 50 + ], + "p50": [ + 50, + 50, + 50 + ], + "p75": [ + 50, + 50, + 50 + ], + "p90": [ + 50, + 50, + 50 + ] + }, + "rented_price": { + "p10": [ + "null", + 50, + 50 + ], + "p25": [ + "null", + 50, + 50 + ], + "p50": [ + "null", + 50, + 50 + ], + "p75": [ + "null", + 50, + 50 + ], + "p90": [ + "null", + 50, + 50 + ] + }, + "tflops": "null", + "supply_demand": { + "total": [ + 2.0, + 2.0, + 2.0 + ], + "available": [ + 2.0, + 1.57, + 0.55 + ], + "rented": [ + 0.0, + 0.43, + 1.45 + ] + }, + "timestamps": [ + 1793555368, + 1793558968, + 1793562568 + ] + }, + "NVIDIA GeForce RTX 3090": { + "available_price": { + "p10": [ + 33, + 33, + 33 + ], + "p25": [ + 33, + 33, + 33 + ], + "p50": [ + 33, + 33, + 33 + ], + "p75": [ + 25025, + 14314, + 33 + ], + "p90": [ + 70010, + 40020, + 33 + ] + }, + "rented_price": { + "p10": [ + "null", + 100000, + 100000 + ], + "p25": [ + "null", + 100000, + 100000 + ], + "p50": [ + "null", + 100000, + 100000 + ], + "p75": [ + "null", + 100000, + 100000 + ], + "p90": [ + "null", + 100000, + 100000 + ] + }, + "tflops": 35.6, + "supply_demand": { + "total": [ + 4.0, + 4.0, + 4.0 + ], + "available": [ + 4.0, + 3.57, + 3.0 + ], + "rented": [ + 0.0, + 0.43, + 1.0 + ] + }, + "timestamps": [ + 1793555368, + 1793558968, + 1793562568 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_locations.json b/test/fixtures/next/stats_gpu_locations.json new file mode 100644 index 0000000..a8cb635 --- /dev/null +++ b/test/fixtures/next/stats_gpu_locations.json @@ -0,0 +1,22 @@ +[ + { + "city": "Mukachevo", + "country": "Ukraine", + "verified": 2, + "gpus": 2, + "rented": 0, + "model": "Arc B580", + "longitude": 22.718, + "latitude": 48.4425 + }, + { + "city": "Mukachevo", + "country": "Ukraine", + "verified": 4, + "gpus": 4, + "rented": 1, + "model": "NVIDIA GeForce RTX 3090", + "longitude": 22.718, + "latitude": 48.4425 + } +] \ No newline at end of file diff --git a/test/fixtures/nodes/index.json b/test/fixtures/nodes/index.json deleted file mode 100644 index 6d6dc9d..0000000 --- a/test/fixtures/nodes/index.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "id": 1, - "ip": "192.168.1.1", - "state": "online", - "location": {"country": "DE", "city": "Frankfurt"}, - "prices": {"gpu_hour": 0.5, "cpu_hour": 0.1}, - "system": {"gpu": "RTX 4090", "cpu": "AMD EPYC", "ram": 128} - }, - { - "id": 2, - "ip": "192.168.1.2", - "state": "offline", - "location": {"country": "US", "city": "New York"}, - "prices": {"gpu_hour": 0.4, "cpu_hour": 0.08}, - "system": {"gpu": "RTX 3090", "cpu": "Intel Xeon", "ram": 64} - } -] diff --git a/test/fixtures/nodes/show.json b/test/fixtures/nodes/show.json deleted file mode 100644 index 8cd8a68..0000000 --- a/test/fixtures/nodes/show.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 1, - "ip": "192.168.1.1", - "state": "online", - "location": {"country": "DE", "city": "Frankfurt"}, - "prices": {"gpu_hour": 0.5, "cpu_hour": 0.1}, - "system": {"gpu": "RTX 4090", "cpu": "AMD EPYC", "ram": 128} -} diff --git a/test/octaspace/client_test.rb b/test/octaspace/client_test.rb index d7ea647..3589ead 100644 --- a/test/octaspace/client_test.rb +++ b/test/octaspace/client_test.rb @@ -5,14 +5,10 @@ class OctaSpace::ClientTest < Minitest::Test def test_initializes_all_resources client = test_client - assert_instance_of OctaSpace::Resources::Accounts, client.accounts - assert_instance_of OctaSpace::Resources::Nodes, client.nodes - assert_instance_of OctaSpace::Resources::Sessions, client.sessions - assert_instance_of OctaSpace::Resources::Apps, client.apps - assert_instance_of OctaSpace::Resources::Network, client.network - assert_instance_of OctaSpace::Resources::Compute, client.compute - assert_instance_of OctaSpace::Resources::Render, client.render - assert_instance_of OctaSpace::Resources::IdleJobs, client.idle_jobs + {accounts: "Accounts", compute: "Compute", hosting: "Hosting", network: "Network", + render: "Render", sessions: "Sessions", stats: "Stats"}.each do |reader, name| + assert_instance_of OctaSpace::Resources.const_get(name), client.public_send(reader) + end end def test_uses_standard_transport_by_default diff --git a/test/octaspace/money_test.rb b/test/octaspace/money_test.rb new file mode 100644 index 0000000..3f6566f --- /dev/null +++ b/test/octaspace/money_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::MoneyTest < Minitest::Test + Money = OctaSpace::Money + + def test_format + assert_equal "$0.000100", Money.format(100) + assert_equal "$0.000000", Money.format(0) + assert_equal "-$1.250000", Money.format(-1_250_000) + assert_equal "$9007199254.740993", Money.format(9_007_199_254_740_993) + assert_equal "$12", Money.format(12, decimals: 0) + end + + def test_to_usd_is_exact + assert_equal Rational(1, 10_000), Money.to_usd(100) + assert_equal Rational(-5, 4), Money.to_usd(-1_250_000) + end + + def test_from_usd + assert_equal 250_000, Money.from_usd("0.25") + assert_equal 250_000, Money.from_usd("$0.25") + assert_equal 100, Money.from_usd(0.0001) + assert_equal 1_000_000, Money.from_usd(1) + assert_equal 333_333, Money.from_usd(Rational(1, 3)) + assert_equal 1, Money.from_usd("0.0000005") + end + + def test_rejects_bad_input + assert_raises(ArgumentError) { Money.format(1.5) } + assert_raises(ArgumentError) { Money.from_usd("abc") } + assert_raises(ArgumentError) { Money.from_usd(Float::NAN) } + assert_raises(ArgumentError) { Money.from_usd(nil) } + end +end diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb deleted file mode 100644 index 58593ac..0000000 --- a/test/octaspace/payload_helpers_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::PayloadHelpersTest < Minitest::Test - def test_parse_port_list_accepts_arrays - assert_equal [5000, 6000], OctaSpace::PayloadHelpers.parse_port_list([5000, 6000]) - end - - def test_parse_port_list_parses_json_strings - assert_equal [8080, 8888], OctaSpace::PayloadHelpers.parse_port_list("[8080,8888]") - end - - def test_parse_port_list_returns_empty_array_for_invalid_string - assert_equal [], OctaSpace::PayloadHelpers.parse_port_list("not-json") - end - - def test_parse_port_list_returns_empty_array_for_nil - assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil) - end -end diff --git a/test/octaspace/resources/accounts_test.rb b/test/octaspace/resources/accounts_test.rb index b6f0002..55ff6d4 100644 --- a/test/octaspace/resources/accounts_test.rb +++ b/test/octaspace/resources/accounts_test.rb @@ -3,46 +3,28 @@ require "test_helper" class OctaSpace::AccountsResourceTest < Minitest::Test - def setup - super - @client = test_client + def test_balance + stub_get("/accounts/balance", fixture_path: "next/account_balance.json") + balance = test_client.accounts.balance + + assert_equal 1_234_567, balance.balance + assert_equal "USD", balance.currency + assert_equal 6, balance.decimals + assert_equal Rational(1_234_567, 1_000_000), balance.to_usd + assert_equal "$1.234567", balance.formatted + refute balance.negative? end - def test_profile_returns_response - stub_get("/accounts", fixture_path: "accounts/show.json") - response = @client.accounts.profile - assert response.success? - assert_equal "acc-123-abc", response.data["account_uuid"] - assert_equal "user@example.com", response.data["email"] - end - - def test_balance_returns_response - stub_get("/accounts/balance", fixture_path: "accounts/balance.json") - response = @client.accounts.balance - assert response.success? - assert response.data.key?("balance") - end - - def test_generate_wallet_returns_success - stub_post("/accounts", status: 201, body: '{"wallet":"0xabc123","network":"ETH"}') - response = @client.accounts.generate_wallet - assert response.success? - end - - def test_profile_raises_authentication_error_on_401 - stub_error(:get, "/accounts", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.accounts.profile } - end + def test_negative_balance_and_other_decimals + stub_request(:get, "#{StubHelpers::BASE_URL}/accounts/balance") + .to_return(status: 200, body: '{"balance":-1250,"currency":"USD","decimals":3}') + balance = test_client.accounts.balance - def test_profile_raises_rate_limit_error_on_429 - stub_request(:get, "#{StubHelpers::BASE_URL}/accounts") - .to_return(status: 429, body: "{}", headers: {"Retry-After" => "60"}.merge(json_headers)) - error = assert_raises(OctaSpace::RateLimitError) { @client.accounts.profile } - assert_equal 60, error.retry_after + assert balance.negative? + assert_equal "-$1.250", balance.formatted end - def test_profile_raises_server_error_on_500 - stub_error(:get, "/accounts", status: 500, message: "Internal Server Error") - assert_raises(OctaSpace::ServerError) { @client.accounts.profile } + def test_balance_requires_key + assert_raises(OctaSpace::AuthenticationError) { OctaSpace::Client.new.accounts.balance } end end diff --git a/test/octaspace/resources/apps_test.rb b/test/octaspace/resources/apps_test.rb deleted file mode 100644 index 9ed104a..0000000 --- a/test/octaspace/resources/apps_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::AppsResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_list_returns_array - stub_get("/apps", fixture_path: "apps/index.json") - response = @client.apps.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 2, response.data.size - assert_equal "Stable Diffusion", response.data.first["name"] - assert_equal "[5000,6000]", response.data.first["ports"] - assert_equal "[8080]", response.data.first["http_ports"] - end - - def test_list_passes_params - stub_request(:get, "#{StubHelpers::BASE_URL}/apps") - .with(query: {"category" => "AI"}) - .to_return(status: 200, body: "[]", headers: json_headers) - response = @client.apps.list(category: "AI") - assert response.success? - end - - def test_list_raises_authentication_error_on_401 - stub_error(:get, "/apps", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.apps.list } - end -end diff --git a/test/octaspace/resources/hosting_test.rb b/test/octaspace/resources/hosting_test.rb new file mode 100644 index 0000000..decfa15 --- /dev/null +++ b/test/octaspace/resources/hosting_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::HostingResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = test_client + end + + def test_nodes + stub_get("/hosting/nodes", fixture_path: "next/hosting_nodes.json") + node = @client.hosting.nodes.find { |n| n.number == "M95S-WZG8-7" } + + assert node.idle? + assert node.verified? + refute node.maintenance? + assert node.offered?(:compute) + assert_nil node.services["compute"].reason + assert_equal 100, node.prices.base + assert_equal 0, node.prices.min_rent_minutes + assert_equal 132_114_866_176, node.system.memory.size + assert_equal "NVIDIA GeForce RTX 3090", node.system.gpus.first.model + assert_equal "P8", node.system.gpus.first.pstate + assert_equal "Mukachevo", node.location.city + assert_equal Time.utc(2026, 11, 1, 18, 43, 45), node.online_time + assert_nil node.activity + end + + def test_nodes_repair_null_strings_in_gpus + stub_request(:get, "#{BASE}/hosting/nodes").to_return(status: 200, + body: '[{"number":"A","system":{"gpus":[{"fan_speed":"null","mem_free_mb":"","power_draw_watt":"12.5"}]}}]') + gpu = @client.hosting.nodes.first.system.gpus.first + + assert_nil gpu.fan_speed + assert_nil gpu.mem_free_mb + assert_in_delta 12.5, gpu.power_draw_watt + end + + def test_set_prices_sends_only_given_fields + stub_request(:patch, "#{BASE}/hosting/nodes/M95S-WZG8-7/prices") + .with(body: '{"base":250000,"min_rent_minutes":15}') + .to_return(status: 204, body: "") + + assert_nil @client.hosting.set_prices("M95S-WZG8-7", base: 250_000, min_rent_minutes: 15, traffic: nil) + end + + def test_set_prices_validates_locally + assert_raises(ArgumentError) { @client.hosting.set_prices("A") } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", base: "100") } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", base: 0.25) } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", gpu_hour: 1) } + end + + def test_set_prices_api_errors + stub_request(:patch, "#{BASE}/hosting/nodes/ZZZZ-ZZZZ-Z/prices").to_return(status: 400, body: '{"message":"Invalid node number"}') + stub_request(:patch, "#{BASE}/hosting/nodes/0000-0000-0/prices").to_return(status: 404, body: '{"message":"Node not found"}') + + assert_raises(OctaSpace::ValidationError) { @client.hosting.set_prices("ZZZZ-ZZZZ-Z", base: 100) } + assert_raises(OctaSpace::NotFoundError) { @client.hosting.set_prices("0000-0000-0", base: 100) } + end +end diff --git a/test/octaspace/resources/idle_jobs_test.rb b/test/octaspace/resources/idle_jobs_test.rb deleted file mode 100644 index 1e66b27..0000000 --- a/test/octaspace/resources/idle_jobs_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::IdleJobsResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_find_returns_single_job - stub_get("/idle_jobs/69/42", fixture_path: "idle_jobs/show.json") - response = @client.idle_jobs.find(node_id: 69, job_id: 42) - assert response.success? - assert_equal 42, response.data["id"] - assert_equal "completed", response.data["status"] - end - - def test_find_raises_not_found - stub_error(:get, "/idle_jobs/69/99999", status: 404, message: "Job not found") - assert_raises(OctaSpace::NotFoundError) { @client.idle_jobs.find(node_id: 69, job_id: 99999) } - end - - def test_logs_returns_data - stub_get("/idle_jobs/69/42/logs", fixture_path: "idle_jobs/logs.json") - response = @client.idle_jobs.logs(node_id: 69, job_id: 42) - assert response.success? - end - - def test_logs_raises_authentication_error_on_401 - stub_error(:get, "/idle_jobs/69/42", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.idle_jobs.find(node_id: 69, job_id: 42) } - end -end diff --git a/test/octaspace/resources/network_test.rb b/test/octaspace/resources/network_test.rb index a1c9a96..854d32a 100644 --- a/test/octaspace/resources/network_test.rb +++ b/test/octaspace/resources/network_test.rb @@ -3,22 +3,29 @@ require "test_helper" class OctaSpace::NetworkResourceTest < Minitest::Test - def setup - super - @client = test_client + def test_info_without_key + stub_get("/network", fixture_path: "next/network.json") + info = OctaSpace::Client.new.network.info + + assert_equal 3, info.nodes.online + assert_equal 76, info.power.cpu_cores + assert_in_delta 142.4, info.power.tflops + assert_equal 6_588_996, info.render.frames_total + assert_equal [], info.render.frames_by_week + assert_equal 87, info.sessions.total + assert_equal 0, info.vpn.nodes + assert_equal Time.at(1_793_566_134), info.time + assert_requested(:get, "#{StubHelpers::BASE_URL}/network") { |req| !req.headers.key?("Authorization") } end - def test_info_returns_response - stub_get("/network", fixture_path: "network/index.json") - response = @client.network.info - assert response.success? - assert response.data.key?("blockchain") - assert response.data.key?("market_price") - assert response.data.key?("nodes") + def test_info_never_sends_key + stub_get("/network", fixture_path: "next/network.json") + test_client.network.info + assert_requested(:get, "#{StubHelpers::BASE_URL}/network") { |req| !req.headers.key?("Authorization") } end - def test_info_raises_server_error_on_500 - stub_error(:get, "/network", status: 500, message: "Internal Server Error") - assert_raises(OctaSpace::ServerError) { @client.network.info } + def test_unavailable + stub_request(:get, "#{StubHelpers::BASE_URL}/network").to_return(status: 503, body: '{"message":"Network information is unavailable"}') + assert_raises(OctaSpace::ServiceUnavailableError) { OctaSpace::Client.new.network.info } end end diff --git a/test/octaspace/resources/nodes_test.rb b/test/octaspace/resources/nodes_test.rb deleted file mode 100644 index b7160da..0000000 --- a/test/octaspace/resources/nodes_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::NodesResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_list_returns_array - stub_get("/nodes", fixture_path: "nodes/index.json") - response = @client.nodes.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 2, response.data.size - end - - def test_find_returns_single_node - stub_get("/nodes/1", fixture_path: "nodes/show.json") - response = @client.nodes.find(1) - assert response.success? - assert_equal 1, response.data["id"] - assert_equal "online", response.data["state"] - end - - def test_find_raises_not_found - stub_error(:get, "/nodes/99999", status: 404, message: "Node not found") - assert_raises(OctaSpace::NotFoundError) { @client.nodes.find(99999) } - end - - def test_reboot_returns_success - stub_request(:get, "#{StubHelpers::BASE_URL}/nodes/1/reboot") - .to_return(status: 200, body: "{}", headers: json_headers) - response = @client.nodes.reboot(1) - assert response.success? - end - - def test_update_prices - stub_request(:patch, "#{StubHelpers::BASE_URL}/nodes/1/prices") - .to_return(status: 200, body: "{}", headers: json_headers) - response = @client.nodes.update_prices(1, gpu_hour: 0.5, cpu_hour: 0.1) - assert response.success? - end - - def test_list_passes_params - stub_request(:get, "#{StubHelpers::BASE_URL}/nodes") - .with(query: {"state" => "online"}) - .to_return(status: 200, body: "[]", headers: json_headers) - response = @client.nodes.list(state: "online") - assert response.success? - end -end diff --git a/test/octaspace/resources/stats_test.rb b/test/octaspace/resources/stats_test.rb new file mode 100644 index 0000000..45a86f9 --- /dev/null +++ b/test/octaspace/resources/stats_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::StatsResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = OctaSpace::Client.new + end + + def test_gpu_current + stub_request(:get, "#{BASE}/stats/gpu/current").with(query: {"verified" => "yes"}) + .to_return(status: 200, body: fixture("next/stats_gpu_current.json")) + market = @client.stats.gpu_current(verified: :yes) + + gpu = market.gpus.first + assert_equal "NVIDIA GeForce RTX 3090", gpu.model + assert_equal 100_000, gpu.rented_price.p50 + assert_kind_of Time, market.time + end + + def test_gpu_current_null_strings + stub_request(:get, "#{BASE}/stats/gpu/current") + .to_return(status: 200, body: '{"at":1,"gpus":[{"model":"X","tflops":"null","available_price":{"p10":"null"}}]}') + gpu = @client.stats.gpu_current.gpus.first + + assert_nil gpu.tflops + assert_nil gpu.available_price.p10 + end + + def test_gpu_history_params_and_points + from = Time.at(1_793_000_000) + stub_request(:get, "#{BASE}/stats/gpu/history") + .with(query: {"gpu_name" => "Arc B580,NVIDIA GeForce RTX 3090", "start" => "1793000000", "end" => "1793086400", "step" => "3600"}) + .to_return(status: 200, body: fixture("next/stats_gpu_history.json")) + + history = @client.stats.gpu_history(gpu_name: ["Arc B580", "NVIDIA GeForce RTX 3090"], from: from, to: 1_793_086_400, step: 3600) + arc = history.fetch("Arc B580") + assert_instance_of OctaSpace::Types::GpuHistory, arc + assert_nil arc.tflops + + points = arc.points + assert_equal arc.timestamps.size, points.size + assert_equal arc.supply_demand.total.first, points.first.total + assert_equal arc.rented_price.p50, points.map { |p| p.rented_price.p50 } + assert_equal Time.at(arc.timestamps.first), points.first.time + end + + def test_gpu_history_validation_error + stub_request(:get, "#{BASE}/stats/gpu/history").with(query: {"step" => "10"}) + .to_return(status: 400, body: '{"message":"step must be at least 300 seconds"}') + + error = assert_raises(OctaSpace::ValidationError) { @client.stats.gpu_history(step: 10) } + assert_equal "step must be at least 300 seconds", error.message + end + + def test_gpu_locations + stub_get("/stats/gpu/locations", fixture_path: "next/stats_gpu_locations.json") + loc = @client.stats.gpu_locations.first + + assert_equal "Mukachevo", loc.city + assert_equal "Arc B580", loc.model + assert_equal 2, loc.gpus + end +end diff --git a/test/octaspace/types_test.rb b/test/octaspace/types_test.rb deleted file mode 100644 index 2ed6b4f..0000000 --- a/test/octaspace/types_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::TypesTest < Minitest::Test - # --- Node --- - - def test_node_online - node = OctaSpace::Types::Node.new( - id: 1, ip: "1.2.3.4", state: "online", - location: {"city" => "Amsterdam"}, prices: {}, system: {} - ) - assert node.online? - refute node.offline? - end - - def test_node_offline - node = OctaSpace::Types::Node.new( - id: 2, ip: "5.6.7.8", state: "offline", - location: {}, prices: {}, system: {} - ) - refute node.online? - assert node.offline? - end - - def test_node_equality - attrs = {id: 1, ip: "1.2.3.4", state: "online", location: {}, prices: {}, system: {}} - assert_equal OctaSpace::Types::Node.new(**attrs), OctaSpace::Types::Node.new(**attrs) - end - - def test_node_immutability - node = OctaSpace::Types::Node.new(id: 1, ip: "x", state: "online", location: {}, prices: {}, system: {}) - assert_raises(NoMethodError) { node.id = 99 } - end - - # --- Balance --- - - def test_balance_to_s - balance = OctaSpace::Types::Balance.new(amount: "1000000000000000000", currency: "OCTA") - assert_equal "1000000000000000000 OCTA", balance.to_s - end - - def test_balance_equality - b1 = OctaSpace::Types::Balance.new(amount: "100", currency: "OCTA") - b2 = OctaSpace::Types::Balance.new(amount: "100", currency: "OCTA") - assert_equal b1, b2 - end - - # --- Account --- - - def test_account_to_s - account = OctaSpace::Types::Account.new( - account_uuid: "acc-123", email: "user@example.com", - avatar: nil, balance: "100" - ) - assert_equal "OctaSpace Account ", account.to_s - end -end