Point a coding client at any node and the fleet describes itself: which models it serves, and how large a prompt each one will actually take. No model list written down on the client, no context window typed in by hand, and nothing to regenerate when the fleet changes.

The claim this makes: a metadata-aware client pointed at any node discovers models, safe context windows and capabilities from the live fleet. The claim it does not make: universal zero-config for every client. Cline, and Roo Code configured as a generic OpenAI-compatible provider, still require manual entry and will not read the enriched fields.

Why this needs doing at all

The market standardised the transport and never standardised discovery. OpenAI's GET /v1/models returns {id, object, created, owned_by} — no window, no output ceiling, no capabilities — and has not been extended despite years of requests. So every client either ships a hardcoded table of the models it has heard of, or makes the user type the numbers in.

Four separate projects are closing that gap right now, in three incompatible spellings of the same integer. viiwork is unusually well placed to answer, because every one of those formats assumes one host with one model loaded, while a viiwork node already computes the minimum context across the hosts its router would route to. That number is the thing a client can safely size a prompt against.

The number

Everything below publishes one figure, the served context: the total prompt+completion budget per slot that the fleet guarantees.

  • It is the minimum across hosts serving the model. Nothing stops an operator loading one model at 32K on one machine and 98K on another, and the router will route to either — so the floor is the only number a client can rely on.
  • Only a host that is fresh and has slots contributes it. A host whose report has gone stale asserts nothing, and a draining host takes no work, so neither is offering a window. It is the smallest windows that drain first, so this is not a hypothetical.
  • It is not the checkpoint's architectural maximum. No node knows that number, and it is not what the fleet will serve.
  • When no host can say, the figure is absent, not zero — in every format that can express the difference.

One canonical record in internal/discovery holds it, and every format below is a thin serializer over that record. Three formats that each assembled their own view would be three things to keep true, and the first one written would quietly become the source of truth for the rest.

Path 1 — any client that reads /v1/models

Nothing to configure. The list every OpenAI-compatible client already fetches now carries the window in both spellings the ecosystem converged on:

{
  "id": "granite-4.2-8b",
  "object": "model",
  "owned_by": "local",
  "max_model_len": 32768,
  "context_length": 32768
}

max_model_len is vLLM's name for it, context_length is OpenRouter's, and both formats define their field as a prompt+completion total — which is what makes it correct to emit the same figure as both. Widest reach for the least code: any client that reads either one gets a true window.

Both are additive and omitempty, the permitted form of a C4 contract change. A node one version behind simply omits them, and a reader sees "this node cannot say" rather than a measured zero.

Path 2 — OpenCode, on /api.json

OpenCode has no /v1/models discovery for openai-compatible providers. That is an open feature request, not a feature, and there is no vLLM provider either — a config naming one does nothing. What OpenCode does have is a catalogue source: OPENCODE_MODELS_URL, from which it fetches <url>/api.json, caches it for five minutes and refreshes it in the background. That is the seam internal/catalog fills.

Two things, once:

export OPENCODE_MODELS_URL=http://gb1:8086     # any node; every node serves the whole mesh
{
  "$schema": "https://opencode.ai/config.json",
  "provider": { "viiwork": {} }
}

That is the entire client configuration. opencode models then lists the fleet:

viiwork/DeepSeek-V4-Flash
viiwork/big
viiwork/granite-4.2-8b
viiwork/summarise

scripts/setup-opencode.sh writes both for you.

The document's shape is models.dev's, because that is what OpenCode parses. It is deliberately not in meshapi: nothing here crosses a boundary between viiwork nodes, and the fields belong to a third party.

What the document says

  • Every model /v1/models lists, including aliases and pipelines. The two endpoints render one list, so a model can never be callable but invisible.
  • limit.context is the served context. An alias inherits its target's. A pipeline has no capacity row anywhere, so it gets a deliberately small fallback rather than a zero — see what viiwork does not claim.
  • limit.output is a quarter of the window, capped at 32768. This is a guess, and it is documented as one in the code. It stays because models.dev requires the field and omitting it is what made OpenCode ask for 32000 output tokens and have every request rejected by a smaller backend.
  • api is the node the client reached, not a name baked in at build time. Any node is an entry point and every node serves the whole mesh, so the address that fetched the catalogue is the address that will serve inference.
  • modalities.input is ["text"], or ["text", "image"] for a model that some host with slots says it reads images with (v2.10.0). A client that reads image there attaches pictures, so the word appears only when a request with one has somewhere to go.
  • tool_call is true and npm is @ai-sdk/openai-compatible. Without the latter OpenCode treats the provider as one of its own native APIs and never reaches the node at all.

Where the fleet appears in the picker

OpenCode orders its model picker by provider name, compared byte by byte, after its own entry. api.catalog.provider_name is therefore the only thing that decides the position, and the default — " viiwork", with a leading space — is what puts the fleet at the top. A plain viiwork sorts below every capitalised hosted provider. Rename it to whatever you like; the leading character is the whole mechanism.

Note that opencode models, the CLI, sorts by provider id instead. The two orderings differ, so the CLI is not a test of the picker.

Chaining a hosted catalogue

Setting OPENCODE_MODELS_URL replaces the client's whole catalogue, so on its own it costs you Anthropic, OpenAI and the rest. api.catalog.upstream chains one back in:

api:
  catalog:
    upstream: https://models.opencode.ai
    upstream_ttl: 1h

