Skip to content

External Async Wrapper Reference Implementation🔗

This page gives a minimum reference implementation for a customer-owned async wrapper.

Use it together with External Async Wrapper on Kubernetes. That page defines the required contract. This page shows the shortest practical implementation shape.

Minimum requirements🔗

Use this minimum interface split:

Customer wrapper exposes to Hub Customer wrapper calls downstream
GET /weights GET /weights
GET /schema GET /schema
POST /ticket POST /api/v1/predict-async
GET /ticket/{uuid} GET /api/v1/predict-async/{jobId}/status
GET /results/{uuid} GET /api/v1/predict-async/{jobId}/results

Make sure to persist Hub-facing ticket state durably.

Required storage🔗

Persist one row or record per Hub-facing ticket with at least:

{
  "ticket_uuid": "ticket-123",
  "downstream_job_id": "pred_abc123",
  "status": "RUNNING",
  "last_error": ""
}

This state should survive pod restarts.

Endpoint implementation🔗

GET /weights🔗

Minimum behavior:

  • proxy downstream GET /weights when available
  • otherwise return cached or static weights

Example response:

{
  "available_weights": [
    {
      "model_type": "openfold3",
      "version": "3.0.0",
      "model_version_id": "apheris-openfold3-preview",
      "model_version_tag": "3.0.0-openfold3-by-file",
      "description": "OpenFold3-preview model, trained with a 2021-09-30 PDB data cutoff.",
      "model_scope": ["inference", "finetuning", "affinity"]
    }
  ]
}

GET /schema🔗

Minimum behavior:

  • proxy downstream GET /schema when available
  • otherwise return cached or static schema

Example response:

{
  "openapi": "3.1.0",
  "info": {
    "title": "FastAPI",
    "version": "0.1.0"
  },
  "paths": {
    "/weights": {},
    "/schema": {},
    "/api/v1/predict-async": {},
    "/api/v1/predict-async/{jobId}/status": {},
    "/api/v1/predict-async/{jobId}/results": {}
  }
}

The customer wrapper should proxy the full downstream OpenAPI document, not a reduced subset.

POST /ticket🔗

Input from Hub:

{
  "request_id": "hub-request-id",
  "requestParams": {
    "queries": {
      "ubiquitin": {
        "chains": [
          {
            "molecule_type": "protein",
            "chain_ids": ["A"],
            "sequence": "MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG",
            "msa": "monomer.a3m"
          }
        ]
      }
    }
  },
  "modelParams": {
    "diffusion_samples": 1,
    "seed": 42
  },
  "inputPath": "request-42/assets",
  "modelName": "openfold3",
  "weightVersion": "3.0.0"
}

Minimum implementation steps:

  1. validate request
  2. generate Hub-facing ticket_uuid
  3. resolve inputPath for downstream
  4. persist ticket with PENDING
  5. call downstream POST /api/v1/predict-async
  6. persist downstream jobId
  7. update ticket to RUNNING
  8. return 202

Downstream request:

{
  "requestParams": {
    "queries": {
      "ubiquitin": {
        "chains": [
          {
            "molecule_type": "protein",
            "chain_ids": ["A"],
            "sequence": "MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG",
            "msa": "monomer.a3m"
          }
        ]
      }
    }
  },
  "modelParams": {
    "diffusion_samples": 1,
    "seed": 42
  },
  "inputPath": "request-42/assets",
  "modelName": "openfold3",
  "weightVersion": "3.0.0"
}

Downstream success response:

{
  "jobId": "pred_abc123",
  "jobStatus": "running"
}

Hub-facing response:

{
  "uuid": "ticket-123",
  "status": "RUNNING",
  "request_id": "hub-request-id"
}

GET /ticket/{uuid}🔗

Minimum implementation steps:

  1. load persisted ticket
  2. if ticket is terminal, return it
  3. if ticket has downstream jobId, poll downstream status
  4. map downstream status to Hub-facing status
  5. persist status transition
  6. return Hub-facing payload

Wait/stop rules:

  • keep waiting on PENDING
  • keep waiting on RUNNING
  • stop successfully on COMPLETED
  • stop with failure on FAILED or any FAILED_*

Status mapping:

Downstream status Hub-facing status
running RUNNING
completed COMPLETED
failed FAILED
not yet submitted PENDING

Running response:

{
  "uuid": "ticket-123",
  "status": "RUNNING",
  "request_id": "hub-request-id"
}

Failed response:

{
  "uuid": "ticket-123",
  "status": "FAILED",
  "request_id": "hub-request-id",
  "error": "FAILED_NIM_REQUEST: timeout contacting downstream wrapper"
}

Downstream failed status payload:

{
  "jobId": "pred_abc123",
  "jobStatus": "failed",
  "outputPath": "job_openfold3_pred_abc123",
  "problem": {
    "type": "urn:apheris:problem:prediction-failed",
    "title": "Prediction failed.",
    "detail": "No such file or directory: request-42/assets/monomer.a3m"
  }
}

GET /results/{uuid}🔗

Minimum implementation steps:

  1. load persisted ticket
  2. require COMPLETED
  3. call downstream GET /api/v1/predict-async/{jobId}/results
  4. return Hub-facing result envelope

Response:

{
  "uuid": "ticket-123",
  "status": "COMPLETED",
  "request_id": "hub-request-id",
  "results": {
    "jobId": "pred_abc123",
    "jobStatus": "completed",
    "outputPath": "job_openfold3_pred_abc123",
    "predictions": [
      {
        "query_id": "ubiquitin",
        "sample_id": 0,
        "cif": "data_ubiquitin\n#\n",
        "top_level_stats": {
          "avg_plddt": 91.2,
          "ptm": 0.84,
          "iptm": 0.0,
          "gpde": 0.23,
          "affinity_prediction": null,
          "disorder": 0.05,
          "has_clash": 0.0,
          "sample_ranking_score": 0.84,
          "chain_ptm": {},
          "chain_pair_iptm": {},
          "bespoke_iptm": {}
        },
        "top_level_metrics": {},
        "ligand_metrics_per_chain": {},
        "protein_metrics_per_chain": {},
        "plddt": [91.2, 90.8],
        "pae": [[0.1, 0.2], [0.2, 0.1]],
        "pde": [[0.2, 0.3], [0.3, 0.2]],
        "atoms_in_chain": {
          "A": 602
        },
        "residues_in_chain": {
          "A": 76
        }
      }
    ]
  }
}

Kubernetes requirement for inputPath🔗

Recommended:

  • mount the same PVC into the Hub and downstream wrapper so the same inputPath works in both pods

Only use path rewriting if the customer wrapper explicitly translates the Hub-facing inputPath before calling the downstream Apheris wrapper.

Build checklist🔗

Before considering the implementation complete, verify:

  • GET /weights works even if downstream execution is scaled down to zero
  • GET /schema works even if downstream execution is scaled down to zero
  • POST /ticket returns 202 and a non-empty uuid
  • GET /ticket/{uuid} only returns PENDING, RUNNING, COMPLETED, or FAILED
  • GET /results/{uuid} returns a complete predictions array
  • raw failure detail is preserved in error
  • ticket state survives pod restart