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
modelfield is always read. A string that isn’t a catalog id may pin a model you didn’t intend, or come back with amodelFallbacknote — see Legacymodelstrings.
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.
| Filter | Type | Behaviour |
|---|---|---|
query | string | Free text over display name, description, model id, family, and tags. Every whitespace-separated term must match — "fast cheap vision" narrows, it doesn’t widen. |
capabilities | string[] | AND-filter over the capability flags: vision, tools, jsonMode, reasoning. |
family | string | Exact, case-insensitive. Families in the catalog today: claude, qwen, deepseek, gemini, kimi, minimax, llama, glm. |
minContextWindow | number | Tokens. |
maxCreditsPerMTokOutput | number | Price ceiling in credits per million output tokens, markup included — the same number the row quotes. Not USD. |
includeUnavailable | boolean | Default false. Set true to see catalogued models that nothing currently routes (or that are blacklisted), each with a reason. |
limit | number | Default 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:
-
creditsrates 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.streamingisfalseon every row, always. The broker is request/response; it cannot relay a stream. Reportingtruewould be a promise the bridge can’t keep. -
availablemeans 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.unavailableReasonisnot-routable(in the catalog, but nothing routes it) orblacklisted. Whether a provider key is present isn’t visible to discovery; it shows up at invoke time asmodel-unavailable. Unavailable models are omitted unless you passincludeUnavailable: true; they’re reported rather than hidden so you can tell “typo” from “not offered”.available: trueis 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 pinnedllm.invokeon a model this list reported as available can still come backmodel-unavailable(409), and your app must handle that 409: retry, pin a differentidfrom the list, or dropmodelentirely and let the platform pick. Thefalseside is authoritative in the other direction —not-routableandblacklistedare a real no. -
credits(the top-level block) isnullwhen 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.invokedoes the opposite and fails closed; it’s about to spend money.) -
catalogVersionis 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 value | What 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" } }modelFallbackis added as a key on the existing payload, never a wrapper, so response handling written for a plain completion body keeps working.reasonisnot-in-catalogorcatalog-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.modelis 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.contentempty and the answer stranded inmessage.reasoning_content, the platform promotes it intocontent— sopayload.choices[0].message.contentis the one field to read. It stays empty only when the model genuinely produced no visible answer (a tool-call turn, orfinish_reason: "length").reasoning_contentis 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.
reason | HTTP | Means | What to do |
|---|---|---|---|
model-not-found | 400 | You 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-unavailable | 409 | A 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-args | 400 | A 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-credits | 402 | The 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-reached | 402 | This 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-granted | 403 | llm.invoke was never granted. | app.requestPermissions(['llm.invoke']), if it’s in your manifest’s optional set. |
rate-limited | 429 | Too many broker calls in a short window. | Wait retryAfterSeconds from the response, then retry. |
upstream-failed | 502 | The 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 endCache 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.invoketoday, 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.
Related
- Building Tile Apps — manifest, permissions, the local dev loop, personal apps.
- Tiles — the app tile itself.