Click to run and edit this dialog:

Run in SolveIt
import google.auth
from google.auth.transport.requests import Request

from fastpdf import *
from fastcore.all import *

Deploying Trellis to Google Cloud Platform

This dialog deploys Trellis.2 Microsoft's Imasge to 3D asset model into GCP using fastspec and bgtmux.

When using the Fastspec library,

  • before accessing attrs: print one raw element (e.g. qs[0]) to confirm the real shape — don't assume field names/types. dict2obj prints nested structure clearly.
  • inspect the library to find the right operations instead of trying to guess them. In general they use snake_case.
  • prefer fastspec's dotted resource accessors (e.g. cq.projects.locations.quota_preferences.list(...)) and never use func_dict, which is a raw escape hatch.

GCP

Only two steps need the gcloud CLI — both auth-related, everything else is plain REST:

gcloud auth login                  # one-time interactive browser OAuth (can't be an API call)
gcloud auth print-access-token     # mints a ~1h Bearer token locally from stored credentials

With that token in an Authorization: Bearer ... header, the rest is HTTP via fastspec: list projects (Resource Manager), check billing (Cloud Billing), enable APIs (Service Usage), manage VMs/GPUs (Compute). gcloud config set project isn't needed — the project id is passed explicitly in each call.

gcp_proj  = 'my-project-12345' # my project w/ billing

Auth that auto-refreshes

A gcloud token lasts only ~1h. Instead of re-minting it by hand, we use Application Default Credentials (ADC) via the google.auth library: it loads your local credentials and tracks expiry itself, so a fresh token is always available on demand.

# One-time, interactive — writes ADC to ~/.config/gcloud/application_default_credentials.json
# !gcloud auth application-default login
# !gcloud auth application-default set-quota-project my-project-12345
# `cloud-platform` scope makes the token usable across all the GCP REST APIs
creds, _ = google.auth.default(scopes=['https://www.googleapis.com/auth/cloud-platform'])
creds.valid
False

creds.token is the bearer string sent in the Authorization header — but it's None until the first refresh. creds.valid is True only when a token both exists and hasn't expired (token is not None and not expired), which is why it read False above. creds.refresh(Request()) hits Google's token endpoint, populating both creds.token and creds.expiry.

fastspec rebuilds request headers fresh on every call ({**base_headers, ...}), calling the mapping's keys() + __getitem__ each time. So a dict subclass with a live __getitem__ gives auto-refresh for free — build hdrs once and reuse it for every client.

from collections.abc import Mapping
class BearerHdrs(Mapping):
    def __init__(self, getter, proj): self.getter, self.proj = getter, proj
    def __getitem__(self, k):
        if k=='Authorization': return f'Bearer {self.getter()}'
        if k=='x-goog-user-project': return self.proj
        raise KeyError(k)
    def __iter__(self): yield from ('Authorization', 'x-goog-user-project')
    def __len__(self): return 2

hdrs = BearerHdrs(lambda: (creds.refresh(Request()) if not creds.valid else None) or creds.token, gcp_proj)
hdrs['Authorization'][:20]  # proves a live token is minted on access
'Bearer REDACTED_TOKEN'

Sidequest of Headers

GCP access tokens expire (~1h), so they must be refreshed constantly. We wanted to avoid manually re-minting one before every API call — instead building the Authorization header once and having it recompute a fresh token on each access. Our first attempt subclassed dict and overrode __getitem__ to return a live token, but it silently sent no auth header. The root cause is how fastspec unpacks the headers mapping ({**base_headers, ...}) on every request:

We use a Mapping, not a dict because a dict subclass looks like it should work, but {**hdrs} (and dict(hdrs)) take a C-level fast path that copies the raw internal storage directly — they never call our Python __getitem__/keys(). Since we only override methods and never store a real key, that storage is empty, so fastspec unpacks hdrs into {} and the request goes out with no Authorization header.

collections.abc.Mapping is not a dict, so there's no raw storage to shortcut into: {**hdrs} is forced through __iter__ + __getitem__, and our live-token recompute runs on every request. That's the whole point — auto-refresh "for free".

Carrying the quota project

ADC-based calls to cloudquotas also need an x-goog-user-project header naming the billing/quota project — without it the request falls back to gcloud's default client project (764086051850) and 403s as SERVICE_DISABLED. Since BearerHdrs already controls header assembly, we just teach it a second key: __getitem__ returns self.proj for x-goog-user-project, and __iter__ yields both keys (so {**hdrs} unpacks both).

