The ModelSphere community portal. A full-stack BFF that owns identity and federates everything else. It exists because the open-source inference stack (swiss and friends) has no user management, login, or unified entry point.
swiss is a deploy control plane: near-stateless, "the cluster is the only source of truth." Identity is the opposite — users, roles and sessions are data the portal owns. Keeping them apart lets swiss stay a backend and lets the portal be the front door for many backends, mirroring Rise Global's apiserver/console split.
The identity layer is deliberately a subset of Global's, kept compatible so a community install can be upgraded to the commercial Global without a migration:
| Surface | Kept identical to Global |
|---|---|
| User/role data | iam.theriseunion.io/v1alpha1 User, IAMRole, IAMRoleBinding CRDs |
| Tokens | OAuth2 password grant, HS256 JWT, same claim set |
| Authorization | K8s-RBAC-shaped, system:masters short-circuit, platform scope only |
Upgrade = share the JWT secret and point the identity endpoints at Global's apiserver. The console frontend contract does not change.
Decision (2026-09-26): the API group stays iam.theriseunion.io. A separate
group (iam.modelsphere.dev was considered) would isolate the two products but
turn the upgrade into a data migration. Consequences of sharing it:
- On a cluster where Global runs (e.g. a member cluster), console and Global read and write the same users, roles and bindings. That is the upgraded state, not a conflict; the helm chart leaves existing CRDs untouched.
- Those CRDs hold roles for every Global scope (platform, workspace, cluster,
namespace, nodegroup) side by side. Console guards only platform-level things,
so its authorizer honours only bindings labelled
scope=platform,scope-value=global-- Global's own selector at that level. Anything else would let a namespace admin act as a console admin. - The seeded
adminmatches Global's (groupsystem:masters, same default hash).
A laptop, a demo, or a single-tenant cluster that is already behind something
else does not want a login page. Set it and console stops issuing and verifying
tokens; every request carries a synthetic admin in system:masters, which is
the group the authorizer already short-circuits on.
| Decision | Why |
|---|---|
| A synthetic identity, not a User CRD | nothing is read, so the mode works on a cluster where the iam CRDs were never installed |
Reuse system:masters rather than bypass the authorizer |
the RBAC path stays wired up and simply always answers yes; there is no second code path to keep correct |
jwtSecret becomes optional |
nothing is signed. It is still generated by the chart, so turning the login back on is one value |
| The password-reset guard is skipped | the seeded admin still carries the annotation; with nobody logging in there is no password to change, and the guard would lock the API behind a screen nothing can satisfy |
/api/me reports authDisabled |
the shell skips the login page and hides logout and change-password, which it cannot honour |
| The admin User is still seeded | dropping it from the manifest would have helm delete it, so flipping the flag twice would lose an operator-chosen password |
Nothing in the stack authorises anything in this mode: swissd behind console already runs with its own auth off and trusts console's RBAC. It is logged as a warning at startup and called out in the chart's NOTES.
Delegating login to a backend's own auth (swissd's file-based credentials) is deliberately not built. It would be a third authentication path for the one case that wants a password without the CRDs, and the two modes here cover the installs that exist.
browser ─▶ web (Vite / React 19 / Tailwind 4 / @modelsphere/ui)
shell: login, layout, sidebar, route guards
modules: iam, swiss, playground, router, … (src/modules/*)
│ same-origin /oauth, /api/iam, /api/deploy, /api/llm…
▼
console (Go BFF)
internal/iam identity kernel: CRD types + OAuth2 (HS256) + RBAC
internal/gateway resolves the inference entrypoint from the cluster
internal/server mux + auth middleware + static SPA
+ backend proxy (proxy.go)
│
▼ HTTP: X-Remote-User / X-Remote-Group + Bearer JWT,
or the backend's own key (apiKeyEnv, or the gateway's
Secret read at runtime)
swissd, llm-openresty, and other backends
Users and roles are read/written as CRDs via the dynamic client — no scheme, no generated clients, one less thing to keep in sync with Global.
The web stack pins the same versions as swiss/web, so swiss pages compile here
unchanged. Moving either one moves both.
The console is a shell plus compile-time modules. A module is a feature area (iam, inferences, playground, router, docs, later container management); the shell owns everything around it.
| Owned by | What |
|---|---|
shell (web/src/shell/) |
login, password reset, layout, sidebar, route guards, query client, session on every request |
module (web/src/modules/<id>/) |
its pages, its API client, its overview cards |
web/src/modules/index.ts |
which modules are installed, in sidebar order — plug in/out = one line |
A module exports one declaration (ConsoleModule, web/src/shell/module.tsx):
export const inferencesModule: ConsoleModule = {
id: "inferences",
title: "模型服务", // sidebar group
basePath: "/inferences", // every page mounts below it
overview: InferencesOverview, // optional cards on the home page
pages: [
{ path: "", element: <InferenceList />, permission: "swiss.view", menu: { label: "推理服务", icon: Bot } },
{ path: "catalog/:name", element: <ModelPage />, permission: "swiss.view" },
],
};Rules:
- A module imports from
@/shellonly, never from a file under it or from another module. One exception:inferencesbuilds on swiss (see "The inferences module"). permissionis a UI permission (a Role'suiPermissions); it guards the route and hides the menu entry.topbar: trueadds the page's menu entry to the header;topbar: { label: "模型广场" }gives it a separate header label without changing the sidebar or route.- The first home overview receives shell-owned session content through
ModuleOverviewProps.session; it places this below cluster information, or renders it alone when inference viewing is forbidden. Without overview modules, Home renders the session itself. - Links go through
useModulePath():p("catalog")→/inferences/catalog. A leading/is still relative to the module. - API calls go through
apiFetch(drop-infetchwith the session) orrequest<T>(JSON helper). - The sidebar opens the group of the module the current path is mounted under (
activeGroupTitle), so a page with no menu entry -- a detail page -- still keeps its module's group open. validateModulesrejects duplicate ids/basePaths, shell-reserved paths and ids (common,shell: they are i18n namespaces) and pages declared twice at startup.- Text goes through
useT()(see "i18n"); the declaration'stitleand menu labels stay Chinese.
Deliberately not built: runtime loading, micro-frontends, cross-module slots. The
earlier Cordis design (branch docs/console-plugin-design) is parked; revisit it
when one of those is actually needed.
Rise Global's engine and model (console/src/i18n/engine.ts, ADR-0011), cut to
this console: zh-CN and en-US, switchable at runtime.
| Concern | Decision |
|---|---|
| Engine | one i18next instance with ICU messages ({name}, {n, plural, …}), web/src/shell/i18n.ts, exported through @/shell |
| Choice | localStorage["rise-locale"] -- Global's key, so it carries over on upgrade; else the browser's language; else zh-CN. Mirrored to <html lang>, followed across tabs |
| Switch | top-bar language menu, the preferences panel, the login page; one state behind all three |
| Namespaces | common (shared words), shell, ui (@modelsphere/ui's component strings, see "UI library"), and one per module = its id |
| A module's strings | modules/<id>/locales/{zh-CN,en-US}.json, registered by modules/<id>/i18n.ts, which also exports the module's useT() |
| Component text | key-based: t("users.title"); another namespace with a prefix, t("common:actions.cancel"); a node inside a message with tNodes |
| Non-React code | getT(ns)(key) at call time, never at import time: the locale changes under it |
| Dates, numbers | formatDateTime / formatNumber follow the locale |
| Missing key | falls back to zh-CN |
Sidebar labels are the contract layer, as in Global: the declaration keeps its Chinese literal, which is the zh-CN text, and other languages look it up by a derived key, falling back to the literal:
| Label | Key |
|---|---|
module title |
{id}:group.{title} |
page menu.label |
{id}:menu.{pageKey(path)}: "" → index, login-history → loginHistory |
page topbar.label |
{id}:menu.{pageKey(path)}Topbar; Chinese uses the declared label |
So group/menu appear only in the non-Chinese files. t() returns one
function per (locale, namespace): a fresh one per render would re-run every
effect keyed on it (Global's request loop).
modules/swiss is a copy of swiss/web and is not edited, so the swiss panels
Model Serving embeds stay Chinese until swiss itself takes i18n.
web/src/shell/i18n.test.tsx checks, for every locales/*.json: the same keys in
each language, valid ICU, no {{…}}, no group/menu in zh-CN; and an English
label for every installed module's group and menu entry.
Adding a language: append it to LOCALES, add *.<locale>.json beside each
zh-CN file (the parity test lists what is missing).
web/packages/ui: the open UI kit, a fork of the parts of Rise Global's design
system (rise-global/design@684df9fc) the console uses. Open-sourcing approved;
Rise Global keeps its own, closed library -- two libraries, no syncing.
| Concern | Decision |
|---|---|
| Contents | shadcn (base-nova) on Base UI; ResourceTable, PageBanner, ConfirmDialog, DataSelect, Spinner, …; tokens as a Tailwind preset; base.css |
| Delivery | source, not built: "@modelsphere/ui": "file:./packages/ui", compiled by Vite and checked by the console's tsc; Tailwind scans packages/ui/src |
| Where | inside console while it has one consumer. It imports nothing from the console, so moving it to its own repository (git subtree split) is mechanical; do that when swiss or another ModelSphere UI adopts it |
| Component strings | the kit's own ui namespace (src/locales/ui.*.ts); the shell registers it and plugs its i18next instance in through configureUiI18n (shell/i18n.ts). Without that the kit renders Chinese |
| Look | Rise Global's current tokens, including its neutral (near-black) default primary |
| Brand | the Spinner's logo variants draw a neutral mark, not the Rise logo |
| Usage rules | the kit's README.md; Global's PATTERNS.md is where they came from |
The swiss pages are a copy of swiss/web/src (see "Bringing swiss in"), so they
must stay editable only upstream. That rules out
rewriting 56 Badge and 33 Field call sites onto the console's kit: the next
sync would undo it.
So the seam is the import path, not the call site. The pages import
@swiss/components/ui/*; an alias resolves it to one of two kits with the same
export names and prop shapes.
| Kit | Comes from | Picked by |
|---|---|---|
components/ui/shadcn/ |
swiss, verbatim | UI_KIT=shadcn / tsconfig.shadcn.json |
components/ui/rise/ |
console, adapters over @modelsphere/ui |
the default |
The directory keeps its name: it is the kit in Rise Global's look, now drawn by
@modelsphere/ui. Adapting rather than re-exporting is not uniform:
| Adapter | Over the kit |
|---|---|
button, card, table, Input |
same shadcn lineage: re-exported |
Dialog |
{open,onClose,title,subtitle,footer,size} over the Base UI compound, re-laid-out as fixed header, scrolling body, pinned footer; sm:max-w-* as well, or the kit's sm:max-w-sm wins |
Tabs, Switch, HoverHint |
swiss's props (onSelect, onChange/label, text) onto Base UI's (onValueChange, onCheckedChange + aria-label, render) |
Badge |
the kit's, which renders a <span> and has success/warning; only swiss's muted is local, on secondary |
code, toast |
no kit equivalent: the shadcn ones, re-exported |
The shell is Rise in both builds. The flag swaps the module, not console's
chrome, and is there for the case where a Rise component reads wrong on a deploy
page -- the tables are the likely one, since Rise's TableHead/TableCell are
h-12 px-4 against swiss's h-10 px-3.
/inferences (Model Serving) is every swiss page redone in Rise Global's model
service layout. It replaced the swiss module's own pages, Model Deployment at
/swiss, which the console no longer mounts: modules/swiss is a library here
(API client, form model, panels) and the source of the standalone swiss UI
(build:swiss).
| Page | Path | Layout |
|---|---|---|
| Home dashboard | / |
six capability/resource totals; primary 24-hour deployment curve beside capacity gauges, followed by the live service table and a two-column resources/activity row; cluster information and collapsed personal session in the right rail |
| Inference services | /inferences |
resource table, server-side paging, row menu |
| Service detail | /inferences/:release/details?namespace=…&tab=… |
header card over tabs: overview, instances, logs, events, cluster resources, health check, versions, activity, plan; SLO edited in a sheet from the header |
| Model library | /inferences/catalog?catalog=…&q=… |
card grid, facet filters in the URL (swiss's parameter names) |
| Model detail | /inferences/catalog/:name?catalog=…&version=… |
header card over variants and information tabs; add, edit and delete variants on a writable catalog (see "Catalog writes") |
| Nodes | /inferences/nodes |
GPU KPIs, a card per GPU product, node table with pods, taints and conditions on expand |
| Activity | /inferences/runs?action=…&namespace=…&release=… |
every run, output on expand |
| Site profile | /inferences/site-profile |
grouped read view; edit in a sheet, form or YAML |
| Setup | /inferences/setup |
first run; swiss's gate sends an uninitialised site here |
The home overview is gated by swiss.view and does not run SwissScope, so an uninitialised backend cannot redirect the whole console home to setup.
| Dashboard data | Contract |
|---|---|
| Service total | server summary, not the six-row preview count |
| Capability totals | all configured catalogs; non-deprecated models deduplicated by model/source/revision identity, recipes from all listed versions deduplicated by that identity/version/digest/variant id; any unreadable catalog keeps totals unknown |
| Deployment curve | last 24 hours of completed audit operations by startedAt; changed successful installs are starts, changed successful apply/rollback are updates, failed install/apply/rollback are failures; not a history of Pods serving |
| Timeline completeness | current Console reads only the published /api/runs?limit=500 endpoint and aggregates on the client; reaching that cap is incomplete and never shown as a complete zero curve; no database remains unknown. It does not call the unmerged /api/runs/timeline prototype. Complete server-side aggregation and serving metrics require separately reviewed backend work before Console integration |
| Cluster sidebar | name, namespace, deployment mode and swissd version from cluster info; distinct Kubernetes node versions from Node.Kubelet, never from the swissd version; ready/schedulable counts from node inventory |
| Service state and instances | live release status for each preview row; a deployed Helm release alone is not serving |
| Allocated GPUs | pod resource allocation, not GPU utilisation; usageError means unknown |
| Resource charts | node allocation bars and GPU allocation/node online gauges from the existing nodes snapshot; unavailable readings stay unknown, with no synthetic GPU-utilisation or token/request history |
| Refresh | services/status every 15 s, activity every 20 s, nodes every 30 s; no overview-wide refresh control, with local retries on read failures |
| Read failures | inline error and retry, never a successful empty state |
| Service preview | standard ResourceTable surface and title header; pagination={false} renders all supplied rows and emptySize="compact" bounds the empty state |
| Activity preview | five recent operations in a compact SectionCard feed, with timestamps, result and detail links |
Writes are right-side sheets, as Rise Global's are:
deploy / upgrade sheet: form (anchored, collapsible sections) -> compose -> dry run -> review (what moves, diff, plan, note, force) -> install | apply
rollback sheet: diff of the revision -> note -> rollback(expectRevision)
SLO sheet: swiss's SLO form (save, reset)
uninstall: ConfirmDialog, typed to confirm
variant sheet: file YAML (add: a copy of the default variant) -> check (dryRun) -> commit (If-Match | If-None-Match: *)
delete variant: ConfirmDialog, typed to confirm; If-Match read at confirm time
| Part | Where it comes from |
|---|---|
| pages, sheets, forms | web/src/modules/inferences/, on @modelsphere/ui |
| state, tabs, install track, revision/run join, catalog filters and fit, node aggregation, pipeline state, chart version choice, service-id rule | lib.ts, catalog-lib.ts, nodes-lib.ts, deploy-lib.ts, profile-lib.ts; pure and tested |
API client, types, form model (Form, planRequest, formFromPlan), catalog and GPU helpers, route directives |
swiss: @swiss/lib/*, @swiss/components/DeploySettings (its pure exports) |
| cluster resources, health check, SLO, plan, diff panels | swiss's components (ReleaseObjects, Endpoint, SLOCard, Provenance, DiffView) |
It is the one module that imports another. The API client, the form model and
the panels are swiss's domain, and a copy would be a second implementation of
the same thing drifting from the first; the layout is what this module owns.
Helpers swiss keeps private are ported into the module's *-lib.ts with a note.
The cost: inferences cannot be built without modules/swiss.
Contexts: swiss's Gate runs in this module's context (SwissScope), so its
redirect to /setup lands on /inferences/setup; swiss links in the embedded
components (/site-profile) resolve the same way. SwissScope also shows
swissd's cluster warnings (site profile or catalog failing to load) above every
page, as swiss's layout does.
The service id becomes the helm release and the chart's Service names
(<id>-cart), so it is checked as a DNS-1035 label and defaults to the model
name made into one (mimo-v2.5 -> mimo-v2-5).
The SLO is shown read-only under cluster resources; editing it is a header action, "Edit SLO", opening swiss's SLO form in a sheet. The action is always there: greyed out, with the reason on hover, until the plan is applied with SLO on (when swiss's page shows its SLO card).
The logs and events tabs, and the overview's latest events, read console's own workload API rather than swissd's (see "Workload reads").
Not there yet:
- Search on the services list. swissd pages its list (at most 100 a page) and
takes no query; filtering one page on the client would miss the others. It
needs
?q=on swissd's/api/deployments. - swiss's panels are English. They are swiss's, and swiss's pages have no Chinese yet. Route option labels are translated by inferences; untranslated help and new options use the engine's text.
- Cluster resources needs a recent swissd. An older one (0.5.6, for one) has
no
/objects: the tab says so, and the install track reports the route and scaler as unreadable rather than absent. - Upgrade picks chart versions through
/api/catalog/:name/chart-versions, which swissd has from 0.6.2 (swiss #13).
The deploy and upgrade form reads the running openresty's catalog instead of treating bundled defaults as deployment defaults (console #52; agreed design).
DeploySheet -> GET /api/llm/route-options[?route=<existing>]
-> configured gateway -> /<route>/_route_opts
-> common grouped rows + advanced picker + runtime state
| Concern | Decision |
|---|---|
| Authorization | Console login and get backends for llm, the same grant as the inference proxy |
| New deployment | Probe existing ConfigMap routes for a catalog; never show their per-route values as the new deployment's |
| Upgrade | Query the recorded route, display value and set separately from saved overrides |
| Defaults | Live fallback supplies placeholders; no copied values are submitted automatically |
| Legacy or empty gateway | 404 uses bundled metadata with all fallback values stripped |
| Other failures | Surface a catalog warning; do not claim a deployment default |
| Text | Per-key locale translations where present; new options retain engine text |
| Ownership | Inferences adapts the Swiss metadata without editing the copied upstream module |
The deploy and upgrade forms expose the charts' existing continuationGateway values (console #63).
form -> recovery values -> adaptive compose -> optional timeout reset -> validate -> diff -> apply
nginx -> continuation gateway -> CART -> engine
| Concern | Decision |
|---|---|
| Controls | Enable gateway, actual model identity, idle timeout, continuation execution and tool-call buffering |
| Upgrade state | Seed from the stored plan once; query refreshes preserve edits |
| Validation | Check all recorded plan layers, chart compatibility, CART, ModelRoute, actual model, timeout and engine |
| Advanced YAML | Preserve precedence; stop when its effective settings differ from the recovery controls |
| Timeout reset | Clearing removes a retained form override by recomposing the validated form without fromRelease, pinned to the resolved source and chart |
| Integrity | Recovery and adaptive cleanup share source pinning and normalized plan comparison; reject unrelated changes |
| Ownership | Existing plan API and chart values; copied Swiss sources remain unchanged |
modules/inferences/features.ts holds the single compile-time ENABLE_MONITOR flag, false until the Monitor backend is public (console #76).
| Concern | Decision |
|---|---|
| Deploy / upgrade | Hide Monitor and its model/GPU inputs; public defaults and form requests explicitly disable it |
| Profile | Hide the Monitor ConfigMap form field and summary; retain stored profile data |
| ServiceMonitor | Independent Prometheus Operator integration; visible and configurable as before |
| Re-enable | Change the one flag and rebuild; the existing controls, translations and request handling remain |
| Ownership | Console adapter applies public defaults; copied Swiss UI is unchanged |
The master switch and every needsAdaptive field share one section (console #75); the floor fraction has its own label.
| Concern | Decision |
|---|---|
| Groups | Gateway needsAdaptive metadata decides membership; known adaptive_cc_* keys are recognized without a live catalog |
| Off | Hide dependent common/advanced inputs, clear their entered values, and prevent adding adaptive custom keys while off |
| Capacity | default_max remains in Capacity & failover; it governs limits even with adaptive control off |
| Upgrade removal | Swiss fromRelease recursively retains omitted map keys. Compose normally first for its model-move/chart validation; if stale adaptive form keys survive, recompose the full validated form with those keys removed, pinned to the just-resolved model/version/chart |
| Integrity | Preserve unrelated form values and explicit advanced YAML; reject source/chart identity or other layer changes between the two compositions |
| Application | Only the cleaned plan enters the existing diff/revision/apply flow; composition performs no workload write |
Events and logs are Kubernetes data, not swiss's, so console serves them itself with its own login and authorization instead of a swissd endpoint.
| Endpoint | Returns | Authorized as | ServiceAccount needs |
|---|---|---|---|
GET /api/k8s/namespaces/{ns}/events?prefix=<release> |
events of objects named <release> or <release>-*, newest first, at most 500 |
list events |
events list, watch |
GET /api/k8s/namespaces/{ns}/pods?prefix=<release> |
those pods with their containers' state | list pods |
pods list |
GET /api/k8s/namespaces/{ns}/pods/{pod} |
one pod's containers | get pods |
pods get |
GET /api/k8s/namespaces/{ns}/pods/{pod}/log?container=&tailLines=&sinceSeconds=&previous= |
text/plain, at most 5000 lines and 2 MiB, with timestamps |
get pods/log |
pods/log get |
- The resources are console's (
iam.theriseunion.io), not the backend's: a role grants reading workloads on its own. The seeded admin passes everything. - Authorization is platform-scope like the rest of console: a user who may read events reads them in every namespace.
- A 403 from the cluster means console's ServiceAccount lacks the grant and is returned as 502 with that said, so it is not read as the caller's.
- The prefix match is the server's coarse cut; the page narrows it to the names
a release's chart gives (
ofRelease). - Logs need a typed clientset;
cluster.Kubebuilds one beside the dynamic client.
The user guide (/help/docs) is part of the web bundle, not a separate site.
- Content is Markdown, one file per chapter per locale:
web/src/modules/docs/content/{locale}/{slug}.md. The first#line is the chapter title;CHAPTERSinchapters.tsis the order. - Shipped with the binary, so it describes the pages of this build and needs no network access — the same reason the UI is embedded.
- Links starting with
/are console routes and navigate in place;chapters.test.tsfails when one points at a page that does not exist, and when a locale lacks a chapter. - Linked from the top bar as well as the sidebar: the page declares
topbar: true, and the shell renders every such page the user may open next to the language switch. The shell knows no module paths. - No permission: every signed-in user may read it. Chapters still say which permission each page needs.
- Changing a page's behaviour means updating its chapter in both locales in the same PR.
Each module's backend is a backends entry; console proxies it after login.
browser /api/deploy/catalog ──▶ console
authenticate (token) ─▶ password-reset guard ─▶ RBAC: verb on backends/<name>
strip client X-Remote-*, Cookie; set X-Remote-User, X-Remote-Group, Bearer
──▶ swissd /api/catalog
| Aspect | Behaviour |
|---|---|
| Path | prefix replaced by the url's path (/api/deploy + http://swissd/api) |
| Prefix | must be under /api/ (that is what puts it behind login), not /api/iam or /api/me |
| Authorization | resource backends, resourceName = backend name; GET/HEAD/OPTIONS get, POST create, PUT/PATCH update, DELETE delete |
| Identity | X-Remote-User / X-Remote-Group, as Rise Global's apiserver sets for a ReverseProxy — a backend runs unchanged behind either |
| Credential | apiKeyEnv names an environment variable console sends as Authorization: Bearer <value>, replacing the caller's token. The browser never sees it. Empty means the token is forwarded unchanged. swissd does not read console's JWT: behind console it runs with server.auth.disabled and trusts console's RBAC |
| Streaming | flushed as it arrives (SSE, chunked progress) |
| Backend down | JSON 502 |
A backend must trust X-Remote-* only from console (network policy / mTLS),
never from browsers.
In-cluster, a static backend's credential comes from a Secret rather than the
chart's values: helm/console renders an env entry per backendSecrets key. A
variable that is named but unset is logged at startup and the request goes without
it — console starts, and the backend answers 401, rather than the pod refusing
to boot.
Naming an inference gateway in a values file means copying three things that all move: the Service address, which route serves every deployed model, and the key. The first goes stale when the gateway is reinstalled, the second lies about what is deployed the moment a model is added, and the third ends up in release history or in a second copy that nobody rotates.
So the llm backend names where the truth is instead:
backends:
- name: llm
prefix: /api/llm
gateway:
profile: llm/site-profile # swiss's site profile, "namespace/name"
# ...or, with no swissd:
# configMap: llm/openresty-conf # route keys: session_route_<route>.conf
# service: llm/openresty # the entrypoint
# route: llm-gateway # optional: pin one route
# secretRef: llm/openresty-keys # optional: override where the key lives| Step | Where it comes from |
|---|---|
| Entrypoint | profile route.nginxService + route.nginxPort (default 8080) → http://<name>.<ns>.svc:<port> |
| Routes | every session_route_<route>.conf in the ConfigMap, aggregate ones (peers_by_model, serving several models) first; route: pins one. Which route serves which model is not in the ConfigMap -- see "Models across routes" |
| Key | profile route.auth.secretRef/secretKey (default entry keys, format key1:owner1,…; the first key is used), sent as Authorization: Bearer <key> — llm-openresty accepts nothing else. A bare Secret name means the entrypoint's namespace |
| Overrides | apiKeyEnv (a copied key in the console's namespace), and route |
Resolution is cached for 30s and re-read after that, so a model deployed later
appears without redeploying the console; a refresh that fails keeps the last good
answer and logs it, because a transient API error should not take inference down.
An entrypoint that has never resolved answers 502 with the reason
(backend llm: site profile llm/absent: not found) instead of a confusing 404.
RBAC follows the references: helm/console renders one read-only Role and binding
per namespace they name, on configmaps and secrets. No resourceNames,
because the profile is what names the route ConfigMap and the key Secret, and a
chart cannot know at render time what a ConfigMap will say later. Nothing reads
the Service: the address is assembled from the reference.
The model library edits a writable catalog through swissd, under /api/deploy
like the rest of swiss: swissd stands in for the catalog's address and passes its
catalog-server API through as it is, adding the token it keeps in its own Secret
(swiss's docs/swiss-design.md). So the page calls catalog-server's own paths
(model-catalog's docs/openapi.yaml) below /api/deploy/catalogs/<name>/api/v2/.
Console adds nothing to the path.
| Concern | Decision |
|---|---|
| Which catalogs | those the site profile marks writable and whose token is set (GET /api/deploy/secrets lists keys, never values); the model page links to the site profile when the token is missing |
| Token | a field of the profile editor, shown once a catalog is ticked writable; optional, so a missing token never blocks a profile save (the field, the profile page and the model page say it is missing); after the profile saves, typed tokens are written to <profile>-secrets and the tokens of catalogs no longer writable removed |
| Authorization | the swiss backend's verbs: update adds or replaces a variant and sets a token, delete deletes either |
| Author | catalog-server names the commit's author by the token, so every console edit is the token owner's; the console user is not recorded yet |
| Setup | an empty catalog (GET init says so) offers Set up this catalog instead of editing: site and repo, starting from the catalog's URL in the profile, then POST init |
| Settings, model | Catalog settings (catalog.yaml) in the library toolbar and Edit model (metadata.yaml) on a model's page: the file at its digest, checked then committed |
| New model | New model in the library toolbar, with fields for its name and first variant id that write into both files: metadata.yaml and a first variant copied from one the catalog has, in one commit (catalog-server's POST models); the page then opens the new model |
| Freshness | swissd drops its cached index on a write, so the page refetches and shows the commit |
Console owns no catalog storage and publishes no index of its own. A writable catalog is a catalog-server the site profile names; an install whose own catalog is a static index gets one from the swiss chart, which vendors catalog-server since 0.7.0 (swiss.catalogServer; the chart README covers enabling it, its token, and the profile entry). A console-hosted catalog was rejected: it would duplicate catalog-server's repository and write API, and its publication would be deprecated schema v1, which the LLMSvc backend cannot deploy.
The Playground is chat against the inference gateway, for the question a deployment page cannot answer: does this model actually generate? swissd's own chat probe is one prompt, 32 tokens, non-streaming, and it lives on a release; the Playground is a conversation, streaming, for anyone with the permission.
| Concern | Decision |
|---|---|
| Where it runs | web/src/modules/playground/, mounted at /playground; the page is lazy-loaded (Markdown and highlighting are most of the weight) |
| Pages | /playground — one conversation. A compare page (2–4 columns, one prompt to each) was removed to get the one conversation right first |
| Which backend | llm → the gateway, resolved from the cluster; one picker lists every model on every route (see "Models across routes") |
| Gateway key | read by console from the Secret the site profile names (or apiKeyEnv). Never in a values file, never in a chart value, never in the browser |
| Conversation identity | each conversation sends its own X-Session-Id; the gateway pins it to one engine, so its prefix cache stays warm across turns |
| Parameters | system prompt, temperature, top_p, max_tokens, seed, stop (one per line), frequency/presence penalty, reasoning_effort; an empty field is left out of the request, so the engine's default applies |
| Token counts | the page sends stream_options.include_usage: openresty injects it only on aggregate routes, and autoconfig's per-model routes are not |
| Stats per answer | TTFT, total time, input/output tokens, tok/s over the decoding window (after TTFT), and cache hit rate = prompt_tokens_details.cached_tokens / prompt_tokens, shown only when the engine reports it |
| Reasoning models | delta.reasoning_content, or a leading <think>…</think> in content, is shown in a collapsible block above the answer |
| Reasoning in history | never sent back: the next turn's history carries only the answer, as the OpenAI-style APIs expect |
| View code | cURL / Python / Node.js reproducing the current request against <origin>/v1, key read from $MODELSPHERE_API_KEY — an API key an admin issued (see Router) |
| Access | UI permission playground.use guards the pages; the backend needs get and create on backends/llm (models are a GET, completions a POST) |
A user therefore needs a role carrying both:
{"uiPermissions": ["playground.use"],
"rules": [{"apiGroups": ["iam.theriseunion.io"], "resources": ["backends"],
"resourceNames": ["llm"], "verbs": ["get", "create"]}]}The modelsphere stack's gateway is fed by autoconfig: one plain route per model
(session_route_<model>.conf, peers only), and no route that lists them all.
Console makes one model list and one endpoint out of it:
resolve (30 s): ConfigMap keys -> routes [aggregate..., plain...]
index (per backend): GET <gateway>/<route>/v1/models for every route, in parallel
model -> first route listing it
request: GET /v1/models -> answered from the index
anything with "model" -> <gateway>/<route of that model>/...
| Concern | Decision |
|---|---|
| Freshness | the index is kept 30 s and refreshed behind a stale answer; an unknown model re-asks at once (at most every 2 s), so a model deployed a moment ago works on its first request |
| A route that does not answer | left out, not fatal. openresty loads a route up to a minute after autoconfig writes it and answers 502 until then, so a list missing a route is re-asked after 2 s instead of 30 s |
| Same model on two routes | the first wins; aggregate routes come first, so a hand-written aggregate route beside autoconfig's is preferred |
| No model in the request | fine with one route, 400 with several |
| Unknown model | /api/llm: 400 listing what is served; /v1: 404 model_not_found, as OpenAI answers |
| Scoped keys | /v1/models and /v1/models/<id> are answered by the router from the index, filtered by the key; nothing is rewritten on the way back |
| Replicas | each keeps its own index; a route added or removed is seen by all within one resolve (≤ 30 s) |
Pointing an install at the stack's gateway:
playground:
gateway:
configMap: llm-route/openresty-conf
service: llm-route/openresty
secretRef: llm-route/openresty # the openresty chart's key Secret, if auth is onVerified on daocloud-ce against the stack's own charts (helm-charts openresty
0.1.20 + autoconfig 0.4.0, ModelRoute routing.modelsphere.dev): two llama.cpp
engines, two ModelRoutes, two plain routes. The Playground lists and chats with
both, with token stats; /v1 routes each model to its engine through the OpenAI
SDK; a key scoped to one model sees and uses only it; deleting and recreating a
ModelRoute is followed by both replicas.
The router is how programs reach the models: /v1, opened by API keys admins
issue. It is its own domain -- internal/router, the router web module,
/api/router -- not part of iam: a key authorizes inference, not a console
user. console owns the keys; the gateway keeps its single key and is not
changed.
client --Bearer ms_…--> console /v1 --gateway key--> llm-openresty
verify key (hash, expiry)
read body (capped) -> check `model` against the key
| Concern | Decision |
|---|---|
| Endpoint | /v1/* → the backend router.backend names (llm), resolved like it; Authorization, Cookie, X-Remote-* stripped, the backend's credential added; X-Session-Id passes unchanged; streams as it arrives |
| Errors | OpenAI envelope ({"error":{"message","type","code"}}): 401 invalid_api_key / expired_api_key, 403 model_not_allowed, 413 request_too_large, 502/503 when the gateway or the key store is unavailable |
| Format | ms_<16 hex id>_<32 hex secret>; the list shows ms_<first 4 of id>*** |
| Hashing | salted SHA-256 of the secret. The secret is 128 random bits, so a slow hash buys nothing and would cost CPU on every inference call |
| Issuing | /api/router/apikeys list/create/delete, RBAC resource apikeys — system:masters unless a role grants it; the page (路由 → API 密钥) needs UI permission apikeys.view. The plaintext is in the create response only |
| Expiry | 7, 30 or 180 days, or never; nothing else is accepted |
| Model scope | none = every model. A scoped key: JSON bodies must name an allowed model; a request naming none (multipart, files) is refused; GET /v1/models is filtered to the allowed ones |
| Body cap | router.maxBodyBytes, default 16 MiB — the body is read whole to find model |
| Storage | one Secret <release>-api-keys in console's namespace, one entry per key id holding its JSON. console creates it; the chart never owns it, so upgrade/rollback/uninstall leave issued keys alone. No CRD in the shared iam.theriseunion.io group |
| Replicas | each replica caches the keys: re-read every 10 s, and at most once a second for an unknown key. A key deleted elsewhere keeps working here for up to 10 s; a failed re-read keeps the last good set |
| Writes | create and delete only, each a read-modify-write retried on resourceVersion conflicts. Usage is never written back: see Router metrics |
| Availability | console is now on the inference path: 2 replicas by default, spread across nodes, PDB maxUnavailable: 1; on SIGTERM /readyz fails, 5 s for endpoints to catch up, then up to 60 s for in-flight generations (terminationGracePeriodSeconds: 75) |
Usage is observed, not stored: writing "last used" to the keys' Secret would put
an apiserver write on the inference path. Served on server.metricsAddr
(:9090), a listener of its own so metrics are not reachable wherever the UI is,
behind a ClusterIP Service of its own (<release>-metrics) whatever type the UI's
Service is; the chart adds prometheus.io/* pod annotations and an optional ServiceMonitor.
| Metric | Labels | Meaning |
|---|---|---|
router_requests_total |
key_id, key_name, model, code |
requests forwarded, by the status the gateway answered. model is empty unless that status was 2xx, so a caller cannot mint series by naming models that do not exist |
router_key_last_request_timestamp_seconds |
key_id, key_name |
last request that passed the key check -- "last used", per replica: take max across pods |
router_rejected_total |
reason |
answered by the router itself: invalid_key, expired, model_not_allowed, too_large, unreadable, keys_unavailable, no_backend |
A deleted key's series are dropped on the replica that deleted it; elsewhere they stay until that pod restarts. Go runtime and process metrics are exported beside them.
helm install modelsphere ./helm/console installs console, its CRDs and the
seeded admin. The chart carries no inference path of its own: models come from
swiss, and the Playground and /v1 reach them through swiss's gateway.
browser ─► console ─/api/llm─┐
program ─► console ─/v1──────┴─► swiss's gateway (llm-openresty) ─► models deployed in Model Serving
| Concern | Decision |
|---|---|
| Models | from swiss: swiss.enabled=true (the subchart) or externalSwiss (an existing swissd). The llm backend reads the gateway from swissd's site profile; playground.gateway names another gateway instead |
| No swiss | a valid install: login, users and roles. There is no llm backend, so the Playground lists nothing and the router is off |
| Access | Service type NodePort by default, so the address works when the install returns; NOTES prints it |
| Not bundled | a gateway (removed in #9) and a CPU demo model (demo.enabled, removed): the Playground lists Model Serving's deployments, so a model running outside swiss never showed there |
CI (.github/workflows/publish.yml, the same scheme as swiss) publishes the image to ghcr.io/modelsphere/console and the chart to oci://ghcr.io/modelsphere/charts/console at one version: <appVersion>-git<sha7> for every commit on main, X.Y.Z for a release tag. The packaged chart points at the image published beside it.
| Gap | Why it matters | Where it is fixed |
|---|---|---|
| swiss is off by default | the chart pulls the published swiss chart, but its engines need GPUs and hostPath weights, and routing needs autoconfig with the ModelRoute and LLMSLORequirement CRDs | swiss.enabled=true; see "Bringing swiss in" |
| swiss and console read the gateway key differently | swiss sends the Secret value verbatim (apiKey), console parses key:owner (keys) |
one format, in swiss |
swiss's frontend is copied into web/src/modules/swiss/ without changes on the
swiss side yet. The copy is two commits: a verbatim one naming the swiss commit,
then the edits that mount it, so a resync is "copy again, replay the edits".
| Concern | In the copy |
|---|---|
| Left out | main.tsx, index.css, vite-env.d.ts, components/Layout.tsx, routes/Login.tsx, routes/PreviewDeploySettings.tsx |
| Imports | @/… → @swiss/… (alias to src/modules/swiss) |
| Links | Link, NavLink, Navigate, useNavigate come from lib/host.ts, which resolves absolute paths against the mounting module (/inferences) |
| API | lib/api.ts fetches through hostFetch(apiPath(…)): /api/x → /api/deploy/x, with the console session |
| Login | console's. components/Session.tsx's gate shows an error instead of swiss's login, and still sends an uninitialised site to setup |
| Layout | console's. Model Serving's SwissScope keeps swiss's cluster warnings above each page; the site switcher is not carried over |
| Theme | Rise tokens; warning and success added in src/index.css |
| Access | every page needs UI permission swiss.view; calls need the verb on backends/swiss |
What swiss could still take on so the copy needs configuration, not page edits:
| # | Today in swiss/web |
Change in swiss | Why |
|---|---|---|---|
| 1 | imports use @/… |
use @swiss/… |
@/ is the console's root; console adds one alias @swiss → src/modules/swiss |
| 2 | absolute links to="/catalog", navigate("/") |
one swissPath() helper (console binds it to useModulePath) |
panels render under /inferences |
| 3 | fetch("/api/...") |
one client with a configurable base and fetch | console sets base /api/deploy and apiFetch |
| 4 | main.tsx holds routes, router, query client, layout |
module.tsx exports pages + menu; main.tsx keeps the standalone shell |
console mounts the pages, not the app |
| 5 | index.css declares the theme variables |
move them to a standalone-only file | the Rise tokens provide the theme here |
API types: swissd exports openapi.json (committed, CI-checked), and the console
generates the TypeScript client from it, replacing the hand-written types in
swiss/web/src/lib/api.ts.
Not in scope now. The module contract keeps the door open: pages take no props from the shell, links and fetches go through shell functions, and backend requests carry the same headers Global's apiserver sets.
- The token preset is Tailwind 3-style, loaded through
@config. A Tailwind 4 CSS build of the tokens would droptailwind.config.ts.
| Phase | Scope |
|---|---|
| P0 | Scaffold: Go BFF skeleton, config, dynamic client, Docker, helm chart. (done) |
| P1 | Login loop: iam CRD types, dynamic CRUD, /oauth/token, auth middleware, helm-seeded admin, frontend login + guard. |
| P2 | User management: user CRUD API + UI, i18n (zh-CN/en-US). (i18n done: shell, iam, playground, router; swiss pages pending swiss.) Open UI kit @modelsphere/ui replacing @riseaicloud/ui everywhere, swiss module included; web/vendor/ deleted. (done) |
| P3 | Full roles: authorizer + role/binding CRUD + role/permission UI. |
| P4 | Federation: module shell, stack aligned with swiss, backend proxy with identity headers and per-backend RBAC. (done) |
| P5 | swiss module: copy into modules/swiss, mounted through lib/host.ts (see "Bringing swiss in"). (done; unmounted again once Model Serving covered every page) Then: swiss prepares its frontend (table above) and exports openapi.json; CODEOWNERS. |
| P6 | Playground: chat module, llm backend resolved from the cluster (site profile or route ConfigMap) with the gateway key read from its Secret, streaming SSE end to end. (done) Then: Markdown, compare page (since removed), full parameters, stats, view code. (done) The router: /v1, API keys, usage metrics. (done) One-command install. (done; its built-in gateway and CPU demo model since removed: models come from swiss) |
| P7 | Container management modules, moved over from Rise Global. |
Each phase is independently committable and verifiable.