The node then serves its own provider plus every provider upstream publishes, with the fleet's entry winning on a collision. The upstream is fetched once per TTL, never per request, and it is an enrichment rather than a dependency: if it cannot be reached the node logs it and serves the fleet alone. A stale copy is kept rather than dropped, so providers do not vanish from a client's list because one refresh failed.

This is off by default. A node on a tailnet may have no route off it, and it should not acquire one because a config file said nothing.

Operational notes

  • The endpoint is read-only and holds no state beyond the upstream cache. It costs a node nothing when nobody fetches it.
  • It is not under /v1: the path is OpenCode's to choose, since it appends /api.json to whatever URL it is given.
  • api.catalog.enabled: false turns it off; /api.json is then a 404.
  • Behind a proxy that rewrites the host, set api.catalog.base_url to the address clients actually use.
  • The client caches for five minutes, so discovery is up to five minutes stale during churn whatever viiwork does. That is the client's cache and there is nothing useful to do about it from this side.

Path 3 — Roo Code and other LiteLLM readers, on /v1/model/info

Roo Code's generic OpenAI-compatible provider makes you hand-type a context window for every model. Its LiteLLM provider reads one from /v1/model/info. Same fleet, same transport — the only difference is whether the numbers are discovered or typed. So configure Roo Code as a LiteLLM provider pointed at a node:

Base URL:  http://gb1:8086
API key:   (anything; viiwork authenticates nothing)

The document:

{
  "data": [
    {
      "model_name": "granite-4.2-8b",
      "litellm_params": { "model": "granite-4.2-8b" },
      "model_info": {
        "max_input_tokens": 32768,
        "mode": "chat",
        "supports_function_calling": true
      }
    }
  ]
}

model_info also carries "supports_vision": true for a model that some host with slots says it reads images with (v2.10.0). The field is left out otherwise, which this format reads as unknown.

Serving this shape does not make a node a LiteLLM proxy. Only the discovery document is LiteLLM's; inference stays on the OpenAI-compatible endpoints the node already serves, which is the transport these clients speak anyway. That is what keeps it safe — a node must never advertise a dialect it will not then accept requests in, and here it advertises none.

The endpoint is unconditional, unlike /api.json. It renders exactly what /v1/models already publishes, in another client's spelling, so there is no exposure to opt out of.

What viiwork does not claim

Three deliberate silences, and two deliberate approximations. They are marked in the code so that the next serializer does not copy them by accident.

No output ceiling. viiwork has no output limit distinct from the shared window: a slot's context is one prompt+completion budget and the split between them is the client's to choose. Any output figure could only be a guess, so /v1/models and /v1/model/info emit none. The one exception is the models.dev document's limit.output, which is required by that format — and it is documented there as a client-side budgeting hint, not a server capability.

No cost, and no modality beyond text and images. Images are listed only on a host's own word (see models.md); audio and video are never claimed. Local inference is free at the point of use; the electricity is in the energy store, not in a per-token price.

No architectural maximum. No node knows the largest context a checkpoint could support, only the context it was loaded at.

max_input_tokens is knowingly generous. LiteLLM defines it as a prompt-only ceiling, while viiwork's number is a shared prompt+completion budget, so a client budgeting input + output against it will overshoot slightly. It is emitted anyway, because it is still a true hard bound — the server really will accept a prompt of that size, it just leaves no room to answer — and because the alternative is not "no claim": Roo Code defaults its context window to 200000 when the field is absent, which is wrong by an order of magnitude on every model on this fleet. A slightly generous true bound beats an invented one.

A pipeline gets a fallback window in the catalogue. A pipeline is node-local and appears in no capacity report, so nothing can size it. The record says so, and every format that can express "unknown" passes it through — but models.dev cannot, and OpenCode reads a zero as a window rather than as silence. So the catalogue substitutes a deliberately small figure. The two errors are not symmetric: too large a window makes every request fail at the backend, too small a one only shortens the conversation.

Not served, and why

Ollama's /api/tags and /api/show. The largest client ecosystem by a distance, and the temptation is real. But serving Ollama discovery without Ollama's /api/chat dialect would advertise a fleet that then refuses the requests it invited. That makes it a C8 client-API-dialect decision rather than a discovery one, and it deserves its own spec. Unexamined rather than rejected.

LM Studio's /api/v0/models. Its max_context_length versus loaded_context_length split is exactly viiwork's architectural-versus-served distinction, and is the right mental model to carry through every serializer. The client ecosystem is small enough that the shape itself can wait.

Checking it by hand

curl -s http://gb1:8086/v1/models     | jq '.data[] | {id, max_model_len}'
curl -s http://gb1:8086/api.json      | jq '.viiwork.models | keys'
curl -s http://gb1:8086/v1/model/info | jq '.data[] | {model_name, ctx: .model_info.max_input_tokens}'

All three must agree on both the model list and the number; a node test pins that, because no single client would ever notice if they drifted.

Against the real OpenCode client, with no node and no GPU:

go test -tags catalogfixture -run TestCatalogFixture ./internal/node/ &
OPENCODE_MODELS_URL=http://127.0.0.1:18087 opencode models

The fixture serves canned fleet data through the real handler. CATALOGFIXTURE_UPSTREAM=https://models.opencode.ai exercises the chained path, and CATALOGFIXTURE_FOR=150s bounds the run. Go tests prove the document says what viiwork means; only the client proves the client agrees — and an integration test caught a real bug this way already, advertising a 512-token model a 1024-token output limit.