|
| 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. |
0 commit comments