Skip to content

Commit 40c3cb3

Browse files
authored
feat: target groups — named groups with per-group selectors and fallback chains (#373)
## Summary Replaces the flat `targets` array and single top-level `selector` on aliases with a structured `target_groups` model. Each group has its own name and selector strategy; the router works through groups in order, falling back to the next group if all targets in the current group are unhealthy. ## Changes ### Database - New `target_groups` column on alias tables (stores group name + selector as JSON) - SQLite migration `0036` and PostgreSQL migration `0043` ### Backend - Config types updated to parse `target_groups` with per-group selectors; legacy `targets`/`selector` fields auto-migrated on read - `ConfigRepository` reads/writes the grouped structure from DB - `Router` iterates groups in priority order, selecting a target within each group using that group's selector, falling back on empty healthy set - `Target` interface exported from `CooldownManager` for use in tests ### Backend Tests - `target-groups-migration.test.ts`: round-trip DB read/write of grouped format - `target-groups.test.ts`: router fallback behaviour across 2 and 3 groups, health filtering, selector strategies per group ### Frontend - `AliasTargetGroup` interface added to API types; `Alias.targets` → `Alias.target_groups` - New `TargetGroupEditor` component: drag-and-drop reordering of groups and targets (including cross-group moves) - `AliasTableRow` renders targets nested under their group with group name and selector as a header - `Models.tsx` replaces the old flat target list + selector dropdown with `TargetGroupEditor` - `useModels` updated for group-indexed toggle/test handlers and import logic ### Bug fix (in TargetGroupEditor) Drag event bubbling caused dropping a single target to move *all* targets from the source group. Fixed by stopping propagation on target-level drag events so the parent group handler cannot overwrite the `dataTransfer` payload, and updating the group drop handler to accept target drops onto empty group space. ### Docs - `CONFIGURATION.md` updated with `target_groups` schema - OpenAPI spec updated for new alias structure
1 parent a3ba60d commit 40c3cb3

48 files changed

Lines changed: 8273 additions & 1191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ ENV PORT=4000
5252
ENV LOG_LEVEL=info
5353
ENV DATA_DIR=/app/data
5454
ENV DATABASE_URL=sqlite:///app/data/plexus.db
55-
ENV CONFIG_FILE=/app/config/plexus.yaml
5655
ENV APP_VERSION=${APP_VERSION}
5756
# ADMIN_KEY must be provided at runtime (no default for security)
5857

GROUPS.md

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# Target Groups Implementation Plan
2+
3+
## Goal
4+
Support named, ordered target groups within a model alias, each with its own selector strategy. The dispatcher exhausts all healthy targets in group N before moving to group N+1.
5+
6+
## Non-Goal
7+
There is NO backward compatibility in the application runtime. After startup, every alias and every target row is in grouped format. Old flat configs are converted exactly once at startup.
8+
9+
---
10+
11+
## 1. Database Schema (additive columns only)
12+
13+
### `modelAliases` table (SQLite + Postgres)
14+
Add one column:
15+
- `targetGroups` — JSON array of `{ name: string, selector: string }` objects defining the groups and their order.
16+
17+
### `modelAliasTargets` table (SQLite + Postgres)
18+
Add one column:
19+
- `groupName` — text label of the group this target belongs to (e.g. `'subscription'`, `'payg'`).
20+
21+
No new tables. No dropped columns. Migrations auto-generated by CI.
22+
23+
---
24+
25+
## 2. In-Memory Types (`config.ts`)
26+
27+
```ts
28+
export interface ModelTargetGroup {
29+
name: string;
30+
selector: 'random' | 'in_order' | 'cost' | 'latency' | 'usage' | 'performance' | 'e2e_performance';
31+
targets: ModelTarget[];
32+
}
33+
34+
export interface ModelConfig {
35+
// ... existing fields ...
36+
target_groups: ModelTargetGroup[];
37+
}
38+
```
39+
40+
`ModelConfigSchema` is updated to accept `target_groups`.
41+
42+
The Zod schema still tolerates old flat payloads (`selector` + `targets`) at the **API boundary only**, so that a PUT/PATCH with the old shape parses. The shape is immediately normalized to `target_groups` inside `ModelConfigSchema.parse()` so that downstream code never sees the flat format.
43+
44+
---
45+
46+
## 3. Startup Migration (one-time, DB-level)
47+
48+
After `ConfigService.initialize()` loads the cache, a new method `ConfigService.migrateLegacyTargetGroups()` runs **once per startup**.
49+
50+
It operates at raw DB row level (no `rowToModelConfig` involvement):
51+
52+
1. SELECT all aliases WHERE `targetGroups IS NULL`
53+
2. For each legacy alias:
54+
- Read its `selector` (fallback `'random'`)
55+
- Read all its targets from `modelAliasTargets`
56+
- UPDATE alias row: `targetGroups = JSON.stringify([{ name: 'default', selector }])`
57+
- UPDATE all its target rows: `groupName = 'default'`
58+
3. If any rows were touched, `rebuildCache()`
59+
60+
After this runs, **zero aliases in the DB have `targetGroups IS NULL`**.
61+
62+
This is the **only** place in the codebase that understands the old format.
63+
64+
---
65+
66+
## 4. Config Repository (`config-repository.ts`)
67+
68+
### Read path
69+
`rowToModelConfig` always assumes `targetGroups` is populated. It:
70+
1. Parses `targetGroups` JSON into group definitions `[{name, selector}]`
71+
2. Partitions `targetRows` by `groupName`
72+
3. Builds `ModelTargetGroup[]` in definition order
73+
74+
If `targetGroups` is somehow null (should never happen post-migration), throw a clear error so we know the migration failed.
75+
76+
### Write path
77+
`saveAlias` always writes the grouped format:
78+
- `targetGroups` = JSON of `config.target_groups.map(g => ({ name: g.name, selector: g.selector }))`
79+
- Each target row gets `groupName` set to its group's name
80+
- `sortOrder` is the index within the group
81+
82+
---
83+
84+
## 5. Router (`router.ts`)
85+
86+
Both `resolve()` and `resolveCandidates()` iterate `alias.target_groups`.
87+
88+
### `resolveCandidates()`
89+
```
90+
for each group in alias.target_groups (in order):
91+
1. Filter group.targets through existing health checks (enabled, cooldown, type, api_match)
92+
2. If no healthy targets, skip to next group
93+
3. Enrich targets with modelConfig
94+
4. Order within group using group.selector
95+
5. Append ordered results to flat candidate list
96+
return flat list
97+
```
98+
99+
The Dispatcher already iterates through this flat list. Because group 1 candidates appear first, the Dispatcher naturally exhausts them before trying group 2.
100+
101+
### `resolve()`
102+
```
103+
for each group in alias.target_groups (in order):
104+
1. Filter group.targets through health checks
105+
2. If no healthy targets, skip to next group
106+
3. Select one target using group.selector
107+
4. Return it immediately
108+
```
109+
110+
---
111+
112+
## 6. Management API (`routes/management/config.ts`)
113+
114+
No changes. The existing PUT/PATCH handlers pass the body through `ModelConfigSchema.safeParse()`, which normalizes old flat shapes to grouped at parse time.
115+
116+
---
117+
118+
## 7. Frontend API Layer (`frontend/src/lib/api.ts`)
119+
120+
### Type changes
121+
```ts
122+
export interface AliasTargetGroup {
123+
name: string;
124+
selector: string;
125+
targets: Array<{ provider: string; model: string; apiType?: string[]; enabled?: boolean }>;
126+
}
127+
128+
export interface Alias {
129+
// ... existing fields ...
130+
target_groups: AliasTargetGroup[];
131+
}
132+
```
133+
134+
### Read normalization (`getAliases`)
135+
- If backend response has `target_groups`, use it directly.
136+
- If backend response has old flat `selector` + `targets` (only possible during rollout window), normalize immediately:
137+
```ts
138+
target_groups: [{ name: 'default', selector: val.selector || 'random', targets }]
139+
```
140+
- TODO comment on the fallback: remove after one release cycle.
141+
142+
### Write serialization (`saveAlias`)
143+
Always sends the grouped format to the backend:
144+
```ts
145+
body.target_groups = alias.target_groups.map(g => ({
146+
name: g.name,
147+
selector: g.selector,
148+
targets: g.targets.map(t => ({ ... }))
149+
}))
150+
```
151+
152+
---
153+
154+
## 8. Frontend UI (`frontend/src/pages/Models.tsx`, `AliasTableRow.tsx`, `useModels.ts`)
155+
156+
### Table display (`AliasTableRow.tsx`)
157+
Instead of a flat list of targets, render targets grouped by `target_groups.name`:
158+
```
159+
subscription [e2e_performance]
160+
neuralwatt → zai-org/GLM-5.1-FP8
161+
zenmux-sub → z-ai/glm-5.1
162+
...
163+
164+
payg [cost]
165+
kilocode → z-ai/glm-5.1
166+
apertis-payg → glm-5.1
167+
```
168+
169+
### Edit modal (`Models.tsx`)
170+
- Selector at top level is **removed** from the main form (it lives inside each group now)
171+
- Target list editor becomes group-aware:
172+
- Add/remove/rename groups
173+
- Drag targets between groups or within a group
174+
- Per-group selector dropdown
175+
- Auto-add / import features append targets to a user-selected group (defaulting to the first group)
176+
177+
### Hook (`useModels.ts`)
178+
- `EMPTY_ALIAS` initializes with one default group:
179+
```ts
180+
target_groups: [{ name: 'default', selector: 'random', targets: [] }]
181+
```
182+
- `handleToggleTarget` finds the target within the correct group
183+
- Import logic appends to the first group by default
184+
185+
---
186+
187+
## 9. Tests
188+
189+
### Backend
190+
1. **Startup migration test**: verify legacy alias gets `targetGroups='[{"name":"default","selector":"cost"}]'` and targets get `groupName='default'`
191+
2. **Router tests**: multi-group ordering; group-specific selectors; empty group skipped
192+
3. **Dispatcher tests**: verify failover exhausts group 1 before group 2
193+
4. **Repository round-trip**: save grouped alias → reload → identical `target_groups`
194+
195+
### Frontend
196+
1. **API normalization test**: old flat payload → `target_groups` with `default` group
197+
2. **UI tests**: group editor renders; drag-and-drop between groups; group selector respected
198+
199+
---
200+
201+
## 10. TODOs for Cleanup
202+
203+
After the migration has run in production for one release cycle, the following temporary paths can be deleted:
204+
205+
1. `ConfigService.migrateLegacyTargetGroups()` — entire method
206+
2. `rowToModelConfig` null-check on `targetGroups` — remove the throw, keep the happy path
207+
3. Frontend `getAliases` flat-format fallback — remove the `else` branch
208+
4. `ModelConfigSchema` flat-shape tolerance — remove `selector` and `targets` from the schema (keep only `target_groups`)
209+
210+
Each TODO will be marked with `TODO(#target-groups-cleanup): remove after migration period`.
211+
212+
---
213+
214+
## 11. Example Config After Migration
215+
216+
### Database state
217+
```sql
218+
SELECT slug, target_groups FROM model_aliases WHERE slug = 'glm-5.1';
219+
-- glm-5.1 | [{"name":"subscription","selector":"e2e_performance"},{"name":"payg","selector":"cost"},{"name":"backup","selector":"in_order"}]
220+
221+
SELECT provider_slug, model_name, group_name FROM model_alias_targets WHERE alias_id = 42;
222+
-- neuralwatt | zai-org/GLM-5.1-FP8 | subscription
223+
-- zenmux-sub | z-ai/glm-5.1 | subscription
224+
-- apertis-sub | glm-5.1 | subscription
225+
-- llm-gateway | glm-5.1 | subscription
226+
-- kilocode | z-ai/glm-5.1 | payg
227+
-- apertis-payg | glm-5.1 | payg
228+
-- wisgate | glm-5.1 | backup
229+
```
230+
231+
### API payload
232+
```json
233+
{
234+
"target_groups": [
235+
{
236+
"name": "subscription",
237+
"selector": "e2e_performance",
238+
"targets": [
239+
{ "provider": "neuralwatt", "model": "zai-org/GLM-5.1-FP8" },
240+
{ "provider": "zenmux-sub", "model": "z-ai/glm-5.1" }
241+
]
242+
},
243+
{
244+
"name": "payg",
245+
"selector": "cost",
246+
"targets": [
247+
{ "provider": "kilocode", "model": "z-ai/glm-5.1" }
248+
]
249+
}
250+
]
251+
}
252+
```
253+
254+
---
255+
256+
Ready for review. Let me know if you want any changes before I start implementing.

docker-compose.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ services:
77
ports:
88
- "4000:4000"
99
volumes:
10-
- ./config:/app/config
1110
- ./data:/app/data
1211
environment:
1312
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
1413
- DATABASE_URL=${DATABASE_URL:-sqlite:///app/data/plexus.db}
1514
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
1615
- LOG_LEVEL=info
1716
- DATA_DIR=/app/data
18-
- CONFIG_FILE=/app/config/plexus.yaml

0 commit comments

Comments
 (0)