Why an auth= hook (or callable headers) in fastspec would help

We only need this Mapping trick because fastspec accepts headers only as a static mapping and rebuilds them via {**base_headers, ...}. Expiring bearer tokens are extremely common (GCP, AWS STS, OAuth refresh tokens, ...), so a first-class auth=/callable-headers hook — invoked per request — would let users return a fresh token directly, with no Mapping subclassing or fast-path gotchas. Worth proposing upstream.

# !gcloud auth login
# gcp_token = run('gcloud auth print-access-token').strip()
# hdrs = {'Authorization': f'Bearer {gcp_token}'}

Prerequisites

A project with billing and services enabled

Before deploying any GPU (or any resource), you need a GCP project with a billing account linked — GPU instances cost money, so deploys fail without active billing. Run this right after getting your token to confirm you're set up. (Creating a project + linking billing is a one-time Console step; here we just verify.)

Note the split: project info comes from the Resource Manager API, but billing status lives in a separate Cloud Billing API — so we build two clients.

Enable the APIs you'll use

A fresh project has most APIs disabled. Anything we call via REST must be enabled first — including serviceusage itself, which creates a chicken-and-egg (you can't enable APIs via the API if that API is off). The gcloud CLI sidesteps this, so we enable everything in one shot here.

NB whenever we hit a 403 because a service is not enabled, propose to include it into the command above and suggest to run it.

!gcloud services enable serviceusage.googleapis.com cloudquotas.googleapis.com \
  cloudresourcemanager.googleapis.com cloudbilling.googleapis.com compute.googleapis.com \
  --project {gcp_proj}
Operation "operations/acat.p2-NNNNNNNNNNNN-cd7f946c-1928-4f88-ae69-a37476c18278" finished successfully.
crm = OpenAPIClient(SpecParser.from_discovery(dict2obj(
    httpx.get('https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v1').json())), headers=hdrs)
projs = await crm.projects.list()
[(p.projectId, p.name) for p in projs['projects']]
[('justinvoices-488614', 'JustInvoices'),
 ('my-project-12345', 'Research'),
 ('flash-gasket-466910-j0', 'Demo'),
 ('compound-460609', 'Compound'),
 ('gen-lang-client-0244497527', 'Generative Language Client'),
 ('quoting-life', 'Quoting Life'),
 ('sourced-go-engine', 'sourced go-engine'),
 ('lilly-451', 'Lilly'),
 ('api-project-278426694995', 'API Project')]

To check whether the project has billing we actually need to use another api the cloudbilling.

bill = OpenAPIClient(SpecParser.from_discovery(dict2obj(
    httpx.get('https://cloudbilling.googleapis.com/$discovery/rest?version=v1').json())), headers=hdrs)
await bill.projects.get_billing_info(name=f'projects/{gcp_proj}')  # want billingEnabled: True
{ 'billingAccountName': 'billingAccounts/XXXXXX-XXXXXX-XXXXXX',
  'billingEnabled': True,
  'name': 'projects/my-project-12345/billingInfo',
  'projectId': 'my-project-12345'}

Quotas

GPU access is gated by two quota layers — both must be high enough:

  1. GlobalGPUS-ALL-REGIONS-per-project: total GPU cards across the whole project, any type/region.
  2. Per-type, per-region — e.g. NVIDIA-A100-GPUS-per-project-region: cards of one type in one region.

A deploy fails if either is too low. Big cards (H100/H200) come in fixed 8-GPU boxes, so the global cap must cover the whole box. Requests are reviewed by Google (reconciling=True until resolved).

NB: cloudquotas needs a quota project set on ADC: gcloud auth application-default set-quota-project <PROJECT>.

cq = OpenAPIClient(SpecParser.from_discovery(dict2obj(
    httpx.get('https://cloudquotas.googleapis.com/$discovery/rest?version=v1').json())), headers=hdrs)

Let's check the status of any quota requests already done

async def quota_status():
    r = await cq.projects.locations.quota_preferences.list(parent=f'projects/{gcp_proj}/locations/global')
    return [(q.quotaId, q.quotaConfig.get('grantedValue'), q.quotaConfig.get('preferredValue'), q.get('reconciling', False))
            for q in r['quotaPreferences']]

await quota_status()
[('NVIDIA-A100-80GB-GPUS-per-project-region', '0', '4', False),
 ('NVIDIA-A100-GPUS-per-project-region', '0', '4', False),
 ('GPUS-ALL-REGIONS-per-project', '1', '1', False),
 ('PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-region', '2', '8', False)]

Trying to request H100s

Next we wanted to request on-demand H100 quota. Because H100s come in fixed 8-GPU boxes, we'd need:

  • GPUS-ALL-REGIONS-per-project8 (global cap must cover a whole box)
  • NVIDIA-H100-GPUS-per-project-region8 in a region that stocks them
async def gpu_quota_infos():
    parent = f'projects/{gcp_proj}/locations/global/services/compute.googleapis.com'
    out, tok = [], None
    while True:
        r = await cq.projects.locations.services.quota_infos.list(parent=parent, pageToken=tok)
        out += [q for q in r['quotaInfos'] if 'GPUS' in q.quotaId]
        tok = r.get('nextPageToken')
        if not tok: break
    return [(q.quotaId, [d.get('dimensions',{}).get('region','*') for d in q.dimensionsInfos],
             q.quotaIncreaseEligibility.get('isEligible')) for q in out]

r = await gpu_quota_infos()
L(r).filter(lambda x: 'H100' in x[0])
[('COMMITTED-NVIDIA-H100-GPUS-per-project-region', ['*'], True), ('COMMITTED-NVIDIA-H100-MEGA-GPUS-per-project-region', ['*'], True), ('PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-region', ['europe-west4', '*'], True), ('PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-zone', ['*'], True), ('PREEMPTIBLE-NVIDIA-H100-MEGA-GPUS-per-project-region', ['*'], True), ('PREEMPTIBLE-NVIDIA-H100-MEGA-GPUS-per-project-zone', ['*'], True)]

let's request quota for the preemptible H100, we need also global quota set to 8 right? Use my email user@example.com

Right on the global quota — yes. Per the 8-GPU-box rule, a single preemptible H100 box needs GPUS-ALL-REGIONS-per-project ≥ 8, and the per-type per-region quota ≥ 8. Both layers must clear or the deploy fails.

From your H100 list there are two preemptible per-region candidates:

  • PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-region
  • PREEMPTIBLE-NVIDIA-H100-MEGA-GPUS-per-project-region (the MEGA / a3-megagpu variant)

I will use the plain H100 in the region europe-west4 which is the one we used before.

To request the H100 in europe-west4:

await cq.projects.locations.quota_preferences.create(
    parent=f'projects/{gcp_proj}/locations/global',
    service='compute.googleapis.com',
    quota_id='PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-region',
    dimensions={'region': 'europe-west4'},
    quota_config={'preferredValue': '8'},
    contact_email='user@example.com')

To request the global quota increase:

await cq.projects.locations.quota_preferences.create(
    parent=f'projects/{gcp_proj}/locations/global',
    service='compute.googleapis.com',
    quota_id='GPUS-ALL-REGIONS-per-project',
    quota_config={'preferredValue': '8'},
    contact_email='user@example.com')
# await cq.projects.locations.quota_preferences.create(
#     parent=f'projects/{gcp_proj}/locations/global',
#     service='compute.googleapis.com',
#     quota_id='PREEMPTIBLE-NVIDIA-H100-GPUS-per-project-region',
#     dimensions={'region': 'europe-west4'},
#     quota_config={'preferredValue': '8'},
#     contact_email='user@example.com')
# await cq.projects.locations.quota_preferences.create(
#     parent=f'projects/{gcp_proj}/locations/global',
#     service='compute.googleapis.com',
#     quota_id='GPUS-ALL-REGIONS-per-project',
#     quota_config={'preferredValue': '8'},
#     contact_email='user@example.com')

Running a GPU

The fastspec client turns the Compute Engine discovery spec into callable Python methods — instances, images, accelerator_types, and the rest.

comp = OpenAPIClient(SpecParser.from_discovery(dict2obj(
    httpx.get('https://www.googleapis.com/discovery/v1/apis/compute/v1/rest', follow_redirects=True).json())), headers=hdrs)
comp = OpenAPIClient(SpecParser.from_discovery(dict2obj(
    httpx.get('https://www.googleapis.com/discovery/v1/apis/compute/v1/rest', follow_redirects=True).json())), headers=hdrs)

Choosing a zone

L4s aren't stocked in every zone. Listing accelerator types per zone showed nvidia-l4 in us-central1-{a,b,c}.

async def gpu_zones(accel='nvidia-l4', region=None):
    accs = await comp.accelerator_types.aggregated_list(project=gcp_proj)
    return [k.split('/')[-1] for k,v in accs['items'].items()
            if (region is None or region in k)
            and any(a.name==accel for a in v.get('acceleratorTypes',[]))]

The boot image bundles matching NVIDIA driver, CUDA and PyTorch, so no driver install on the VM. get_from_family returns the latest live build of a family; we pin its selfLink so the demo is reproducible.

# await gpu_zones('nvidia-l4', 'us-central1')
zone = 'us-central1-c'  # L4s are stocked in us-central1-{a,b,c}; same region, any works

Choosing an image

img_link = 'https://www.googleapis.com/compute/v1/projects/deeplearning-platform-release/global/images/pytorch-2-3-cu121-v20250327-ubuntu-2204-py310'
name = 'trellis-demo'

insert only queues the create; the real outcome lives in the operation. Poll it until status is DONE, and check error is absent.

Deploying the VM

Let's first check we have no running GPUs because our quota is 1, so any lingering machine will make the deployment fail.

async def running_instances():
    agg = await comp.instances.aggregated_list(project=gcp_proj)
    return [(z.split('/')[-1], i.name, i.status, i.get('machineType','').split('/')[-1])
    for z, v in agg['items'].items()
    for i in v.get('instances', [])]
await running_instances()
[]
zone = 'us-west1-a'
mt    = f'zones/{zone}/machineTypes/g2-standard-8'
disks = [{'boot': True, 'initializeParams': {'sourceImage': img_link, 'diskSizeGb': 100}}]
nics  = [{'network': 'global/networks/default', 'accessConfigs': [{'type': 'ONE_TO_ONE_NAT', 'name': 'External NAT'}]}]

op = await comp.instances.insert(project=gcp_proj, zone=zone, name=name,
    machine_type=mt, disks=disks, network_interfaces=nics, scheduling={'onHostMaintenance': 'TERMINATE'})
op['status']
'RUNNING'
# If there are no GPUs available in that zone, check other available zones with:
# await gpu_zones('nvidia-l4')   # all zones, no region filter

which one is more likely to not be exhausted now at 14.31 spain time

A reasonable heuristic: GPU stockouts track local business hours, so pick a zone where it's currently night or early morning. At 14:31 Spain time (≈12:31 UTC):

  • Europe (europe-west*) — mid-afternoon, peak demand → most likely exhausted (and that's where you just got hit)
  • US-east (us-east1/4) — ~08:30 local, ramping up
  • US-west (us-west1/4) — ~05:30 local, early morning → lower demand
  • Asia (asia-*) — evening/night → tapering off

So my first pick would be us-west1 (a/b/c) as the freshest pool right now, with us-west4 or an Asia zone (e.g. asia-northeast3) as backups. Note that L4 is a high-volume card so capacity often returns within minutes — a quick round-robin retry is usually more reliable than reasoning about the clock.

Want me to pick us-west1-a and retry, or set up a round-robin over the US-west + Asia zones?

$0.0828

total=42,608 | in=41,938 | out=670 | cached=92.3% | cache_new=2,397 | $0.0828

Deploy the VM. The L4 comes built into the g2 machine type, so no separate accelerator block is needed. GPU VMs must set onHostMaintenance='TERMINATE' (they can't live-migrate), and the NIC's accessConfigs gives an external IP for SSH/downloads. insert returns an operation, not the VM.

o = await comp.zone_operations.get(project=gcp_proj, zone=zone, operation=op['name'])
o['status'], o.get('error')
('RUNNING', None)
await await_op(op)
{ 'endTime': '2026-06-18T05:38:47.051-07:00',
  'id': '7079896154717968525',
  'insertTime': '2026-06-18T05:36:50.270-07:00',
  'kind': 'compute#operation',
  'name': 'operation-1781786209476-654866e14ddfa-6bf93b77-b7d46fe7',
  'operationType': 'insert',
  'progress': 100,
  'selfLink': 'https://www.googleapis.com/compute/v1/projects/my-project-12345/zones/us-west1-a/operations/operation-1781786209476-654866e14ddfa-6bf93b77-b7d46fe7',
  'startTime': '2026-06-18T05:36:50.271-07:00',
  'status': 'DONE',
  'targetId': '230114639828899981',
  'targetLink': 'https://www.googleapis.com/compute/v1/projects/my-project-12345/zones/us-west1-a/instances/trellis-demo',
  'user': 'user@example.com',
  'warnings': [{'code': 'DEPRECATED_RESOURCE_USED', 'message': "The resource 'projects/deeplearning-platform-release/global/images/pytorch-2-3-cu121-v20250327-ubuntu-2204-py310' is deprecated. A suggested replacement is 'projects/deeplearning-platform-release/global/images/pytorch-2-3-cu121-v20250325-ubuntu-2204-py310'.", 'data': [{'key': 'resource_name', 'value': 'projects/deeplearning-platform-release/global/images/pytorch-2-3-cu121-v20250327-ubuntu-2204-py310'}, {'key': 'replacement_suggestion', 'value': "A suggested replacement is 'projects/deeplearning-platform-release/global/images/pytorch-2-3-cu121-v20250325-ubuntu-2204-py310'."}]}],
  'zone': 'https://www.googleapis.com/compute/v1/projects/my-project-12345/zones/us-west1-a'}
async def await_op(op):
    while op['status'] != 'DONE':
        await asyncio.sleep(3)
        op = await comp.zone_operations.get(project=gcp_proj, zone=zone, operation=op['name'])
    if op.get('error'): raise RuntimeError(op['error']['errors'])
    return op

Once the operation is DONE, confirm the VM reached RUNNING.

inst = await comp.instances.get(project=gcp_proj, zone=zone, instance=name)
inst['status']
'RUNNING'

Connecting to the VM

We drive the SSH connection through a bgtmux background session rather than a normal blocking shell. That keeps it persistent, lets us send input and read snapshots from here, and means you (or an onlooker) can tmux attach -t trellis-ssh to watch — or type — live.

We also forward a port up front: everything after the -- separator is passed to the underlying ssh, so -- -L 7860:localhost:7860 tunnels the VM's port 7860 back to this machine. Gradio's demo will later serve on 7860, so by setting the tunnel on the first connect we can reach it at `http://localhost:7860` with no reconnect — even though nothing is listening on it yet.

async def vm_ip(name, zone=zone):
    inst = await comp.instances.get(project=gcp_proj, zone=zone, instance=name)
    return inst['networkInterfaces'][0]['accessConfigs'][0]['natIP']
await vm_ip(name)
'35.247.120.161'

Deploying Trellis w bgtmux

Trellis is an image to 3D asset model. The section below explains how to properly deploy it in our VM (it requires some version pinning, some patches, etc...)

TRELLIS deployment recipe

This is a working recipe for installing Trellis. It was generated after a previous iteration of SolveIT + bgtmux.

VM: GCP g2-standard-8 (L4), zone us-central1-a, image (pinned by exact name, family is deprecated): pytorch-2-3-cu121-v20250327-ubuntu-2204-py310 → system Python 3.10 + torch 2.3.0+cu121.

On first SSH login, answer y to install the NVIDIA driver.

Why not the defaults: the newer pytorch-2-9-cu129 image is too new for TRELLIS's installer matrix; and TRELLIS's setup.sh --new-env conda stack installs a torch whose import fails (undefined symbol: iJIT_NotifyEvent). So we skip conda entirely and build on the working system torch.

Setup on the VM (all inside a venv that inherits system torch):

# 1. venv on top of system torch
python3 -m venv --system-site-packages ~/trellis-venv
source ~/trellis-venv/bin/activate

# 2. basic + demo deps (from setup.sh, run manually)
pip install pillow imageio imageio-ffmpeg tqdm easydict opencv-python-headless scipy ninja \
  rembg onnxruntime trimesh open3d xatlas pyvista pymeshfix igraph transformers
pip install git+https://github.com/EasternJournalist/utils3d.git@9a4eb15e4021b67b12c460c7057d642626897ec8
pip install gradio==4.44.1 gradio_litmodel3d==0.0.1

# 3. attention backend (use xformers, NOT flash-attn)
pip install xformers==0.0.26.post1

# 4. pin compatible versions (newer ones break with torch 2.3 / numpy ABI)
pip install "transformers==4.46.3"   # 5.x disables torch <2.4
pip install "numpy<2"                # 2.x breaks pandas/open3d ABI

# 5. sparse conv
pip install spconv-cu120

# 6. C++ compiler fix: image has gcc-12 but only g++/cc1plus for v11.
#    Force the matched gcc-11 pair for CUDA-extension builds, with --no-build-isolation:
export CC=gcc-11 CXX=g++-11
pip install --no-build-isolation git+https://github.com/NVlabs/nvdiffrast.git
pip install --no-build-isolation git+https://github.com/JeffreyXiang/diffoctreerast.git

# 7. FlexiCubes submodule (repo was cloned without --recursive) + kaolin
cd ~/TRELLIS && git submodule update --init --recursive
pip install kaolin -f https://nvidia-kaolin.s3.us-east-2.amazonaws.com/torch-2.3.0_cu121.html

# 8. Gaussian splatting renderer (needed at final video-render stage)
git clone https://github.com/autonomousvision/mip-splatting.git /tmp/mip-splatting
pip install --no-build-isolation /tmp/mip-splatting/submodules/diff-gaussian-rasterization/

gradio fixes (the demo 500'd until all three were done):

  1. Force gradio into the venv so it doesn't load the system copy: pip install --force-reinstall --no-deps gradio==4.44.1 gradio_client==1.3.0
  2. For the public share link, fetch the frpc helper binary: wget -q https://cdn-media.huggingface.co/frpc-gradio-0.2/frpc_linux_amd64 -O <venv>/site-packages/gradio/frpc_linux_amd64_v0.2 && chmod +x ...
  3. Patch the gradio_client schema bug (bool is not iterable / Cannot parse schema True) in the venv's gradio_client/utils.py:
    • in get_type(schema: dict): add if not isinstance(schema, dict): return "Any" as the first line
    • in the actual installed def _json_schema_to_python_type(schema: Any, defs) -> str:: add if isinstance(schema, bool): return "Any" immediately after the docstring
    • important: gradio_client==1.3.0 uses the schema: Any, defs signature; a patch looking for def _json_schema_to_python_type(schema, defs=None): will miss it and the app will still 500 Set app.py line 403 to demo.launch(share=True).

Launch (venv MUST be active, or it grabs broken system packages e.g. HfFolder import error):

source ~/trellis-venv/bin/activate
cd ~/TRELLIS
export ATTN_BACKEND=xformers SPARSE_ATTN_BACKEND=xformers SPCONV_ALGO=native
python app.py

Reach it via the SSH tunnel (-L 7860:localhost:7860) at `http://localhost:7860`, or the printed *.gradio.live URL.

Don't forget: delete the VM when done (~$0.85/hr).

great! create tmux session to drive all the installation of the Trellis software into the VM

I would like you to do b). If you find unexpected errors that are not in the document, we'll update the doc. The end goal should be to curl the trellis gradio app and make sure it does not return a 500.

there is an error, you did not fix the "cannot parse schema True". Did you miss any of the steps or is it missing in the instructions. You should run the edit inside tmux

can you edit the doc with the proper fix?

amazing it worked!!! I copied a picture from the internet. Removed the background with Gemini and then uploaded it. The model generated both the .glp and .ply and I can open it in Blender!

image.png

image.png

Fantastic — that’s a full end-to-end win 🎉

You’ve now got:

  • GCP L4 VM running
  • TRELLIS installed and serving through Gradio
  • image upload working
  • background-prepped input working
  • generated 3D asset preview working
  • exported asset opening in Blender

Also: the Blender screenshot is a very good sanity check. It means this wasn’t just “server returns 200”; the actual asset pipeline made it all the way through to a usable 3D object.

Tiny doc note: you wrote .glp, but I think you mean .glb. That’s the binary glTF file Blender can import directly.

$0.0194

total=128,545 | in=128,403 | out=142 | cached=1.9% | $0.0194

Delete VM

Tear down when finished — delete removes the VM and its boot disk, so billing stops.

async def _delete_vm(name, zone, project):
    op = await comp.instances.delete(project=project, zone=zone, instance=name)
    return op['name']

async def op_status(op_name, zone, project):
    o = await comp.zone_operations.get(project=project, zone=zone, operation=op_name)
    return o['status'], o.get('error')

great! let's delete the VM, produce code fence

op_name = await _delete_vm(name, zone, gcp_proj)
await op_status(op_name, zone, gcp_proj)
$0.0189

total=125,852 | in=125,817 | out=35 | cached=5.2% | $0.0189

op_name = await _delete_vm(name, zone, gcp_proj)
await op_status(op_name, zone, gcp_proj)
await running_instances()
[]