Skip to Content
WorkspacesTile App Models & Credits

Tile App Models & Credits

A tile app calls a language model through the platform — no API key, no vendor account, no net: grant to an LLM host. This page is the contract for that: how to discover which models your app can use, how to choose one, what each call costs, and what to do when a call is refused.

Everything here rides the single llm.invoke permission. See Building Tile Apps for the manifest and the rest of the permission model.

One permission, per-call model choice

There is exactly one LLM permission: llm.invoke. It names no provider and no model. A legacy llm.invoke:<provider> string (from an older manifest) still parses and folds into plain llm.invoke — the suffix is ignored.

That same permission also authorizes llm.discover. There is deliberately no separate discovery permission: a new string would force a re-consent prompt on every already-installed app for a read-only, zero-cost metadata call.

So a user granting llm.invoke grants access to the whole model catalog. Model choice happens in the call, not in the grant — the model field of llm.invoke.

The model field is always read. A string that isn’t a catalog id may pin a model you didn’t intend, or come back with a modelFallback note — see Legacy model strings.

llm.discover: what can this app run?

Capability llm, method discover (browse is an accepted alias). Read-only, spends nothing, touches no upstream.

import { createTileApp } from '@yolo-labs/app-sdk'; const app = createTileApp(); const res = await app.call('llm', 'discover', { capabilities: ['tools'], // AND-filter over capability flags maxCreditsPerMTokOutput: 10000, // price ceiling, in CREDITS (not USD) limit: 10, }); if (!res.ok) throw new Error(res.message); const { models, credits, catalogVersion } = res.payload;

With @yolo-labs/app-sdk ≥ 0.6.0 the typed helper does the same thing with the shapes attached:

const res = await app.llmDiscover({ family: 'claude', includeUnavailable: true });

Filters

Every filter is optional; {} returns the whole (available) catalog.

FilterTypeBehaviour
querystringFree text over display name, description, model id, family, and tags. Every whitespace-separated term must match — "fast cheap vision" narrows, it doesn’t widen.
capabilitiesstring[]AND-filter over the capability flags: vision, tools, jsonMode, reasoning.
familystringExact, case-insensitive. Families in the catalog today: claude, qwen, deepseek, gemini, kimi, minimax, llama, glm.
minContextWindownumberTokens.
maxCreditsPerMTokOutputnumberPrice ceiling in credits per million output tokens, markup included — the same number the row quotes. Not USD.
includeUnavailablebooleanDefault false. Set true to see catalogued models that nothing currently routes (or that are blacklisted), each with a reason.
limitnumberDefault 50, clamped to [1, 200]. An out-of-range value is clamped, never rejected.

A wrong-typed filter (family: 42, capabilities: 'vision') comes back invalid-args with a message naming the field — a silently ignored filter is indistinguishable from a filter that doesn’t work.

Results are ordered routable-first, then cheapest output price, then id.

Response

{ "models": [{ "id": "vertex/claude-sonnet-4-5", // ← pass back VERBATIM as `model` "provider": "vertex", "model": "claude-sonnet-4-5", "displayName": "Claude Sonnet 4.5 (Vertex)", "family": "claude", "description": "Claude Sonnet 4.5 served through Google Vertex AI. Same capabilities as the direct Anthropic lane…", "capabilities": { "vision": true, "tools": true, "jsonMode": true, "reasoning": true, "streaming": false }, "contextWindow": 200000, "maxOutputTokens": 64000, "speed": "balanced", // fast | balanced | slow "quality": "frontier", // basic | good | frontier "tags": ["frontier", "coding", "reasoning", "vision", "vertex"], "credits": { "perMTokInput": 18750, "perMTokOutput": 93750, "perMTokCachedInput": 1875 }, "available": true, "unavailableReason": null, "wireFormat": "anthropic-messages" }], "credits": { "balance": 48200, // spendable now; null when unmetered "allowanceCredits": 50000, "allowanceRemaining": 12000, "purchasedRemaining": 36200, "unmetered": false, "period": "2026-07" }, "catalogVersion": "2026-07-30" }

id is the contract. It is "provider/model" and it is exactly what you pass back as llm.invoke’s model. Don’t rebuild it from provider + model yourself — model ids contain slashes (workers-ai/@cf/moonshotai/kimi-k2.6), the platform splits on the first one, and a hand-joined string is one bug away from an unpinnable request.

Notes on the other fields:

  • credits rates are per million tokens and include the platform markup. They are the price you will actually be charged, not a pre-markup upstream figure — see What a call costs.

  • capabilities.streaming is false on every row, always. The broker is request/response; it cannot relay a stream. Reporting true would be a promise the bridge can’t keep.

  • available means routable, not “will serve right now”. It is computed live (not stored) from the routing table and the model blacklist, so a model an operator just retired or blacklisted reads as unavailable immediately. unavailableReason is not-routable (in the catalog, but nothing routes it) or blacklisted. Whether a provider key is present isn’t visible to discovery; it shows up at invoke time as model-unavailable. Unavailable models are omitted unless you pass includeUnavailable: true; they’re reported rather than hidden so you can tell “typo” from “not offered”.

    ⚠️ available: true is a necessary condition, not a guarantee. Discovery answers from the routing table; whether a specific call gets served is decided at request time by the router — provider-key presence, an open circuit breaker, or shedding under budget pressure. Discovery cannot see any of that. So a pinned llm.invoke on a model this list reported as available can still come back model-unavailable (409), and your app must handle that 409: retry, pin a different id from the list, or drop model entirely and let the platform pick. The false side is authoritative in the other direction — not-routable and blacklisted are a real no.

  • credits (the top-level block) is null when the balance couldn’t be read. Discovery deliberately fails open on a credits outage — the model list is still valid and an app that only wanted the catalog shouldn’t break. (llm.invoke does the opposite and fails closed; it’s about to spend money.)

  • catalogVersion is a stamp you can compare between calls to tell “the catalog changed” from “my filter changed”. Treat it as opaque.

llm.invoke: running a model

The args are a thin OpenAI /v1/chat/completions body. The response payload is the completion body, OpenAI-shaped, verbatim.

const res = await app.call('llm', 'invoke', { model: 'alibaba/qwen3.6-flash', messages: [ { role: 'system', content: 'You summarise in one sentence.' }, { role: 'user', content: text }, ], max_tokens: 256, }); if (res.ok) { console.log(res.payload.choices[0].message.content); console.log('served by', res.payload.model); } else { console.warn(res.reason, res.message); }

The SDK’s typed equivalent:

const res = await app.llmInvoke({ model: 'auto', messages: [{ role: 'user', content: text }], max_tokens: 256 });

Choosing the model

model valueWhat happens
absent, "", or "auto"The platform routes by activity and account tier. This is the default; "auto" is the explicit way to ask for it.
"provider/model" (an id from llm.discover)Hard pin. Validated against the catalog and sent to the router as a pin with no fallback — a pinned call that can’t be served fails loudly rather than quietly running (and billing you for) a different model.
a bare model id, no / (e.g. "qwen3.6-flash")Resolved against the catalog. Exactly one match pins it. Several providers serve it → invalid-args, and the message names the candidate ids — copy one into model. No match → falls back to auto-routing, see below.

Anthropic models (anthropic/*, vertex/*) are served over a different upstream wire format, but that’s handled for you: the request and response stay OpenAI-shaped in both directions, so switching from alibaba/qwen3.6-flash to vertex/claude-sonnet-4-5 is a one-string change.

Legacy model strings

A model string that isn’t a catalog id, such as "gpt-4", is still handled rather than rejected. Check what yours does:

  • If it happens to name a catalog model, it takes effect as a pin. That may be a different (and differently priced) model than the one you were actually being routed to.

  • If it matches nothing and has no slash, the call still succeeds by auto-routing, and the payload carries a warning instead of an error:

    { "choices": [ … ], "model": "…", "modelFallback": { "requested": "gpt-4", "reason": "not-in-catalog" } }

    modelFallback is added as a key on the existing payload, never a wrapper, so response handling written for a plain completion body keeps working. reason is not-in-catalog or catalog-unavailable.

  • If it matches nothing and contains a slash, it is an error (model-not-found) — that syntax is unambiguously a deliberate pin, so a typo there is worth failing on.

The safe move for an existing app is to send "auto" explicitly, or a real id from llm.discover.

Response shape

payload is the router’s completion body. Two things worth knowing:

  • payload.model is the model that actually served the call. Read it there if you care.
  • Reasoning models are normalized. If a model finishes on a normal stop (no tool calls, not truncated) with choices[].message.content empty and the answer stranded in message.reasoning_content, the platform promotes it into content — so payload.choices[0].message.content is the one field to read. It stays empty only when the model genuinely produced no visible answer (a tool-call turn, or finish_reason: "length"). reasoning_content is left in place for apps that read it.

Streaming is not available

The broker is request/response. stream is forced to false server-side and stream_options is dropped with it — don’t send either, and don’t design a token-by-token UI around this API. That’s also why every catalog row reports capabilities.streaming: false.

Errors

Broker failures come back as { ok: false, reason, message }. The reason is stable and machine-readable; the HTTP status is listed for anyone calling the REST surface directly.

reasonHTTPMeansWhat to do
model-not-found400You pinned an explicit provider/model that isn’t in the catalog, or that no routing rule serves (a row llm.discover reports as not-routable).Fix the id. It’s a permanent error — retrying won’t help. Re-run llm.discover and use a row’s id verbatim.
model-unavailable409A real catalogued model that can’t serve right now — blacklisted, circuit breaker open, provider key missing, or shed under budget pressure.Transient. Retry, or fall back to another model. Never treat it as a bad id; the id is fine. Expect this even on a model llm.discover just reported available: true — discovery sees the routing table, not the router’s live state, so only the blacklist half of this list shows up there (via includeUnavailable: true). Handling the 409 is not optional.
invalid-args400A bare model id served by several providers (the message names them), or a wrong-typed llm.discover filter.Read the message — it tells you exactly which value to change.
insufficient-credits402The account can’t cover the call.Tell the user their credits have run out for this period. Two distinct messages: “not enough account credits” (really out) vs. “balance could not be read” (a transient read failure that fails closed, so retry).
spend-cap-reached402This app’s own monthly USD cap. A separate bound from credits — both must pass.User-editable in the Marketplace: filter to Installed and open the app’s detail. Degrade gracefully.
permission-not-granted403llm.invoke was never granted.app.requestPermissions(['llm.invoke']), if it’s in your manifest’s optional set.
rate-limited429Too many broker calls in a short window.Wait retryAfterSeconds from the response, then retry.
upstream-failed502The provider itself errored.Retry with backoff.

Every one of these is a 4xx/5xx with a structured body — deliberately not a 5xx for the refusals, because the edge strips CORS headers on 5xx and you’d see an opaque CORS error instead of the reason.

What a call costs

The unit

1 credit = $0.0002 of upstream model cost, so 5,000 credits = $1.00 of upstream cost. The platform adds a markup on top, and the prices llm.discover quotes already include it — a quote you can’t multiply out to the debit you’re charged would be worse than no quote.

The debit is computed from what actually served the call:

billableInput = inputTokens − cachedInputTokens costUsd = billableInput·inputRate + cachedInput·cachedRate + output·outputRate debit = ceil(costUsd × markup ÷ $0.0002) ← one rounding, at the end

Cache reads get the discounted perMTokCachedInput rate. Cache writes are fresh content being processed, so they bill at the ordinary input rate.

Worked example — 2,000 input and 500 output tokens on vertex/claude-sonnet-4-5, quoted at 18,750 / 93,750 credits per MTok:

input 2,000 ÷ 1,000,000 × 18,750 = 37.5 output 500 ÷ 1,000,000 × 93,750 = 46.875 ────── 84.375 → 85 credits (≈ $0.0169)

Multiply the quoted per-MTok rates by your token counts and round up. The real debit is derived from the USD total and rounded up once at the end, so your estimate can differ by a credit or so on small calls — it will never be a surprise.

A call is charged only if it completes. A failed or refused call costs nothing: the reservation taken before the call is released in full.

Where credits come from

Credits come from a monthly allowance: a per-tier grant that resets each period and does not roll over. Free 1,000 · Pro 50,000 · Power 200,000 · Enterprise unmetered. Purchasable top-ups are not on sale.

On an unmetered account, balance / allowanceCredits / allowanceRemaining are null — that means “no bound”, not zero. If you render a balance, don’t coerce a null to 0; you’d be telling an enterprise customer they’re broke.

Users see their balance on the Billing page; your app doesn’t need to build any of that.

Enforcement is currently off

Credits are tracked but not yet enforced. The platform reserves, settles and ledgers every llm.invoke today, but a zero balance does not block a call — the deny is behind a flag (TILEAPP_CREDITS_ENABLED) that defaults to off. The accounting runs first so the switch can be flipped with the effect already visible, rather than as a blind cutover onto live apps whose users have never had a balance.

Don’t design around the current leniency. insufficient-credits is a real, documented outcome that will start firing; handle it now. The per-app spend cap (spend-cap-reached) is enforced regardless of the credits flag.

Last updated on