guides/writing_a_provider.md

# Writing a provider

ExAtlas providers are plain Elixir modules that implement the
`ExAtlas.Provider` behaviour. They're free to use any HTTP client, any auth
scheme, and any data model internally — the only contract is the
callbacks and the normalized structs they return.

## Minimum viable provider

```elixir
defmodule MyCloud.Provider do
  @behaviour ExAtlas.Provider

  alias ExAtlas.Spec

  @impl true
  def capabilities, do: [:http_proxy]

  @impl true
  def spawn_compute(%Spec.ComputeRequest{} = req, ctx) do
    client = build_client(ctx)

    body = %{
      # translate ExAtlas.Spec.ComputeRequest into MyCloud's native shape
      "gpu" => translate_gpu(req.gpu),
      "image" => req.image,
      "ports" => Enum.map(req.ports, fn {p, _} -> p end)
    }

    case Req.post(client, url: "/instances", json: body) do
      {:ok, %{status: 201, body: body}} ->
        {:ok, to_compute(body)}

      {:ok, %{status: status, body: body}} ->
        {:error, ExAtlas.Error.from_response(status, body, :mycloud)}

      {:error, err} ->
        {:error, ExAtlas.Error.new(:transport, provider: :mycloud, raw: err)}
    end
  end

  # ... implement the rest of the callbacks ...

  defp build_client(%{api_key: key}) do
    Req.new(
      base_url: "https://api.mycloud.example.com/v1",
      auth: {:bearer, key},
      retry: :transient,
      receive_timeout: 30_000
    )
  end

  defp translate_gpu(canonical) do
    case ExAtlas.Spec.GpuCatalog.for_provider(canonical, :mycloud) do
      {:ok, id} -> id
      {:error, _} -> raise "no mapping for #{inspect(canonical)}"
    end
  end

  defp to_compute(body) do
    %Spec.Compute{
      id: body["id"],
      provider: :mycloud,
      status: body["status"] |> to_atom_status(),
      ports: translate_ports(body),
      raw: body
    }
  end
end
```

## Use it right away

The top-level `ExAtlas.*` functions accept modules directly — no
registration required:

```elixir
ExAtlas.spawn_compute(
  provider: MyCloud.Provider,
  gpu: :h100,
  image: "..."
)
```

If you want the short atom form (`provider: :mycloud`) for your own app,
alias it in your own wrapper module or add it to your host app's
`ExAtlas.Config` mapping.

## Callbacks breakdown

### Required

- **`capabilities/0`** — list of atoms. Callers use this to branch on
  optional features. Be honest: declaring `:serverless` when you don't
  implement `run_job/2` will surface as runtime errors, not compile errors.
- **`spawn_compute/2`** — must return `{:ok, %ExAtlas.Spec.Compute{}}` or
  `{:error, %ExAtlas.Error{} | term()}`.
- **`get_compute/2`** — fetch by id, return normalized struct.
- **`list_compute/2`** — honor at minimum `:status` and `:name` filters.
- **`terminate/2`** — return `:ok` on success, `{:error, ...}` on failure.
- **`stop/2` / `start/2`** — can return `{:error, :unsupported}` if your
  cloud doesn't distinguish pause from destroy.

### Optional (declared via `@optional_callbacks`)

- `run_job/2`, `get_job/2`, `cancel_job/2`, `stream_job/2` — skip if you
  don't implement serverless.
- `list_gpu_types/1` — skip if your cloud has no pricing catalog.

## Register GPU mappings

Add your provider to `ExAtlas.Spec.GpuCatalog`'s `@providers` map so
callers can use the canonical atoms (`:h100`, `:a100_80g`, ...) against
your cloud. This is the only code change inside the ExAtlas library itself
that a new provider typically requires.

## Use the shared conformance suite

```elixir
# test/my_cloud/provider_test.exs
defmodule MyCloud.ProviderTest do
  use ExUnit.Case, async: false

  use ExAtlas.Test.ProviderConformance,
    provider: MyCloud.Provider,
    reset: {MyCloud.TestHelpers, :reset_bypass, []}
end
```

The suite asserts:

- `capabilities/0` returns a list of atoms.
- `spawn_compute/1 → get_compute/1 → terminate/1` round-trips.
- `spawn_compute/1` with `auth: :bearer` returns a token handle.
- `list_compute/0` returns a list.

If your provider passes these, it's wired correctly.

## Error normalization

Use `ExAtlas.Error.from_response/3` to translate HTTP responses into the
canonical shape. Callers that pattern-match on `kind:` atoms will then
work against your provider the same way they work against RunPod.

## `:raw` field

Every normalized struct has a `:raw` field. Put the provider's full
response body there so callers can reach for fields you haven't
normalized yet — this is the forward-compatibility lever.

## Reference implementations

- `ExAtlas.Providers.RunPod` (+ `ExAtlas.Providers.RunPod.Translate`) — full
  production provider against REST + GraphQL.
- `ExAtlas.Providers.Mock` — minimal in-memory implementation, good for
  understanding the callback shapes.
- `ExAtlas.Providers.Stub` — the macro used by Fly/Lambda/Vast placeholders
  to reserve names before their real implementations land.