Skip to content

Commit 62e36d8

Browse files
committed
Release v2.12.0
- Add naming validation rules (provider name, model ID, displayName) - Frontend real-time validation with error hints on Settings form - Rename project to LLM API Bench - Fix Playground history: show providerName/displayName, not raw ID - Fix flash issues: Getting Started hint, provider/model selectors - Adaptive QuickButtons sizing for many options
1 parent 910ab92 commit 62e36d8

8 files changed

Lines changed: 170 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [2.12.0] - 2026-04-28
8+
9+
### Added
10+
- Naming validation rules for Provider name, Model ID, and DisplayName (backend + frontend)
11+
- Provider name: alphanumeric/dash/underscore, no spaces, 1-64 chars
12+
- Model ID: alphanumeric/dash/underscore/dot/slash, 1-64 chars (LiteLLM compatible)
13+
- DisplayName: alphanumeric/space/dash/underscore/dot, 1-64 chars
14+
- Frontend real-time validation with error hints on Settings provider form
15+
- Frontend validation unit tests (16 cases)
16+
- Backend validation boundary tests (4 cases)
17+
18+
### Changed
19+
- Renamed project from LLM API Radar to **LLM API Bench** (repo, UI, docs, Docker image)
20+
- Playground history sidebar now shows `ProviderName/DisplayName` instead of raw model ID
21+
- Backend stores model displayName in playground history for friendly display
22+
- Adaptive QuickButtons sizing: auto-shrink when >7 options to prevent line wrapping
23+
24+
### Fixed
25+
- Getting Started hint no longer flashes on page refresh (waits for data load)
26+
- Playground provider/model selectors no longer flash raw IDs before names load
27+
- Playground history correctly resolves model displayName from provider data
28+
729
## [2.11.3] - 2026-04-28
830

931
### Changed

backend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "llm-benchmark-backend",
3-
"version": "2.11.3",
3+
"version": "2.12.0",
44
"description": "LLM API Bench - Backend",
55
"main": "dist/index.js",
66
"scripts": {

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "frontend",
33
"private": true,
4-
"version": "2.11.3",
4+
"version": "2.12.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

frontend/src/components/SettingsPage.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useProviders } from '../hooks/useProviders';
55
import { Button, Input, InputNumber, Select, Checkbox, Popconfirm, Alert, Tag, Modal } from '../antdImports';
66
import { PlusOutlined, ApiOutlined } from '@ant-design/icons';
77
import { APP_VERSION } from '../constants';
8+
import { validateProviderName, validateModelId, validateDisplayName } from '../utils/validation';
89

910
const FORMAT_OPTIONS: { value: ProviderFormat; label: string }[] = [
1011
{ value: 'openai', label: 'OpenAI Compatible' },
@@ -165,12 +166,18 @@ export function SettingsPage() {
165166
}));
166167
};
167168

169+
const providerNameError = validateProviderName(form.name.trim());
170+
const modelErrors = form.models.map((m) => ({
171+
name: validateModelId(m.name.trim()),
172+
displayName: validateDisplayName(m.displayName.trim()),
173+
}));
174+
168175
const isFormValid =
169-
form.name.trim() &&
176+
!providerNameError &&
170177
form.endpoint.trim() &&
171178
(editingId || form.apiKey.trim()) &&
172179
form.models.length > 0 &&
173-
form.models.every((m) => m.name.trim());
180+
modelErrors.every((e) => !e.name && !e.displayName);
174181

