Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/webui/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1518,7 +1518,8 @@
"display_name": {
"type": "string",
"minLength": 1,
"maxLength": 128
"maxLength": 256,
"description": "The model's inference id with no character rewriting: the full id for cache and preset entries (owner/name for a cache repo), the directory name for models_dir entries, and the last /-separated segment of the served id for single_model. A label only: select and operate on a model by id."
},
"source": {
"$ref": "#/components/schemas/CatalogSourceKind"
Expand Down
2 changes: 1 addition & 1 deletion docs/webui/catalog.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

목록은 `limit`(기본 50, 최대 200), 반환받은 `cursor`, `q`(최대 128바이트), `source`, `task`, `lifecycle`, `support`, `completeness`를 받습니다. 커서는 최대 512바이트이며 투영 대상은 1,000개 항목으로 제한됩니다. 페이지 순서는 인벤토리가 변하지 않을 때 결정적이며 동시 새로고침을 가로지르는 트랜잭션 스냅샷은 아닙니다. `server_instance_id`와 `snapshot_sequence`를 보관하고 상태 변경 시 [architecture.md](architecture.md)의 재스냅샷 규칙을 따릅니다.

카탈로그 작업과 선택에는 `identity.id`, 추론 요청에는 `identity.inference_id`를 사용합니다. 표시 이름은 어느 쪽의 식별자도 아닙니다. 콘텐츠 fingerprint는 메타데이터를 처음 투영하거나 명시적으로 새로고침할 때 관찰한 제한된 파일시스템 메타데이터를 나타내며, 가중치 내용의 암호학적 검증값도 아니고 매 polling마다 다시 계산하는 값도 아닙니다. revision과 lifecycle은 브라우저가 관리하는 별도 상태 머신이 아니라 풀에서 가져옵니다.
카탈로그 작업과 선택에는 `identity.id`, 추론 요청에는 `identity.inference_id`를 사용합니다. `identity.display_name`은 사람이 읽기 좋게 바꾼 이름이 아니라 문자를 바꾸지 않은 추론 ID입니다(models-dir 항목은 디렉터리 이름, 캐시 항목은 전체 `owner/name`, preset 항목은 전체 preset 이름, single-model 모드는 제공 ID의 마지막 `/` 구간). 그래도 이 값은 표시용 레이블일 뿐입니다. 두 소스가 같은 이름을 노출할 수 있으므로 작업과 선택에는 `identity.id`를 사용합니다. 콘텐츠 fingerprint는 메타데이터를 처음 투영하거나 명시적으로 새로고침할 때 관찰한 제한된 파일시스템 메타데이터를 나타내며, 가중치 내용의 암호학적 검증값도 아니고 매 polling마다 다시 계산하는 값도 아닙니다. revision과 lifecycle은 브라우저가 관리하는 별도 상태 머신이 아니라 풀에서 가져옵니다.

다음 사실을 하나의 “작동함” 배지로 합치지 마십시오.

Expand Down
2 changes: 1 addition & 1 deletion docs/webui/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The adapter paths below are relative to the server's validated API prefix. See [

The list accepts `limit` (default 50, maximum 200), the returned `cursor`, `q` (maximum 128 bytes), `source`, `task`, `lifecycle`, `support`, and `completeness`. Cursors are at most 512 bytes. Projection is bounded to 1,000 inventory entries. Pagination is deterministic for an unchanged inventory, not a transactional snapshot spanning concurrent refreshes: retain `server_instance_id` and `snapshot_sequence`, and follow the resnapshot rules in [architecture.md](architecture.md) when state changes.

Use `identity.id` for catalog operations and selection; use `identity.inference_id` for inference requests. A display name is not either identity. Content fingerprints describe bounded filesystem metadata observed when metadata is first projected or explicitly refreshed; they are not cryptographic verification of weight contents and are not recomputed on every poll. Revision and lifecycle fields come from the pool rather than a browser-maintained state machine.
Use `identity.id` for catalog operations and selection; use `identity.inference_id` for inference requests. `identity.display_name` is the inference id with no character rewriting (the directory name for a models-dir entry, the full `owner/name` for a cache entry, the full preset name for a preset entry, and the last `/`-separated segment of the served id in single-model mode), not a humanized form. It is still only a label: two sources can expose the same name, so operations and selection use `identity.id`. Content fingerprints describe bounded filesystem metadata observed when metadata is first projected or explicitly refreshed; they are not cryptographic verification of weight contents and are not recomputed on every poll. Revision and lifecycle fields come from the pool rather than a browser-maintained state machine.

Do not collapse these separate facts into a single “works” badge:

Expand Down
61 changes: 61 additions & 0 deletions src/server/webui/catalog_contract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,64 @@ fn pooling_parent_symlink_is_not_embedding_layout_evidence() {
assert_eq!(entry.metadata.model_type.as_deref(), Some("qwen3"));
assert!(entry.supported);
}

#[test]
fn display_name_is_the_inference_id_verbatim() {
let root = temp_dir("verbatim-display-name");
let path = write_model(&root, "weights", "qwen3");
let sources = [
(
"Meta-Llama-3.1-8B-Instruct_4bit",
RouterModelSource::ModelsDir,
),
("qwen3-0.6b-4bit", RouterModelSource::ModelsDir),
("qwen3_0.6b_4bit", RouterModelSource::ModelsDir),
("mlx-community/Qwen3-4B-4bit", RouterModelSource::Cache),
("team/qwen3-preset", RouterModelSource::Preset),
];
let models: Vec<_> = sources
.iter()
.map(|(name, source)| model(name, path.clone(), *source))
.collect();
let listed = |q: Option<&str>| -> Vec<(String, String)> {
let query = CatalogQuery {
q: q.map(str::to_string),
..Default::default()
};
let page = list_catalog(models.clone(), &query, "srv".into(), 1).expect("catalog");
let mut rows: Vec<_> = page
.items
.into_iter()
.map(|item| (item.identity.inference_id, item.identity.display_name))
.collect();
rows.sort();
rows
};
let verbatim = |names: &[&str]| -> Vec<(String, String)> {
let mut rows: Vec<_> = names
.iter()
.map(|name| (name.to_string(), name.to_string()))
.collect();
rows.sort();
rows
};

// Every row prints its inference id: `-` and `_` survive, the two
// spellings stay distinct, and cache and preset names keep `owner/`.
let all_names: Vec<_> = sources.iter().map(|(name, _)| *name).collect();
assert_eq!(listed(None), verbatim(&all_names));
assert_eq!(
listed(Some("qwen3-0.6b-4bit")),
verbatim(&["qwen3-0.6b-4bit"])
);
assert_eq!(
listed(Some("qwen3_0.6b_4bit")),
verbatim(&["qwen3_0.6b_4bit"])
);

let single = single_model_entry(path.clone(), "Qwen3_0.6B-4bit".into(), lifecycle());
assert_eq!(single.identity.display_name, "Qwen3_0.6B-4bit");
// An empty last segment falls back to the whole id, never to "".
let trailing = single_model_entry(path, "served/".into(), lifecycle());
assert_eq!(trailing.identity.display_name, "served/");
}
23 changes: 16 additions & 7 deletions src/server/webui/catalog_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub(super) fn catalog_entry(model: RouterCatalogModel) -> CatalogEntry {
identity: ModelIdentity {
id: model.ui_model_id,
inference_id: model.name.clone(),
display_name: display_name(&model.name),
display_name: display_name(&model.name, source_kind(model.source)),
source: source_kind(model.source),
source_key_hash: model.source_key_hash,
generation: model.generation,
Expand Down Expand Up @@ -176,7 +176,7 @@ pub(super) fn single_model_entry_with_provider(
identity: ModelIdentity {
id,
inference_id: inference_id.clone(),
display_name: display_name(&inference_id),
display_name: display_name(&inference_id, CatalogSourceKind::SingleModel),
source: CatalogSourceKind::SingleModel,
source_key_hash,
generation: 1,
Expand Down Expand Up @@ -476,11 +476,20 @@ fn removal_status(
}
}

fn display_name(name: &str) -> String {
name.rsplit('/')
.next()
.unwrap_or(name)
.replace(['-', '_'], " ")
/// The label every WebUI surface prints for a model: its inference id
/// verbatim, with no character rewriting. Cache and preset names are kept
/// whole (`owner/name`); models-dir and single-model names keep their last
/// `/` segment, or the whole name when that segment is empty.
fn display_name(name: &str, source: CatalogSourceKind) -> String {
match source {
CatalogSourceKind::Cache | CatalogSourceKind::Preset => name.to_string(),
CatalogSourceKind::ModelsDir | CatalogSourceKind::SingleModel => name
.rsplit('/')
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or(name)
.to_string(),
}
}

fn source_kind(source: RouterModelSource) -> CatalogSourceKind {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/webui/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>mlxcel WebUI</title>
<script type="module" crossorigin src="./assets/index-Bfja5tJs.js"></script>
<script type="module" crossorigin src="./assets/index-Bm7r1mqL.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DnPas1LF.css">
</head>
<body>
Expand Down
28 changes: 14 additions & 14 deletions src/webui/assets/mlxcel-webui-manifest.json
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
{
"budgets": {
"embedded_asset_bytes": 735618,
"embedded_asset_bytes": 736094,
"embedded_asset_limit_bytes": 5242880,
"initial_js_gzip_bytes": 156169,
"initial_js_gzip_bytes": 156337,
"initial_js_gzip_limit_bytes": 204800,
"total_js_gzip_bytes": 157344,
"total_js_gzip_bytes": 157512,
"total_js_gzip_limit_bytes": 716800
},
"files": [
{
"bytes": 951,
"gzip_bytes": 612,
"path": "assets/code-highlight-CzWARrNf.js",
"sha256": "0836645d623e175f3bdf4e62055f59cd6c46731e70ebd8273ce40f01f14bb705"
"path": "assets/code-highlight-BZ-vBiaX.js",
"sha256": "cfe95687f9d5a8a5459ab78875a784ee5927bdd209f46bb3dbbc6aa1b4795ffd"
},
{
"bytes": 1134,
"gzip_bytes": 563,
"path": "assets/history-DTnpX_xH.js",
"sha256": "103a3311d0e5bba6c02d76639dfa212184549c255c78bdd388417119f012eee2"
"path": "assets/history-BTvgVv_A.js",
"sha256": "efae643f5f37ab937f06028768e8cb4da28721d76b260f37d8a7045b285785e2"
},
{
"bytes": 605868,
"gzip_bytes": 156169,
"path": "assets/index-Bfja5tJs.js",
"sha256": "8e4681ff9cd6ed2203ae90a77d568a1e5a819578791d8c5158126210601e1938"
"bytes": 606344,
"gzip_bytes": 156337,
"path": "assets/index-Bm7r1mqL.js",
"sha256": "b9e29de85c47695bf2af437540d2cbebd2d8a2cc373e5573d9e5731491cfe6f0"
},
{
"bytes": 111884,
Expand All @@ -34,9 +34,9 @@
},
{
"bytes": 453,
"gzip_bytes": 288,
"gzip_bytes": 287,
"path": "index.html",
"sha256": "11ac28ffef10a24f7805a45715bfcd10848e3c0545a3d32c8d5250fa6b173a63"
"sha256": "cb4fc1384bcccc6db17e67d200ef6c0beeb9d7c9cb10c7c904e55ecd176c21d8"
},
{
"bytes": 13233,
Expand Down Expand Up @@ -67,5 +67,5 @@
},
"pnpm_version": "11.18.0",
"schema_version": 1,
"source_digest_sha256": "b101d708fd3063f571e863c5ca418e8adc4d207097aa7d76f36ae8f0261036e8"
"source_digest_sha256": "c83258ce8764335524d31014a3ddb44ec857d5d20332766fd09a2d51cad70eb4"
}
2 changes: 1 addition & 1 deletion tests/fixtures/webui/scenarios/identity-collision.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"identity": {
"id": "mdl_uB9xAUQSKlrb9ELybOgV-92lVC7XjiMXju6pwZZBbAU",
"inference_id": "alpha",
"display_name": "alpha models dir",
"display_name": "alpha",
"source": "models_dir",
"source_key_hash": "fb957973e2e6f9fb17bbb5bf2922d6bb67dde35b08c94fa80f8154722b58af2e",
"generation": 1,
Expand Down
12 changes: 6 additions & 6 deletions tests/fixtures/webui/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@
},
{
"key": "models.long_name",
"en": "Qwen3 Very Long Local Checkpoint Name With Mixed English and 한국어 모델 이름",
"ko": "Qwen3 매우 긴 로컬 체크포인트 이름과 한국어 모델 이름",
"en": "Qwen3-Very-Long-Local-Checkpoint-Name-With-Mixed-English-and-한국어-모델-이름",
"ko": "Qwen3-Very-Long-Local-Checkpoint-Name-With-Mixed-English-and-한국어-모델-이름",
"test_id": "models-long-name"
},
{
Expand Down Expand Up @@ -1250,14 +1250,14 @@
},
{
"key": "models.library.delete_body",
"en": "Permanently delete the managed cache entry {name} ({source}). This removes files from disk, unlike Unload. Type the model name to confirm.",
"ko": "관리 캐시 항목 {name} ({source})을 영구 삭제합니다. 언로드와 달리 디스크 파일을 제거합니다. 모델 이름을 입력하여 확인하세요.",
"en": "Permanently delete the managed cache entry {name} ({source}). This removes files from disk, unlike Unload. To confirm, type the model ID shown below, not the model name.",
"ko": "관리 캐시 항목 {name} ({source})을 영구 삭제합니다. 언로드와 달리 디스크 파일을 제거합니다. 확인하려면 모델 이름이 아니라 아래에 표시된 모델 ID를 입력하세요.",
"test_id": "models-library-delete-body"
},
{
"key": "models.library.confirm_name",
"en": "Model name to confirm",
"ko": "확인용 모델 이름",
"en": "Opaque model ID shown above (starts with mdl_)",
"ko": "위에 표시된 모델 ID (mdl_로 시작)",
"test_id": "models-library-confirm-name"
},
{
Expand Down
16 changes: 15 additions & 1 deletion webui/src/features/models/browser-fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,26 @@ describe('Playwright fixture contract loader (no browser)', () => {
it('executes the canonical validator and validates the complete CJK catalog', async () => {
const { validateAgainstSchema } = await loadValidator();
validateAgainstSchema('BootstrapResponse', bootstrap);
const items = Array.from({ length: 120 }, (_, index) => ({ ...model(), identity: { ...model().identity, id: `mdl_${String(index).padStart(43, '0')}`, display_name: `模型 ${index}` } }));
const items = Array.from({ length: 120 }, (_, index) => ({ ...model(), identity: { ...model().identity, id: `mdl_${String(index).padStart(43, '0')}`, display_name: `模型-${index}` } }));
const page = { schema_version: 'webui.ui-api.v1', items, pagination: { limit: 200, next_cursor: null, total_known: items.length }, server_instance_id: bootstrap.server.server_instance_id, snapshot_sequence: 1 };
expect(() => validateAgainstSchema('CatalogListResponse', page)).not.toThrow();
expect(() => validateAgainstSchema('CatalogListResponse', { ...page, items: [{ ...items[0], identity: { ...items[0].identity, id: 'id_invalid' } }] })).toThrow();
expect(() => validateAgainstSchema('CatalogListResponse', { ...page, extra: true })).toThrow();
});
it('accepts a full-length cache owner/name as display_name and rejects one past inference_id bounds', async () => {
const { validateAgainstSchema } = await loadValidator();
const pageWith = (display_name: string) => ({
schema_version: 'webui.ui-api.v1',
items: [{ ...model(), identity: { ...model().identity, display_name } }],
pagination: { limit: 50, next_cursor: null, total_known: 1 },
server_instance_id: bootstrap.server.server_instance_id,
snapshot_sequence: 1,
});
const cacheId = `${'o'.repeat(96)}/${'n'.repeat(96)}`;
expect(cacheId).toHaveLength(193);
expect(() => validateAgainstSchema('CatalogListResponse', pageWith(cacheId))).not.toThrow();
expect(() => validateAgainstSchema('CatalogListResponse', pageWith('m'.repeat(257)))).toThrow();
});
});


Expand Down
Loading
Loading