Skip to content

Commit 73e6681

Browse files
committed
feat: finalize admin UI and routing improvements
1 parent 3d746ac commit 73e6681

180 files changed

Lines changed: 12004 additions & 9286 deletions

File tree

Some content is hidden

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

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ jobs:
4747
working-directory: webui
4848
- run: npm test
4949
working-directory: webui
50+
- run: npx playwright install --with-deps chromium
51+
working-directory: webui
52+
- run: npm run test:e2e
53+
working-directory: webui
5054
- run: npm run typecheck
5155
working-directory: webui
5256
- run: npm run build

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ docker rm auto-router
3030

3131
## 自动路由
3232

33-
将请求中的 `model` 设为 `auto`,即可请求自动路由。启用 Jev 时,它推荐简单、中等或复杂路由组;本地策略根据请求特征、模型能力和上下文窗口过滤候选,再从所选模型组中决定具体上游。Jev 不可用或未启用时,服务仍按本地路由策略处理请求。
33+
将请求中的 `model` 设为 `auto`,即可请求自动路由。启用 Jev 时,它只在当前有合格成员的 `simple`、`medium`、`complex` 组中选择任务组;本地策略先按请求特征、模型能力、上下文窗口及硬限制过滤成员,再严格按组内配置顺序选择主模型。不会跨组重试,也不会由本地评分改变组内主备顺序。
34+
35+
Jev 不可用、未启用或推荐置信度低于 `routing.policy.low_confidence` 时,使用 `routing.auto.default_group`(默认 `medium`);所选组没有合格成员时明确失败,不自动切换任务等级。故障转移只尝试同组后续成员。`routing.auto.failover.max_attempts` 统计首次请求,范围为 1–8,默认 2;请求发送前可确认未送达的失败默认允许重试,超时与 HTTP 状态码默认不重试。启用超时或状态码重试可能导致重复计费或重复执行,状态码仅支持 408、425、429、500、502、503、504。以上设置可在管理台“系统设置 → 全局路由行为”调整。
3436

3537
`GET /v1/models` 始终首先列出虚拟模型 `auto`,默认声明:
3638

@@ -82,7 +84,7 @@ Anthropic 和 Gemini 原生接口要求所选上游支持相同协议。OpenAI C
8284

8385
## 发布前验证
8486

85-
CI 运行 Go vet/test/race/build 与 WebUI 的 OpenAPI 类型生成一致性检查、Vitest、类型检查和生产构建;Playwright E2E 不在 CI 中运行,发布前需本地执行并检查失败 trace:
87+
CI 运行 Go vet/test/race/build 与 WebUI 的 OpenAPI 类型生成一致性检查、Vitest、Playwright E2E、类型检查、生产构建和嵌入资源一致性检查。发布前仍建议本地复跑 E2E,并检查失败 trace:
8688