175182
const isNameDuplicate =
176183
form.name.trim() &&
@@ -369,14 +376,17 @@ export function SettingsPage() {
369376
<div>
370377
<label className="text-[11px] text-text-secondary mb-1 block">Provider Name</label>
371378
<Input
372-
placeholder="e.g. My OpenAI"
379+
placeholder="e.g. My-OpenAI"
373380
value={form.name}
374-
status={isNameDuplicate ? 'error' : undefined}
381+
status={isNameDuplicate || (form.name && providerNameError) ? 'error' : undefined}
375382
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
376383
/>
377384
{isNameDuplicate && (
378385
<span className="text-[10px] text-accent-rose mt-0.5 block">Provider name already exists</span>
379386
)}
387+
{!isNameDuplicate && form.name && providerNameError && (
388+
<span className="text-[10px] text-accent-rose mt-0.5 block">{providerNameError}</span>
389+
)}
380390
</div>
381391
<div>
382392
<label className="text-[11px] text-text-secondary mb-1 block">Format</label>
@@ -464,17 +474,27 @@ export function SettingsPage() {
464474
size="small"
465475
placeholder="e.g. gpt-4o"
466476
value={model.name}
477+
status={model.name && modelErrors[idx]?.name ? 'error' : undefined}
467478
onChange={(e) => updateModel(idx, 'name', e.target.value)}
468479
/>
480+
{model.name && modelErrors[idx]?.name && (
481+
<span className="text-[9px] text-accent-rose mt-0.5 block">{modelErrors[idx].name}</span>
482+
)}
469483
</div>
470484
<div>
471485
<label className="text-[10px] text-text-tertiary mb-0.5 block">Display Name</label>
472486
<Input
473487
size="small"
474488
placeholder="e.g. GPT-4o (optional)"
475489
value={model.displayName}
490+
status={model.displayName && modelErrors[idx]?.displayName ? 'error' : undefined}
476491
onChange={(e) => updateModel(idx, 'displayName', e.target.value)}
477492
/>
493+
{model.displayName && modelErrors[idx]?.displayName && (
494+
<span className="text-[9px] text-accent-rose mt-0.5 block">
495+
{modelErrors[idx].displayName}
496+
</span>
497+
)}
478498
</div>
479499
</div>
480500
<div>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { validateProviderName, validateModelId, validateDisplayName } from './validation';
3+
4+
describe('validateProviderName', () => {
5+
it('accepts valid names', () => {
6+
expect(validateProviderName('OpenAI')).toBeNull();
7+
expect(validateProviderName('ZAI-CN-OpenAI')).toBeNull();
8+
expect(validateProviderName('my_provider')).toBeNull();
9+
expect(validateProviderName('a')).toBeNull();
10+
expect(validateProviderName('A'.repeat(64))).toBeNull();
11+
});
12+
13+
it('rejects empty', () => {
14+
expect(validateProviderName('')).not.toBeNull();
15+
});
16+
17+
it('rejects spaces', () => {
18+
expect(validateProviderName('My Provider')).not.toBeNull();
19+
});
20+
21+
it('rejects dots', () => {
22+
expect(validateProviderName('provider.name')).not.toBeNull();
23+
});
24+
25+
it('rejects over 64 chars', () => {
26+
expect(validateProviderName('A'.repeat(65))).not.toBeNull();
27+
});
28+
29+
it('rejects starting with non-alphanumeric', () => {
30+
expect(validateProviderName('-provider')).not.toBeNull();
31+
expect(validateProviderName('_provider')).not.toBeNull();
32+
});
33+
});
34+
35+
describe('validateModelId', () => {
36+
it('accepts valid IDs', () => {
37+
expect(validateModelId('gpt-4o')).toBeNull();
38+
expect(validateModelId('glm-5.1')).toBeNull();
39+
expect(validateModelId('z-ai/glm-4.7')).toBeNull();
40+
expect(validateModelId('claude-3.5-sonnet')).toBeNull();
41+
expect(validateModelId('a'.repeat(64))).toBeNull();
42+
});
43+
44+
it('rejects empty', () => {
45+
expect(validateModelId('')).not.toBeNull();
46+
});
47+
48+
it('rejects spaces', () => {
49+
expect(validateModelId('gpt 4o')).not.toBeNull();
50+
});
51+
52+
it('rejects over 64 chars', () => {
53+
expect(validateModelId('a'.repeat(65))).not.toBeNull();
54+
});
55+
56+
it('rejects starting with non-alphanumeric', () => {
57+
expect(validateModelId('/vendor/model')).not.toBeNull();
58+
expect(validateModelId('.hidden')).not.toBeNull();
59+
});
60+
});
61+
62+
describe('validateDisplayName', () => {
63+
it('returns null for empty (optional)', () => {
64+
expect(validateDisplayName('')).toBeNull();
65+
});
66+
67+
it('accepts valid display names', () => {
68+
expect(validateDisplayName('GLM 5.1')).toBeNull();
69+
expect(validateDisplayName('Gemini 2.5 Flash-Lite')).toBeNull();
70+
expect(validateDisplayName('DeepSeek-V3.2')).toBeNull();
71+
expect(validateDisplayName('A'.repeat(64))).toBeNull();
72+
});
73+
74+
it('rejects special characters', () => {
75+
expect(validateDisplayName('name@test')).not.toBeNull();
76+
expect(validateDisplayName('name<script>')).not.toBeNull();
77+
});
78+
79+
it('rejects over 64 chars', () => {
80+
expect(validateDisplayName('A'.repeat(65))).not.toBeNull();
81+
});
82+
83+
it('rejects starting with non-alphanumeric', () => {
84+
expect(validateDisplayName(' GLM 5')).not.toBeNull();
85+
expect(validateDisplayName('-Model')).not.toBeNull();
86+
});
87+
});

frontend/src/utils/validation.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Shared naming validation rules — mirrors backend schemas.ts.
3+
* Used for real-time input feedback in the UI.
4+
*/
5+
6+
// Provider name: alphanumeric, dash, underscore, NO spaces, 1-64 chars
7+
const PROVIDER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8+
9+
// Model ID: alphanumeric, dash, underscore, dot, slash (LiteLLM vendor/model), 1-64 chars
10+
const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,63}$/;
11+
12+
// Display name: alphanumeric, space, dash, underscore, dot, 1-64 chars
13+
const DISPLAY_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9 ._-]{0,63}$/;
14+
15+
export function validateProviderName(value: string): string | null {
16+
if (!value) return 'Provider name is required';
17+
if (!PROVIDER_NAME_RE.test(value)) return '1-64 chars: letters, digits, dash, underscore. No spaces.';
18+
return null;
19+
}
20+
21+
export function validateModelId(value: string): string | null {
22+
if (!value) return 'Model ID is required';
23+
if (!MODEL_ID_RE.test(value)) return '1-64 chars: letters, digits, dash, underscore, dot, slash.';
24+
return null;
25+
}
26+
27+
export function validateDisplayName(value: string): string | null {
28+
if (!value) return null; // optional
29+
if (!DISPLAY_NAME_RE.test(value)) return '1-64 chars: letters, digits, space, dash, underscore, dot.';
30+
return null;
31+
}

0 commit comments

Comments
 (0)