From 4debf379d93ceb0384e008e81aead9955287cae5 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:28:40 +0200 Subject: [PATCH 01/19] Refactor: pricing class to simplify logic / use new sdk feature --- src/aleph_client/commands/pricing.py | 545 ++++++++++++++------------- 1 file changed, 290 insertions(+), 255 deletions(-) diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index dd491784..7c2ecc53 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -2,13 +2,23 @@ import logging from decimal import Decimal -from enum import Enum from typing import Annotated, Optional -import aiohttp import typer +from aleph.sdk import AlephHttpClient +from aleph.sdk.client.services.crn import NetworkGPUS +from aleph.sdk.client.services.pricing import ( + PAYG_GROUP, + PRICING_GROUPS, + GroupEntity, + Price, + PricingEntity, + PricingModel, + PricingPerEntity, + Tier, +) from aleph.sdk.conf import settings -from aleph.sdk.utils import displayable_amount, safe_getattr +from aleph.sdk.utils import displayable_amount from pydantic import BaseModel from rich import box from rich.console import Console, Group @@ -16,7 +26,8 @@ from rich.table import Table from rich.text import Text -from aleph_client.commands.utils import setup_logging, validated_prompt +from aleph_client.commands.instance.network import call_program_crn_list +from aleph_client.commands.utils import colorful_json, setup_logging from aleph_client.utils import async_lru_cache, sanitize_url logger = logging.getLogger(__name__) @@ -26,51 +37,10 @@ ) -class PricingEntity(str, Enum): - STORAGE = "storage" - WEB3_HOSTING = "web3_hosting" - PROGRAM = "program" - PROGRAM_PERSISTENT = "program_persistent" - INSTANCE = "instance" - INSTANCE_CONFIDENTIAL = "instance_confidential" - INSTANCE_GPU_STANDARD = "instance_gpu_standard" - INSTANCE_GPU_PREMIUM = "instance_gpu_premium" - - -class GroupEntity(str, Enum): - STORAGE = "storage" - WEBSITE = "website" - PROGRAM = "program" - INSTANCE = "instance" - CONFIDENTIAL = "confidential" - GPU = "gpu" - ALL = "all" - - -PRICING_GROUPS: dict[str, list[PricingEntity]] = { - GroupEntity.STORAGE: [PricingEntity.STORAGE], - GroupEntity.WEBSITE: [PricingEntity.WEB3_HOSTING], - GroupEntity.PROGRAM: [PricingEntity.PROGRAM, PricingEntity.PROGRAM_PERSISTENT], - GroupEntity.INSTANCE: [PricingEntity.INSTANCE], - GroupEntity.CONFIDENTIAL: [PricingEntity.INSTANCE_CONFIDENTIAL], - GroupEntity.GPU: [PricingEntity.INSTANCE_GPU_STANDARD, PricingEntity.INSTANCE_GPU_PREMIUM], - GroupEntity.ALL: list(PricingEntity), -} - -PAYG_GROUP: list[PricingEntity] = [ - PricingEntity.INSTANCE, - PricingEntity.INSTANCE_CONFIDENTIAL, - PricingEntity.INSTANCE_GPU_STANDARD, - PricingEntity.INSTANCE_GPU_PREMIUM, -] - -MAX_VALUE = Decimal(999_999_999) - - class SelectedTierPrice(BaseModel): hold: Decimal payg: Decimal # Token by second - storage: Optional[SelectedTierPrice] + storage: Optional[SelectedTierPrice] = None class SelectedTier(BaseModel): @@ -84,255 +54,320 @@ class SelectedTier(BaseModel): class Pricing: - def __init__(self, **kwargs): - self.data = kwargs.get("data", {}).get("pricing", {}) + def __init__(self, pricing_aggregate: PricingModel): + self.data = pricing_aggregate + self.console = Console() + + def _format_name(self, entity: PricingEntity): + return entity.value.replace("_", " ").title() + + def _format_tier_id(self, name: str): + return name.split("-", 1)[1] + + def _tier_matches(self, tier: Tier, only_tier: Optional[int]) -> bool: + if only_tier is None: + return True + try: + short_id = int(self._format_tier_id(tier.id)) + except ValueError: + return False + return short_id == only_tier + + def _process_network_gpu_info(self, tier: Tier, network_gpu: NetworkGPUS): + available_gpu: dict[str, int] = {} + for crn_url, gpus in network_gpu.available_gpu_list.items(): + for gpu in gpus: + if gpu.model == tier.model: + # On this dict we want only count if there is a GPU for simplify draw + if not available_gpu.get(crn_url, 0): + available_gpu[crn_url] = 0 + available_gpu[crn_url] += 1 + + # If gpu is not available we checked if there is some but being used + used_gpu: dict[str, int] = {} + if len(available_gpu) == 0: + for crn_url, gpus in network_gpu.used_gpu_list.items(): + for gpu in gpus: + if gpu.model == tier.model: + # On this dict we want only count if there is a GPU for simplify draw + if not used_gpu.get(crn_url, 0): + used_gpu[crn_url] = 0 + + used_gpu[crn_url] += 1 + return available_gpu, used_gpu + + def build_storage_and_website( + self, + price_dict, + storage: bool = False, + ): + infos = [] + if "fixed" in price_dict: + infos.append( + Text.from_markup( + "Service & Availability (Holding): [orange1]" + f"{displayable_amount(price_dict.get('fixed'),decimals=3)}" + " tokens[/orange1]\n" + ) + ) + if "storage" in price_dict and isinstance(price_dict["storage"], Price): + is_storage = "+ " if not storage else "" + ammount = Decimal(str(price_dict["storage"].holding)) if price_dict["storage"].holding else Decimal("0") + infos.append( + Text.from_markup( + f"{is_storage}" # If it not STORAGE then it's 'additionnal' charge so we do + at end + "$ALEPH (Holding): [bright_cyan]" + f"{displayable_amount(ammount, decimals=5)}" + " token/Mib[/bright_cyan] -or- [bright_cyan]" + f"{displayable_amount(ammount * 1024, decimals=5)}" + " token/GiB[/bright_cyan]" + ) + ) + return Group(*infos) - def display_table_for( + def build_column( self, - pricing_entity: Optional[PricingEntity] = None, - compute_units: Optional[int] = 0, - vcpus: Optional[int] = 0, - memory: Optional[int] = 0, - gpu_models: Optional[dict[str, dict[str, dict[str, int]]]] = None, - persistent: Optional[bool] = None, - selector: bool = False, - exit_on_error: bool = True, - verbose: bool = True, - ) -> Optional[SelectedTier]: - """Display pricing table for an entity""" - - if not compute_units: - compute_units = 0 - if not vcpus: - vcpus = 0 - if not memory: - memory = 0 - - if not pricing_entity: - if persistent is not None: - # Program entity selection: Persistent or Non-Persistent - pricing_entity = PricingEntity.PROGRAM_PERSISTENT if persistent else PricingEntity.PROGRAM - - entity_name = safe_getattr(pricing_entity, "value") - if pricing_entity: - entity = self.data.get(entity_name) - label = entity_name.replace("_", " ").title() - else: - logger.error(f"Entity {entity_name} not found") - if exit_on_error: - raise typer.Exit(1) + entity: PricingEntity, + entity_info: PricingPerEntity, + ): + # Common Column for PROGRAM / INSTANCE / CONF / GPU + self.table.add_column("Tier", style="cyan") + self.table.add_column("Compute Units", style="orchid") + self.table.add_column("vCPUs", style="bright_cyan") + self.table.add_column("RAM (GiB)", style="bright_cyan") + self.table.add_column("Disk (GiB)", style="bright_cyan") + + # GPU Case + if entity in PRICING_GROUPS[GroupEntity.GPU]: + self.table.add_column("GPU Model", style="orange1") + self.table.add_column("VRAM (GiB)", style="orange1") + + cu_price = entity_info.price.get("compute_unit") + if isinstance(cu_price, Price) and cu_price.holding: + self.table.add_column("$ALEPH (Holding)", style="red", justify="center") + + if isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP: + self.table.add_column("$ALEPH (Pay-As-You-Go)", style="green", justify="center") + + if entity in PRICING_GROUPS[GroupEntity.PROGRAM]: + self.table.add_column("+ Internet Access", style="orange1", justify="center") + + def fill_tier( + self, + tier: Tier, + entity: PricingEntity, + entity_info: PricingPerEntity, + network_gpu: Optional[NetworkGPUS] = None, + ): + tier_id = self._format_tier_id(tier.id) + self.table.add_section() + + if not entity_info.compute_unit: + error = f"No compute unit defined for tier {tier_id} in entity {entity}" + raise ValueError(error) + + row = [ + tier_id, + str(tier.compute_units), + str(entity_info.compute_unit.vcpus), + f"{entity_info.compute_unit.memory_mib * tier.compute_units / 1024:.0f}", + f"{entity_info.compute_unit.disk_mib * tier.compute_units / 1024:.0f}", + ] + + # Gpu Case + if entity in PRICING_GROUPS[GroupEntity.GPU] and tier.model: + if not network_gpu: # No info about if it available on network + row.append(tier.model) else: - return None - - unit = entity.get("compute_unit", {}) - unit_vcpus = unit.get("vcpus") - unit_memory = unit.get("memory_mib") - unit_disk = unit.get("disk_mib") - price = entity.get("price", {}) - price_unit = price.get("compute_unit") - price_storage = price.get("storage") - price_fixed = price.get("fixed") - tiers = entity.get("tiers", []) - - displayable_group = None - tier_data: dict[int, SelectedTier] = {} - auto_selected = (compute_units or vcpus or memory) and not gpu_models - if tiers: - if auto_selected: - tiers = [ - tier - for tier in tiers - if compute_units <= tier["compute_units"] - and vcpus <= unit_vcpus * tier["compute_units"] - and memory <= unit_memory * tier["compute_units"] - ] - if tiers: - tiers = tiers[:1] + # Find how many of that GPU is currently available + available_gpu, used_gpu = self._process_network_gpu_info(network_gpu=network_gpu, tier=tier) + + gpu_line = tier.model + if available_gpu: + gpu_line += "[white] Available on: [/white]" + for crn_url, count in available_gpu.items(): + gpu_line += f"\n[bright_yellow]• {crn_url}[/bright_yellow]: [white]{count}[/white]" + elif used_gpu: + gpu_line += "[red] GPU Already in use: [/red]" + for crn_url, count in used_gpu.items(): + if count > 0: + gpu_line += f"\n[orange]• {crn_url}[/orange][white]:[/white][orange] {count}[/orange]" else: - requirements = [] - if compute_units: - requirements.append(f"compute_units>={compute_units}") - if vcpus: - requirements.append(f"vcpus>={vcpus}") - if memory: - requirements.append(f"memory>={memory}") - typer.echo( - f"Minimum tier with required {' & '.join(requirements)}" - f" not found for {pricing_entity.value}" - ) - if exit_on_error: - raise typer.Exit(1) - else: - return None + gpu_line += "[red] Currently not available on network[/red]" + row.append(Text.from_markup(gpu_line)) + row.append(str(tier.vram)) + + cu_price = entity_info.price.get("compute_unit") + # Fill Holding row + if isinstance(cu_price, Price) and cu_price.holding: + if entity == PricingEntity.INSTANCE_CONFIDENTIAL or ( + entity == PricingEntity.INSTANCE and tier.compute_units > 4 + ): + row.append(Text.from_markup("[red]Not Available[/red]")) + else: + row.append( + f"{displayable_amount(Decimal(str(cu_price.holding)) * tier.compute_units, decimals=3)} tokens" + ) + # Fill PAYG row + if isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP: + payg_price = cu_price.payg + payg_hourly = Decimal(str(payg_price)) * tier.compute_units + row.append( + f"{displayable_amount(payg_hourly, decimals=3)} token/hour" + f"\n{displayable_amount(payg_hourly * 24, decimals=3)} token/day" + ) + # Program Case we additional price + if entity in PRICING_GROUPS[GroupEntity.PROGRAM]: + program_price = entity_info.price.get("compute_unit") + if isinstance(program_price, Price) and program_price.holding is not None: + amount = Decimal(str(program_price.holding)) * tier.compute_units * 2 + internet_cell = ( + "✅ Included" + if entity == PricingEntity.PROGRAM_PERSISTENT + else f"{displayable_amount(amount)} tokens" + ) + row.append(internet_cell) + else: + row.append("N/A") + self.table.add_row(*row) + + def fill_column( + self, + entity: PricingEntity, + entity_info: PricingPerEntity, + network_gpu: Optional[NetworkGPUS], + only_tier: Optional[int] = None, # <-- now int + ): + any_added = False + + if not entity_info.tiers: + error = f"No tiers defined for entity {entity}" + raise ValueError(error) + + for tier in entity_info.tiers: + if not self._tier_matches(tier, only_tier): + continue + self.fill_tier(tier=tier, entity=entity, entity_info=entity_info, network_gpu=network_gpu) + any_added = True + return any_added + def display_table_for( + self, entity: PricingEntity, network_gpu: Optional[NetworkGPUS] = None, tier: Optional[int] = None + ): + info = self.data[entity] + label = self._format_name(entity=entity) + price = info.price + + if entity in [PricingEntity.WEB3_HOSTING, PricingEntity.STORAGE]: + displayable_group = self.build_storage_and_website( + price_dict=price, storage=entity == PricingEntity.STORAGE + ) + self.console.print( + Panel( + displayable_group, + title=f"Pricing: {label}", + border_style="orchid", + expand=False, + title_align="left", + ) + ) + else: + # Create a new table for each entity table = Table( border_style="magenta", box=box.MINIMAL, ) - table.add_column("Tier", style="cyan") - table.add_column("Compute Units", style="orchid") - table.add_column("vCPUs", style="bright_cyan") - table.add_column("RAM (GiB)", style="bright_cyan") - table.add_column("Disk (GiB)", style="bright_cyan") - if "model" in tiers[0]: - table.add_column("GPU Model", style="orange1") - if "vram" in tiers[0]: - table.add_column("VRAM (GiB)", style="orange1") - if "holding" in price_unit: - table.add_column("$ALEPH (Holding)", style="red", justify="center") - if "payg" in price_unit and pricing_entity in PAYG_GROUP: - table.add_column("$ALEPH (Pay-As-You-Go)", style="green", justify="center") - if pricing_entity in PRICING_GROUPS[GroupEntity.PROGRAM]: - table.add_column("+ Internet Access", style="orange1", justify="center") - - for tier in tiers: - tier_id = tier["id"].split("-", 1)[1] - current_units = tier["compute_units"] - table.add_section() - row = [ - tier_id, - str(current_units), - str(unit_vcpus * current_units), - f"{unit_memory * current_units / 1024:.0f}", - f"{unit_disk * current_units / 1024:.0f}", - ] - if "model" in tier: - if gpu_models is None: - row.append(tier["model"]) - elif tier["model"] in gpu_models: - gpu_line = tier["model"] - for device, details in gpu_models[tier["model"]].items(): - gpu_line += f"\n[bright_yellow]• {device}[/bright_yellow]\n" - gpu_line += f" [grey50]↳ [white]{details['count']}[/white]" - gpu_line += f" available on [white]{details['on_crns']}[/white] CRN(s)[/grey50]" - row.append(Text.from_markup(gpu_line)) - else: - continue - if "vram" in tier: - row.append(f"{tier['vram'] / 1024:.0f}") - if "holding" in price_unit: - row.append( - f"{displayable_amount(Decimal(price_unit['holding']) * current_units, decimals=3)} tokens" - ) - if "payg" in price_unit and pricing_entity in PAYG_GROUP: - payg_hourly = Decimal(price_unit["payg"]) * current_units - row.append( - f"{displayable_amount(payg_hourly, decimals=3)} token/hour" - f"\n{displayable_amount(payg_hourly*24, decimals=3)} token/day" - ) - if pricing_entity in PRICING_GROUPS[GroupEntity.PROGRAM]: - internet_cell = ( - "✅ Included" - if pricing_entity == PricingEntity.PROGRAM_PERSISTENT - else f"{displayable_amount(Decimal(price_unit['holding']) * current_units * 2)} tokens" - ) - row.append(internet_cell) - table.add_row(*row) - - tier_data[tier_id] = SelectedTier( - tier=tier_id, - compute_units=current_units, - vcpus=unit_vcpus * current_units, - memory=unit_memory * current_units, - disk=unit_disk * current_units, - gpu_model=tier.get("model"), - price=SelectedTierPrice( - hold=Decimal(price_unit["holding"]) * current_units if "holding" in price_unit else MAX_VALUE, - payg=Decimal(price_unit["payg"]) / 3600 * current_units if "payg" in price_unit else MAX_VALUE, - storage=SelectedTierPrice( - hold=Decimal(price_storage["holding"]) if "holding" in price_storage else MAX_VALUE, - payg=Decimal(price_storage["payg"]) / 3600 if "payg" in price_storage else MAX_VALUE, - storage=None, - ), - ), + self.table = table + + self.build_column(entity=entity, entity_info=info) + + any_rows_added = self.fill_column(entity=entity, entity_info=info, network_gpu=network_gpu, only_tier=tier) + + # If no rows were added, the filter was too restrictive + # So add all tiers without filter + if not any_rows_added: + self.fill_column(entity=entity, entity_info=info, network_gpu=network_gpu, only_tier=None) + + storage_price = info.price.get("storage") + extra_price_holding = "" + if isinstance(storage_price, Price) and storage_price.holding: + extra_price_holding = ( + f"[red]{displayable_amount(Decimal(str(storage_price.holding)) * 1024, decimals=5)}" + " token/GiB[/red] (Holding) -or- " ) - extra_price_holding = ( - f"[red]{displayable_amount(Decimal(price_storage['holding'])*1024, decimals=5)}" - " token/GiB[/red] (Holding) -or- " - if "holding" in price_storage - else "" - ) + payg_storage_price = "0" + if isinstance(storage_price, Price) and storage_price.payg: + payg_storage_price = displayable_amount(Decimal(str(storage_price.payg)) * 1024 * 24, decimals=5) + infos = [ Text.from_markup( f"Extra Volume Cost: {extra_price_holding}" - f"[green]{displayable_amount(Decimal(price_storage['payg'])*1024*24, decimals=5)}" + f"[green]{payg_storage_price}" " token/GiB/day[/green] (Pay-As-You-Go)" ) ] displayable_group = Group( - table, - Text.assemble(*infos), - ) - else: - infos = [Text("\n")] - if price_fixed: - infos.append( - Text.from_markup( - f"Service & Availability (Holding): [orange1]{displayable_amount(price_fixed, decimals=3)}" - " tokens[/orange1]\n\n+ " - ) - ) - infos.append( - Text.from_markup( - "$ALEPH (Holding): [bright_cyan]" - f"{displayable_amount(Decimal(price_storage['holding']), decimals=5)}" - " token/Mib[/bright_cyan] -or- [bright_cyan]" - f"{displayable_amount(Decimal(price_storage['holding'])*1024, decimals=5)}" - " token/GiB[/bright_cyan]" - ) - ) - displayable_group = Group( + self.table, Text.assemble(*infos), ) - if gpu_models and not tier_data: - typer.echo(f"No GPU available for {label} at the moment.") - raise typer.Exit(1) - elif verbose: - console = Console() - console.print( + self.console.print( Panel( displayable_group, - title=f"Pricing: {'Selected ' if compute_units else ''}{label}", + title=f"Pricing: {label}", border_style="orchid", expand=False, title_align="left", ) ) - if selector and pricing_entity not in [PricingEntity.STORAGE, PricingEntity.WEB3_HOSTING]: - if not auto_selected: - tier_id = validated_prompt("Select a tier by index", lambda tier_id: tier_id in tier_data) - return next(iter(tier_data.values())) if auto_selected else tier_data[tier_id] - - return None - @async_lru_cache -async def fetch_pricing() -> Pricing: +async def fetch_pricing_aggregate() -> Pricing: """Fetch pricing aggregate and format it as Pricing""" + async with AlephHttpClient(api_server=settings.API_HOST) as client: + pricing = await client.pricing.get_pricing_aggregate() - async with aiohttp.ClientSession() as session: - async with session.get(pricing_link) as resp: - if resp.status != 200: - logger.error("Unable to fetch pricing aggregate") - raise typer.Exit(1) - - data = await resp.json() - return Pricing(**data) + return Pricing(pricing) async def prices_for_service( service: Annotated[GroupEntity, typer.Argument(help="Service to display pricing for")], - compute_units: Annotated[int, typer.Option(help="Compute units to display pricing for")] = 0, + tier: Annotated[Optional[int], typer.Option(help="Service specific Tier")] = None, + json: Annotated[bool, typer.Option(help="JSON output instead of Rich Table")] = False, + no_null: Annotated[bool, typer.Option(help="Exclude null values in JSON output")] = False, + with_current_availability: Annotated[ + bool, + typer.Option( + "--with-current-availability/--ignore-availability", + help="(GPU only) Show prices only for GPU types currently accessible on the network.", + ), + ] = False, debug: bool = False, ): """Display pricing for services available on aleph.im & twentysix.cloud""" setup_logging(debug) - group = PRICING_GROUPS[service] - pricing = await fetch_pricing() - for entity in group: - pricing.display_table_for(entity, compute_units=compute_units, exit_on_error=False) + group: list[PricingEntity] = PRICING_GROUPS[service] + + pricing = await fetch_pricing_aggregate() + # Fetch Current availibity + network_gpu = None + if (service in [GroupEntity.GPU, GroupEntity.ALL]) and with_current_availability: + crn_lists = await call_program_crn_list() + network_gpu = crn_lists.find_gpu_on_network() + if json: + for entity in group: + typer.echo( + colorful_json( + pricing.data[entity].model_dump_json( + indent=4, + exclude_none=no_null, + ) + ) + ) + else: + for entity in group: + pricing.display_table_for(entity, network_gpu=network_gpu, tier=tier) From 1a33aed2ab2fa1d02e26105bce9f452a0126d919 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:29:24 +0200 Subject: [PATCH 02/19] refactor: use new pricing logic from sdk side --- src/aleph_client/commands/program.py | 37 +++++++++++++++++----------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 715d8d6d..e1a1b956 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -47,7 +47,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.account import get_balance -from aleph_client.commands.pricing import PricingEntity, SelectedTier, fetch_pricing +from aleph_client.commands.pricing import PricingEntity, fetch_pricing_aggregate from aleph_client.commands.utils import ( display_mounted_volumes, filter_only_valid_messages, @@ -169,22 +169,31 @@ async def upload( typer.echo(f"{user_code.model_dump_json(indent=4)}") program_ref = user_code.item_hash - pricing = await fetch_pricing() + pricing = await fetch_pricing_aggregate() pricing_entity = PricingEntity.PROGRAM_PERSISTENT if persistent else PricingEntity.PROGRAM - tier = cast( # Safe cast - SelectedTier, - pricing.display_table_for( - pricing_entity, - compute_units=compute_units, - vcpus=vcpus, - memory=memory, - selector=True, - verbose=verbose, - ), + + tier = pricing.data[pricing_entity].get_closest_tier( + vcpus=vcpus, + memory_mib=memory, + compute_unit=compute_units, ) + + if not tier: + pricing.display_table_for(pricing_entity) + tiers = list(pricing.data[pricing_entity].tiers) + choices = [tier.extract_tier_id() for tier in tiers] + + chosen = validated_prompt( + prompt=f"Choose a tier ({', '.join(choices)}):", + validator=lambda s: s in choices, + default=choices[0], + ) + tier = next(t for t in tiers if t.id.endswith(f"-{chosen}") or t.extract_tier_id() == chosen) + + specs = pricing.data[pricing_entity].get_services_specs(tier) name = name or validated_prompt("Program name", lambda x: x and len(x) < 65) - vcpus = tier.vcpus - memory = tier.memory + vcpus = specs.vcpus + memory = specs.memory_mib runtime = runtime or input(f"Ref of runtime? [{settings.DEFAULT_RUNTIME_ID}] ") or settings.DEFAULT_RUNTIME_ID volumes = [] From 14fbcad8255f5db1099ba582db4f24dfcdcda775 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:30:22 +0200 Subject: [PATCH 03/19] Refactor: instance commands to use logic from sdk / simplify and reduce number of number call --- .../commands/instance/__init__.py | 209 ++++++++++++------ src/aleph_client/commands/instance/display.py | 15 +- src/aleph_client/commands/instance/network.py | 55 +---- 3 files changed, 161 insertions(+), 118 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index f43b4434..582b93a8 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -6,13 +6,15 @@ import shutil from decimal import Decimal from pathlib import Path -from typing import Annotated, Any, Optional, Union, cast +from typing import Annotated, Any, Optional, Union import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.chains.ethereum import ETHAccount +from aleph.sdk.client.services.crn import NetworkGPUS +from aleph.sdk.client.services.pricing import Price from aleph.sdk.client.vm_client import VmClient from aleph.sdk.client.vm_confidential_client import VmConfidentialClient from aleph.sdk.conf import load_main_configuration, settings @@ -64,16 +66,14 @@ from aleph_client.commands.account import get_balance from aleph_client.commands.instance.display import CRNTable, show_instances from aleph_client.commands.instance.network import ( - fetch_crn_info, - fetch_crn_list, + call_program_crn_list, fetch_settings, find_crn_of_vm, ) from aleph_client.commands.instance.port_forwarder import app as port_forwarder_app -from aleph_client.commands.pricing import PricingEntity, SelectedTier, fetch_pricing +from aleph_client.commands.pricing import PricingEntity, fetch_pricing_aggregate from aleph_client.commands.utils import ( find_sevctl_or_exit, - found_gpus_by_model, get_annotated_constraint, get_or_prompt_volumes, setup_logging, @@ -84,6 +84,7 @@ wait_for_processed_instance, yes_no_input, ) +from aleph_client.models import CRNInfo from aleph_client.utils import AsyncTyper, sanitize_url from aleph_client.voucher import VoucherManager @@ -173,6 +174,11 @@ async def create( account = _load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() + # Start the fetch in the background (async_lru_cache already returns a future) + # We'll await this at the point we need it + crn_list_future = call_program_crn_list() + crn_list = None + # Loads default configuration if no chain is set if payment_chain is None: config = load_main_configuration(settings.CONFIG_FILE) @@ -314,19 +320,25 @@ async def create( if not firmware_message: raise typer.Exit(code=1) + if not crn_list: + crn_list = await crn_list_future + # Filter and prepare the list of available GPUs - crn_list = None - found_gpu_models: Optional[dict[str, dict[str, dict[str, int]]]] = None + found_gpu_models: Optional[NetworkGPUS] = None if gpu: echo("Fetching available GPU list...") - crn_list = await fetch_crn_list(latest_crn_version=True, ipv6=True, stream_address=True, gpu=True) - found_gpu_models = found_gpus_by_model(crn_list) - if not found_gpu_models: + + # Await the future to get the actual crn_list before first use + + filtered_crns = crn_list.filter_crn(latest_crn_version=True, ipv6=True, stream_address=True, gpu=True) + found_gpu_models = crn_list.find_gpu_on_network() + if not found_gpu_models or not found_gpu_models.total_gpu_count: echo("No available GPU found. Try again later.") + echo(f"Currently {0 if not found_gpu_models else found_gpu_models.total_gpu_count} being used") raise typer.Exit(code=1) premium = yes_no_input(f"{help_strings.GPU_PREMIUM_OPTION}?", default=False) if premium is None else premium - pricing = await fetch_pricing() + pricing = await fetch_pricing_aggregate() pricing_entity = ( PricingEntity.INSTANCE_CONFIDENTIAL if confidential @@ -336,22 +348,55 @@ async def create( else PricingEntity.INSTANCE_GPU_STANDARD if gpu else PricingEntity.INSTANCE ) ) - tier = cast( # Safe cast - SelectedTier, - pricing.display_table_for( - pricing_entity, - compute_units=compute_units, - vcpus=vcpus, - memory=memory, - gpu_models=found_gpu_models, - selector=True, - ), + + tier = pricing.data[pricing_entity].get_closest_tier( + vcpus=vcpus, + memory_mib=memory, + compute_unit=compute_units, ) + + if not tier: + pricing.display_table_for(entity=pricing_entity, network_gpu=found_gpu_models, tier=None) + tiers = list(pricing.data[pricing_entity].tiers) + + # GPU entities: filter to tiers that actually use the selected GPUs + eligible = ( + [t for t in tiers if pricing._process_network_gpu_info(tier=t, network_gpu=found_gpu_models)[0]] + if pricing_entity in [PricingEntity.INSTANCE_GPU_PREMIUM, PricingEntity.INSTANCE_GPU_STANDARD] + else tiers + ) + + if pricing_entity in [PricingEntity.INSTANCE_GPU_PREMIUM, PricingEntity.INSTANCE_GPU_STANDARD] and not eligible: + echo("No eligible tiers for the selected GPUs.") + raise typer.Exit(code=1) + + if pricing_entity == PricingEntity.INSTANCE_CONFIDENTIAL and payment_type == PaymentType.hold: + echo("No eligible tier, please change payment type") + + # Options shown to the user + if pricing_entity == PricingEntity.INSTANCE and payment_type == PaymentType.hold: + options = ["1", "2", "3", "4"] # hold instances up to tier 4 + pool = eligible or tiers + else: + pool = eligible or tiers + options = [t.extract_tier_id() for t in pool] + + chosen = validated_prompt( + prompt=f"Choose a tier ({', '.join(options)}):", + validator=lambda s: s in options, + default=options[0], + ) + + tier = next(t for t in pool if t.id.endswith(f"-{chosen}") or t.extract_tier_id() == chosen) + name = name or validated_prompt("Instance name", lambda x: x and len(x) < 65) - vcpus = tier.vcpus - memory = tier.memory - disk_size = tier.disk - gpu_model = tier.gpu_model + specs = pricing.data[pricing_entity].get_services_specs(tier) + + vcpus = specs.vcpus + memory = specs.memory_mib + disk_size = specs.disk_mib + gpu_model = specs.gpu_model + disk_size_info = f"Rootfs Size: {round(disk_size/1024, 2)} GiB (defaulted to included storage in tier)" if not isinstance(rootfs_size, int): rootfs_size = validated_int_prompt( @@ -375,22 +420,36 @@ async def create( # Early check with minimal cost (Gas + Aleph ERC20) available_funds = Decimal(0 if is_stream else (await get_balance(address))["available_amount"]) try: + # Get compute_unit price from PricingPerEntity + compute_unit_price = pricing.data[pricing_entity].price.get("compute_unit") if is_stream and isinstance(account, ETHAccount): if account.CHAIN != payment_chain: account.switch_chain(payment_chain) if safe_getattr(account, "superfluid_connector"): - account.can_start_flow(tier.price.payg) + if isinstance(compute_unit_price, Price) and compute_unit_price.payg: + payg_price = Decimal(str(compute_unit_price.payg)) * tier.compute_units + flow_rate_per_second = payg_price / Decimal(3600) + account.can_start_flow(flow_rate_per_second) + else: + echo("No PAYG price available for this tier.") + raise typer.Exit(code=1) else: echo("Superfluid connector not available on this chain.") raise typer.Exit(code=1) - elif available_funds < tier.price.hold: - raise InsufficientFundsError(TokenType.ALEPH, float(tier.price.hold), float(available_funds)) + elif not is_stream: + if isinstance(compute_unit_price, Price) and compute_unit_price.holding: + hold_price = Decimal(str(compute_unit_price.holding)) * tier.compute_units + if available_funds < hold_price: + raise InsufficientFundsError(TokenType.ALEPH, float(hold_price), float(available_funds)) + else: + echo("No holding price available for this tier.") + raise typer.Exit(code=1) except InsufficientFundsError as e: echo(e) raise typer.Exit(code=1) from e stream_reward_address = None - crn, gpu_id = None, None + crn, crn_info, gpu_id = None, None, None if is_stream or confidential or gpu: if crn_url: try: @@ -402,27 +461,42 @@ async def create( if crn_url or crn_hash: if not gpu: echo("Fetching compute resource node's list...") + if not crn_list: + crn_list = await crn_list_future - try: - async with AlephHttpClient() as client: - crn_list = (await client.crn.get_crns_list()).get("crns") - crn = await fetch_crn_info(crn_list, crn_url, crn_hash) - if crn: - if (crn_hash and crn_hash != crn.hash) or (crn_url and crn_url != crn.url): - echo( - f"* Provided CRN *\nUrl: {crn_url}\nHash: {crn_hash}\n\n* Found CRN *\nUrl: " - f"{crn.url}\nHash: {crn.hash}\n\nMismatch between provided CRN and found CRN" - ) - raise typer.Exit(1) - crn.display_crn_specs() - else: - echo(f"* Provided CRN *\nUrl: {crn_url}\nHash: {crn_hash}\n\nProvided CRN not found") - raise typer.Exit(1) - except Exception as e: - raise typer.Exit(1) from e + crn = crn_list.find_crn( + address=crn_url, + crn_hash=crn_hash, + ) + + if crn: + if (crn_hash and crn_hash != crn.hash) or (crn_url and crn_url != crn.address): + echo( + f"* Provided CRN *\nUrl: {crn_url}\nHash: {crn_hash}\n\n* Found CRN *\nUrl: " + f"{crn.address}\nHash: {crn.hash}\n\nMismatch between provided CRN and found CRN" + ) + raise typer.Exit(1) - while not crn: + # Now we build a CRNInfo to display full info about the node + crn_info = CRNInfo.from_unsanitized_input(crn) + crn_info.display_crn_specs() + else: + echo(f"* Provided CRN *\nUrl: {crn_url}\nHash: {crn_hash}\n\nProvided CRN not found") + raise typer.Exit(1) + + while not crn_info: + if not crn_list: + crn_list = await crn_list_future + + filtered_crns = crn_list.filter_crn( + latest_crn_version=True, + ipv6=True, + stream_address=is_stream, + gpu=gpu, + confidential=confidential, + ) crn_table = CRNTable( + crn_list=filtered_crns, only_latest_crn_version=True, only_reward_address=is_stream, only_qemu=True, @@ -434,10 +508,10 @@ async def create( if not selection: # User has ctrl-c raise typer.Exit(1) - crn, gpu_id = selection - crn.display_crn_specs() + crn_info, gpu_id = selection + crn_info.display_crn_specs() if not yes_no_input("Deploy on this node?", default=True): - crn = None + crn_info = None continue elif crn_url or crn_hash: logger.debug( @@ -446,31 +520,30 @@ async def create( ) requirements, trusted_execution, gpu_requirement, tac_accepted = None, None, None, None - if crn: - stream_reward_address = safe_getattr(crn, "stream_reward_address") or "" - if is_stream and not stream_reward_address: + if crn and crn_info: + if is_stream and not crn_info.stream_reward_address: echo("Selected CRN does not have a defined or valid receiver address.") raise typer.Exit(1) - if not safe_getattr(crn, "qemu_support"): + if not crn_info.qemu_support: echo("Selected CRN does not support QEMU hypervisor.") raise typer.Exit(1) if confidential: - if not safe_getattr(crn, "confidential_computing"): + if not crn_info.confidential_computing: echo("Selected CRN does not support confidential computing.") raise typer.Exit(1) trusted_execution = TrustedExecutionEnvironment(firmware=confidential_firmware_as_hash) if gpu: - if not safe_getattr(crn, "gpu_support"): + if not crn_info.gpu_support: echo("Selected CRN does not support GPU computing.") raise typer.Exit(1) - if not crn.compatible_available_gpus: + if not crn_info.compatible_available_gpus: echo("Selected CRN does not have any GPU available.") raise typer.Exit(1) else: # If gpu_id is None, default to the first available GPU if gpu_id is None: gpu_id = 0 - selected_gpu = crn.compatible_available_gpus[gpu_id] + selected_gpu = crn_info.compatible_available_gpus[gpu_id] gpu_selection = Text.from_markup( f"[orange3]Vendor[/orange3]: {selected_gpu['vendor']}\n[orange3]Model[/orange3]: " f"{selected_gpu['model']}\n[orange3]Device[/orange3]: {selected_gpu['device_name']}" @@ -495,8 +568,8 @@ async def create( if not yes_no_input("Confirm this GPU device?", default=True): echo("GPU device selection cancelled.") raise typer.Exit(1) - if crn.terms_and_conditions: - tac_accepted = await crn.display_terms_and_conditions(auto_accept=crn_auto_tac) + if crn_info.terms_and_conditions: + tac_accepted = await crn_info.display_terms_and_conditions(auto_accept=crn_auto_tac) if tac_accepted is None: echo("Failed to fetch terms and conditions.\nContact support or use a different CRN.") raise typer.Exit(1) @@ -507,8 +580,8 @@ async def create( requirements = HostRequirements( node=NodeRequirements( - node_hash=crn.hash, - terms_and_conditions=(ItemHash(crn.terms_and_conditions) if tac_accepted else None), + node_hash=crn_info.hash, + terms_and_conditions=(ItemHash(crn_info.terms_and_conditions) if tac_accepted else None), ), gpu=gpu_requirement, ) @@ -586,8 +659,8 @@ async def create( infos = [] # Instances that need to be started by notifying a specific CRN - crn_url = crn.url if crn and crn.url else None - if crn and (is_stream or confidential or gpu): + crn_url = crn_info.url if crn_info and crn_info.url else None + if crn_info and (is_stream or confidential or gpu): if not crn_url: # Not the ideal solution logger.debug(f"Cannot allocate {item_hash}: no CRN url") @@ -605,7 +678,7 @@ async def create( community_wallet_address = fetched_settings.get("community_wallet_address") flow_crn_amount = required_tokens * Decimal("0.8") flow_hash_crn = await account.manage_flow( - receiver=crn.stream_reward_address, + receiver=crn_info.stream_reward_address, flow=flow_crn_amount, update_type=FlowUpdate.INCREASE, ) @@ -620,7 +693,7 @@ async def create( echo("Flow creation failed. Check your wallet balance and try recreate the VM.") raise typer.Exit(code=1) # Wait for the flow transactions to be confirmed - await wait_for_confirmed_flow(account, crn.stream_reward_address) + await wait_for_confirmed_flow(account, crn_info.stream_reward_address) await wait_for_confirmed_flow(account, community_wallet_address) if flow_hash_crn and flow_hash_community: flow_info = "\n".join( @@ -631,7 +704,7 @@ async def create( f" | {displayable_amount(86400*required_tokens, decimals=3)}/day" f" | {displayable_amount(2628000*required_tokens, decimals=3)}/month[/violet]", "Flow Distribution": "\n[bright_cyan]80% ➜ CRN wallet[/bright_cyan]" - f"\n Address: {crn.stream_reward_address}\n Tx: {flow_hash_crn}" + f"\n Address: {crn_info.stream_reward_address}\n Tx: {flow_hash_crn}" f"\n[bright_cyan]20% ➜ Community wallet[/bright_cyan]" f"\n Address: {community_wallet_address}\n Tx: {flow_hash_community}", }.items() @@ -647,7 +720,7 @@ async def create( ) # Notify CRN - async with VmClient(account, crn.url) as crn_client: + async with VmClient(account, crn_info.url) as crn_client: status, result = await crn_client.start_instance(vm_id=item_hash) logger.debug(status, result) if int(status) != 200: diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 5f04c5e3..efbcdbc0 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -6,6 +6,7 @@ from typing import Optional, Union, cast from aleph.sdk.client.http import AlephHttpClient +from aleph.sdk.client.services.crn import CRN from aleph.sdk.query.responses import PriceResponse from aleph.sdk.types import ( CrnExecutionV1, @@ -33,7 +34,7 @@ from aleph_client.commands.files import download from aleph_client.commands.help_strings import ALLOCATION_AUTO, ALLOCATION_MANUAL -from aleph_client.commands.instance.network import fetch_crn_list +from aleph_client.commands.instance.network import build_crn_info from aleph_client.commands.node import _format_score from aleph_client.models import CRNInfo @@ -548,6 +549,7 @@ class CRNTable(App[tuple[CRNInfo, int]]): filtered_crns: int = 0 label_start = reactive("Loading CRNs list ") label_end = reactive("") + crn_list: list[CRN] only_reward_address: bool = False only_qemu: bool = False only_confidentials: bool = False @@ -572,6 +574,7 @@ class CRNTable(App[tuple[CRNInfo, int]]): def __init__( self, + crn_list: list[CRN], only_latest_crn_version: bool = False, only_reward_address: bool = False, only_qemu: bool = False, @@ -586,6 +589,7 @@ def __init__( self.only_confidentials = only_confidentials self.only_gpu = only_gpu self.only_gpu_model = only_gpu_model + self.crn_list = crn_list def compose(self): """Create child widgets for the app.""" @@ -622,13 +626,15 @@ async def on_mount(self): task.add_done_callback(self.tasks.discard) async def fetch_node_list(self): - crn_list = await fetch_crn_list() + + crn_info = await build_crn_info(self.crn_list) + self.crns = ( - {RowKey(crn.hash): (crn, 0) for crn in crn_list} + {RowKey(crn.hash): (crn, 0) for crn in crn_info} if not self.only_gpu_model else { RowKey(f"{crn.hash}_{gpu_id}"): (crn, gpu_id) - for crn in crn_list + for crn in crn_info for gpu_id in range(len(crn.compatible_available_gpus)) } ) @@ -694,7 +700,6 @@ async def add_crn_info(self, crn: CRNInfo, gpu_id: int): # Fetch terms and conditions tac = await crn.terms_and_conditions_content - self.table.add_row( _format_score(crn.score), crn.name, diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 3391b17a..72989007 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -6,6 +6,7 @@ from aiohttp import ClientConnectorError, ClientResponseError, ClientSession, InvalidURL from aleph.sdk import AlephHttpClient +from aleph.sdk.client.services.crn import CRN, CrnList from aleph.sdk.conf import settings from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError from aleph_message.models import InstanceMessage @@ -15,12 +16,7 @@ from typer import Exit from aleph_client.models import CRNInfo -from aleph_client.utils import ( - async_lru_cache, - extract_valid_eth_address, - fetch_json, - sanitize_url, -) +from aleph_client.utils import async_lru_cache, fetch_json, sanitize_url logger = logging.getLogger(__name__) @@ -40,7 +36,7 @@ @async_lru_cache -async def call_program_crn_list() -> dict: +async def call_program_crn_list() -> CrnList: """Call program to fetch the compute resource node list.""" error = None try: @@ -83,46 +79,15 @@ async def fetch_latest_crn_version() -> str: @async_lru_cache -async def fetch_crn_list( - latest_crn_version: bool = False, - ipv6: bool = False, - stream_address: bool = False, - confidential: bool = False, - gpu: bool = False, -) -> list[CRNInfo]: - """Fetch compute resource node list, unfiltered by default. +async def fetch_network_gpu(crn_list=None): + async with AlephHttpClient(api_server=settings.API_HOST) as client: + return await client.crn.fetch_gpu_on_network(crn_list=crn_list) - Args: - latest_crn_version (bool): Filter by latest crn version. - ipv6 (bool): Filter invalid IPv6 configuration. - stream_address (bool): Filter invalid payment receiver address. - confidential (bool): Filter by confidential computing support. - gpu (bool): Filter by GPU support. - Returns: - list[CRNInfo]: List of compute resource nodes. - """ - data = await call_program_crn_list() - # current_crn_version = await fetch_latest_crn_version() - # Relax current filter to allow use aleph-vm versions since 1.5.1. - # TODO: Allow to specify that option on settings aggregate on maybe on GitHub - current_crn_version = "1.5.1" - crns = [] - for crn in data.get("crns"): - gpu_support = crn.get("gpu_support") - available_gpu = crn.get("compatible_available_gpus") - if latest_crn_version and (crn.get("version") or "0.0.0") < current_crn_version: - continue - if ipv6: - ipv6_check = crn.get("ipv6_check") - if not ipv6_check or not all(ipv6_check.values()): - continue - if stream_address and not extract_valid_eth_address(crn.get("payment_receiver_address") or ""): - continue - if confidential and not crn.get("confidential_support"): - continue - if gpu and (not gpu_support or not available_gpu): - continue +async def build_crn_info(crn_list: list[CRN]) -> list[CRNInfo]: + """Build a list of CRNInfo from CRN List already filtered.""" + crns: list[CRNInfo] = [] + for crn in crn_list: try: crns.append(CRNInfo.from_unsanitized_input(crn)) except ValidationError: From ede5809efee3987884cc978c846b1400bb9e78ce Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:30:49 +0200 Subject: [PATCH 04/19] Fix: unit test --- tests/unit/conftest.py | 11 ++++++ tests/unit/test_account_transact.py | 54 +++++++++++++++++++++++++++++ tests/unit/test_instance.py | 28 +++++++++++---- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_account_transact.py diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1e759857..3fc3dbe6 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -18,6 +18,7 @@ import pytest from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.ethereum import ETHAccount, get_fallback_private_key +from aleph.sdk.client.services.crn import CrnList from aleph.sdk.types import StoredContent from aleph_message.models import Chain, ItemHash, ItemType, StoreContent, StoreMessage from aleph_message.models.base import MessageType @@ -265,6 +266,16 @@ def mock_crn_list(): ] +@pytest.fixture +def mock_crn_list_obj(mock_crn_list): + """ + Wrap the raw mock_crn_list data into a CrnList object, + same type as call_program_crn_list() would return. + """ + # call_program_crn_list expects a JSON dict with "crns" + return CrnList.from_api({"crns": mock_crn_list}) + + @pytest.fixture def mock_crn_info(mock_crn_list): """Create a mock CRNInfo object.""" diff --git a/tests/unit/test_account_transact.py b/tests/unit/test_account_transact.py new file mode 100644 index 00000000..81a59b1b --- /dev/null +++ b/tests/unit/test_account_transact.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +import pytest +import typer +from aleph.sdk.exceptions import InsufficientFundsError +from aleph.sdk.types import TokenType +from typer.testing import CliRunner + +from aleph_client.__main__ import app + +from .mocks import create_mock_load_account + +runner = CliRunner() + + +@pytest.fixture +def mock_account(): + """Create a mock account that can be configured for testing.""" + mock_loader = create_mock_load_account() + return mock_loader() + + +def test_account_can_transact_success(mock_account): + """Test that account.can_transact() succeeds when sufficient funds are available.""" + # This should succeed as the mock is configured to return True + assert mock_account.can_transact() is True + + +@patch("aleph_client.commands.account._load_account") +def test_account_can_transact_error_handling(mock_load_account): + """Test that error is handled properly when account.can_transact() fails.""" + # Setup mock account that will raise InsufficientFundsError + mock_account = MagicMock() + mock_account.can_transact.side_effect = InsufficientFundsError( + token_type=TokenType.GAS, required_funds=0.1, available_funds=0.05 + ) + mock_load_account.return_value = mock_account + + # Add a test command that uses the safety check + @app.command() + def test_command(): + try: # Safety check to ensure account can transact + mock_account.can_transact() + except Exception as e: + print(str(e)) + raise typer.Exit(code=1) from e + return 0 + + # Run the command + result = runner.invoke(app, ["test-command"]) + + # Verify error handling + assert result.exit_code == 1 + assert "Insufficient funds (GAS)" in result.stdout diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a43b828e..ba2ffdcf 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -9,6 +9,7 @@ import aiohttp import pytest from aiohttp import InvalidURL +from aleph.sdk.client.services.crn import CrnList from aleph_message.models import Chain from aleph_message.models.execution.base import Payment, PaymentType from aleph_message.models.execution.environment import ( @@ -257,7 +258,17 @@ def create_mock_shutil(): def create_mock_client(mock_crn_list, payment_type="superfluid"): # Create a proper mock for the crn service mock_crn_service = MagicMock() - mock_crn_service.get_crns_list = AsyncMock(return_value={"crns": mock_crn_list}) + # Ensure we have a valid CrnList even if mock_crn_list is None + crn_list_data = mock_crn_list + mock_crn_service.get_crns_list = AsyncMock(return_value=CrnList.from_api({"crns": crn_list_data})) + + mock_crn_service.fetch_gpu_on_network = AsyncMock( + return_value=Dict( + total_gpu_count=1, + available_gpu_list={"https://test.gpu.crn.com": [{"model": "RTX 4000 ADA", "device_name": "GPU Device"}]}, + used_gpu_list={}, + ) + ) mock_client = AsyncMock( get_message=AsyncMock(return_value=True), @@ -289,7 +300,14 @@ def response_get_program_price(ptype): # Create a proper mock for the crn service mock_crn_service = MagicMock() - mock_crn_service.get_crns_list = AsyncMock(return_value={"crns": mock_crn_list or []}) + mock_crn_service.get_crns_list = AsyncMock(return_value=CrnList.from_api({"crns": mock_crn_list})) + mock_crn_service.fetch_gpu_on_network = AsyncMock( + return_value=Dict( + total_gpu_count=1, + available_gpu_list={"https://test.gpu.crn.com": [{"model": "RTX 4000 ADA", "device_name": "GPU Device"}]}, + used_gpu_list={}, + ) + ) mock_response_get_message = create_mock_instance_message(mock_account, payg=True) mock_response_create_instance = MagicMock(item_hash=FAKE_VM_HASH) @@ -471,16 +489,14 @@ async def test_create_instance(args, expected, mock_crn_list, mock_api_response) @patch("aleph_client.commands.instance._load_account", mock_load_account) @patch("aleph_client.commands.instance.get_balance", mock_get_balance) @patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class) + @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) - @patch("aleph_client.commands.pricing.validated_prompt", mock_validated_prompt) + @patch("aleph_client.commands.utils.validated_prompt", mock_validated_prompt) @patch("aleph_client.commands.instance.network.fetch_latest_crn_version", mock_fetch_latest_crn_version) @patch("aleph_client.commands.instance.yes_no_input", mock_yes_no_input) @patch("aleph_client.commands.instance.wait_for_processed_instance", mock_wait_for_processed_instance) @patch("aleph_client.commands.instance.wait_for_confirmed_flow", mock_wait_for_confirmed_flow) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) - @patch( - "aleph_client.commands.instance.network.call_program_crn_list", AsyncMock(return_value={"crns": mock_crn_list}) - ) @patch("aleph_client.commands.instance.display.CRNTable.run_async", AsyncMock(return_value=(None, 0))) @patch("aiohttp.ClientSession.get") async def create_instance(mock_get, instance_spec): From 8447e375acd79124d7e89cf90cb46f627898f93b Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:32:04 +0200 Subject: [PATCH 05/19] (to revert): use sdk branch for pricing --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b1d3dd2..6bf8ed7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,8 +31,8 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.1", - "aleph-sdk-python>=2.0.5", - "base58==2.1.1", # Needed now as default with _load_account changement + "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-pricing-services", + "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", From e9767d77909923a63c65dd8f5daa650a0ee3ce0c Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 11:43:22 +0200 Subject: [PATCH 06/19] fix: linting issue --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6bf8ed7b..109811d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,14 +34,14 @@ dependencies = [ "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-pricing-services", "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", - "py-sr25519-bindings==0.2", # Needed for DOT signatures + "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", "pygments==2.19.1", - "pynacl==1.5", # Needed now as default with _load_account changement + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.9.*", "setuptools>=65.5", - "substrate-interface==1.7.11", # Needed for DOT signatures + "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", ] From 62f238f92faecd4ee0bffb7694a8d2ef77240b13 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 1 Sep 2025 12:41:12 +0200 Subject: [PATCH 07/19] Unit: some more testing on pricing --- tests/unit/test_pricing.py | 120 ++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_pricing.py b/tests/unit/test_pricing.py index 1de00120..fe9c9564 100644 --- a/tests/unit/test_pricing.py +++ b/tests/unit/test_pricing.py @@ -1,10 +1,17 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from aleph.sdk.client.services.pricing import PricingEntity, PricingModel +from rich.console import Console -from aleph_client.commands.pricing import GroupEntity, prices_for_service +from aleph_client.commands.pricing import ( + GroupEntity, + Pricing, + fetch_pricing_aggregate, + prices_for_service, +) @pytest.mark.parametrize( @@ -24,3 +31,112 @@ async def run(mock_get): await run() captured = capsys.readouterr() assert captured.out.startswith("\n╭─ Pricing:") + + +@pytest.mark.parametrize( + ids=["instance", "instance_conf", "program", "program_persistent"], + argnames="entity", + argvalues=[ + PricingEntity.INSTANCE, + PricingEntity.INSTANCE_CONFIDENTIAL, + PricingEntity.PROGRAM, + PricingEntity.PROGRAM_PERSISTENT, + ], +) +def test_pricing_display_table_for_compute(entity, mock_pricing_info_response): + """Test the display_table_for method for compute entities.""" + # Load pricing data from mock response + pricing_data = mock_pricing_info_response.json.return_value["data"]["pricing"] + + pricing_model = PricingModel.model_validate(pricing_data) + + pricing = Pricing(pricing_model) + pricing.console = MagicMock(spec=Console) + + pricing.display_table_for(entity) + + assert pricing.console.print.called + + pricing.display_table_for(entity, tier=1) + + assert pricing.console.print.call_count == 2 + + +def test_pricing_display_table_for_storage(mock_pricing_info_response): + """Test the display_table_for method for storage and web3 hosting.""" + # Load pricing data from mock response + pricing_data = mock_pricing_info_response.json.return_value["data"]["pricing"] + + pricing_model = PricingModel.model_validate(pricing_data) + + pricing = Pricing(pricing_model) + pricing.console = MagicMock(spec=Console) + + pricing.display_table_for(PricingEntity.STORAGE) + + assert pricing.console.print.called + + pricing.display_table_for(PricingEntity.WEB3_HOSTING) + + assert pricing.console.print.call_count == 2 + + +@pytest.mark.asyncio +@patch("aleph_client.commands.instance.network.call_program_crn_list") +async def test_pricing_display_gpu_info(mock_call_program_crn_list, mock_pricing_info_response, mock_crn_list_obj): + """Test the display_table_for method with GPU information.""" + # Setup mock for call_program_crn_list + mock_call_program_crn_list.return_value = mock_crn_list_obj + + # Load pricing data from mock response + pricing_data = mock_pricing_info_response.json.return_value["data"]["pricing"] + + pricing_model = PricingModel.model_validate(pricing_data) + + pricing = Pricing(pricing_model) + pricing.console = MagicMock(spec=Console) + + network_gpu = mock_crn_list_obj.find_gpu_on_network() + + pricing.display_table_for(PricingEntity.INSTANCE_GPU_STANDARD, network_gpu=network_gpu) + + pricing.display_table_for(PricingEntity.INSTANCE_GPU_PREMIUM, network_gpu=network_gpu) + + assert pricing.console.print.call_count == 2 + + +@pytest.mark.asyncio +async def test_fetch_pricing_aggregate(mock_pricing_info_response): + """Test the fetch_pricing_aggregate function.""" + # Load pricing data from mock response + pricing_data = mock_pricing_info_response.json.return_value["data"]["pricing"] + + pricing_model = PricingModel.model_validate(pricing_data) + + @patch("aleph_client.commands.pricing.AlephHttpClient") + async def run(mock_client_class): + # Setup the mock client + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock() + + mock_client.pricing = MagicMock() + mock_client.pricing.get_pricing_aggregate = AsyncMock(return_value=pricing_model) + + mock_client_class.return_value = mock_client + + # Clear the cache + fetch_pricing_aggregate.cache_clear() + + result = await fetch_pricing_aggregate() + + assert isinstance(result, Pricing) + assert result.data == pricing_model + + # Call again to test caching + await fetch_pricing_aggregate() + + assert mock_client.pricing.get_pricing_aggregate.call_count == 2 + + await run() From c0ef8ce5495ee89fbdf413a2d3eed727abb09afc Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:53:44 +0200 Subject: [PATCH 08/19] Feature: new credits commands --- src/aleph_client/__main__.py | 3 + src/aleph_client/commands/credit.py | 107 ++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/aleph_client/commands/credit.py diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index c86b1f15..c3d72259 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -6,6 +6,7 @@ about, account, aggregate, + credit, domain, files, instance, @@ -30,9 +31,11 @@ app.add_typer(files.app, name="file", help="Manage files (upload and pin on IPFS) on aleph.im & twentysix.cloud") app.add_typer(program.app, name="program", help="Manage programs (micro-VMs) on aleph.im & twentysix.cloud") app.add_typer(instance.app, name="instance", help="Manage instances (VMs) on aleph.im & twentysix.cloud") +app.add_typer(credit.app, name="credits", help="Credits commmands on aleph.im") app.add_typer(domain.app, name="domain", help="Manage custom domain (DNS) on aleph.im & twentysix.cloud") app.add_typer(node.app, name="node", help="Get node info on aleph.im & twentysix.cloud") app.add_typer(about.app, name="about", help="Display the informations of Aleph CLI") + app.command("pricing")(pricing.prices_for_service) if __name__ == "__main__": diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py new file mode 100644 index 00000000..7f982773 --- /dev/null +++ b/src/aleph_client/commands/credit.py @@ -0,0 +1,107 @@ +import logging +from pathlib import Path +from typing import Annotated, Optional + +import typer +from aiohttp import ClientResponseError +from aleph.sdk import AlephHttpClient +from aleph.sdk.account import _load_account +from aleph.sdk.conf import settings +from aleph.sdk.query.filters import CreditsFilter +from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.utils import displayable_amount +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from aleph_client.commands import help_strings +from aleph_client.commands.utils import setup_logging +from aleph_client.utils import AsyncTyper + +logger = logging.getLogger(__name__) +app = AsyncTyper(no_args_is_help=True) +console = Console() + + +@app.command() +async def show( + address: Annotated[ + str, + typer.Argument(help="Address of the wallet you want to check / None if you want check your current accounts"), + ] = "", + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, + private_key_file: Annotated[ + Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) + ] = settings.PRIVATE_KEY_FILE, + json: Annotated[bool, typer.Option(help="Display as json")] = False, + debug: Annotated[bool, typer.Option()] = False, +): + """Display the numbers of credits for a specific address.""" + + setup_logging(debug) + + account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + + if account and not address: + address = account.get_address() + + if address: + async with AlephHttpClient(api_server=settings.API_HOST) as client: + credit = await client.get_credit_balance(address=address) + if json: + typer.echo(credit.model_dump_json(indent=4)) + else: + infos = [ + Text.from_markup(f"Address: [bright_cyan]{address}[/bright_cyan]\n"), + Text("Credits:"), + Text.from_markup(f"[bright_cyan] {displayable_amount(credit.credits, decimals=2)}[/bright_cyan]"), + ] + console.print( + Panel( + Text.assemble(*infos), + title="Credits Infos", + border_style="bright_cyan", + expand=False, + title_align="left", + ) + ) + else: + typer.echo("Error: Please provide either a private key, private key file, or an address.") + + +@app.command(name="list") +async def list_credits( + page_size: Annotated[int, typer.Option(help="Numbers of element per page")] = 100, + page: Annotated[int, typer.Option(help="Current Page")] = 1, + min_balance: Annotated[ + Optional[int], typer.Option(help="Minimum balance required to be taken into account") + ] = None, + json: Annotated[bool, typer.Option(help="Display as json")] = False, +): + try: + async with AlephHttpClient(api_server=settings.API_HOST) as client: + credit_filter = CreditsFilter(min_balance=min_balance) if min_balance else None + filtered_credits = await client.get_credits(credit_filter=credit_filter, page_size=page_size, page=page) + if json: + typer.echo(filtered_credits.model_dump_json(indent=4)) + else: + table = Table(title="Credits Information", border_style="white") + table.add_column("Address", style="bright_cyan") + table.add_column("Credits", justify="right", style="bright_cyan") + + for credit in filtered_credits.credit_balances: + table.add_row(credit.address, f"{displayable_amount(credit.credits, decimals=2)}") + + # Add pagination footer + pagination_info = Text.assemble( + f"Page: {filtered_credits.pagination_page} of {filtered_credits.pagination_total} | ", + f"Items per page: {filtered_credits.pagination_per_page} | ", + f"Page size: {filtered_credits.pagination_total}", + ) + table.caption = pagination_info + + console.print(table) + except ClientResponseError as e: + typer.echo("Failed to retrieve credits.") + raise (e) From 0063f6b2e9562c29931c584fafe4904f126a94c5 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:54:21 +0200 Subject: [PATCH 09/19] Feature: pricing now handle credits payment --- src/aleph_client/commands/pricing.py | 51 +++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index 7c2ecc53..75774705 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -26,7 +26,6 @@ from rich.table import Table from rich.text import Text -from aleph_client.commands.instance.network import call_program_crn_list from aleph_client.commands.utils import colorful_json, setup_logging from aleph_client.utils import async_lru_cache, sanitize_url @@ -111,18 +110,27 @@ def build_storage_and_website( ) ) if "storage" in price_dict and isinstance(price_dict["storage"], Price): - is_storage = "+ " if not storage else "" - ammount = Decimal(str(price_dict["storage"].holding)) if price_dict["storage"].holding else Decimal("0") - infos.append( - Text.from_markup( - f"{is_storage}" # If it not STORAGE then it's 'additionnal' charge so we do + at end - "$ALEPH (Holding): [bright_cyan]" - f"{displayable_amount(ammount, decimals=5)}" - " token/Mib[/bright_cyan] -or- [bright_cyan]" - f"{displayable_amount(ammount * 1024, decimals=5)}" - " token/GiB[/bright_cyan]" + prefix = "+ " if not storage else "" + storage_price = price_dict["storage"] + + def fmt(value, unit): + amount = Decimal(str(value)) if value else Decimal("0") + return ( + f"{displayable_amount(amount, decimals=5)} {unit}/Mib[/bright_cyan] -or- " + f"[bright_cyan]{displayable_amount(amount * 1024, decimals=5)} {unit}/GiB[/bright_cyan]" ) - ) + + holding = fmt(storage_price.holding, "token") + + lines = [f"{prefix}$ALEPH (Holding): [bright_cyan]{holding}"] + + # Show credits ONLY for storage, and only if a credit price exists + if storage and storage_price.credit: + credit = fmt(storage_price.credit, "credit") + lines.append(f"Credits: [bright_cyan]{credit}") + + infos.append(Text.from_markup("\n".join(lines))) + return Group(*infos) def build_column( @@ -149,6 +157,9 @@ def build_column( if isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP: self.table.add_column("$ALEPH (Pay-As-You-Go)", style="green", justify="center") + if isinstance(cu_price, Price) and cu_price.credit: + self.table.add_column("$ Credits", style="green", justify="center") + if entity in PRICING_GROUPS[GroupEntity.PROGRAM]: self.table.add_column("+ Internet Access", style="orange1", justify="center") @@ -216,6 +227,15 @@ def fill_tier( f"{displayable_amount(payg_hourly, decimals=3)} token/hour" f"\n{displayable_amount(payg_hourly * 24, decimals=3)} token/day" ) + # Fill Credit row + if isinstance(cu_price, Price) and cu_price.credit: + credit_price = cu_price.credit + credit_hourly = Decimal(str(credit_price)) * tier.compute_units + row.append( + f"{displayable_amount(credit_hourly, decimals=3)} credit/hour" + f"\n{displayable_amount(credit_hourly * 24, decimals=3)} credit/day" + ) + # Program Case we additional price if entity in PRICING_GROUPS[GroupEntity.PROGRAM]: program_price = entity_info.price.get("compute_unit") @@ -300,11 +320,16 @@ def display_table_for( if isinstance(storage_price, Price) and storage_price.payg: payg_storage_price = displayable_amount(Decimal(str(storage_price.payg)) * 1024 * 24, decimals=5) + extra_price_credits = "0" + if isinstance(storage_price, Price) and storage_price.credit: + extra_price_credits = displayable_amount(Decimal(str(storage_price.credit)) * 1024 * 24, decimals=5) + infos = [ Text.from_markup( f"Extra Volume Cost: {extra_price_holding}" f"[green]{payg_storage_price}" " token/GiB/day[/green] (Pay-As-You-Go)" + f" -or- [green]{extra_price_credits} credit/GiB/day[/green] (Credits)\n" ) ] displayable_group = Group( @@ -356,6 +381,8 @@ async def prices_for_service( # Fetch Current availibity network_gpu = None if (service in [GroupEntity.GPU, GroupEntity.ALL]) and with_current_availability: + from aleph_client.commands.instance.network import call_program_crn_list + crn_lists = await call_program_crn_list() network_gpu = crn_lists.find_gpu_on_network() if json: From 2ddc076c6f0fda556dfbfc4d955a9f85d54d540b Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:55:05 +0200 Subject: [PATCH 10/19] Feature: aleph account balance also show credits ballance --- src/aleph_client/commands/account.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index d12e1b24..a5204abb 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,6 +9,7 @@ import aiohttp import typer +from aleph.sdk import AlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key @@ -334,12 +335,26 @@ async def balance( ), ] + try: + # Fetch user Credits + async with AlephHttpClient() as client: + credits_balance = await client.get_credit_balance(address) + infos += [ + Text("\nCredits:"), + Text.from_markup( + f"[bright_cyan] {displayable_amount(credits_balance.credits, decimals=2)}[/bright_cyan]" + ), + ] + except Exception as e: + # In the case we call on ccn that does not support credits yet + logger.warning(f"Failed to fetch credits balance: {e}") + # Get vouchers and add them to Account Info panel vouchers = await voucher_manager.get_all(address=address) if vouchers: voucher_names = [voucher.name for voucher in vouchers] infos += [ - Text("\n\nVouchers:"), + Text("\nVouchers:"), Text.from_markup(f"\n [bright_cyan]{', '.join(voucher_names)}[/bright_cyan]"), ] From 5238be23ad5d3fb84b501299eb057476989e396b Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:55:39 +0200 Subject: [PATCH 11/19] Feature: handle credit payment using aleph instance create --- .../commands/instance/__init__.py | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 582b93a8..1b5c99a5 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -229,8 +229,10 @@ async def create( raise ValueError(msg) # Checks if payment-chain is compatible with PAYG - is_stream = payment_type != PaymentType.hold - if is_stream: + is_stream = payment_type == PaymentType.superfluid + is_credit = payment_type == PaymentType.credit + + if is_stream: # credit don't have payment-chain res if address != account.get_address(): console.print("Payment delegation is incompatible with Pay-As-You-Go.") raise typer.Exit(code=1) @@ -417,40 +419,57 @@ async def create( immutable_volume=immutable_volume, ) - # Early check with minimal cost (Gas + Aleph ERC20) - available_funds = Decimal(0 if is_stream else (await get_balance(address))["available_amount"]) - try: - # Get compute_unit price from PricingPerEntity - compute_unit_price = pricing.data[pricing_entity].price.get("compute_unit") - if is_stream and isinstance(account, ETHAccount): - if account.CHAIN != payment_chain: - account.switch_chain(payment_chain) - if safe_getattr(account, "superfluid_connector"): - if isinstance(compute_unit_price, Price) and compute_unit_price.payg: - payg_price = Decimal(str(compute_unit_price.payg)) * tier.compute_units - flow_rate_per_second = payg_price / Decimal(3600) - account.can_start_flow(flow_rate_per_second) + compute_unit_price = pricing.data[pricing_entity].price.get("compute_unit") + if payment_type in [PaymentType.hold, PaymentType.superfluid]: + # Early check with minimal cost (Gas + Aleph ERC20) + available_funds = Decimal(0 if is_stream else (await get_balance(address))["available_amount"]) + try: + # Get compute_unit price from PricingPerEntity + if is_stream and isinstance(account, ETHAccount): + if account.CHAIN != payment_chain: + account.switch_chain(payment_chain) + if safe_getattr(account, "superfluid_connector"): + if isinstance(compute_unit_price, Price) and compute_unit_price.payg: + payg_price = Decimal(str(compute_unit_price.payg)) * tier.compute_units + flow_rate_per_second = payg_price / Decimal(3600) + account.can_start_flow(flow_rate_per_second) + else: + echo("No PAYG price available for this tier.") + raise typer.Exit(code=1) else: - echo("No PAYG price available for this tier.") + echo("Superfluid connector not available on this chain.") raise typer.Exit(code=1) - else: - echo("Superfluid connector not available on this chain.") - raise typer.Exit(code=1) - elif not is_stream: - if isinstance(compute_unit_price, Price) and compute_unit_price.holding: - hold_price = Decimal(str(compute_unit_price.holding)) * tier.compute_units - if available_funds < hold_price: - raise InsufficientFundsError(TokenType.ALEPH, float(hold_price), float(available_funds)) - else: - echo("No holding price available for this tier.") - raise typer.Exit(code=1) - except InsufficientFundsError as e: - echo(e) - raise typer.Exit(code=1) from e + elif not is_stream: + if isinstance(compute_unit_price, Price) and compute_unit_price.holding: + hold_price = Decimal(str(compute_unit_price.holding)) * tier.compute_units + if available_funds < hold_price: + raise InsufficientFundsError(TokenType.ALEPH, float(hold_price), float(available_funds)) + else: + echo("No holding price available for this tier.") + raise typer.Exit(code=1) + except InsufficientFundsError as e: + echo(e) + raise typer.Exit(code=1) from e + + if payment_type == PaymentType.credit: + async with AlephHttpClient(api_server=settings.API_HOST) as client: + try: + credit_info = await client.get_credit_balance(address=address) + if isinstance(compute_unit_price, Price) and compute_unit_price.credit: + credit_price = Decimal(str(compute_unit_price.credit)) * tier.compute_units + if credit_info.credits < credit_price: + raise InsufficientFundsError(TokenType.CREDIT, float(credit_price), float(credit_info.credits)) + available_funds = credit_info.credits + else: + echo("No credits price available for this tier.") + raise typer.Exit(code=1) + except Exception as e: + echo(f"Failed to fetch credit info, error: {e}") + raise typer.Exit(code=1) from e stream_reward_address = None crn, crn_info, gpu_id = None, None, None - if is_stream or confidential or gpu: + if is_stream or confidential or gpu or is_credit: if crn_url: try: crn_url = sanitize_url(crn_url) @@ -660,7 +679,7 @@ async def create( # Instances that need to be started by notifying a specific CRN crn_url = crn_info.url if crn_info and crn_info.url else None - if crn_info and (is_stream or confidential or gpu): + if crn_info and (is_stream or confidential or gpu or is_credit): if not crn_url: # Not the ideal solution logger.debug(f"Cannot allocate {item_hash}: no CRN url") From b2438c93c1b265d4e7fc5d07e12f2f794c51e374 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:56:14 +0200 Subject: [PATCH 12/19] feature: add credits to mocked pricing aggregate --- tests/unit/mock_data/pricing_data.json | 39 +++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/unit/mock_data/pricing_data.json b/tests/unit/mock_data/pricing_data.json index 2da0dbb8..70f747ef 100644 --- a/tests/unit/mock_data/pricing_data.json +++ b/tests/unit/mock_data/pricing_data.json @@ -6,11 +6,13 @@ "price": { "storage": { "payg": "0.000000977", - "holding": "0.05" + "holding": "0.05", + "credit": "0.000000977" }, "compute_unit": { "payg": "0.011", - "holding": "200" + "holding": "200", + "credit": "0.011" } }, "tiers": [ @@ -48,7 +50,8 @@ "storage": { "price": { "storage": { - "holding": "0.333333333" + "holding": "0.333333333", + "credit": "0.333333333" } } }, @@ -56,11 +59,13 @@ "price": { "storage": { "payg": "0.000000977", - "holding": "0.05" + "holding": "0.05", + "credit": "0.000000977" }, "compute_unit": { "payg": "0.055", - "holding": "1000" + "holding": "1000", + "credit": "0.055" } }, "tiers": [ @@ -107,11 +112,13 @@ "price": { "storage": { "payg": "0.000000977", - "holding": "0.05" + "holding": "0.05", + "credit": "0.000000977" }, "compute_unit": { "payg": "0.055", - "holding": "1000" + "holding": "1000", + "credit": "0.055" } }, "tiers": [ @@ -149,10 +156,12 @@ "instance_gpu_premium": { "price": { "storage": { - "payg": "0.000000977" + "payg": "0.000000977", + "credit": "0.000000977" }, "compute_unit": { - "payg": "0.56" + "payg": "0.56", + "credit": "0.56" } }, "tiers": [ @@ -179,11 +188,13 @@ "price": { "storage": { "payg": "0.000000977", - "holding": "0.05" + "holding": "0.05", + "credit": "0.000000977" }, "compute_unit": { "payg": "0.11", - "holding": "2000" + "holding": "2000", + "credit": "0.11" } }, "tiers": [ @@ -221,10 +232,12 @@ "instance_gpu_standard": { "price": { "storage": { - "payg": "0.000000977" + "payg": "0.000000977", + "credit": "0.000000977" }, "compute_unit": { - "payg": "0.28" + "payg": "0.28", + "credit": "0.28" } }, "tiers": [ From e31c6b1a67267da8360c0c07c90488909e307c9e Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:56:56 +0200 Subject: [PATCH 13/19] Unit: credit payment case for instance creations --- tests/unit/test_instance.py | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index ba2ffdcf..de9b0069 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -170,7 +170,7 @@ def test_sanitize_url_with_https_scheme(): assert sanitize_url(url) == url -def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False, tac=False): +def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False, tac=False, credit=False): tmp = list(FAKE_VM_HASH) random.shuffle(tmp) vm_item_hash = "".join(tmp) @@ -198,14 +198,19 @@ def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False volumes=[], ), ) - if payg or coco or gpu or tac: - vm.content.metadata["name"] += "_payg" # type: ignore - vm.content.payment = Payment(chain=Chain.AVAX, receiver=FAKE_ADDRESS_EVM, type=PaymentType.superfluid) # type: ignore + if payg or coco or gpu or tac or credit: + # Set payment type based on what's being requested + if credit: + vm.content.metadata["name"] += "_credit" # type: ignore + vm.content.payment = Payment(chain=Chain.ETH, receiver=FAKE_ADDRESS_EVM, type=PaymentType.credit) # type: ignore + else: + vm.content.metadata["name"] += "_payg" # type: ignore + vm.content.payment = Payment(chain=Chain.AVAX, receiver=FAKE_ADDRESS_EVM, type=PaymentType.superfluid) # type: ignore # We load the good CRN for the good Type of VM if coco: crn_hash = FAKE_CRN_CONF_HASH - elif payg: + elif payg or credit: crn_hash = FAKE_CRN_GPU_HASH else: crn_hash = FAKE_CRN_BASIC_HASH @@ -242,7 +247,8 @@ def create_mock_instance_messages(mock_account): coco = create_mock_instance_message(mock_account, coco=True) gpu = create_mock_instance_message(mock_account, gpu=True) tac = create_mock_instance_message(mock_account, tac=True) - return AsyncMock(return_value=[regular, payg, coco, gpu, tac]) + credit = create_mock_instance_message(mock_account, credit=True) + return AsyncMock(return_value=[regular, payg, coco, gpu, tac, credit]) def create_mock_validate_ssh_pubkey_file(): @@ -270,6 +276,9 @@ def create_mock_client(mock_crn_list, payment_type="superfluid"): ) ) + # Define required tokens based on payment type + required_tokens = 0.00001527777777777777 if payment_type == "superfluid" else 1000 + mock_client = AsyncMock( get_message=AsyncMock(return_value=True), get_stored_content=AsyncMock( @@ -277,10 +286,15 @@ def create_mock_client(mock_crn_list, payment_type="superfluid"): ), get_estimated_price=AsyncMock( return_value=MagicMock( - required_tokens=0.00001527777777777777 if payment_type == "superfluid" else 1000, + required_tokens=required_tokens, payment_type=payment_type, ) ), + get_credit_balance=AsyncMock( + return_value=MagicMock( + credits=5000, # Enough credits for testing + ) + ), ) # Set the crn attribute to the properly mocked service mock_client.crn = mock_crn_service @@ -325,6 +339,11 @@ def response_get_program_price(ptype): create_instance=AsyncMock(return_value=[mock_response_create_instance, 200]), get_program_price=None, forget=AsyncMock(return_value=(MagicMock(), 200)), + get_credit_balance=AsyncMock( + return_value=MagicMock( + credits=5000, # Enough credits for testing + ) + ), ) # Set the service attributes @@ -392,6 +411,7 @@ def create_mock_vm_coco_client(): "coco_hold_evm", "coco_superfluid_evm", "gpu_superfluid_evm", + "regular_credit_instance", ], argnames="args, expected", argvalues=[ @@ -463,6 +483,15 @@ def create_mock_vm_coco_client(): }, (FAKE_VM_HASH, FAKE_CRN_GPU_URL, "BASE"), ), + ( # regular_credit_instance + { + "payment_type": "credit", + "payment_chain": "ETH", # Not actually used for credit payment type + "rootfs": "debian12", + "crn_url": FAKE_CRN_BASIC_URL, + }, + (FAKE_VM_HASH, FAKE_CRN_BASIC_URL, "ETH"), + ), ], ) @pytest.mark.asyncio From 7656f556563a36378b05a104fad2e3c0148fd2aa Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 11:57:15 +0200 Subject: [PATCH 14/19] Unit: new unit test for credits commands --- tests/unit/test_credits.py | 295 +++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/unit/test_credits.py diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py new file mode 100644 index 00000000..37918fc8 --- /dev/null +++ b/tests/unit/test_credits.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp import ClientResponseError + +from aleph_client.commands.credits import list_credits, show + + +@pytest.fixture +def mock_credit_balance_response(): + """Create a mock response for credit balance API call.""" + mock_response = AsyncMock() + mock_response.__aenter__.return_value = mock_response + mock_response.status = 200 + mock_response.json = AsyncMock( + return_value={ + "address": "0x1234567890123456789012345678901234567890", + "credits": 1000000000, # 10 credits with 8 decimals + } + ) + return mock_response + + +@pytest.fixture +def mock_credits_list_response(): + """Create a mock response for credits list API call.""" + mock_response = AsyncMock() + mock_response.__aenter__.return_value = mock_response + mock_response.status = 200 + mock_response.json = AsyncMock( + return_value={ + "credit_balances": [ + { + "address": "0x1234567890123456789012345678901234567890", + "credits": 1000000000, # 10 credits with 8 decimals + }, + { + "address": "0x0987654321098765432109876543210987654321", + "credits": 500000000, # 5 credits with 8 decimals + }, + ], + "pagination_page": 1, + "pagination_total": 1, + "pagination_per_page": 100, + } + ) + return mock_response + + +@pytest.fixture +def mock_credit_error_response(): + """Create a mock error response for credit API calls.""" + mock_response = AsyncMock() + mock_response.__aenter__.return_value = mock_response + mock_response.status = 404 + mock_response.json = AsyncMock( + side_effect=ClientResponseError(request_info=AsyncMock(), history=AsyncMock(), status=404, message="Not Found") + ) + return mock_response + + +@pytest.mark.asyncio +async def test_show_command(mock_credit_balance_response, capsys): + """Test the show command with an explicit address.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credit_balance_response + + # Run the show command with an explicit address + await show( + address="0x1234567890123456789012345678901234567890", + private_key=None, + private_key_file=None, + json=False, + debug=False, + ) + + await run() + captured = capsys.readouterr() + assert "Credits Infos" in captured.out + assert "0x1234567890123456789012345678901234567890" in captured.out + # The credits might be displayed in their raw form without formatting + assert "1000000000" in captured.out + + +@pytest.mark.asyncio +async def test_show_json_output(mock_credit_balance_response, capsys): + """Test the show command with JSON output.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credit_balance_response + + # Run the show command with JSON output + await show( + address="0x1234567890123456789012345678901234567890", + private_key=None, + private_key_file=None, + json=True, + debug=False, + ) + + await run() + captured = capsys.readouterr() + assert "0x1234567890123456789012345678901234567890" in captured.out + assert "1000000000" in captured.out + + +@pytest.mark.asyncio +async def test_show_with_account(mock_credit_balance_response): + """Test the show command using account-derived address.""" + + @patch("aiohttp.ClientSession.get") + @patch("aleph_client.commands.credits._load_account") + async def run(mock_load_account, mock_get): + mock_get.return_value = mock_credit_balance_response + + # Setup mock account that returns a specific address + mock_account = AsyncMock() + mock_account.get_address.return_value = "0x1234567890123456789012345678901234567890" + mock_load_account.return_value = mock_account + + # Run the show command without explicit address (should use account address) + await show( + address="", + private_key="dummy_private_key", + private_key_file=None, + json=False, + debug=False, + ) + + # Verify the account was loaded and its address used + mock_load_account.assert_called_once() + mock_account.get_address.assert_called_once() + + await run() + + +@pytest.mark.asyncio +async def test_show_no_address_no_account(capsys): + """Test the show command with no address and no account.""" + + @patch("aleph_client.commands.credits._load_account") + async def run(mock_load_account): + # Setup the mock account to return None (no account found) + mock_load_account.return_value = None + + # Run the show command without address and without account + await show( + address="", + private_key=None, + private_key_file=None, + json=False, + debug=False, + ) + + await run() + captured = capsys.readouterr() + assert "Error: Please provide either a private key, private key file, or an address." in captured.out + + +@pytest.mark.asyncio +async def test_show_api_error(mock_credit_error_response): + """Test the show command handling API errors.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credit_error_response + + # Run the show command and expect an exception + with pytest.raises(ClientResponseError): + await show( + address="0x1234567890123456789012345678901234567890", + private_key=None, + private_key_file=None, + json=False, + debug=False, + ) + + await run() + + +@pytest.mark.asyncio +async def test_list_credits_default(mock_credits_list_response, capsys): + """Test the list_credits command with default parameters.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credits_list_response + + # Run the list_credits command with default parameters + await list_credits( + page_size=100, + page=1, + min_balance=None, + json=False, + ) + + await run() + captured = capsys.readouterr() + assert "Credits Information" in captured.out + assert "0x1234567890123456789012345678901234567890" in captured.out + # The credits might be displayed in their raw form without formatting + assert "1000000000" in captured.out + + +@pytest.mark.asyncio +async def test_list_credits_with_filter(mock_credits_list_response, capsys): + """Test the list_credits command with min_balance filter.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credits_list_response + + # Run the list_credits command with min_balance filter + await list_credits( + page_size=100, + page=1, + min_balance=1000000, # 0.01 credits with 8 decimals + json=False, + ) + + await run() + captured = capsys.readouterr() + assert "Credits Information" in captured.out + + +@pytest.mark.asyncio +async def test_list_credits_json_output(mock_credits_list_response, capsys): + """Test the list_credits command with JSON output.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credits_list_response + + # Run the list_credits command with JSON output + await list_credits( + page_size=100, + page=1, + min_balance=None, + json=True, + ) + + await run() + captured = capsys.readouterr() + assert "credit_balances" in captured.out + assert "pagination_page" in captured.out + + +@pytest.mark.asyncio +async def test_list_credits_custom_pagination(mock_credits_list_response): + """Test the list_credits command with custom pagination parameters.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credits_list_response + + # Run the list_credits command with custom pagination + await list_credits( + page_size=50, + page=2, + min_balance=None, + json=False, + ) + + # Verify that the parameters were passed correctly + # In the SDK, these parameters are passed as part of the 'params' argument, not in the URL + called_params = mock_get.call_args[1]["params"] + assert called_params["pagination"] == "50" + assert called_params["page"] == "2" + + await run() + + +@pytest.mark.asyncio +async def test_list_credits_api_error(mock_credit_error_response): + """Test the list_credits command handling API errors.""" + + @patch("aiohttp.ClientSession.get") + async def run(mock_get): + mock_get.return_value = mock_credit_error_response + + # Run the list_credits command and expect it to handle the error + with pytest.raises(ClientResponseError): + await list_credits( + page_size=100, + page=1, + min_balance=None, + json=False, + ) + + await run() From a9959d8bde04cb210b17e953d4122780c7758797 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 12:00:56 +0200 Subject: [PATCH 15/19] Fix: pyrpojecttoml to use aleph-message 1.0.4 and credit branch from sdk --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 109811d7..acf591e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,18 +30,18 @@ dynamic = [ "version" ] dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", - "aleph-message>=1.0.1", - "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-pricing-services", - "base58==2.1.1", # Needed now as default with _load_account changement + "aleph-message>=1.0.4", + "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-credits-system", + "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", - "py-sr25519-bindings==0.2", # Needed for DOT signatures + "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", "pygments==2.19.1", - "pynacl==1.5", # Needed now as default with _load_account changement + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.9.*", "setuptools>=65.5", - "substrate-interface==1.7.11", # Needed for DOT signatures + "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", ] From 24c73a18cca194f4db4ee2050321f46ca44e7f6b Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 12:11:10 +0200 Subject: [PATCH 16/19] fix: test_credits --- tests/unit/test_credits.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py index 37918fc8..343bd2a9 100644 --- a/tests/unit/test_credits.py +++ b/tests/unit/test_credits.py @@ -5,7 +5,7 @@ import pytest from aiohttp import ClientResponseError -from aleph_client.commands.credits import list_credits, show +from aleph_client.commands.credit import list_credits, show @pytest.fixture @@ -17,7 +17,7 @@ def mock_credit_balance_response(): mock_response.json = AsyncMock( return_value={ "address": "0x1234567890123456789012345678901234567890", - "credits": 1000000000, # 10 credits with 8 decimals + "credits": 1000000000, } ) return mock_response @@ -34,11 +34,11 @@ def mock_credits_list_response(): "credit_balances": [ { "address": "0x1234567890123456789012345678901234567890", - "credits": 1000000000, # 10 credits with 8 decimals + "credits": 1000000000, }, { "address": "0x0987654321098765432109876543210987654321", - "credits": 500000000, # 5 credits with 8 decimals + "credits": 500000000, }, ], "pagination_page": 1, From 2cc8e1f9a33b61dfe390a39a92f75b5f3e526957 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 12:26:06 +0200 Subject: [PATCH 17/19] Fix: mocked was trying to access .commands.credits instead of .commands.credit --- tests/unit/test_credits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py index 343bd2a9..5d3b8e50 100644 --- a/tests/unit/test_credits.py +++ b/tests/unit/test_credits.py @@ -114,7 +114,7 @@ async def test_show_with_account(mock_credit_balance_response): """Test the show command using account-derived address.""" @patch("aiohttp.ClientSession.get") - @patch("aleph_client.commands.credits._load_account") + @patch("aleph_client.commands.credit._load_account") async def run(mock_load_account, mock_get): mock_get.return_value = mock_credit_balance_response @@ -143,7 +143,7 @@ async def run(mock_load_account, mock_get): async def test_show_no_address_no_account(capsys): """Test the show command with no address and no account.""" - @patch("aleph_client.commands.credits._load_account") + @patch("aleph_client.commands.credit._load_account") async def run(mock_load_account): # Setup the mock account to return None (no account found) mock_load_account.return_value = None From 6da5931885e01cc47a4cfa2dce8707f3972a44bc Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 14:42:47 +0200 Subject: [PATCH 18/19] Feature: aleph credits buy --- src/aleph_client/commands/credit.py | 29 +++++++++++++++++++++++++++++ tests/unit/test_credits.py | 18 +++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 7f982773..776d89c0 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -105,3 +105,32 @@ async def list_credits( except ClientResponseError as e: typer.echo("Failed to retrieve credits.") raise (e) + + +@app.command(name="buy") +async def buy_credits( + debug: Annotated[bool, typer.Option()] = False, +): + """Purchase Aleph credits through the payment website.""" + setup_logging(debug) + + # Payment URL + payment_url = "https://pay.aleph.im" + + infos = [ + Text.from_markup("To purchase Aleph credits, visit:"), + Text.from_markup( + f"\n\n[bright_yellow][link={payment_url}]{payment_url}[/link][/bright_yellow]", + style="italic", + ), + ] + + console.print( + Panel( + Text.assemble(*infos), + title="Buy Credits", + border_style="bright_cyan", + expand=False, + title_align="left", + ) + ) diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py index 5d3b8e50..4c23beea 100644 --- a/tests/unit/test_credits.py +++ b/tests/unit/test_credits.py @@ -5,7 +5,7 @@ import pytest from aiohttp import ClientResponseError -from aleph_client.commands.credit import list_credits, show +from aleph_client.commands.credit import buy_credits, list_credits, show @pytest.fixture @@ -293,3 +293,19 @@ async def run(mock_get): ) await run() + + +@pytest.mark.asyncio +async def test_buy_credits(capsys): + """Test the buy_credits command outputs the payment URL information.""" + + # Call the buy_credits function + await buy_credits(debug=False) + + # Capture the output + captured = capsys.readouterr() + + # Check that the expected elements are in the output + assert "Buy Credits" in captured.out + assert "To purchase Aleph credits, visit:" in captured.out + assert "https://pay.aleph.im" in captured.out From 0dedf59a26b1940d0cbfff65dd5d26f27f859861 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 9 Sep 2025 15:03:16 +0200 Subject: [PATCH 19/19] fix: redirect to console app instead of dummy url --- src/aleph_client/commands/credit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 776d89c0..6373c0a9 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -115,7 +115,7 @@ async def buy_credits( setup_logging(debug) # Payment URL - payment_url = "https://pay.aleph.im" + payment_url = "https://app.aleph.cloud/console/" infos = [ Text.from_markup("To purchase Aleph credits, visit:"),