8789
```sh
8890
cd webui

‎cmd/auto-router/logs_check.go‎

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ func writeStoredTrace(output io.Writer, trace *logging.JevTrace) {
172172
trace.Status, orNone(trace.FailureReason), trace.InputMode, formatStoredOptionalInt(trace.LatencyMS))
173173
fmt.Fprintf(output, " jev model_count=%d candidate_count=%d selected=%s\n",
174174
trace.ModelCount, trace.CandidateCount, orNone(trace.Selected))
175+
fmt.Fprintf(output, " jev group_count=%d recommended_group=%s selected_group=%s candidate_groups=%s\n",
176+
trace.GroupCount, orNone(trace.RecommendedGroup), orNone(trace.SelectedGroup), formatStringsOrNone(trace.CandidateGroups))
175177
fmt.Fprintf(output, " jev confidence=%s band=%s fallback=%s evidence_hash=%s\n",
176178
formatStoredOptionalFloat(trace.Confidence), orNone(trace.ConfidenceBand),
177179
orNone(trace.FallbackReason), orNone(trace.EvidenceHash))
@@ -181,6 +183,11 @@ func writeStoredTrace(output io.Writer, trace *logging.JevTrace) {
181183
distribution = append(distribution, fmt.Sprintf("%s=%.6f", probability.Model, probability.Probability))
182184
}
183185
fmt.Fprintf(output, " jev distribution=%s\n", formatStringsOrNone(distribution))
186+
groupDistribution := make([]string, 0, len(trace.GroupProbabilities))
187+
for _, probability := range trace.GroupProbabilities {
188+
groupDistribution = append(groupDistribution, fmt.Sprintf("%s=%.6f", probability.Group, probability.Probability))
189+
}
190+
fmt.Fprintf(output, " jev group_distribution=%s\n", formatStringsOrNone(groupDistribution))
184191
}
185192

186193
// logsCheckJSONEntry is the JSON shape of one report line. The keys match the text
@@ -232,19 +239,24 @@ type logsCheckJSONEvent struct {
232239
}
233240

234241
type logsCheckJSONJev struct {
235-
Status string `json:"status"`
236-
FailureReason string `json:"failure_reason,omitempty"`
237-
InputMode string `json:"input_mode"`
238-
Selected string `json:"selected,omitempty"`
239-
ConfidenceBand string `json:"confidence_band,omitempty"`
240-
FallbackReason string `json:"fallback_reason,omitempty"`
241-
EvidenceHash string `json:"evidence_hash,omitempty"`
242-
LatencyMS *int64 `json:"latency_ms,omitempty"`
243-
CandidateModels []string `json:"candidate_models"`
244-
ModelCount int `json:"model_count"`
245-
CandidateCount int `json:"candidate_count"`
246-
Confidence *float64 `json:"confidence,omitempty"`
247-
Probabilities []float64 `json:"probabilities,omitempty"`
242+
Status string `json:"status"`
243+
FailureReason string `json:"failure_reason,omitempty"`
244+
InputMode string `json:"input_mode"`
245+
Selected string `json:"selected,omitempty"`
246+
ConfidenceBand string `json:"confidence_band,omitempty"`
247+
FallbackReason string `json:"fallback_reason,omitempty"`
248+
EvidenceHash string `json:"evidence_hash,omitempty"`
249+
LatencyMS *int64 `json:"latency_ms,omitempty"`
250+
CandidateModels []string `json:"candidate_models"`
251+
ModelCount int `json:"model_count"`
252+
CandidateCount int `json:"candidate_count"`
253+
CandidateGroups []string `json:"candidate_groups"`
254+
GroupCount int `json:"group_count"`
255+
RecommendedGroup string `json:"recommended_group,omitempty"`
256+
SelectedGroup string `json:"selected_group,omitempty"`
257+
Confidence *float64 `json:"confidence,omitempty"`
258+
Probabilities []float64 `json:"probabilities,omitempty"`
259+
GroupProbabilities []logging.GroupProbability `json:"group_probabilities,omitempty"`
248260
}
249261

250262
// writeLogsJSON prints one JSON object per entry. The values are the stored ones:
@@ -296,18 +308,23 @@ func writeLogsJSON(output io.Writer, events []storage.StoredEvent) error {
296308
if entry.JevTrace != nil {
297309
trace := entry.JevTrace
298310
rendered := &logsCheckJSONJev{
299-
Status: trace.Status,
300-
FailureReason: trace.FailureReason,
301-
InputMode: trace.InputMode,
302-
Selected: trace.Selected,
303-
ConfidenceBand: trace.ConfidenceBand,
304-
FallbackReason: trace.FallbackReason,
305-
EvidenceHash: trace.EvidenceHash,
306-
LatencyMS: trace.LatencyMS,
307-
CandidateModels: trace.CandidateModels,
308-
ModelCount: trace.ModelCount,
309-
CandidateCount: trace.CandidateCount,
310-
Confidence: trace.Confidence,
311+
Status: trace.Status,
312+
FailureReason: trace.FailureReason,
313+
InputMode: trace.InputMode,
314+
Selected: trace.Selected,
315+
ConfidenceBand: trace.ConfidenceBand,
316+
FallbackReason: trace.FallbackReason,
317+
EvidenceHash: trace.EvidenceHash,
318+
LatencyMS: trace.LatencyMS,
319+
CandidateModels: trace.CandidateModels,
320+
ModelCount: trace.ModelCount,
321+
CandidateCount: trace.CandidateCount,
322+
CandidateGroups: trace.CandidateGroups,
323+
GroupCount: trace.GroupCount,
324+
RecommendedGroup: trace.RecommendedGroup,
325+
SelectedGroup: trace.SelectedGroup,
326+
Confidence: trace.Confidence,
327+
GroupProbabilities: trace.GroupProbabilities,
311328
}
312329
if rendered.CandidateModels == nil {
313330
rendered.CandidateModels = []string{}

‎cmd/auto-router/main.go‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,6 @@ func run(ctx context.Context, args []string, output io.Writer) error {
297297
SyncRegistry: func(syncCtx context.Context) (any, error) {
298298
return syncGate.Do(syncCtx, func(syncCtx context.Context) (any, error) {
299299
current := settingsStore.Config()
300-
if len(current.Registry.Sync.Include) == 0 {
301-
return nil, api.ErrSyncAllowlistEmpty
302-
}
303300
if err := syncRegistry(syncCtx, current, db, logger); err != nil {
304301
return nil, err
305302
}
@@ -452,7 +449,10 @@ func newAutoRouter(cfg config.Config, store *models.Store, engine *policy.Engine
452449
Engine: engine,
453450
DefaultPreference: cfg.Routing.DefaultPreference,
454451
InputMode: cfg.Jev.DomainInputMode(),
455-
FailoverEnabled: cfg.Routing.Auto.Failover.Enabled,
452+
Failover: autoFailoverPolicy(cfg.Routing.Auto.Failover),
453+
DefaultGroup: auto.GroupName(cfg.Routing.Auto.DefaultGroup),
454+
LowConfidence: cfg.Routing.Policy.LowConfidence,
455+
HighConfidence: cfg.Routing.Policy.HighConfidence,
456456
GroupConfig: groupConfigFromStorage(groups),
457457
}
458458
if settingsStore != nil {
@@ -493,12 +493,29 @@ func groupConfigFromStorage(groups *atomic.Pointer[storage.ModelGroups]) auto.Gr
493493
return auto.GroupConfig{Simple: convert(stored.Simple), Medium: convert(stored.Medium), Complex: convert(stored.Complex)}
494494
}
495495

496+
func autoFailoverPolicy(cfg config.RoutingAutoFailoverConfig) auto.FailoverPolicy {
497+
return auto.FailoverPolicy{
498+
Enabled: cfg.Enabled,
499+
MaxAttempts: cfg.MaxAttempts,
500+
RetryPreRequestFailure: cfg.RetryOn.PreRequestFailure,
501+
RetryTimeout: cfg.RetryOn.Timeout,
502+
RetryStatusCodes: append([]int(nil), cfg.RetryOn.StatusCodes...),
503+
}
504+
}
505+
496506
func autoRuntimeSnapshot(snapshot *settings.Snapshot, groups *atomic.Pointer[storage.ModelGroups]) auto.RuntimeSettings {
497507
var client auto.JevCaller
498508
if prepared, ok := snapshot.Runtime.(*jev.Client); ok && prepared != nil {
499509
client = prepared
500510
}
501-
return auto.RuntimeSettings{Engine: snapshot.Engine, JevEnabled: snapshot.Config.Jev.Enabled, Jev: client, InputMode: snapshot.Config.Jev.DomainInputMode(), FailoverEnabled: snapshot.Config.Routing.Auto.Failover.Enabled, DefaultPreference: snapshot.Config.Routing.DefaultPreference, GroupConfig: groupConfigFromStorage(groups)}
511+
cfg := snapshot.Config
512+
return auto.RuntimeSettings{
513+
Engine: snapshot.Engine, JevEnabled: cfg.Jev.Enabled, Jev: client,
514+
InputMode: cfg.Jev.DomainInputMode(), Failover: autoFailoverPolicy(cfg.Routing.Auto.Failover),
515+
DefaultGroup: auto.GroupName(cfg.Routing.Auto.DefaultGroup),
516+
LowConfidence: cfg.Routing.Policy.LowConfidence, HighConfidence: cfg.Routing.Policy.HighConfidence,
517+
DefaultPreference: cfg.Routing.DefaultPreference, GroupConfig: groupConfigFromStorage(groups),
518+
}
502519
}
503520
func (v settingsValues) RuntimeSnapshot() auto.RuntimeSettings {
504521
return autoRuntimeSnapshot(v.store.Current(), v.groups)

‎cmd/auto-router/policy_check.go‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ type policyCheckCandidate struct {
9696
SupportsVision bool `json:"supports_vision"`
9797
SupportsReasoning bool `json:"supports_reasoning"`
9898
SupportsAudioInput bool `json:"supports_audio_input"`
99-
PairPriority int `json:"pair_priority"`
10099
ProviderPriority int `json:"provider_priority"`
101100
}
102101

@@ -248,7 +247,6 @@ func policyCandidates(path string) ([]decision.Candidate, string, error) {
248247
SupportsVision: entry.SupportsVision,
249248
SupportsReasoning: entry.SupportsReasoning,
250249
SupportsAudioInput: entry.SupportsAudioInput,
251-
PairPriority: entry.PairPriority,
252250
ProviderPriority: entry.ProviderPriority,
253251
})
254252
}
@@ -349,22 +347,22 @@ func builtinPolicyCandidates() []decision.Candidate {
349347
ModelID: "gpt-4o", ProviderKey: "openai", GatewayModel: "gpt-4o",
350348
ContextWindow: 128000, MaxOutput: &output,
351349
SupportsTools: true, SupportsVision: true,
352-
PairPriority: 0, ProviderPriority: 0,
350+
ProviderPriority: 0,
353351
}, {
354352
ModelID: "gpt-4o-mini", ProviderKey: "openai", GatewayModel: "gpt-4o-mini",
355353
ContextWindow: 128000, MaxOutput: &output,
356354
SupportsTools: true, SupportsVision: true,
357-
PairPriority: 1, ProviderPriority: 0,
355+
ProviderPriority: 0,
358356
}, {
359357
ModelID: "o4-mini", ProviderKey: "openai", GatewayModel: "o4-mini",
360358
ContextWindow: 200000, MaxOutput: &output,
361359
SupportsTools: true, SupportsReasoning: true,
362-
PairPriority: 2, ProviderPriority: 0,
360+
ProviderPriority: 0,
363361
}, {
364362
ModelID: "llama-3.3-70b", ProviderKey: "groq", GatewayModel: "llama-3.3-70b-versatile",
365363
ContextWindow: 131072, MaxOutput: &output,
366-
SupportsTools: true,
367-
PairPriority: 3, ProviderPriority: 1,
364+
SupportsTools: true,
365+
ProviderPriority: 1,
368366
},
369367
}
370368
}

‎cmd/auto-router/registry.go‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,22 @@ func (g *registrySyncGate) Do(ctx context.Context, run func(context.Context) (an
4343
return run(ctx)
4444
}
4545

46-
// syncRegistry fetches the models.dev catalog, imports the allowlisted models
47-
// and records the outcome. It never rewrites local overrides and never deletes
48-
// rows. On any error the transaction is rolled back and the database keeps its
49-
// previous registry.
46+
var errNoMappableRegistryModels = errors.New("models.dev catalog contains no models with a usable context window")
47+
48+
// syncRegistry fetches models.dev and imports every mappable catalog entry when
49+
// no explicit include is configured. A non-empty include remains an explicit
50+
// scope override. Entries without usable context metadata are skipped with
51+
// warnings; a full-catalog sync with no mappable pairs fails before storage can
52+
// disable rows from the previous catalog. Local overrides and rows are never
53+
// deleted. On any error the transaction is rolled back and the registry is kept.
5054
func syncRegistry(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger) error {
51-
if len(cfg.Registry.Sync.Include) == 0 {
52-
return errors.New("registry.sync.include is empty; refusing to synchronize an empty allowlist")
53-
}
55+
include := append([]string(nil), cfg.Registry.Sync.Include...)
56+
fullCatalogScope := len(include) == 0
5457
client, err := modelsdev.NewClient(cfg.Registry.Sync.URL, time.Duration(cfg.Registry.Sync.Timeout))
5558
if err != nil {
5659
return err
5760
}
58-
logger.Info("model registry sync starting", "url", client.URL(), "allowlisted_entries", len(cfg.Registry.Sync.Include))
61+
5962
payload, err := client.Fetch(ctx)
6063
if err != nil {
6164
return err
@@ -64,18 +67,31 @@ func syncRegistry(ctx context.Context, cfg config.Config, db *sql.DB, logger *sl
6467
if err != nil {
6568
return err
6669
}
67-
result, err := modelsdev.Map(document, cfg.Registry.Sync.Include)
70+
var result *modelsdev.Result
71+
if fullCatalogScope {
72+
result, include, err = modelsdev.MapAll(document)
73+
if err == nil && len(result.Pairs) == 0 {
74+
return errNoMappableRegistryModels
75+
}
76+
} else {
77+
result, err = modelsdev.Map(document, include)
78+
}
6879
if err != nil {
6980
return err
7081
}
82+
scopeSource := "explicit_include"
83+
if fullCatalogScope {
84+
scopeSource = "modelsdev_catalog"
85+
}
86+
logger.Info("model registry sync starting", "url", client.URL(), "scope_entries", len(include), "scope_source", scopeSource)
7187
summary, err := storage.ApplyRegistryImport(ctx, db, models.SourceModelsDev, storage.Import{
7288
Providers: result.Providers,
7389
Models: result.Models,
7490
Pairs: result.Pairs,
7591
Sync: &storage.SyncState{
7692
URL: client.URL(),
7793
FetchedAt: time.Now().UTC(),
78-
AllowlistDigest: modelsdev.IncludeDigest(cfg.Registry.Sync.Include),
94+
AllowlistDigest: modelsdev.IncludeDigest(include),
7995
ImportedPairs: len(result.Pairs),
8096
SkippedPairs: result.Skipped,
8197
Warnings: result.Warnings,
@@ -103,7 +119,7 @@ func logImportSummary(logger *slog.Logger, message string, summary storage.Impor
103119
logger.Info(message,
104120
slog.Group("providers", "added", summary.ProvidersAdded, "updated", summary.ProvidersUpdated, "disabled", summary.ProvidersDisabled, "admin_owned", summary.ProvidersAdminOwned),
105121
slog.Group("models", "added", summary.ModelsAdded, "updated", summary.ModelsUpdated, "disabled", summary.ModelsDisabled, "admin_owned", summary.ModelsAdminOwned),
106-
slog.Group("pairs", "added", summary.PairsAdded, "updated", summary.PairsUpdated, "disabled", summary.PairsDisabled, "admin_owned", summary.PairsAdminOwned),
122+
slog.Group("pairs", "added", summary.PairsAdded, "updated", summary.PairsUpdated, "disabled", summary.PairsDisabled, "excluded", summary.PairsExcluded, "admin_owned", summary.PairsAdminOwned),
107123
)
108124
if summary.ProvidersAdminOwned+summary.ModelsAdminOwned+summary.PairsAdminOwned > 0 {
109125
// This is not a warning: skipping administrator-owned rows is the documented

0 commit comments

Comments
 (0)