Self-serve plan config API
The self-serve plan/tier definitions — the model allowlist, cross-vendor fallback, monthly budget, cap-soft-degrade config, scheduled-tasks cap and seat cap — are stored in the database and editable through this endpoint. Prices are not part of this config: what a plan costs is the product catalog, which mirrors Stripe and is the only price authority. The trial credit a new trial workspace receives lives in the catalog too (trial_credit_eur on the trial row). Changing a plan takes effect immediately: the write invalidates the cached configuration across all workers, and the next request reads the new values.
These definitions used to be deployment settings; they were migrated into the database (one row per plan) and back-filled from the previous values on first boot, so behaviour is unchanged across the cutover. Secrets are never part of this config: the Stripe secret key and webhook secret, and the self-serve provider-key pool binding, are not editable here.
Custom plan models. Migration 0327 sets the self_serve_custom model list to claude-haiku-4-5, claude-sonnet-5, qwen3.8-27b, gemma-4-31b-it, mistral-small-4 on every environment (before it, the paid plan's list was empty on prod and beta, so every model was refused). The Myra-hosted entries are usable only with the EU-Gov add-on — see Tenant entitlements. Which models are Myra-hosted is decided by the provider (myra), not by this list; to offer a new model, add its id here.
Base URL: https://<your-gateway-host>/admin/v1
Every endpoint requires the platform admin role; a lower role is 403, an unauthenticated caller 401. Access is re-gated server-side — the client is never the authorization boundary.
List plans
Returns one object per configured plan:
[
{
"plan": "self_serve_custom",
"budget_usd": 15,
"models": ["claude-3-5-haiku-20241022", "gpt-4o-mini"],
"efficient_models": ["claude-3-5-haiku-20241022"],
"fallback": "myra:qwen3.8-27b",
"degrade_provider": "openai",
"degrade_model": "claude-3-5-haiku-20241022",
"degrade_multiplier": 2.0,
"scheduled_tasks": 10,
"max_seats": 500,
"updated_at": 1750000000,
"updated_by": "<admin-user-id>"
}
]
Updating a plan
Upserts the plan named in the body. The change is audited (plan_config / update in the audit log) and takes effect immediately (the config cache is invalidated on success).
Model picker copy follows automatically (AGF-2930). The short model lines a self-serve user sees in the picker are written for the set of models the workspace is offered (the plan's models intersected with the EU-Gov add-on gate), relative to each other — e.g. which model is the cheapest or the only one that sees images. Changing models changes the set, so the next picker load generates copy for the new set; nothing needs to be edited by hand. See Tagline resolution and the kill switch plan_model_copy_enabled.
Accepted body
| Field | Type | Rule |
|---|---|---|
plan |
string | Required. Must be self_serve_custom or self_serve_trial. |
budget_usd |
number | Required. >= 0. |
models |
array of strings | Required (may be empty). Exact model_price.model ids the plan may use — bare model ids, not provider:model. Every id is checked against the model catalog on write (see below). |
efficient_models |
array of strings | Optional (default []). The over-cap efficient subset; entries outside models are dropped when the config is read (the degrade set can never widen the plan). |
fallback |
string or null |
Optional. provider:model (e.g. myra:qwen3.8-27b) or null to disable the plan's cross-vendor failover. The provider must be one a self-serve gateway can actually be keyed for — see the rejection rule below. |
degrade_provider / degrade_model |
string or null |
Optional; both or neither. The cap-soft-degrade swap target. |
degrade_multiplier |
number or null |
Optional. Must be in (1.0, 10.0]. null disables the secondary hard cap (disarms degrade). |
scheduled_tasks |
integer | Required. >= 0 (0 disables scheduled tasks — used by the self_serve_trial plan). Max active scheduled prompt-tasks per user. |
max_seats |
integer | Required. 1–10000. Max active (non-deleted) users a self-serve tenant on this plan may have; the invite path rejects over-cap adds with 403 seat_limit_reached. |
| (retired) | — | The former Stripe price-id fields and the trial credit are retired: prices come only from the product catalog, and the trial credit is edited there. A body that carries one of them with null (or not at all) is accepted and the field ignored — so a browser tab opened before the change keeps saving; a non-null value is refused (below). The GET no longer returns them. |
Rejected (fail closed — nothing is persisted)
Every rejection is a 400 unless noted; on rejection the row is not written:
planmissing or not one of the two known plans (self_serve_custom,self_serve_trial).budget_usdmissing, non-numeric, or negative.scheduled_tasksmissing, non-integer, or< 0.max_seatsmissing, non-integer,< 1, or> 10000.models(orefficient_models) not a JSON array, or containing a non-string / empty entry.models(orefficient_models) naming a model the gateway does not know — nomodel_pricerow for that id. A plan can only entitle a model that exists, because the model allowlist is fail-closed: an unknown id is not "ignored", it is simply never offered or routed, and nothing says why.models(orefficient_models) naming a deprecated model (model_price.deprecated_atset). Entitling one denies it to every tenant on that tier.models(orefficient_models) naming a model served by the platform key pool (provideranthropic) that has no price (bothinput_per_1kandoutput_per_1kare0). Such a model would be served on Myra's own key while cost calculation prices it at0, so spend would never be metered and the tenant budget would never be reached.fallbackpresent but notprovider:model.fallbacknaming a provider a self-serve gateway can never hold a key for. Self-serve tenants have noprovider_configrows at all, so only two kinds of provider can carry their traffic: a platform-managed keyless provider (e.g.myra), oranthropicwhen the managed key pool is wired. Anything else is refused, because the cross-vendor failover swap would otherwise convert a key-pool outage into424 provider_key_missing— telling a customer to add a provider key in gateway settings they do not have, for a vendor they never chose. Checked only when the value changes: this endpoint rewrites every column, so a row written before this rule keeps saving (an edit that only changesbudget_usdis not blocked by a legacyfallback). Set it tonullto clear one. A legacy unservable value is reported once per boot as across-vendor FAILOVER DISARMEDwarning, and at request time the failover is skipped and the turn fails closed with a retryable503 provider_quota_exhaustedrather than the misdirected 424.- only one of
degrade_provider/degrade_modelset. degrade_multiplierpresent but not in(1.0, 10.0].fallback's model half, anddegrade_model, must pass a catalog check — an unknown or deprecated model is rejected, and the target must be priced (a0/0price is rejected for any provider here, unlike the entitlement list; see the zero-price note below) — anddegrade_providermust be a servable self-serve provider just likefallback's provider. When any of these validations cannot read the model catalog, the write returns503(not400).- a retired field with a value (absent,
nulland""count as not sent and are ignored) →400 {"error":"field_retired","field":"<name>","message":"The field '<name>' was removed. Reload the page."}. Retired:stripe_price_monthly,stripe_price_annual,trial_credit_eur. - a second plan-config edit is already in progress →
409(retry).
Two different zero-price rules apply, by role:
- In the entitlement list (
models/efficient_models), a zero price is only rejected for pool-served models. Locally hosted models (providermyra) are legitimately zero-priced and stay entitleable — a blanket "must be billable" rule would reject every future edit of a plan that includes one, including an edit that only changes the budget or the seat cap, because this endpoint rewrites every column. - As a swap target (
fallback's model half ordegrade_model), a0/0price is rejected for every provider, includingmyra: the platform silently swaps traffic onto this target during an outage, so an unpriced target would be unmetered and the tenant budget would never be reached. So a locally hosted model that is fine to entitle at zero price cannot be used as the fallback/degrade target unless it is priced.
If the model catalog cannot be read, the request is rejected with 503 (retryable) rather than 400: the submitted model list may be perfectly valid and the database merely unreachable.
An empty models array remains valid and means deny all — a deliberate fail-safe state, not an error.
Prices are not stored here: signup and conversion checkout charge the plan's catalog slot (the component price the product catalog resolves from its Stripe mirror). When the catalog cannot resolve a price for the chosen plan and interval, checkout answers 503 checkout_unavailable rather than billing a wrong or missing price — never a silent mis-bill.