From 484c929d67e647b97e63546d1de7194990503a0a Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 2 Oct 2025 15:25:14 +0200 Subject: [PATCH 1/6] feature: pricing now allow to specify a payment-type --- src/aleph_client/commands/pricing.py | 129 +++++++++++++++++++-------- 1 file changed, 92 insertions(+), 37 deletions(-) diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index c9f0ba4f..06a0789d 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -19,6 +19,7 @@ ) from aleph.sdk.conf import settings from aleph.sdk.utils import displayable_amount +from aleph_message.models.execution.base import PaymentType from rich import box from rich.console import Console, Group from rich.panel import Panel @@ -105,9 +106,11 @@ def fmt(value, unit): 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}") + # Credits not handled for now on storage so we keep that comment for now + + # 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))) @@ -117,6 +120,7 @@ def build_column( self, entity: PricingEntity, entity_info: PricingPerEntity, + payment_type: Optional[PaymentType] = None, ): # Common Column for PROGRAM / INSTANCE / CONF / GPU self.table.add_column("Tier", style="cyan") @@ -131,13 +135,20 @@ def build_column( 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: + + if (isinstance(cu_price, Price) and cu_price.holding) and ( + payment_type is None or payment_type == PaymentType.hold + ): self.table.add_column("$ALEPH (Holding)", style="red", justify="center") - if isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP: + if (isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP) and ( + payment_type is None or payment_type == PaymentType.superfluid + ): self.table.add_column("$ALEPH (Pay-As-You-Go)", style="green", justify="center") - if isinstance(cu_price, Price) and cu_price.credit: + if (isinstance(cu_price, Price) and cu_price.credit) and ( + payment_type is None or payment_type == PaymentType.credit + ): self.table.add_column("$ Credits", style="green", justify="center") if entity in PRICING_GROUPS[GroupEntity.PROGRAM]: @@ -149,6 +160,7 @@ def fill_tier( entity: PricingEntity, entity_info: PricingPerEntity, network_gpu: Optional[NetworkGPUS] = None, + payment_type: Optional[PaymentType] = None, ): tier_id = self._format_tier_id(tier.id) self.table.add_section() @@ -190,7 +202,9 @@ def fill_tier( cu_price = entity_info.price.get("compute_unit") # Fill Holding row - if isinstance(cu_price, Price) and cu_price.holding: + if (isinstance(cu_price, Price) and cu_price.holding) and ( + payment_type is None or payment_type == PaymentType.hold + ): if entity == PricingEntity.INSTANCE_CONFIDENTIAL or ( entity == PricingEntity.INSTANCE and tier.compute_units > 4 ): @@ -200,7 +214,9 @@ def fill_tier( 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: + if (isinstance(cu_price, Price) and cu_price.payg and entity in PAYG_GROUP) and ( + payment_type is None or payment_type == PaymentType.superfluid + ): payg_price = cu_price.payg payg_hourly = Decimal(str(payg_price)) * tier.compute_units row.append( @@ -208,7 +224,9 @@ def fill_tier( f"\n{displayable_amount(payg_hourly * 24, decimals=3)} token/day" ) # Fill Credit row - if isinstance(cu_price, Price) and cu_price.credit: + if (isinstance(cu_price, Price) and cu_price.credit) and ( + payment_type is None or payment_type == PaymentType.credit + ): credit_price = cu_price.credit credit_hourly = Decimal(str(credit_price)) * tier.compute_units row.append( @@ -237,6 +255,7 @@ def fill_column( entity_info: PricingPerEntity, network_gpu: Optional[NetworkGPUS], only_tier: Optional[int] = None, # <-- now int + payment_type: Optional[PaymentType] = None, ): any_added = False @@ -247,12 +266,18 @@ def fill_column( 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) + self.fill_tier( + tier=tier, entity=entity, entity_info=entity_info, network_gpu=network_gpu, payment_type=payment_type + ) any_added = True return any_added def display_table_for( - self, entity: PricingEntity, network_gpu: Optional[NetworkGPUS] = None, tier: Optional[int] = None + self, + entity: PricingEntity, + network_gpu: Optional[NetworkGPUS] = None, + tier: Optional[int] = None, + payment_type: Optional[PaymentType] = None, ): info = self.data[entity] label = self._format_name(entity=entity) @@ -279,39 +304,68 @@ def display_table_for( ) self.table = table - self.build_column(entity=entity, entity_info=info) + self.build_column(entity=entity, entity_info=info, payment_type=payment_type) - any_rows_added = self.fill_column(entity=entity, entity_info=info, network_gpu=network_gpu, only_tier=tier) + any_rows_added = self.fill_column( + entity=entity, entity_info=info, network_gpu=network_gpu, only_tier=tier, payment_type=payment_type + ) # 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- " - ) - - 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) - - 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) + def build_extra_volume_cost( + storage_price: Optional[Price], payment_type: Optional[PaymentType] = None + ) -> Text: + """ + Returns a Text markup with the correct extra volume cost + depending on payment_type. + """ + + extra_price_holding = "" + payg_storage_price = "0" + extra_price_credits = "0" + + if isinstance(storage_price, Price): + if storage_price.holding: + extra_price_holding = ( + f"[red]{displayable_amount(Decimal(str(storage_price.holding)) * 1024, decimals=5)}" + " token/GiB[/red] (Holding)" + ) + + if storage_price.payg: + payg_storage_price = displayable_amount( + Decimal(str(storage_price.payg)) * 1024 * 24, decimals=5 + ) + + if storage_price.credit: + extra_price_credits = displayable_amount( + Decimal(str(storage_price.credit)) * 1024 * 24, decimals=5 + ) + + # Handle by payment_type + if payment_type is None: + return 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" + ) + elif payment_type == PaymentType.superfluid: + return Text.from_markup( + f"Extra Volume Cost: [green]{payg_storage_price} token/GiB/day[/green] (Pay-As-You-Go)\n" + ) + elif payment_type == PaymentType.hold: + return Text.from_markup(f"Extra Volume Cost: {extra_price_holding}\n") + elif payment_type == PaymentType.credit: + return Text.from_markup( + f"Extra Volume Cost: [green]{extra_price_credits} credit/GiB/day[/green] (Credits)\n" + ) + else: + return Text("Extra Volume Cost: Unknown payment type\n") - 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" - ) - ] + storage_price = info.price.get("storage") + infos = [build_extra_volume_cost(storage_price=storage_price, payment_type=payment_type)] displayable_group = Group( self.table, Text.assemble(*infos), @@ -340,6 +394,7 @@ async def fetch_pricing_aggregate() -> Pricing: async def prices_for_service( service: Annotated[GroupEntity, typer.Argument(help="Service to display pricing for")], tier: Annotated[Optional[int], typer.Option(help="Service specific Tier")] = None, + payment_type: Annotated[Optional[PaymentType], typer.Option(help="Payment Type")] = 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[ @@ -376,4 +431,4 @@ async def prices_for_service( ) else: for entity in group: - pricing.display_table_for(entity, network_gpu=network_gpu, tier=tier) + pricing.display_table_for(entity, network_gpu=network_gpu, tier=tier, payment_type=payment_type) From e373edabe07708b525b1b68b0eafd61357675634 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 6 Oct 2025 15:23:45 +0200 Subject: [PATCH 2/6] fix: get_annotated_constraint was not getting the max value --- src/aleph_client/commands/utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index f55523d4..b4174724 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -100,10 +100,16 @@ def get_annotated_constraint(annotated_type: type, constraint_name: str) -> Any if not args: return None - for arg in args: - if isinstance(arg, FieldInfo): - value = getattr(arg, constraint_name, None) - return value + # We only care about FieldInfo, not the base type (like int) + field_info = next((a for a in args if isinstance(a, FieldInfo)), None) + if not field_info: + return None + + # FieldInfo stores constraint objects in its metadata list + for meta in getattr(field_info, "metadata", []): + if hasattr(meta, constraint_name): + return getattr(meta, constraint_name) + return None From 80889653d26de24898501e7f58ab5bf2da6e1dd4 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 6 Oct 2025 15:26:30 +0200 Subject: [PATCH 3/6] fix: pricing only show the pricing of the selected payment_type --- src/aleph_client/commands/instance/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index d2f54603..d11b0e66 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -213,7 +213,9 @@ async def create( ) ) async with AuthenticatedAlephHttpClient(account=account) as client: - vouchers = await client.voucher.fetch_vouchers_by_chain(chain=Chain(account.CHAIN)) + vouchers = await client.voucher.fetch_vouchers_by_chain( + address=address, chain=Chain(account.CHAIN) + ) if len(vouchers) == 0: console.print("No NFT vouchers find on this account") raise typer.Exit(code=1) @@ -354,7 +356,9 @@ async def create( ) if not tier: - pricing.display_table_for(entity=pricing_entity, network_gpu=found_gpu_models, tier=None) + pricing.display_table_for( + entity=pricing_entity, network_gpu=found_gpu_models, tier=None, payment_type=payment_type + ) tiers = list(pricing.data[pricing_entity].tiers) # GPU entities: filter to tiers that actually use the selected GPUs @@ -418,7 +422,8 @@ async def create( 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) - balance_response = await client.get_balances(address) + async with AlephHttpClient(api_server=settings.API_HOST) as client: + balance_response = await client.get_balances(address) available_amount = balance_response.balance - balance_response.locked_amount available_funds = Decimal(0 if is_stream else available_amount) try: From 041e2d4548307a4ac4bb622b49627a8ca1be8ed9 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 6 Oct 2025 16:04:08 +0200 Subject: [PATCH 4/6] fix: aleph instance list to display Credits instead of $ALEPH for credits instance --- src/aleph_client/commands/instance/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index d11b0e66..66abd768 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -213,9 +213,7 @@ async def create( ) ) async with AuthenticatedAlephHttpClient(account=account) as client: - vouchers = await client.voucher.fetch_vouchers_by_chain( - address=address, chain=Chain(account.CHAIN) - ) + vouchers = await client.voucher.fetch_vouchers_by_chain(address=address, chain=Chain(account.CHAIN)) if len(vouchers) == 0: console.print("No NFT vouchers find on this account") raise typer.Exit(code=1) From b2e8c529adcee9b44de31f8b3b729d7d650737ab Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 6 Oct 2025 16:18:45 +0200 Subject: [PATCH 5/6] fix: ports in the aggregate can be None, if None then we continue --- src/aleph_client/commands/instance/port_forwarder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 4698ebeb..58421402 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -74,6 +74,8 @@ async def list_ports( ports_map = config.root for ih, ports in ports_map.items(): + if not ports: + continue name = await client.instance.get_name_of_executable(item_hash=ItemHash(ih)) # If an item hash is specified, only show that one From 9755979eec9579dd3eadd7e17fe280cd566fac26 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 7 Oct 2025 17:42:46 +0200 Subject: [PATCH 6/6] fix: ports in the aggregate can be None, if None then we continue --- src/aleph_client/commands/instance/__init__.py | 15 +++------------ src/aleph_client/commands/instance/display.py | 2 +- src/aleph_client/commands/program.py | 4 ++-- tests/unit/test_display.py | 3 +++ tests/unit/test_instance.py | 5 ++++- tests/unit/test_program.py | 2 ++ 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 66abd768..bed8b2d5 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -6,7 +6,7 @@ import shutil from decimal import Decimal from pathlib import Path -from typing import Annotated, Any, Optional, Union +from typing import Annotated, Any, Optional import aiohttp import typer @@ -30,8 +30,6 @@ ) from aleph.sdk.query.responses import PriceResponse from aleph.sdk.types import ( - CrnExecutionV1, - CrnExecutionV2, InstanceAllocationsInfo, InstanceWithScheduler, PortFlags, @@ -470,7 +468,6 @@ async def create( 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 or is_credit: if crn_url: @@ -610,7 +607,7 @@ async def create( payment = Payment( chain=payment_chain, - receiver=stream_reward_address if stream_reward_address else None, + receiver=crn_info.stream_reward_address if crn_info and crn_info.stream_reward_address else None, type=payment_type, ) @@ -636,7 +633,7 @@ async def create( try: content = make_instance_content(**content_dict) price: PriceResponse = await client.get_estimated_price(content) - required_tokens = Decimal(price.required_tokens) + required_tokens = price.required_tokens if price.cost is None else Decimal(price.cost) except Exception as e: echo(f"Failed to estimate instance cost, error: {e}") raise typer.Exit(code=1) from e @@ -839,7 +836,6 @@ async def delete( existing_message: InstanceMessage = await client.get_message( item_hash=ItemHash(item_hash), message_type=InstanceMessage ) - except MessageNotFoundError: echo("Instance does not exist") raise typer.Exit(code=1) from None @@ -872,11 +868,6 @@ async def delete( if isinstance(instance_info, InstanceWithScheduler): echo(f"Instance {item_hash} was auto-scheduled, VM will be erased automatically.") elif instance_info is not None and hasattr(instance_info, "crn_url") and instance_info.crn_url: - execution: Optional[Union[CrnExecutionV1, CrnExecutionV2]] = await client.crn.get_vm( - instance_info.crn_url, item_hash=ItemHash(item_hash) - ) - if not execution: - echo("VM is not running or CRN not accessible, Skipping ...") try: async with VmClient(account, instance_info.crn_url) as manager: status, _ = await manager.erase_instance(vm_id=item_hash) diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 31c58d50..28919711 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -283,7 +283,7 @@ async def _prepare_instance_column(self): # Price information async with AlephHttpClient() as client: price: PriceResponse = await client.get_program_price(self.message.item_hash) - required_tokens = Decimal(price.required_tokens) + required_tokens = price.required_tokens if price.cost is None else Decimal(price.cost) if self.is_hold: aleph_price = Text(f"{displayable_amount(required_tokens, decimals=3)} (fixed)", style="magenta3") diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 285cb722..4105656f 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -242,7 +242,7 @@ async def upload( try: content = make_program_content(**content_dict) price: PriceResponse = await client.get_estimated_price(content) - required_tokens = Decimal(price.required_tokens) + required_tokens = price.required_tokens if price.cost is None else Decimal(price.cost) except Exception as e: typer.echo(f"Failed to estimate program cost, error: {e}") raise typer.Exit(code=1) from e @@ -575,7 +575,7 @@ async def list_programs( if message.sender != message.content.address: payer = Text.assemble("\nPayer: ", Text(str(message.content.address), style="orange1")) price: PriceResponse = await client.get_program_price(message.item_hash) - required_tokens = Decimal(price.required_tokens) + required_tokens = price.required_tokens if price.cost is None else Decimal(price.cost) if price.payment_type == PaymentType.hold.value: aleph_price = Text( f"{displayable_amount(required_tokens, decimals=3)} (fixed)".ljust(13), style="violet" diff --git a/tests/unit/test_display.py b/tests/unit/test_display.py index e0b7aeac..f84243d7 100644 --- a/tests/unit/test_display.py +++ b/tests/unit/test_display.py @@ -147,6 +147,7 @@ async def test_instance_display_columns(): with patch("aleph_client.commands.instance.display.download", AsyncMock(return_value=None)): mock_price = MagicMock() mock_price.required_tokens = "0.001" + mock_price.cost = "0.001" mock_client = AsyncMock() mock_client.get_program_price = AsyncMock(return_value=mock_price) @@ -321,6 +322,7 @@ async def test_instance_table_builder(): mock_client = AsyncMock() mock_price = MagicMock() mock_price.required_tokens = "0.001" + mock_price.cost = "0.001" mock_client.get_program_price = AsyncMock(return_value=mock_price) mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) @@ -430,6 +432,7 @@ async def test_show_instances(): mock_client = AsyncMock() mock_price = MagicMock() mock_price.required_tokens = "0.001" + mock_price.cost = "0.001" mock_client.get_program_price = AsyncMock(return_value=mock_price) mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index 8c3622a7..a050c1fd 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -292,6 +292,7 @@ def create_mock_client( get_estimated_price=AsyncMock( return_value=MagicMock( required_tokens=required_tokens, + cost=required_tokens, payment_type=payment_type, ) ), @@ -317,8 +318,10 @@ def create_mock_auth_client( ): def response_get_program_price(ptype): + required_tokens = 0.00001527777777777777 if ptype == "superfluid" else 1000 return MagicMock( - required_tokens=0.00001527777777777777 if ptype == "superfluid" else 1000, + required_tokens=required_tokens, + cost=required_tokens, payment_type=ptype, ) diff --git a/tests/unit/test_program.py b/tests/unit/test_program.py index cb61b558..fab43dce 100644 --- a/tests/unit/test_program.py +++ b/tests/unit/test_program.py @@ -100,12 +100,14 @@ def create_mock_auth_client(mock_account, swap_persistent=False): get_estimated_price=AsyncMock( return_value=MagicMock( required_tokens=1000, + cost=1000, payment_type="hold", ) ), get_program_price=AsyncMock( return_value=MagicMock( required_tokens=1000, + cost=1000, payment_type="hold", ) ),